fix(permissions): fix symlinked writable roots in sandbox permissions (#15981)

## Summary
- preserve logical symlink paths during permission normalization and
config cwd handling
- bind real targets for symlinked readable/writable roots in bwrap and
remap carveouts and unreadable roots there
- add regressions for symlinked carveouts and nested symlink escape
masking

## Root cause
Permission normalization canonicalized symlinked writable roots and cwd
to their real targets too early. That drifted policy checks away from
the logical paths the sandboxed process can actually address, while
bwrap still needed the real targets for mounts. The mismatch caused
shell and apply_patch failures on symlinked writable roots.

## Impact
Fixes #15781.

Also fixes #17079:
- #17079 is the protected symlinked carveout side: bwrap now binds the
real symlinked writable-root target and remaps carveouts before masking.

Related to #15157:
- #15157 is the broader permission-check side of this path-identity
problem. This PR addresses the shared logical-vs-canonical normalization
issue, but the reported Darwin prompt behavior should be validated
separately before auto-closing it.

This should also fix #14672, #14694, #14715, and #15725:
- #14672, #14694, and #14715 are the same Linux
symlinked-writable-root/bwrap family as #15781.
- #15725 is the protected symlinked workspace path variant; the PR
preserves the protected logical path in policy space while bwrap applies
read-only or unreadable treatment to the resolved target so
file-vs-directory bind mismatches do not abort sandbox setup.

## Notes
- Added Linux-only regressions for symlinked writable ancestors and
protected symlinked directory targets, including nested symlink escape
masking without rebinding the escape target writable.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oaiandCodex authored and GitHub committed 2026-04-10 17:00:58 -07:00
1 parent 0a99943a94
commit b114781495
11 files changed
+503 -111

No files matched your search

+30 -60
View File
@@ -5,6 +5,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -471,6 +472,9 @@ impl FileSystemSandboxPolicy {
// so root-wide aliases do not create duplicate top-level masks.
// Example: keep `/var/...` normalized under `/` instead of
// materializing both `/var/...` and `/private/var/...`.
// Nested symlink paths under a writable root stay logical so
// downstream sandboxes can still bind the real target while
// masking the user-visible symlink inode when needed.
let preserve_raw_carveout_paths = root.as_path().parent().is_some();
let raw_writable_roots: Vec<&AbsolutePathBuf> = writable_entries
.iter()
@@ -1081,14 +1085,17 @@ fn dedup_absolute_paths(
fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
let raw_path = path.to_path_buf();
for ancestor in raw_path.ancestors() {
let Ok(canonical_ancestor) = ancestor.canonicalize() else {
if std::fs::symlink_metadata(ancestor).is_err() {
continue;
}
let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else {
continue;
};
let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
continue;
};
if let Ok(normalized_path) =
AbsolutePathBuf::from_absolute_path(canonical_ancestor.join(suffix))
AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix))
{
return normalized_path;
}
@@ -1422,7 +1429,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn effective_runtime_roots_canonicalize_symlinked_paths() {
fn effective_runtime_roots_preserve_symlinked_paths() {
let cwd = TempDir::new().expect("tempdir");
let real_root = cwd.path().join("real");
let link_root = cwd.path().join("link");
@@ -1436,18 +1443,9 @@ mod tests {
let link_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_blocked = link_root.join("blocked");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_root.canonicalize().expect("canonicalize real root"),
)
.expect("absolute canonical root");
let expected_blocked = AbsolutePathBuf::from_absolute_path(
blocked.canonicalize().expect("canonicalize blocked"),
)
.expect("absolute canonical blocked");
let expected_codex = AbsolutePathBuf::from_absolute_path(
codex_dir.canonicalize().expect("canonicalize .codex"),
)
.expect("absolute canonical .codex");
let expected_root = link_root.clone();
let expected_blocked = link_blocked.clone();
let expected_codex = link_root.join(".codex");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
@@ -1482,7 +1480,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn current_working_directory_special_path_canonicalizes_symlinked_cwd() {
fn current_working_directory_special_path_preserves_symlinked_cwd() {
let cwd = TempDir::new().expect("tempdir");
let real_root = cwd.path().join("real");
let link_root = cwd.path().join("link");
@@ -1497,22 +1495,11 @@ mod tests {
let link_blocked =
AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_root.canonicalize().expect("canonicalize real root"),
)
.expect("absolute canonical root");
let expected_blocked = AbsolutePathBuf::from_absolute_path(
blocked.canonicalize().expect("canonicalize blocked"),
)
.expect("absolute canonical blocked");
let expected_agents = AbsolutePathBuf::from_absolute_path(
agents_dir.canonicalize().expect("canonicalize .agents"),
)
.expect("absolute canonical .agents");
let expected_codex = AbsolutePathBuf::from_absolute_path(
codex_dir.canonicalize().expect("canonicalize .codex"),
)
.expect("absolute canonical .codex");
let expected_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let expected_blocked = link_blocked.clone();
let expected_agents = expected_root.join(".agents");
let expected_codex = expected_root.join(".codex");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
@@ -1617,11 +1604,8 @@ mod tests {
let link_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_private = link_root.join("linked-private");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_root.canonicalize().expect("canonicalize real root"),
)
.expect("absolute canonical root");
let expected_linked_private = expected_root.join("linked-private");
let expected_root = link_root.clone();
let expected_linked_private = link_private.clone();
let unexpected_decoy =
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
.expect("absolute canonical decoy");
@@ -1667,11 +1651,8 @@ mod tests {
let link_root =
AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root");
let link_private = link_root.join("linked-private");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_root.canonicalize().expect("canonicalize real root"),
)
.expect("absolute canonical root");
let expected_linked_private = expected_root.join("linked-private");
let expected_root = link_root.clone();
let expected_linked_private = link_private.clone();
let unexpected_decoy =
AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy"))
.expect("absolute canonical decoy");
@@ -1737,12 +1718,12 @@ mod tests {
#[cfg(unix)]
#[test]
fn tmpdir_special_path_canonicalizes_symlinked_tmpdir() {
fn tmpdir_special_path_preserves_symlinked_tmpdir() {
if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() {
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
.env(SYMLINKED_TMPDIR_TEST_ENV, "1")
.arg("--exact")
.arg("permissions::tests::tmpdir_special_path_canonicalizes_symlinked_tmpdir")
.arg("permissions::tests::tmpdir_special_path_preserves_symlinked_tmpdir")
.output()
.expect("run tmpdir subprocess test");
@@ -1767,20 +1748,10 @@ mod tests {
let link_blocked =
AbsolutePathBuf::from_absolute_path(link_tmpdir.join("blocked")).expect("link blocked");
let expected_root = AbsolutePathBuf::from_absolute_path(
real_tmpdir
.canonicalize()
.expect("canonicalize real tmpdir"),
)
.expect("absolute canonical tmpdir");
let expected_blocked = AbsolutePathBuf::from_absolute_path(
blocked.canonicalize().expect("canonicalize blocked"),
)
.expect("absolute canonical blocked");
let expected_codex = AbsolutePathBuf::from_absolute_path(
codex_dir.canonicalize().expect("canonicalize .codex"),
)
.expect("absolute canonical .codex");
let expected_root =
AbsolutePathBuf::from_absolute_path(&link_tmpdir).expect("absolute symlinked tmpdir");
let expected_blocked = link_blocked.clone();
let expected_codex = expected_root.join(".codex");
unsafe {
std::env::set_var("TMPDIR", &link_tmpdir);
@@ -1932,8 +1903,7 @@ mod tests {
let cwd = TempDir::new().expect("tempdir");
let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
let expected_docs = AbsolutePathBuf::from_absolute_path(
cwd.path()
.canonicalize()
canonicalize_preserving_symlinks(cwd.path())
.expect("canonicalize cwd")
.join("docs"),
)