From dcc4d7b634e0c732e5dab9ab04b6f3b67bfa55f1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 7 Mar 2026 23:46:52 -0800 Subject: [PATCH] linux-sandbox: honor split filesystem policies in bwrap (#13453) ## Why After `#13449`, the Linux helper could receive split filesystem and network policies, but the bubblewrap mount builder still reconstructed filesystem access from the legacy `SandboxPolicy`. That loses explicit unreadable carveouts under writable roots, and it also mishandles `Root` read access paired with explicit deny carveouts. In those cases bubblewrap could still expose paths that the split filesystem policy intentionally blocked. ## What changed - switched bubblewrap mount generation to consume `FileSystemSandboxPolicy` directly at the implementation boundary; legacy `SandboxPolicy` configs still flow through the existing `FileSystemSandboxPolicy::from(&sandbox_policy)` bridge before reaching bwrap - kept the Linux helper and preflight path on the split filesystem policy all the way into bwrap - re-applied explicit unreadable carveouts after readable and writable mounts so blocked subpaths still win under bubblewrap - masked denied directories with `--tmpfs` plus `--remount-ro` and denied files with `--ro-bind-data`, preserving the backing fd until exec - added comments in the unreadable-root masking block to explain why the mount order and directory/file split are intentional - updated Linux helper call sites and tests for the split-policy bwrap path ## Verification - added protocol coverage for root carveouts staying scoped - added core coverage that root-write plus deny carveouts still requires a platform sandbox - added bwrap unit coverage for reapplying blocked carveouts after writable binds - added Linux integration coverage for explicit split-policy carveouts under bubblewrap - validated the final branch state with `cargo test -p codex-linux-sandbox`, `cargo clippy -p codex-linux-sandbox --all-targets -- -D warnings`, and the PR CI reruns --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13453). * __->__ #13453 * #13452 * #13451 * #13449 * #13448 * #13445 * #13440 * #13439 --------- Co-authored-by: viyatb-oai --- codex-rs/linux-sandbox/src/bwrap.rs | 276 +++++++++++++++--- codex-rs/linux-sandbox/src/linux_run_main.rs | 59 ++-- .../linux-sandbox/src/linux_run_main_tests.rs | 25 +- codex-rs/linux-sandbox/src/vendored_bwrap.rs | 21 +- .../linux-sandbox/tests/suite/landlock.rs | 163 +++++++++-- 5 files changed, 456 insertions(+), 88 deletions(-) diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 783c3a428..837d3873f 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -10,12 +10,14 @@ //! - 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::fs::File; +use std::os::fd::AsRawFd; use std::path::Path; use std::path::PathBuf; use codex_core::error::CodexErr; use codex_core::error::Result; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::WritableRoot; /// Linux "platform defaults" that keep common system binaries and dynamic @@ -76,6 +78,12 @@ impl BwrapNetworkMode { } } +#[derive(Debug)] +pub(crate) struct BwrapArgs { + pub args: Vec, + pub preserved_files: Vec, +} + /// Wrap a command with bubblewrap so the filesystem is read-only by default, /// with explicit writable roots and read-only subpaths layered afterward. /// @@ -85,22 +93,25 @@ impl BwrapNetworkMode { /// namespace restrictions apply while preserving full filesystem access. pub(crate) fn create_bwrap_command_args( command: Vec, - sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path, options: BwrapOptions, -) -> Result> { - if sandbox_policy.has_full_disk_write_access() { +) -> Result { + if file_system_sandbox_policy.has_full_disk_write_access() { return if options.network_mode == BwrapNetworkMode::FullAccess { - Ok(command) + Ok(BwrapArgs { + args: command, + preserved_files: Vec::new(), + }) } else { Ok(create_bwrap_flags_full_filesystem(command, options)) }; } - create_bwrap_flags(command, sandbox_policy, cwd, options) + create_bwrap_flags(command, file_system_sandbox_policy, cwd, options) } -fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOptions) -> Vec { +fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOptions) -> BwrapArgs { let mut args = vec![ "--new-session".to_string(), "--die-with-parent".to_string(), @@ -121,20 +132,27 @@ fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOption } args.push("--".to_string()); args.extend(command); - args + BwrapArgs { + args, + preserved_files: Vec::new(), + } } /// Build the bubblewrap flags (everything after `argv[0]`). fn create_bwrap_flags( command: Vec, - sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path, options: BwrapOptions, -) -> Result> { +) -> Result { + let BwrapArgs { + args: filesystem_args, + preserved_files, + } = create_filesystem_args(file_system_sandbox_policy, cwd)?; let mut args = Vec::new(); args.push("--new-session".to_string()); args.push("--die-with-parent".to_string()); - args.extend(create_filesystem_args(sandbox_policy, cwd)?); + args.extend(filesystem_args); // Request a user namespace explicitly rather than relying on bubblewrap's // auto-enable behavior, which is skipped when the caller runs as uid 0. args.push("--unshare-user".to_string()); @@ -150,25 +168,35 @@ fn create_bwrap_flags( } args.push("--".to_string()); args.extend(command); - Ok(args) + Ok(BwrapArgs { + args, + preserved_files, + }) } -/// Build the bubblewrap filesystem mounts for a given sandbox policy. +/// Build the bubblewrap filesystem mounts for a given filesystem policy. /// /// The mount order is important: -/// 1. Full-read policies use `--ro-bind / /`; restricted-read policies start -/// from `--tmpfs /` and layer scoped `--ro-bind` mounts. +/// 1. Full-read policies, and restricted policies that explicitly read `/`, +/// use `--ro-bind / /`; other restricted-read policies start from +/// `--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 /// writable subpaths under `/dev` (for example, `/dev/shm`). /// 4. `--ro-bind ` re-applies read-only protections under /// those writable roots so protected subpaths win. -fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result> { - let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); +/// 5. 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, +) -> Result { + let writable_roots = file_system_sandbox_policy.get_writable_roots_with_cwd(cwd); + let unreadable_roots = file_system_sandbox_policy.get_unreadable_roots_with_cwd(cwd); ensure_mount_targets_exist(&writable_roots)?; - let mut args = if sandbox_policy.has_full_disk_read_access() { + let mut args = if file_system_sandbox_policy.has_full_disk_read_access() { // Read-only root, then mount a minimal device tree. // In bubblewrap (`bubblewrap.c`, `SETUP_MOUNT_DEV`), `--dev /dev` // creates the standard minimal nodes: null, zero, full, random, @@ -191,12 +219,12 @@ fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result< "/dev".to_string(), ]; - let mut readable_roots: BTreeSet = sandbox_policy + let mut readable_roots: BTreeSet = file_system_sandbox_policy .get_readable_roots_with_cwd(cwd) .into_iter() .map(PathBuf::from) .collect(); - if sandbox_policy.include_platform_defaults() { + if file_system_sandbox_policy.include_platform_defaults() { readable_roots.extend( LINUX_PLATFORM_DEFAULT_READ_ROOTS .iter() @@ -206,7 +234,8 @@ fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result< } // A restricted policy can still explicitly request `/`, which is - // semantically equivalent to broad read access. + // the broad read baseline. Explicit unreadable carveouts are + // re-applied later. if readable_roots.iter().any(|root| root == Path::new("/")) { args = vec![ "--ro-bind".to_string(), @@ -228,6 +257,7 @@ fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result< args }; + let mut preserved_files = Vec::new(); for writable_root in &writable_roots { let root = writable_root.root.as_path(); @@ -271,7 +301,44 @@ fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result< } } - Ok(args) + 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); + } + + Ok(BwrapArgs { + args, + preserved_files, + }) } /// Collect unique read-only subpaths across all writable roots. @@ -386,6 +453,11 @@ fn find_first_non_existent_component(target_path: &Path) -> Option { #[cfg(test)] mod tests { use super::*; + use codex_protocol::protocol::FileSystemAccessMode; + use codex_protocol::protocol::FileSystemPath; + use codex_protocol::protocol::FileSystemSandboxEntry; + use codex_protocol::protocol::FileSystemSandboxPolicy; + use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -397,7 +469,7 @@ mod tests { let command = vec!["/bin/true".to_string()]; let args = create_bwrap_command_args( command.clone(), - &SandboxPolicy::DangerFullAccess, + &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), Path::new("/"), BwrapOptions { mount_proc: true, @@ -406,7 +478,7 @@ mod tests { ) .expect("create bwrap args"); - assert_eq!(args, command); + assert_eq!(args.args, command); } #[test] @@ -414,7 +486,7 @@ mod tests { let command = vec!["/bin/true".to_string()]; let args = create_bwrap_command_args( command, - &SandboxPolicy::DangerFullAccess, + &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), Path::new("/"), BwrapOptions { mount_proc: true, @@ -424,7 +496,7 @@ mod tests { .expect("create bwrap args"); assert_eq!( - args, + args.args, vec![ "--new-session".to_string(), "--die-with-parent".to_string(), @@ -452,9 +524,13 @@ mod tests { exclude_slash_tmp: true, }; - let args = create_filesystem_args(&sandbox_policy, Path::new("/")).expect("bwrap fs args"); + let args = create_filesystem_args( + &FileSystemSandboxPolicy::from(&sandbox_policy), + Path::new("/"), + ) + .expect("bwrap fs args"); assert_eq!( - args, + args.args, vec![ "--ro-bind".to_string(), "/".to_string(), @@ -462,11 +538,11 @@ mod tests { "--dev".to_string(), "/dev".to_string(), "--bind".to_string(), - "/dev".to_string(), - "/dev".to_string(), + "/".to_string(), + "/".to_string(), "--bind".to_string(), - "/".to_string(), - "/".to_string(), + "/dev".to_string(), + "/dev".to_string(), ] ); } @@ -488,12 +564,13 @@ mod tests { network_access: false, }; - let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + let args = create_filesystem_args(&FileSystemSandboxPolicy::from(&policy), temp_dir.path()) + .expect("filesystem args"); - assert_eq!(args[0..4], ["--tmpfs", "/", "--dev", "/dev"]); + assert_eq!(args.args[0..4], ["--tmpfs", "/", "--dev", "/dev"]); let readable_root_str = path_to_string(&readable_root); - assert!(args.windows(3).any(|window| { + assert!(args.args.windows(3).any(|window| { window == [ "--ro-bind", @@ -517,15 +594,138 @@ mod tests { // `ReadOnlyAccess::Restricted` always includes `cwd` as a readable // root. Using `"/"` here would intentionally collapse to broad read // access, so use a non-root cwd to exercise the restricted path. - let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + let args = create_filesystem_args(&FileSystemSandboxPolicy::from(&policy), temp_dir.path()) + .expect("filesystem args"); - assert!(args.starts_with(&["--tmpfs".to_string(), "/".to_string()])); + assert!( + args.args + .starts_with(&["--tmpfs".to_string(), "/".to_string()]) + ); if Path::new("/usr").exists() { assert!( - args.windows(3) + args.args + .windows(3) .any(|window| window == ["--ro-bind", "/usr", "/usr"]) ); } } + + #[test] + fn split_policy_reapplies_unreadable_carveouts_after_writable_binds() { + let temp_dir = TempDir::new().expect("temp dir"); + let writable_root = temp_dir.path().join("workspace"); + let blocked = writable_root.join("blocked"); + std::fs::create_dir_all(&blocked).expect("create blocked dir"); + let writable_root = + AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root"); + let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked dir"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: writable_root.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: blocked.clone(), + }, + access: FileSystemAccessMode::None, + }, + ]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + let writable_root_str = path_to_string(writable_root.as_path()); + let blocked_str = path_to_string(blocked.as_path()); + + assert!(args.args.windows(3).any(|window| { + window + == [ + "--bind", + writable_root_str.as_str(), + writable_root_str.as_str(), + ] + })); + assert!( + args.args.windows(3).any(|window| { + window == ["--ro-bind", blocked_str.as_str(), blocked_str.as_str()] + }) + ); + } + + #[test] + fn split_policy_masks_root_read_directory_carveouts() { + let temp_dir = TempDir::new().expect("temp dir"); + let blocked = temp_dir.path().join("blocked"); + std::fs::create_dir_all(&blocked).expect("create blocked dir"); + let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked dir"); + 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, + }, + ]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + let blocked_str = path_to_string(blocked.as_path()); + + assert!( + args.args + .windows(3) + .any(|window| window == ["--ro-bind", "/", "/"]) + ); + assert!( + args.args + .windows(4) + .any(|window| { window == ["--perms", "000", "--tmpfs", blocked_str.as_str()] }) + ); + assert!( + args.args + .windows(2) + .any(|window| window == ["--remount-ro", blocked_str.as_str()]) + ); + } + + #[test] + fn split_policy_masks_root_read_file_carveouts() { + let temp_dir = TempDir::new().expect("temp dir"); + let blocked_file = temp_dir.path().join("blocked.txt"); + std::fs::write(&blocked_file, "secret").expect("create blocked file"); + let blocked_file = + AbsolutePathBuf::from_absolute_path(&blocked_file).expect("absolute blocked file"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: blocked_file.clone(), + }, + access: FileSystemAccessMode::None, + }, + ]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + let blocked_file_str = path_to_string(blocked_file.as_path()); + + assert_eq!(args.preserved_files.len(), 1); + assert!(args.args.windows(5).any(|window| { + window[0] == "--perms" + && window[1] == "000" + && window[2] == "--ro-bind-data" + && window[4] == blocked_file_str + })); + } } diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index dad4a6d3a..2f1b1b6b3 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -178,7 +178,7 @@ pub fn run_main() -> ! { }); run_bwrap_with_proc_fallback( &sandbox_policy_cwd, - &sandbox_policy, + &file_system_sandbox_policy, network_sandbox_policy, inner, !no_proc, @@ -261,7 +261,7 @@ fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_san fn run_bwrap_with_proc_fallback( sandbox_policy_cwd: &Path, - sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, inner: Vec, mount_proc: bool, @@ -270,7 +270,12 @@ fn run_bwrap_with_proc_fallback( let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy); let mut mount_proc = mount_proc; - if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy, network_mode) + if mount_proc + && !preflight_proc_mount_support( + sandbox_policy_cwd, + file_system_sandbox_policy, + network_mode, + ) { eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc"); mount_proc = false; @@ -280,8 +285,13 @@ fn run_bwrap_with_proc_fallback( mount_proc, network_mode, }; - let argv = build_bwrap_argv(inner, sandbox_policy, sandbox_policy_cwd, options); - exec_vendored_bwrap(argv); + let bwrap_args = build_bwrap_argv( + inner, + file_system_sandbox_policy, + sandbox_policy_cwd, + options, + ); + exec_vendored_bwrap(bwrap_args.args, bwrap_args.preserved_files); } fn bwrap_network_mode( @@ -299,47 +309,56 @@ fn bwrap_network_mode( fn build_bwrap_argv( inner: Vec, - sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, sandbox_policy_cwd: &Path, options: BwrapOptions, -) -> Vec { - let mut args = create_bwrap_command_args(inner, sandbox_policy, sandbox_policy_cwd, options) - .unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}")); +) -> crate::bwrap::BwrapArgs { + let mut bwrap_args = create_bwrap_command_args( + inner, + file_system_sandbox_policy, + sandbox_policy_cwd, + options, + ) + .unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}")); - let command_separator_index = args + let command_separator_index = bwrap_args + .args .iter() .position(|arg| arg == "--") .unwrap_or_else(|| panic!("bubblewrap argv is missing command separator '--'")); - args.splice( + bwrap_args.args.splice( command_separator_index..command_separator_index, ["--argv0".to_string(), "codex-linux-sandbox".to_string()], ); let mut argv = vec!["bwrap".to_string()]; - argv.extend(args); - argv + argv.extend(bwrap_args.args); + crate::bwrap::BwrapArgs { + args: argv, + preserved_files: bwrap_args.preserved_files, + } } fn preflight_proc_mount_support( sandbox_policy_cwd: &Path, - sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, ) -> bool { let preflight_argv = - build_preflight_bwrap_argv(sandbox_policy_cwd, sandbox_policy, network_mode); + build_preflight_bwrap_argv(sandbox_policy_cwd, file_system_sandbox_policy, network_mode); let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); !is_proc_mount_failure(stderr.as_str()) } fn build_preflight_bwrap_argv( sandbox_policy_cwd: &Path, - sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, -) -> Vec { +) -> crate::bwrap::BwrapArgs { let preflight_command = vec![resolve_true_command()]; build_bwrap_argv( preflight_command, - sandbox_policy, + file_system_sandbox_policy, sandbox_policy_cwd, BwrapOptions { mount_proc: true, @@ -368,7 +387,7 @@ fn resolve_true_command() -> String { /// - We capture stderr from that preflight to match known mount-failure text. /// We do not stream it because this is a one-shot probe with a trivial /// command, and reads are bounded to a fixed max size. -fn run_bwrap_in_child_capture_stderr(argv: Vec) -> String { +fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> String { const MAX_PREFLIGHT_STDERR_BYTES: u64 = 64 * 1024; let mut pipe_fds = [0; 2]; @@ -397,7 +416,7 @@ fn run_bwrap_in_child_capture_stderr(argv: Vec) -> String { close_fd_or_panic(write_fd, "close write end in bubblewrap child"); } - let exit_code = run_vendored_bwrap_main(&argv); + let exit_code = run_vendored_bwrap_main(&bwrap_args.args, &bwrap_args.preserved_files); std::process::exit(exit_code); } 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 35c60f364..a1adf65b1 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -35,15 +35,17 @@ fn ignores_non_proc_mount_errors() { #[test] fn inserts_bwrap_argv0_before_command_separator() { + let sandbox_policy = SandboxPolicy::new_read_only_policy(); let argv = build_bwrap_argv( vec!["/bin/true".to_string()], - &SandboxPolicy::new_read_only_policy(), + &FileSystemSandboxPolicy::from(&sandbox_policy), Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::FullAccess, }, - ); + ) + .args; assert_eq!( argv, vec![ @@ -69,29 +71,33 @@ fn inserts_bwrap_argv0_before_command_separator() { #[test] fn inserts_unshare_net_when_network_isolation_requested() { + let sandbox_policy = SandboxPolicy::new_read_only_policy(); let argv = build_bwrap_argv( vec!["/bin/true".to_string()], - &SandboxPolicy::new_read_only_policy(), + &FileSystemSandboxPolicy::from(&sandbox_policy), Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::Isolated, }, - ); + ) + .args; assert!(argv.contains(&"--unshare-net".to_string())); } #[test] fn inserts_unshare_net_when_proxy_only_network_mode_requested() { + let sandbox_policy = SandboxPolicy::new_read_only_policy(); let argv = build_bwrap_argv( vec!["/bin/true".to_string()], - &SandboxPolicy::new_read_only_policy(), + &FileSystemSandboxPolicy::from(&sandbox_policy), Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::ProxyOnly, }, - ); + ) + .args; assert!(argv.contains(&"--unshare-net".to_string())); } @@ -104,7 +110,12 @@ fn proxy_only_mode_takes_precedence_over_full_network_policy() { #[test] fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() { let mode = bwrap_network_mode(NetworkSandboxPolicy::Enabled, true); - let argv = build_preflight_bwrap_argv(Path::new("/"), &SandboxPolicy::DangerFullAccess, mode); + let argv = build_preflight_bwrap_argv( + Path::new("/"), + &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), + mode, + ) + .args; assert!(argv.iter().any(|arg| arg == "--")); } diff --git a/codex-rs/linux-sandbox/src/vendored_bwrap.rs b/codex-rs/linux-sandbox/src/vendored_bwrap.rs index 3150a1d86..538552268 100644 --- a/codex-rs/linux-sandbox/src/vendored_bwrap.rs +++ b/codex-rs/linux-sandbox/src/vendored_bwrap.rs @@ -6,6 +6,7 @@ #[cfg(vendored_bwrap_available)] mod imp { use std::ffi::CString; + use std::fs::File; use std::os::raw::c_char; unsafe extern "C" { @@ -27,7 +28,10 @@ mod imp { /// /// On success, bubblewrap will `execve` into the target program and this /// function will never return. A return value therefore implies failure. - pub(crate) fn run_vendored_bwrap_main(argv: &[String]) -> libc::c_int { + pub(crate) fn run_vendored_bwrap_main( + argv: &[String], + _preserved_files: &[File], + ) -> libc::c_int { let cstrings = argv_to_cstrings(argv); let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect(); @@ -39,16 +43,21 @@ mod imp { } /// Execute the build-time bubblewrap `main` function with the given argv. - pub(crate) fn exec_vendored_bwrap(argv: Vec) -> ! { - let exit_code = run_vendored_bwrap_main(&argv); + pub(crate) fn exec_vendored_bwrap(argv: Vec, preserved_files: Vec) -> ! { + let exit_code = run_vendored_bwrap_main(&argv, &preserved_files); std::process::exit(exit_code); } } #[cfg(not(vendored_bwrap_available))] mod imp { + use std::fs::File; + /// Panics with a clear error when the build-time bwrap path is not enabled. - pub(crate) fn run_vendored_bwrap_main(_argv: &[String]) -> libc::c_int { + pub(crate) fn run_vendored_bwrap_main( + _argv: &[String], + _preserved_files: &[File], + ) -> libc::c_int { panic!( r#"build-time bubblewrap is not available in this build. codex-linux-sandbox should always compile vendored bubblewrap on Linux targets. @@ -60,8 +69,8 @@ Notes: } /// Panics with a clear error when the build-time bwrap path is not enabled. - pub(crate) fn exec_vendored_bwrap(_argv: Vec) -> ! { - let _ = run_vendored_bwrap_main(&[]); + pub(crate) fn exec_vendored_bwrap(_argv: Vec, _preserved_files: Vec) -> ! { + let _ = run_vendored_bwrap_main(&[], &[]); unreachable!("run_vendored_bwrap_main should always panic in this configuration") } } diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 760210814..8e2f5ef41 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -9,8 +9,13 @@ use codex_core::exec::process_exec_tool_call; use codex_core::exec_env::create_env; use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -63,13 +68,47 @@ async fn run_cmd_output( .expect("sandboxed command should execute") } -#[expect(clippy::expect_used)] async fn run_cmd_result_with_writable_roots( cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64, use_bwrap_sandbox: bool, network_access: bool, +) -> Result { + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots + .iter() + .map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap()) + .collect(), + read_only_access: Default::default(), + network_access, + // Exclude tmp-related folders from writable roots because we need a + // folder that is writable by tests but that we intentionally disallow + // writing to in the sandbox. + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); + run_cmd_result_with_policies( + cmd, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + timeout_ms, + use_bwrap_sandbox, + ) + .await +} + +#[expect(clippy::expect_used)] +async fn run_cmd_result_with_policies( + cmd: &[&str], + sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + timeout_ms: u64, + use_bwrap_sandbox: bool, ) -> Result { let cwd = std::env::current_dir().expect("cwd should exist"); let sandbox_cwd = cwd.clone(); @@ -84,28 +123,14 @@ async fn run_cmd_result_with_writable_roots( justification: None, arg0: None, }; - - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots - .iter() - .map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap()) - .collect(), - read_only_access: Default::default(), - network_access, - // Exclude tmp-related folders from writable roots because we need a - // folder that is writable by tests but that we intentionally disallow - // writing to in the sandbox. - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program)); process_exec_tool_call( params, &sandbox_policy, - &FileSystemSandboxPolicy::from(&sandbox_policy), - NetworkSandboxPolicy::from(&sandbox_policy), + &file_system_sandbox_policy, + network_sandbox_policy, sandbox_cwd.as_path(), &codex_linux_sandbox_exe, use_bwrap_sandbox, @@ -479,6 +504,110 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() { assert_ne!(codex_output.exit_code, 0); } +#[tokio::test] +async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() { + if should_skip_bwrap_tests().await { + eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable"); + return; + } + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let blocked = tmpdir.path().join("blocked"); + std::fs::create_dir_all(&blocked).expect("create blocked dir"); + let blocked_target = blocked.join("secret.txt"); + + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir")], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir"), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"), + }, + access: FileSystemAccessMode::None, + }, + ]); + let output = expect_denied( + run_cmd_result_with_policies( + &[ + "bash", + "-lc", + &format!("echo denied > {}", blocked_target.to_string_lossy()), + ], + sandbox_policy, + file_system_sandbox_policy, + NetworkSandboxPolicy::Enabled, + LONG_TIMEOUT_MS, + true, + ) + .await, + "explicit split-policy carveout should be denied under bubblewrap", + ); + + assert_ne!(output.exit_code, 0); +} + +#[tokio::test] +async fn sandbox_blocks_root_read_carveouts_under_bwrap() { + if should_skip_bwrap_tests().await { + eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable"); + return; + } + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let blocked = tmpdir.path().join("blocked"); + std::fs::create_dir_all(&blocked).expect("create blocked dir"); + let blocked_target = blocked.join("secret.txt"); + std::fs::write(&blocked_target, "secret").expect("seed blocked file"); + + let sandbox_policy = SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: true, + }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::try_from(blocked.as_path()).expect("absolute blocked dir"), + }, + access: FileSystemAccessMode::None, + }, + ]); + let output = expect_denied( + run_cmd_result_with_policies( + &[ + "bash", + "-lc", + &format!("cat {}", blocked_target.to_string_lossy()), + ], + sandbox_policy, + file_system_sandbox_policy, + NetworkSandboxPolicy::Enabled, + LONG_TIMEOUT_MS, + true, + ) + .await, + "root-read carveout should be denied under bubblewrap", + ); + + assert_ne!(output.exit_code, 0); +} + #[tokio::test] async fn sandbox_blocks_ssh() { // Force ssh to attempt a real TCP connection but fail quickly. `BatchMode`