From a823efca0492ddd80215bb0e625843f54ae87146 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 9 Mar 2026 21:28:54 -0700 Subject: [PATCH 1/4] fix: preserve split filesystem semantics in linux sandbox --- codex-rs/linux-sandbox/README.md | 35 ++- codex-rs/linux-sandbox/src/bwrap.rs | 266 +++++++++++++----- codex-rs/linux-sandbox/src/linux_run_main.rs | 28 ++ .../linux-sandbox/src/linux_run_main_tests.rs | 70 +++++ 4 files changed, 311 insertions(+), 88 deletions(-) diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 32d8d99f01b..354d08a0b27 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -11,30 +11,39 @@ On Linux, the bubblewrap pipeline uses the vendored bubblewrap path compiled into this binary. **Current Behavior** -- Legacy Landlock + mount protections remain available as the legacy pipeline. -- The bubblewrap pipeline is standardized on the vendored path. -- During rollout, the bubblewrap pipeline is gated by the temporary feature - flag `use_linux_sandbox_bwrap` (CLI `-c` alias for - `features.use_linux_sandbox_bwrap`; legacy remains default when off). -- When enabled, the bubblewrap pipeline applies `PR_SET_NO_NEW_PRIVS` and a +- Bubblewrap is the default filesystem sandbox pipeline and is standardized on + the vendored path. +- Legacy Landlock + mount protections remain available only as an explicit + fallback path. +- Split-only filesystem policies that do not round-trip through the legacy + `SandboxPolicy` model are routed through bubblewrap automatically so nested + read-only or denied carveouts are preserved. +- When bubblewrap is active, the helper applies `PR_SET_NO_NEW_PRIVS` and a seccomp network filter in-process. -- When enabled, the filesystem is read-only by default via `--ro-bind / /`. -- When enabled, writable roots are layered with `--bind `. -- When enabled, protected subpaths under writable roots (for example `.git`, +- When bubblewrap is active, the filesystem is read-only by default via + `--ro-bind / /`. +- When bubblewrap is active, writable roots are layered with `--bind + `. +- When bubblewrap is active, protected subpaths under writable roots (for + example `.git`, resolved `gitdir:`, and `.codex`) are re-applied as read-only via `--ro-bind`. -- When enabled, symlink-in-path and non-existent protected paths inside +- When bubblewrap is active, overlapping split-policy entries are applied in + path-specificity order so narrower writable children can reopen broader + read-only parents while narrower denied subpaths still win. +- When bubblewrap is active, symlink-in-path and non-existent protected paths inside writable roots are blocked by mounting `/dev/null` on the symlink or first missing component. -- When enabled, the helper explicitly isolates the user namespace via +- When bubblewrap is active, the helper explicitly isolates the user namespace via `--unshare-user` and the PID namespace via `--unshare-pid`. -- When enabled and network is restricted without proxy routing, the helper also +- When bubblewrap is active and network is restricted without proxy routing, + the helper also isolates the network namespace via `--unshare-net`. - In managed proxy mode, the helper uses `--unshare-net` plus an internal TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy endpoints. - In managed proxy mode, after the bridge is live, seccomp blocks new AF_UNIX/socketpair creation for the user command. -- When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but +- When bubblewrap is active, it mounts a fresh `/proc` via `--proc /proc` by default, but you can skip this in restrictive container environments with `--no-proc`. **Notes** diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 837d3873fd3..c4480809082 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -10,6 +10,7 @@ //! - seccomp + `PR_SET_NO_NEW_PRIVS` applied in-process, and //! - bubblewrap used to construct the filesystem view before exec. use std::collections::BTreeSet; +use std::collections::HashSet; use std::fs::File; use std::os::fd::AsRawFd; use std::path::Path; @@ -258,81 +259,68 @@ fn create_filesystem_args( args }; let mut preserved_files = Vec::new(); - - for writable_root in &writable_roots { - let root = writable_root.root.as_path(); - args.push("--bind".to_string()); - args.push(path_to_string(root)); - args.push(path_to_string(root)); - } - - // Re-apply read-only subpaths after the writable binds so they win. let allowed_write_paths: Vec = writable_roots .iter() .map(|writable_root| writable_root.root.as_path().to_path_buf()) .collect(); - for subpath in collect_read_only_subpaths(&writable_roots) { - if let Some(symlink_path) = find_symlink_in_path(&subpath, &allowed_write_paths) { - args.push("--ro-bind".to_string()); - args.push("/dev/null".to_string()); - args.push(path_to_string(&symlink_path)); - continue; - } + let unreadable_paths: HashSet = unreadable_roots + .iter() + .map(|path| path.as_path().to_path_buf()) + .collect(); + let mut sorted_writable_roots = writable_roots; + sorted_writable_roots.sort_by_key(|writable_root| path_depth(writable_root.root.as_path())); - if !subpath.exists() { - // Keep this in the per-subpath loop: each protected subpath can have - // a different first missing component that must be blocked - // independently (for example, `/repo/.git` vs `/repo/.codex`). - if let Some(first_missing_component) = find_first_non_existent_component(&subpath) - && is_within_allowed_write_paths(&first_missing_component, &allowed_write_paths) - { - args.push("--ro-bind".to_string()); - args.push("/dev/null".to_string()); - args.push(path_to_string(&first_missing_component)); - } - continue; + for writable_root in &sorted_writable_roots { + let root = writable_root.root.as_path(); + args.push("--bind".to_string()); + args.push(path_to_string(root)); + args.push(path_to_string(root)); + + let mut read_only_subpaths: Vec = writable_root + .read_only_subpaths + .iter() + .map(|path| path.as_path().to_path_buf()) + .filter(|path| !unreadable_paths.contains(path)) + .collect(); + read_only_subpaths.sort_by_key(|path| path_depth(path)); + for subpath in read_only_subpaths { + append_read_only_subpath_args(&mut args, &subpath, &allowed_write_paths); } - if is_within_allowed_write_paths(&subpath, &allowed_write_paths) { - args.push("--ro-bind".to_string()); - args.push(path_to_string(&subpath)); - args.push(path_to_string(&subpath)); + let mut nested_unreadable_roots: Vec = unreadable_roots + .iter() + .filter(|path| path.as_path().starts_with(root)) + .map(|path| path.as_path().to_path_buf()) + .collect(); + nested_unreadable_roots.sort_by_key(|path| path_depth(path)); + for unreadable_root in nested_unreadable_roots { + append_unreadable_root_args( + &mut args, + &mut preserved_files, + &unreadable_root, + &allowed_write_paths, + )?; } } - if !unreadable_roots.is_empty() { - // Apply explicit deny carveouts after all readable and writable mounts - // so they win even when the broader baseline includes `/` or a writable - // parent path. - let null_file = File::open("/dev/null")?; - let null_fd = null_file.as_raw_fd().to_string(); - for unreadable_root in unreadable_roots { - let unreadable_root = unreadable_root.as_path(); - if unreadable_root.is_dir() { - // Bubblewrap cannot bind `/dev/null` over a directory, so mask - // denied directories by overmounting them with an empty tmpfs - // and then remounting that tmpfs read-only. - args.push("--perms".to_string()); - args.push("000".to_string()); - args.push("--tmpfs".to_string()); - args.push(path_to_string(unreadable_root)); - args.push("--remount-ro".to_string()); - args.push(path_to_string(unreadable_root)); - continue; - } - - // For files, bind a stable null-file payload over the original path - // so later reads do not expose host contents. `--ro-bind-data` - // expects a live fd number, so keep the backing file open until we - // exec bubblewrap below. - args.push("--perms".to_string()); - args.push("000".to_string()); - args.push("--ro-bind-data".to_string()); - args.push(null_fd.clone()); - args.push(path_to_string(unreadable_root)); - } - preserved_files.push(null_file); + let mut rootless_unreadable_roots: Vec = unreadable_roots + .iter() + .filter(|path| { + !allowed_write_paths + .iter() + .any(|root| path.as_path().starts_with(root)) + }) + .map(|path| path.as_path().to_path_buf()) + .collect(); + rootless_unreadable_roots.sort_by_key(|path| path_depth(path)); + for unreadable_root in rootless_unreadable_roots { + append_unreadable_root_args( + &mut args, + &mut preserved_files, + &unreadable_root, + &allowed_write_paths, + )?; } Ok(BwrapArgs { @@ -341,17 +329,6 @@ fn create_filesystem_args( }) } -/// Collect unique read-only subpaths across all writable roots. -fn collect_read_only_subpaths(writable_roots: &[WritableRoot]) -> Vec { - let mut subpaths: BTreeSet = BTreeSet::new(); - for writable_root in writable_roots { - for subpath in &writable_root.read_only_subpaths { - subpaths.insert(subpath.as_path().to_path_buf()); - } - } - subpaths.into_iter().collect() -} - /// Validate that writable roots exist before constructing mounts. /// /// Bubblewrap requires bind mount targets to exist. We fail fast with a clear @@ -373,6 +350,91 @@ fn path_to_string(path: &Path) -> String { path.to_string_lossy().to_string() } +fn path_depth(path: &Path) -> usize { + path.components().count() +} + +fn append_read_only_subpath_args( + args: &mut Vec, + subpath: &Path, + allowed_write_paths: &[PathBuf], +) { + if let Some(symlink_path) = find_symlink_in_path(subpath, allowed_write_paths) { + args.push("--ro-bind".to_string()); + args.push("/dev/null".to_string()); + args.push(path_to_string(&symlink_path)); + return; + } + + if !subpath.exists() { + if let Some(first_missing_component) = find_first_non_existent_component(subpath) + && is_within_allowed_write_paths(&first_missing_component, allowed_write_paths) + { + args.push("--ro-bind".to_string()); + args.push("/dev/null".to_string()); + args.push(path_to_string(&first_missing_component)); + } + return; + } + + if is_within_allowed_write_paths(subpath, allowed_write_paths) { + args.push("--ro-bind".to_string()); + args.push(path_to_string(subpath)); + args.push(path_to_string(subpath)); + } +} + +fn append_unreadable_root_args( + args: &mut Vec, + preserved_files: &mut Vec, + unreadable_root: &Path, + allowed_write_paths: &[PathBuf], +) -> Result<()> { + if let Some(symlink_path) = find_symlink_in_path(unreadable_root, allowed_write_paths) { + args.push("--ro-bind".to_string()); + args.push("/dev/null".to_string()); + args.push(path_to_string(&symlink_path)); + return Ok(()); + } + + if !unreadable_root.exists() { + if let Some(first_missing_component) = find_first_non_existent_component(unreadable_root) + && is_within_allowed_write_paths(&first_missing_component, allowed_write_paths) + { + args.push("--ro-bind".to_string()); + args.push("/dev/null".to_string()); + args.push(path_to_string(&first_missing_component)); + } + return Ok(()); + } + + if unreadable_root.is_dir() { + args.push("--perms".to_string()); + args.push("000".to_string()); + args.push("--tmpfs".to_string()); + args.push(path_to_string(unreadable_root)); + args.push("--remount-ro".to_string()); + args.push(path_to_string(unreadable_root)); + return Ok(()); + } + + let null_file = if let Some(file) = preserved_files.first() { + file + } else { + preserved_files.push(File::open("/dev/null")?); + preserved_files + .first() + .expect("preserved_files must contain /dev/null") + }; + let null_fd = null_file.as_raw_fd().to_string(); + args.push("--perms".to_string()); + args.push("000".to_string()); + args.push("--ro-bind-data".to_string()); + args.push(null_fd); + args.push(path_to_string(unreadable_root)); + Ok(()) +} + /// Returns true when `path` is under any allowed writable root. fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) -> bool { allowed_write_paths @@ -654,6 +716,60 @@ mod tests { ); } + #[test] + fn split_policy_reenables_nested_writable_subpaths_after_read_only_parent() { + let temp_dir = TempDir::new().expect("temp dir"); + let writable_root = temp_dir.path().join("workspace"); + let docs = writable_root.join("docs"); + let docs_public = docs.join("public"); + std::fs::create_dir_all(&docs_public).expect("create docs/public"); + let writable_root = + AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root"); + let docs = AbsolutePathBuf::from_absolute_path(&docs).expect("absolute docs"); + let docs_public = + AbsolutePathBuf::from_absolute_path(&docs_public).expect("absolute docs/public"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: writable_root.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs.clone() }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: docs_public.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + let docs_str = path_to_string(docs.as_path()); + let docs_public_str = path_to_string(docs_public.as_path()); + let docs_ro_index = args + .args + .windows(3) + .position(|window| window == ["--ro-bind", docs_str.as_str(), docs_str.as_str()]) + .expect("docs should be remounted read-only"); + let docs_public_rw_index = args + .args + .windows(3) + .position(|window| { + window == ["--bind", docs_public_str.as_str(), docs_public_str.as_str()] + }) + .expect("docs/public should be rebound writable"); + + assert!( + docs_ro_index < docs_public_rw_index, + "expected read-only parent remount before nested writable bind: {:#?}", + args.args + ); + } + #[test] fn split_policy_masks_root_read_directory_carveouts() { let temp_dir = TempDir::new().expect("temp dir"); diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index e6304d2d601..90de46b0a2a 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -114,6 +114,12 @@ pub fn run_main() -> ! { file_system_sandbox_policy, network_sandbox_policy, ); + let use_bwrap_sandbox = should_use_bwrap_sandbox( + use_bwrap_sandbox, + &file_system_sandbox_policy, + network_sandbox_policy, + &sandbox_policy_cwd, + ); // Inner stage: apply seccomp/no_new_privs after bubblewrap has already // established the filesystem view. @@ -226,6 +232,17 @@ fn resolve_sandbox_policies( match (sandbox_policy, split_policies) { (Some(sandbox_policy), Some((file_system_sandbox_policy, network_sandbox_policy))) => { + let derived_legacy_policy = file_system_sandbox_policy + .to_legacy_sandbox_policy(network_sandbox_policy, sandbox_policy_cwd) + .unwrap_or_else(|err| { + panic!( + "split sandbox policies require direct runtime enforcement and cannot be paired with legacy sandbox policy: {err}" + ) + }); + assert_eq!( + derived_legacy_policy, sandbox_policy, + "legacy sandbox policy must match split sandbox policies" + ); EffectiveSandboxPolicies { sandbox_policy, file_system_sandbox_policy, @@ -262,6 +279,17 @@ fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_san } } +fn should_use_bwrap_sandbox( + use_bwrap_sandbox: bool, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, +) -> bool { + use_bwrap_sandbox + || file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd) +} + fn run_bwrap_with_proc_fallback( sandbox_policy_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index a1adf65b1d9..ab73cd29c45 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -7,6 +7,8 @@ use codex_protocol::protocol::NetworkSandboxPolicy; #[cfg(test)] use codex_protocol::protocol::SandboxPolicy; #[cfg(test)] +use codex_utils_absolute_path::AbsolutePathBuf; +#[cfg(test)] use pretty_assertions::assert_eq; #[test] @@ -107,6 +109,60 @@ fn proxy_only_mode_takes_precedence_over_full_network_policy() { assert_eq!(mode, BwrapNetworkMode::ProxyOnly); } +#[test] +fn split_only_filesystem_policy_forces_bwrap_usage() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let docs = AbsolutePathBuf::from_absolute_path(&docs).expect("absolute docs"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: docs }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert!(should_use_bwrap_sandbox( + false, + &policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + )); +} + +#[test] +fn root_write_read_only_carveout_forces_bwrap_usage() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let docs = AbsolutePathBuf::from_absolute_path(&docs).expect("absolute docs"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: docs }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert!(should_use_bwrap_sandbox( + false, + &policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + )); +} + #[test] fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() { let mode = bwrap_network_mode(NetworkSandboxPolicy::Enabled, true); @@ -243,6 +299,20 @@ fn resolve_sandbox_policies_rejects_partial_split_policies() { assert!(result.is_err()); } +#[test] +fn resolve_sandbox_policies_rejects_mismatched_legacy_and_split_inputs() { + let result = std::panic::catch_unwind(|| { + resolve_sandbox_policies( + Path::new("/tmp"), + Some(SandboxPolicy::new_read_only_policy()), + Some(FileSystemSandboxPolicy::unrestricted()), + Some(NetworkSandboxPolicy::Enabled), + ) + }); + + assert!(result.is_err()); +} + #[test] fn apply_seccomp_then_exec_without_bwrap_panics() { let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false)); From b3a0fd3bda660c89b3ff1fa70f6659a7595eba21 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 11 Mar 2026 18:30:59 -0700 Subject: [PATCH 2/4] fix: preserve nested linux split carveouts --- codex-rs/linux-sandbox/src/bwrap.rs | 110 +++++++++++++++++- codex-rs/linux-sandbox/src/linux_run_main.rs | 88 ++++++++++---- .../linux-sandbox/src/linux_run_main_tests.rs | 47 ++++---- 3 files changed, 199 insertions(+), 46 deletions(-) diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index c4480809082..455df01aebf 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -183,12 +183,14 @@ fn create_bwrap_flags( /// `--tmpfs /` and layer scoped `--ro-bind` mounts. /// 2. `--dev /dev` mounts a minimal writable `/dev` with standard device nodes /// (including `/dev/urandom`) even under a read-only root. -/// 3. `--bind ` re-enables writes for allowed roots, including +/// 3. Unreadable ancestors of writable roots are masked first so narrower +/// writable descendants can be rebound afterward. +/// 4. `--bind ` re-enables writes for allowed roots, including /// writable subpaths under `/dev` (for example, `/dev/shm`). -/// 4. `--ro-bind ` re-applies read-only protections under +/// 5. `--ro-bind ` re-applies read-only protections under /// those writable roots so protected subpaths win. -/// 5. Explicit unreadable roots are masked last so deny carveouts still win -/// even when the readable baseline includes `/`. +/// 6. Remaining explicit unreadable roots are masked last so deny carveouts +/// still win even when the readable baseline includes `/`. fn create_filesystem_args( file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path, @@ -270,9 +272,38 @@ fn create_filesystem_args( .collect(); let mut sorted_writable_roots = writable_roots; sorted_writable_roots.sort_by_key(|writable_root| path_depth(writable_root.root.as_path())); + let mut unreadable_ancestors_of_writable_roots: Vec = unreadable_roots + .iter() + .filter(|path| { + let unreadable_root = path.as_path(); + !allowed_write_paths + .iter() + .any(|root| unreadable_root.starts_with(root)) + && allowed_write_paths + .iter() + .any(|root| root.starts_with(unreadable_root)) + }) + .map(|path| path.as_path().to_path_buf()) + .collect(); + unreadable_ancestors_of_writable_roots.sort_by_key(|path| path_depth(path)); + for unreadable_root in &unreadable_ancestors_of_writable_roots { + append_unreadable_root_args( + &mut args, + &mut preserved_files, + unreadable_root, + &allowed_write_paths, + )?; + } for writable_root in &sorted_writable_roots { let root = writable_root.root.as_path(); + if let Some(masking_root) = unreadable_ancestors_of_writable_roots + .iter() + .filter(|unreadable_root| root.starts_with(unreadable_root)) + .max_by_key(|unreadable_root| path_depth(unreadable_root)) + { + append_dir_mount_target_args(&mut args, root, masking_root); + } args.push("--bind".to_string()); args.push(path_to_string(root)); args.push(path_to_string(root)); @@ -307,9 +338,10 @@ fn create_filesystem_args( let mut rootless_unreadable_roots: Vec = unreadable_roots .iter() .filter(|path| { + let unreadable_root = path.as_path(); !allowed_write_paths .iter() - .any(|root| path.as_path().starts_with(root)) + .any(|root| unreadable_root.starts_with(root) || root.starts_with(unreadable_root)) }) .map(|path| path.as_path().to_path_buf()) .collect(); @@ -354,6 +386,19 @@ fn path_depth(path: &Path) -> usize { path.components().count() } +fn append_dir_mount_target_args(args: &mut Vec, mount_target: &Path, anchor: &Path) { + let mut mount_target_dirs: Vec = mount_target + .ancestors() + .take_while(|path| *path != anchor) + .map(Path::to_path_buf) + .collect(); + mount_target_dirs.reverse(); + for mount_target_dir in mount_target_dirs { + args.push("--dir".to_string()); + args.push(path_to_string(&mount_target_dir)); + } +} + fn append_read_only_subpath_args( args: &mut Vec, subpath: &Path, @@ -770,6 +815,61 @@ mod tests { ); } + #[test] + fn split_policy_reenables_writable_subpaths_after_unreadable_parent() { + let temp_dir = TempDir::new().expect("temp dir"); + let blocked = temp_dir.path().join("blocked"); + let allowed = blocked.join("allowed"); + std::fs::create_dir_all(&allowed).expect("create blocked/allowed"); + let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked"); + let allowed = AbsolutePathBuf::from_absolute_path(&allowed).expect("absolute allowed"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: blocked.clone(), + }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: allowed.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + let blocked_str = path_to_string(blocked.as_path()); + let allowed_str = path_to_string(allowed.as_path()); + let blocked_none_index = args + .args + .windows(4) + .position(|window| window == ["--perms", "000", "--tmpfs", blocked_str.as_str()]) + .expect("blocked should be masked first"); + let allowed_dir_index = args + .args + .windows(2) + .position(|window| window == ["--dir", allowed_str.as_str()]) + .expect("allowed mount target should be recreated"); + let allowed_bind_index = args + .args + .windows(3) + .position(|window| window == ["--bind", allowed_str.as_str(), allowed_str.as_str()]) + .expect("allowed path should be rebound writable"); + + assert!( + blocked_none_index < allowed_dir_index && allowed_dir_index < allowed_bind_index, + "expected unreadable parent mask before recreating and rebinding writable child: {:#?}", + args.args + ); + } + #[test] fn split_policy_masks_root_read_directory_carveouts() { let temp_dir = TempDir::new().expect("temp dir"); diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 90de46b0a2a..1b00636b51d 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -1,5 +1,6 @@ use clap::Parser; use std::ffi::CString; +use std::fmt; use std::fs::File; use std::io::Read; use std::os::fd::FromRawFd; @@ -113,7 +114,8 @@ pub fn run_main() -> ! { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - ); + ) + .unwrap_or_else(|err| panic!("{err}")); let use_bwrap_sandbox = should_use_bwrap_sandbox( use_bwrap_sandbox, &file_system_sandbox_policy, @@ -213,12 +215,56 @@ struct EffectiveSandboxPolicies { network_sandbox_policy: NetworkSandboxPolicy, } +#[derive(Debug, PartialEq, Eq)] +enum ResolveSandboxPoliciesError { + PartialSplitPolicies, + SplitPoliciesRequireDirectRuntimeEnforcement(String), + FailedToDeriveLegacyPolicy(String), + MismatchedLegacyPolicy { + provided: SandboxPolicy, + derived: SandboxPolicy, + }, + MissingConfiguration, +} + +impl fmt::Display for ResolveSandboxPoliciesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PartialSplitPolicies => { + write!( + f, + "file-system and network sandbox policies must be provided together" + ) + } + Self::SplitPoliciesRequireDirectRuntimeEnforcement(err) => { + write!( + f, + "split sandbox policies require direct runtime enforcement and cannot be paired with legacy sandbox policy: {err}" + ) + } + Self::FailedToDeriveLegacyPolicy(err) => { + write!( + f, + "failed to derive legacy sandbox policy from split policies: {err}" + ) + } + Self::MismatchedLegacyPolicy { provided, derived } => { + write!( + f, + "legacy sandbox policy must match split sandbox policies: provided={provided:?}, derived={derived:?}" + ) + } + Self::MissingConfiguration => write!(f, "missing sandbox policy configuration"), + } + } +} + fn resolve_sandbox_policies( sandbox_policy_cwd: &Path, sandbox_policy: Option, file_system_sandbox_policy: Option, network_sandbox_policy: Option, -) -> EffectiveSandboxPolicies { +) -> Result { // Accept either a fully legacy policy, a fully split policy pair, or all // three views together. Reject partial split-policy input so the helper // never runs with mismatched filesystem/network state. @@ -227,49 +273,51 @@ fn resolve_sandbox_policies( Some((file_system_sandbox_policy, network_sandbox_policy)) } (None, None) => None, - _ => panic!("file-system and network sandbox policies must be provided together"), + _ => return Err(ResolveSandboxPoliciesError::PartialSplitPolicies), }; match (sandbox_policy, split_policies) { (Some(sandbox_policy), Some((file_system_sandbox_policy, network_sandbox_policy))) => { let derived_legacy_policy = file_system_sandbox_policy .to_legacy_sandbox_policy(network_sandbox_policy, sandbox_policy_cwd) - .unwrap_or_else(|err| { - panic!( - "split sandbox policies require direct runtime enforcement and cannot be paired with legacy sandbox policy: {err}" + .map_err(|err| { + ResolveSandboxPoliciesError::SplitPoliciesRequireDirectRuntimeEnforcement( + err.to_string(), ) + })?; + if derived_legacy_policy != sandbox_policy { + return Err(ResolveSandboxPoliciesError::MismatchedLegacyPolicy { + provided: sandbox_policy, + derived: derived_legacy_policy, }); - assert_eq!( - derived_legacy_policy, sandbox_policy, - "legacy sandbox policy must match split sandbox policies" - ); - EffectiveSandboxPolicies { + } + Ok(EffectiveSandboxPolicies { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - } + }) } - (Some(sandbox_policy), None) => EffectiveSandboxPolicies { + (Some(sandbox_policy), None) => Ok(EffectiveSandboxPolicies { file_system_sandbox_policy: FileSystemSandboxPolicy::from_legacy_sandbox_policy( &sandbox_policy, sandbox_policy_cwd, ), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), sandbox_policy, - }, + }), (None, Some((file_system_sandbox_policy, network_sandbox_policy))) => { let sandbox_policy = file_system_sandbox_policy .to_legacy_sandbox_policy(network_sandbox_policy, sandbox_policy_cwd) - .unwrap_or_else(|err| { - panic!("failed to derive legacy sandbox policy from split policies: {err}") - }); - EffectiveSandboxPolicies { + .map_err(|err| { + ResolveSandboxPoliciesError::FailedToDeriveLegacyPolicy(err.to_string()) + })?; + Ok(EffectiveSandboxPolicies { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - } + }) } - (None, None) => panic!("missing sandbox policy configuration"), + (None, None) => Err(ResolveSandboxPoliciesError::MissingConfiguration), } } diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index ab73cd29c45..60220cad2d3 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -251,7 +251,8 @@ fn resolve_sandbox_policies_derives_split_policies_from_legacy_policy() { let sandbox_policy = SandboxPolicy::new_read_only_policy(); let resolved = - resolve_sandbox_policies(Path::new("/tmp"), Some(sandbox_policy.clone()), None, None); + resolve_sandbox_policies(Path::new("/tmp"), Some(sandbox_policy.clone()), None, None) + .expect("legacy policy should resolve"); assert_eq!(resolved.sandbox_policy, sandbox_policy); assert_eq!( @@ -275,7 +276,8 @@ fn resolve_sandbox_policies_derives_legacy_policy_from_split_policies() { None, Some(file_system_sandbox_policy.clone()), Some(network_sandbox_policy), - ); + ) + .expect("split policies should resolve"); assert_eq!(resolved.sandbox_policy, sandbox_policy); assert_eq!( @@ -287,30 +289,33 @@ fn resolve_sandbox_policies_derives_legacy_policy_from_split_policies() { #[test] fn resolve_sandbox_policies_rejects_partial_split_policies() { - let result = std::panic::catch_unwind(|| { - resolve_sandbox_policies( - Path::new("/tmp"), - Some(SandboxPolicy::new_read_only_policy()), - Some(FileSystemSandboxPolicy::default()), - None, - ) - }); + let err = resolve_sandbox_policies( + Path::new("/tmp"), + Some(SandboxPolicy::new_read_only_policy()), + Some(FileSystemSandboxPolicy::default()), + None, + ) + .expect_err("partial split policies should fail"); - assert!(result.is_err()); + assert_eq!(err, ResolveSandboxPoliciesError::PartialSplitPolicies); } #[test] fn resolve_sandbox_policies_rejects_mismatched_legacy_and_split_inputs() { - let result = std::panic::catch_unwind(|| { - resolve_sandbox_policies( - Path::new("/tmp"), - Some(SandboxPolicy::new_read_only_policy()), - Some(FileSystemSandboxPolicy::unrestricted()), - Some(NetworkSandboxPolicy::Enabled), - ) - }); - - assert!(result.is_err()); + let err = resolve_sandbox_policies( + Path::new("/tmp"), + Some(SandboxPolicy::new_read_only_policy()), + Some(FileSystemSandboxPolicy::unrestricted()), + Some(NetworkSandboxPolicy::Enabled), + ) + .expect_err("mismatched legacy and split policies should fail"); + assert!( + matches!( + err, + ResolveSandboxPoliciesError::MismatchedLegacyPolicy { .. } + ), + "{err}" + ); } #[test] From 781ba3c442d1921b24258fb9c0b84834a41dfb9a Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 11 Mar 2026 20:19:33 -0700 Subject: [PATCH 3/4] fix: preserve split-only linux sandbox fallbacks --- codex-rs/linux-sandbox/src/bwrap.rs | 92 ++++++++++++++++++- codex-rs/linux-sandbox/src/linux_run_main.rs | 9 ++ .../linux-sandbox/src/linux_run_main_tests.rs | 39 ++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 455df01aebf..f8692f90217 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -302,7 +302,7 @@ fn create_filesystem_args( .filter(|unreadable_root| root.starts_with(unreadable_root)) .max_by_key(|unreadable_root| path_depth(unreadable_root)) { - append_dir_mount_target_args(&mut args, root, masking_root); + append_mount_target_parent_dir_args(&mut args, root, masking_root); } args.push("--bind".to_string()); args.push(path_to_string(root)); @@ -386,8 +386,15 @@ fn path_depth(path: &Path) -> usize { path.components().count() } -fn append_dir_mount_target_args(args: &mut Vec, mount_target: &Path, anchor: &Path) { - let mut mount_target_dirs: Vec = mount_target +fn append_mount_target_parent_dir_args(args: &mut Vec, mount_target: &Path, anchor: &Path) { + let mount_target_dir = if mount_target.is_dir() { + mount_target + } else { + mount_target + .parent() + .expect("writable file targets under unreadable roots must have a parent") + }; + let mut mount_target_dirs: Vec = mount_target_dir .ancestors() .take_while(|path| *path != anchor) .map(Path::to_path_buf) @@ -870,6 +877,85 @@ mod tests { ); } + #[test] + fn split_policy_reenables_writable_files_after_unreadable_parent() { + let temp_dir = TempDir::new().expect("temp dir"); + let blocked = temp_dir.path().join("blocked"); + let allowed_dir = blocked.join("allowed"); + let allowed_file = allowed_dir.join("note.txt"); + std::fs::create_dir_all(&allowed_dir).expect("create blocked/allowed"); + std::fs::write(&allowed_file, "ok").expect("create note"); + let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked"); + let allowed_dir = + AbsolutePathBuf::from_absolute_path(&allowed_dir).expect("absolute allowed dir"); + let allowed_file = + AbsolutePathBuf::from_absolute_path(&allowed_file).expect("absolute allowed file"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: blocked.clone(), + }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: allowed_file.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + let blocked_str = path_to_string(blocked.as_path()); + let allowed_dir_str = path_to_string(allowed_dir.as_path()); + let allowed_file_str = path_to_string(allowed_file.as_path()); + + assert!( + args.args + .windows(2) + .any(|window| window == ["--dir", allowed_dir_str.as_str()]), + "expected ancestor directory to be recreated: {:#?}", + args.args + ); + assert!( + !args + .args + .windows(2) + .any(|window| window == ["--dir", allowed_file_str.as_str()]), + "writable file target should not be converted into a directory: {:#?}", + args.args + ); + let blocked_none_index = args + .args + .windows(4) + .position(|window| window == ["--perms", "000", "--tmpfs", blocked_str.as_str()]) + .expect("blocked should be masked first"); + let allowed_bind_index = args + .args + .windows(3) + .position(|window| { + window + == [ + "--bind", + allowed_file_str.as_str(), + allowed_file_str.as_str(), + ] + }) + .expect("allowed file should be rebound writable"); + + assert!( + blocked_none_index < allowed_bind_index, + "expected unreadable parent mask before rebinding writable file child: {:#?}", + args.args + ); + } + #[test] fn split_policy_masks_root_read_directory_carveouts() { let temp_dir = TempDir::new().expect("temp dir"); diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 1b00636b51d..ef3b7dad4a2 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -278,6 +278,15 @@ fn resolve_sandbox_policies( match (sandbox_policy, split_policies) { (Some(sandbox_policy), Some((file_system_sandbox_policy, network_sandbox_policy))) => { + if file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd) + { + return Ok(EffectiveSandboxPolicies { + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + }); + } let derived_legacy_policy = file_system_sandbox_policy .to_legacy_sandbox_policy(network_sandbox_policy, sandbox_policy_cwd) .map_err(|err| { diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 60220cad2d3..d9b27fc3e42 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -318,6 +318,45 @@ fn resolve_sandbox_policies_rejects_mismatched_legacy_and_split_inputs() { ); } +#[test] +fn resolve_sandbox_policies_accepts_split_policies_requiring_direct_runtime_enforcement() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let docs = AbsolutePathBuf::from_absolute_path(&docs).expect("absolute docs"); + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: docs }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + ]); + + let resolved = resolve_sandbox_policies( + temp_dir.path(), + Some(sandbox_policy.clone()), + Some(file_system_sandbox_policy.clone()), + Some(NetworkSandboxPolicy::Restricted), + ) + .expect("split-only policy should preserve provided legacy fallback"); + + assert_eq!(resolved.sandbox_policy, sandbox_policy); + assert_eq!( + resolved.file_system_sandbox_policy, + file_system_sandbox_policy + ); + assert_eq!( + resolved.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); +} + #[test] fn apply_seccomp_then_exec_without_bwrap_panics() { let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false)); From 8065d04c7a84fc47b240f648991e9c9277819c1e Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 11 Mar 2026 22:59:23 -0700 Subject: [PATCH 4/4] fix: preserve linux sandbox policy equivalence --- codex-rs/linux-sandbox/src/bwrap.rs | 55 ++++++++++++++----- codex-rs/linux-sandbox/src/linux_run_main.rs | 35 +++++++++++- .../linux-sandbox/src/linux_run_main_tests.rs | 37 +++++++++++++ 3 files changed, 111 insertions(+), 16 deletions(-) diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index f8692f90217..40c9d057042 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -390,9 +390,10 @@ fn append_mount_target_parent_dir_args(args: &mut Vec, mount_target: &Pa let mount_target_dir = if mount_target.is_dir() { mount_target } else { - mount_target - .parent() - .expect("writable file targets under unreadable roots must have a parent") + match mount_target.parent() { + Some(parent) => parent, + None => return, + } }; let mut mount_target_dirs: Vec = mount_target_dir .ancestors() @@ -470,15 +471,10 @@ fn append_unreadable_root_args( return Ok(()); } - let null_file = if let Some(file) = preserved_files.first() { - file - } else { + if preserved_files.is_empty() { preserved_files.push(File::open("/dev/null")?); - preserved_files - .first() - .expect("preserved_files must contain /dev/null") - }; - let null_fd = null_file.as_raw_fd().to_string(); + } + let null_fd = preserved_files[0].as_raw_fd().to_string(); args.push("--perms".to_string()); args.push("000".to_string()); args.push("--ro-bind-data".to_string()); @@ -761,10 +757,39 @@ mod tests { writable_root_str.as_str(), ] })); - assert!( - args.args.windows(3).any(|window| { - window == ["--ro-bind", blocked_str.as_str(), blocked_str.as_str()] + let blocked_mask_index = args + .args + .windows(6) + .position(|window| { + window + == [ + "--perms", + "000", + "--tmpfs", + blocked_str.as_str(), + "--remount-ro", + blocked_str.as_str(), + ] + }) + .expect("blocked directory should be remounted unreadable"); + + let writable_root_bind_index = args + .args + .windows(3) + .position(|window| { + window + == [ + "--bind", + writable_root_str.as_str(), + writable_root_str.as_str(), + ] }) + .expect("writable root should be rebound writable"); + + assert!( + writable_root_bind_index < blocked_mask_index, + "expected unreadable carveout to be re-applied after writable bind: {:#?}", + args.args ); } @@ -783,7 +808,7 @@ mod tests { let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { - path: writable_root.clone(), + path: writable_root, }, access: FileSystemAccessMode::Write, }, diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index ef3b7dad4a2..5f50ab9b8eb 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -294,7 +294,11 @@ fn resolve_sandbox_policies( err.to_string(), ) })?; - if derived_legacy_policy != sandbox_policy { + if !legacy_sandbox_policies_match_semantics( + &sandbox_policy, + &derived_legacy_policy, + sandbox_policy_cwd, + ) { return Err(ResolveSandboxPoliciesError::MismatchedLegacyPolicy { provided: sandbox_policy, derived: derived_legacy_policy, @@ -330,6 +334,35 @@ fn resolve_sandbox_policies( } } +fn legacy_sandbox_policies_match_semantics( + provided: &SandboxPolicy, + derived: &SandboxPolicy, + sandbox_policy_cwd: &Path, +) -> bool { + NetworkSandboxPolicy::from(provided) == NetworkSandboxPolicy::from(derived) + && file_system_sandbox_policies_match_semantics( + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(provided, sandbox_policy_cwd), + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(derived, sandbox_policy_cwd), + sandbox_policy_cwd, + ) +} + +fn file_system_sandbox_policies_match_semantics( + provided: &FileSystemSandboxPolicy, + derived: &FileSystemSandboxPolicy, + sandbox_policy_cwd: &Path, +) -> bool { + provided.has_full_disk_read_access() == derived.has_full_disk_read_access() + && provided.has_full_disk_write_access() == derived.has_full_disk_write_access() + && provided.include_platform_defaults() == derived.include_platform_defaults() + && provided.get_readable_roots_with_cwd(sandbox_policy_cwd) + == derived.get_readable_roots_with_cwd(sandbox_policy_cwd) + && provided.get_writable_roots_with_cwd(sandbox_policy_cwd) + == derived.get_writable_roots_with_cwd(sandbox_policy_cwd) + && provided.get_unreadable_roots_with_cwd(sandbox_policy_cwd) + == derived.get_unreadable_roots_with_cwd(sandbox_policy_cwd) +} + fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) { if apply_seccomp_then_exec && !use_bwrap_sandbox { panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox"); diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index d9b27fc3e42..fcf5faa1620 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -5,6 +5,8 @@ use codex_protocol::protocol::FileSystemSandboxPolicy; #[cfg(test)] use codex_protocol::protocol::NetworkSandboxPolicy; #[cfg(test)] +use codex_protocol::protocol::ReadOnlyAccess; +#[cfg(test)] use codex_protocol::protocol::SandboxPolicy; #[cfg(test)] use codex_utils_absolute_path::AbsolutePathBuf; @@ -357,6 +359,41 @@ fn resolve_sandbox_policies_accepts_split_policies_requiring_direct_runtime_enfo ); } +#[test] +fn resolve_sandbox_policies_accepts_semantically_equivalent_workspace_write_inputs() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let workspace = temp_dir.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + let workspace = AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![workspace], + read_only_access: ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let file_system_sandbox_policy = + FileSystemSandboxPolicy::from(&SandboxPolicy::new_workspace_write_policy()); + + let resolved = resolve_sandbox_policies( + temp_dir.path().join("workspace").as_path(), + Some(sandbox_policy.clone()), + Some(file_system_sandbox_policy.clone()), + Some(NetworkSandboxPolicy::Restricted), + ) + .expect("semantically equivalent legacy workspace-write policy should resolve"); + + assert_eq!(resolved.sandbox_policy, sandbox_policy); + assert_eq!( + resolved.file_system_sandbox_policy, + file_system_sandbox_policy + ); + assert_eq!( + resolved.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); +} + #[test] fn apply_seccomp_then_exec_without_bwrap_panics() { let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false));