mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
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:
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user