fix(protocol): preserve legacy workspace-write semantics (#13957)

## Summary
This is a fast follow to the initial `[permissions]` structure.

- keep the new split-policy carveout behavior for narrower non-write
entries under broader writable roots
- preserve legacy `WorkspaceWrite` semantics by using a cwd-aware bridge
that drops only redundant nested readable roots when projecting from
`SandboxPolicy`
- route the legacy macOS seatbelt adapter through that same legacy
bridge so redundant nested readable roots do not become read-only
carveouts on macOS
- derive the legacy bridge for `command_exec` using the sandbox root cwd
rather than the request cwd so policy derivation matches later sandbox
enforcement
- add regression coverage for the legacy macOS nested-readable-root case

## Examples
### Legacy `workspace-write` on macOS
A legacy `workspace-write` policy can redundantly list a nested readable
root under an already-writable workspace root.

For example, legacy config can effectively mean:
- workspace root (`.` / `cwd`) is writable
- `docs/` is also listed in `readable_roots`

The new shared split-policy helper intentionally treats a narrower
non-write entry under a broader writable root as a carveout for real
`[permissions]` configs. Without this fast follow, the unchanged macOS
seatbelt legacy adapter could project that legacy shape into a
`FileSystemSandboxPolicy` that treated `docs/` like a read-only carveout
under the writable workspace root. In practice, legacy callers on macOS
could unexpectedly lose write access inside `docs/`, even though that
path was writable before the `[permissions]` migration work.

This change fixes that by routing the legacy seatbelt path through the
cwd-aware legacy bridge, so:
- legacy `workspace-write` keeps `docs/` writable when `docs/` was only
a redundant readable root
- explicit `[permissions]` entries like `'.' = 'write'` and `'docs' =
'read'` still make `docs/` read-only, which is the new intended
split-policy behavior

### Legacy `command_exec` with a subdirectory cwd
`command_exec` can run a command from a request cwd that is narrower
than the sandbox root cwd.

For example:
- sandbox root cwd is `/repo`
- request cwd is `/repo/subdir`
- legacy policy is still `workspace-write` rooted at `/repo`

Before this fast follow, `command_exec` derived the legacy bridge using
the request cwd, but the sandbox was later built using the sandbox root
cwd. That mismatch could miss redundant legacy readable roots during
projection and accidentally reintroduce read-only carveouts for paths
that should still be writable under the legacy model.

This change fixes that by deriving the legacy bridge with the same
sandbox root cwd that sandbox enforcement later uses.

## Verification
- `just fmt`
- `cargo test -p codex-core
seatbelt_legacy_workspace_write_nested_readable_root_stays_writable`
- `cargo test -p codex-core test_sandbox_config_parsing`
- `cargo clippy -p codex-core -p codex-app-server --all-targets -- -D
warnings`
- `cargo clean`
This commit is contained in:
viyatb-oai
2026-03-09 18:43:27 -07:00
committed by GitHub
parent 6da84efed8
commit b0cbc25a48
10 changed files with 396 additions and 30 deletions

View File

@@ -3217,12 +3217,6 @@ mod tests {
use tempfile::NamedTempFile;
use tempfile::TempDir;
fn sorted_paths(paths: Vec<AbsolutePathBuf>) -> Vec<PathBuf> {
let mut sorted: Vec<PathBuf> = paths.into_iter().map(|path| path.to_path_buf()).collect();
sorted.sort();
sorted
}
fn sorted_writable_roots(roots: Vec<WritableRoot>) -> Vec<(PathBuf, Vec<PathBuf>)> {
let mut sorted_roots: Vec<(PathBuf, Vec<PathBuf>)> = roots
.into_iter()
@@ -3240,6 +3234,53 @@ mod tests {
sorted_roots
}
fn sandbox_policy_allows_read(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool {
if policy.has_full_disk_read_access() {
return true;
}
policy
.get_readable_roots_with_cwd(cwd)
.iter()
.any(|root| path.starts_with(root.as_path()))
|| policy
.get_writable_roots_with_cwd(cwd)
.iter()
.any(|root| path.starts_with(root.root.as_path()))
}
fn sandbox_policy_allows_write(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool {
if policy.has_full_disk_write_access() {
return true;
}
policy
.get_writable_roots_with_cwd(cwd)
.iter()
.any(|root| root.is_path_writable(path))
}
fn sandbox_policy_probe_paths(policy: &SandboxPolicy, cwd: &Path) -> Vec<PathBuf> {
let mut paths = vec![cwd.to_path_buf()];
paths.extend(
policy
.get_readable_roots_with_cwd(cwd)
.into_iter()
.map(|path| path.to_path_buf()),
);
for root in policy.get_writable_roots_with_cwd(cwd) {
paths.push(root.root.to_path_buf());
paths.extend(
root.read_only_subpaths
.into_iter()
.map(|path| path.to_path_buf()),
);
}
paths.sort();
paths.dedup();
paths
}
fn assert_same_sandbox_policy_semantics(
expected: &SandboxPolicy,
actual: &SandboxPolicy,
@@ -3261,14 +3302,25 @@ mod tests {
actual.include_platform_defaults(),
expected.include_platform_defaults()
);
assert_eq!(
sorted_paths(actual.get_readable_roots_with_cwd(cwd)),
sorted_paths(expected.get_readable_roots_with_cwd(cwd))
);
assert_eq!(
sorted_writable_roots(actual.get_writable_roots_with_cwd(cwd)),
sorted_writable_roots(expected.get_writable_roots_with_cwd(cwd))
);
let mut probe_paths = sandbox_policy_probe_paths(expected, cwd);
probe_paths.extend(sandbox_policy_probe_paths(actual, cwd));
probe_paths.sort();
probe_paths.dedup();
for path in probe_paths {
assert_eq!(
sandbox_policy_allows_read(actual, &path, cwd),
sandbox_policy_allows_read(expected, &path, cwd),
"read access mismatch for {}",
path.display()
);
assert_eq!(
sandbox_policy_allows_write(actual, &path, cwd),
sandbox_policy_allows_write(expected, &path, cwd),
"write access mismatch for {}",
path.display()
);
}
}
#[test]
@@ -3515,6 +3567,67 @@ mod tests {
);
}
#[test]
fn restricted_file_system_policy_treats_read_entries_as_read_only_subpaths() {
let cwd = TempDir::new().expect("tempdir");
let docs =
AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
let docs_public = AbsolutePathBuf::resolve_path_against_base("docs/public", cwd.path())
.expect("resolve docs/public");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: docs.clone() },
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: docs_public.clone(),
},
access: FileSystemAccessMode::Write,
},
]);
assert!(!policy.has_full_disk_write_access());
assert_eq!(
sorted_writable_roots(policy.get_writable_roots_with_cwd(cwd.path())),
vec![
(cwd.path().to_path_buf(), vec![docs.to_path_buf()]),
(docs_public.to_path_buf(), Vec::new()),
]
);
}
#[test]
fn legacy_workspace_write_nested_readable_root_stays_writable() {
let cwd = TempDir::new().expect("tempdir");
let docs =
AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![docs],
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
assert_eq!(
sorted_writable_roots(
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, cwd.path())
.get_writable_roots_with_cwd(cwd.path())
),
vec![(cwd.path().to_path_buf(), Vec::new())]
);
}
#[test]
fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() {
let cwd = if cfg!(windows) {
@@ -3552,6 +3665,8 @@ mod tests {
.expect("resolve readable root");
let writable_root = AbsolutePathBuf::resolve_path_against_base("writable", cwd.path())
.expect("resolve writable root");
let nested_readable_root = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path())
.expect("resolve nested readable root");
let policies = [
SandboxPolicy::DangerFullAccess,
SandboxPolicy::ExternalSandbox {
@@ -3588,10 +3703,20 @@ mod tests {
exclude_tmpdir_env_var: false,
exclude_slash_tmp: true,
},
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![nested_readable_root],
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
];
for expected in policies {
let actual = FileSystemSandboxPolicy::from(&expected)
let actual = FileSystemSandboxPolicy::from_legacy_sandbox_policy(&expected, cwd.path())
.to_legacy_sandbox_policy(NetworkSandboxPolicy::from(&expected), cwd.path())
.expect("legacy bridge should preserve legacy policy semantics");