diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 64d1342bef..95c0f1a380 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -24,6 +24,8 @@ use std::process::Command; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; +use codex_protocol::protocol::FileSystemAccessMode; +use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::WritableRoot; use codex_utils_absolute_path::AbsolutePathBuf; @@ -101,6 +103,7 @@ impl BwrapNetworkMode { pub(crate) struct BwrapArgs { pub args: Vec, pub preserved_files: Vec, + pub cleanup_mount_points: Vec, } /// Wrap a command with bubblewrap so the filesystem is read-only by default, @@ -126,6 +129,7 @@ pub(crate) fn create_bwrap_command_args( Ok(BwrapArgs { args: command, preserved_files: Vec::new(), + cleanup_mount_points: Vec::new(), }) } else { Ok(create_bwrap_flags_full_filesystem(command, options)) @@ -165,6 +169,7 @@ fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOption BwrapArgs { args, preserved_files: Vec::new(), + cleanup_mount_points: Vec::new(), } } @@ -179,6 +184,7 @@ fn create_bwrap_flags( let BwrapArgs { args: filesystem_args, preserved_files, + cleanup_mount_points, } = create_filesystem_args( file_system_sandbox_policy, sandbox_policy_cwd, @@ -216,6 +222,7 @@ fn create_bwrap_flags( Ok(BwrapArgs { args, preserved_files, + cleanup_mount_points, }) } @@ -241,7 +248,8 @@ fn create_filesystem_args( glob_scan_max_depth: Option, ) -> Result { let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd); - // Bubblewrap requires bind mount targets to exist. Skip missing writable + let mut cleanup_mount_points = Vec::new(); + // Bubblewrap requires bind mount sources to exist. Skip missing writable // roots so mixed-platform configs can keep harmless paths for other // environments without breaking Linux command startup. let mut writable_roots = file_system_sandbox_policy @@ -381,6 +389,7 @@ fn create_filesystem_args( append_unreadable_root_args( &mut args, &mut preserved_files, + &mut cleanup_mount_points, unreadable_root, &allowed_write_paths, )?; @@ -401,22 +410,36 @@ fn create_filesystem_args( } let mount_root = symlink_target.as_deref().unwrap_or(root); - args.push("--bind".to_string()); - args.push(path_to_string(mount_root)); - args.push(path_to_string(mount_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(); + if root == cwd { + let top_level_git = root.join(".git"); + if !read_only_subpaths.iter().any(|path| path == &top_level_git) + && !unreadable_paths.contains(&top_level_git) + && !has_explicit_write_entry_for_path(file_system_sandbox_policy, &top_level_git) + { + read_only_subpaths.push(top_level_git); + } + } if let Some(target) = &symlink_target { read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target); } + args.push("--bind".to_string()); + args.push(path_to_string(mount_root)); + args.push(path_to_string(mount_root)); + 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)?; + append_read_only_subpath_args( + &mut args, + &mut cleanup_mount_points, + &subpath, + &allowed_write_paths, + )?; } let mut nested_unreadable_roots: Vec = unreadable_roots .iter() @@ -432,6 +455,7 @@ fn create_filesystem_args( append_unreadable_root_args( &mut args, &mut preserved_files, + &mut cleanup_mount_points, &unreadable_root, &allowed_write_paths, )?; @@ -453,6 +477,7 @@ fn create_filesystem_args( append_unreadable_root_args( &mut args, &mut preserved_files, + &mut cleanup_mount_points, &unreadable_root, &allowed_write_paths, )?; @@ -461,6 +486,7 @@ fn create_filesystem_args( Ok(BwrapArgs { args, preserved_files, + cleanup_mount_points, }) } @@ -787,6 +813,7 @@ fn append_mount_target_parent_dir_args(args: &mut Vec, mount_target: &Pa fn append_read_only_subpath_args( args: &mut Vec, + cleanup_mount_points: &mut Vec, subpath: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { @@ -808,9 +835,7 @@ fn append_read_only_subpath_args( 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)); + append_missing_path_mask_args(args, cleanup_mount_points, &first_missing_component); } return Ok(()); } @@ -823,9 +848,61 @@ fn append_read_only_subpath_args( Ok(()) } +fn track_cleanup_mount_point(cleanup_mount_points: &mut Vec, mount_point: &Path) { + if cleanup_mount_points + .iter() + .any(|existing| existing == mount_point) + { + return; + } + cleanup_mount_points.push(mount_point.to_path_buf()); +} + +fn has_explicit_write_entry_for_path(policy: &FileSystemSandboxPolicy, path: &Path) -> bool { + let Ok(path) = AbsolutePathBuf::from_absolute_path(path) else { + return false; + }; + policy.entries.iter().any(|entry| { + entry.access == FileSystemAccessMode::Write + && matches!(&entry.path, FileSystemPath::Path { path: existing } if existing == &path) + }) +} + +fn append_empty_file_mask_args( + args: &mut Vec, + preserved_files: &mut Vec, + path: &Path, +) -> Result<()> { + if preserved_files.is_empty() { + preserved_files.push(File::open("/dev/null")?); + } + 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()); + args.push(null_fd); + args.push(path_to_string(path)); + Ok(()) +} + +fn append_missing_path_mask_args( + args: &mut Vec, + cleanup_mount_points: &mut Vec, + mount_point: &Path, +) { + args.push("--perms".to_string()); + args.push("000".to_string()); + args.push("--tmpfs".to_string()); + args.push(path_to_string(mount_point)); + args.push("--remount-ro".to_string()); + args.push(path_to_string(mount_point)); + track_cleanup_mount_point(cleanup_mount_points, mount_point); +} + fn append_unreadable_root_args( args: &mut Vec, preserved_files: &mut Vec, + cleanup_mount_points: &mut Vec, unreadable_root: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { @@ -850,9 +927,7 @@ fn append_unreadable_root_args( 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)); + append_missing_path_mask_args(args, cleanup_mount_points, &first_missing_component); } return Ok(()); } @@ -901,16 +976,7 @@ fn append_existing_unreadable_path_args( return Ok(()); } - if preserved_files.is_empty() { - preserved_files.push(File::open("/dev/null")?); - } - 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()); - args.push(null_fd); - args.push(path_to_string(unreadable_root)); - Ok(()) + append_empty_file_mask_args(args, preserved_files, unreadable_root) } /// Returns true when `path` is under any allowed writable root. @@ -1359,6 +1425,132 @@ mod tests { assert!(message.contains(&real_linked_private_str), "{message}"); } + #[test] + fn missing_default_metadata_paths_use_tmpfs_mask() { + let temp_dir = TempDir::new().expect("temp dir"); + let workspace = temp_dir.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"), + }, + access: FileSystemAccessMode::Write, + }]); + + let args = create_filesystem_args(&policy, &workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + + assert_missing_path_masked(&args.args, &workspace.join(".codex")); + assert_missing_path_masked(&args.args, &workspace.join(".git")); + assert!(args.preserved_files.is_empty()); + assert_eq!( + args.cleanup_mount_points, + vec![workspace.join(".codex"), workspace.join(".git")] + ); + assert!( + !workspace.join(".codex").exists() && !workspace.join(".git").exists(), + "tmpfs mask should not materialize host side metadata paths at arg construction time", + ); + } + + #[test] + fn missing_default_git_path_respects_explicit_write_entry() { + let temp_dir = TempDir::new().expect("temp dir"); + let workspace = temp_dir.path().join("workspace"); + let dot_git = workspace.join(".git"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::from_absolute_path(&workspace) + .expect("absolute workspace"), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::from_absolute_path(&dot_git).expect("absolute .git"), + }, + access: FileSystemAccessMode::Write, + }, + ]); + + let args = create_filesystem_args(&policy, &workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + + assert_missing_path_masked(&args.args, &workspace.join(".codex")); + assert!( + !args.args.iter().any(|arg| arg == &path_to_string(&dot_git)), + "explicitly writable missing .git path should not be masked: {:#?}", + args.args + ); + assert_eq!(args.cleanup_mount_points, vec![workspace.join(".codex")]); + } + + #[test] + fn missing_read_only_subpath_uses_tmpfs_mask() { + let temp_dir = TempDir::new().expect("temp dir"); + let workspace = temp_dir.path().join("workspace"); + let blocked = workspace.join("blocked"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let workspace_root = + AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); + let blocked_root = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: workspace_root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: blocked_root }, + access: FileSystemAccessMode::Read, + }, + ]); + + let args = + create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + + assert_missing_path_masked(&args.args, &blocked); + assert_eq!(args.cleanup_mount_points, vec![blocked]); + } + + #[test] + fn missing_unreadable_path_uses_tmpfs_mask() { + let temp_dir = TempDir::new().expect("temp dir"); + let workspace = temp_dir.path().join("workspace"); + let secret = workspace.join("secret"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let workspace_root = + AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); + let secret_root = AbsolutePathBuf::from_absolute_path(&secret).expect("absolute secret"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: workspace_root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: secret_root }, + access: FileSystemAccessMode::None, + }, + ]); + + let args = + create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + + assert_missing_path_masked(&args.args, &secret); + assert_eq!(args.cleanup_mount_points, vec![secret]); + } + #[test] fn ignores_missing_writable_roots() { let temp_dir = TempDir::new().expect("temp dir"); @@ -1393,8 +1585,10 @@ mod tests { "existing writable root should be rebound writable", ); assert!( - !args.args.iter().any(|arg| arg == &missing_root), - "missing writable root should be skipped", + !args.args.windows(3).any(|window| { + window == ["--bind", missing_root.as_str(), missing_root.as_str()] + }), + "missing writable root should not be rebound writable", ); } @@ -1414,6 +1608,11 @@ mod tests { NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH, ) .expect("bwrap fs args"); + assert!(args.preserved_files.is_empty()); + assert_eq!( + args.cleanup_mount_points, + vec![PathBuf::from("/.codex"), PathBuf::from("/.git")] + ); assert_eq!( args.args, vec![ @@ -1430,10 +1629,19 @@ mod tests { "/".to_string(), // Mask the default protected .codex subpath under that writable // root. Because the root is `/` in this test, the carveout path - // appears as `/.codex`. - "--ro-bind".to_string(), - "/dev/null".to_string(), + // appears as `/.codex` and `/.git`. + "--perms".to_string(), + "000".to_string(), + "--tmpfs".to_string(), "/.codex".to_string(), + "--remount-ro".to_string(), + "/.codex".to_string(), + "--perms".to_string(), + "000".to_string(), + "--tmpfs".to_string(), + "/.git".to_string(), + "--remount-ro".to_string(), + "/.git".to_string(), // Rebind /dev after the root bind so device nodes remain // writable/usable inside the writable root. "--bind".to_string(), @@ -2018,4 +2226,25 @@ mod tests { "expected file mask for {path}: {args:#?}" ); } + + /// Assert that `path` is masked due to a bwrap arg sequence like: + /// + /// `bwrap ... --perms 000 --tmpfs PATH --remount-ro PATH` + fn assert_missing_path_masked(args: &[String], path: &Path) { + let path = path_to_string(path); + assert!( + args.windows(6).any(|window| { + window + == [ + "--perms", + "000", + "--tmpfs", + path.as_str(), + "--remount-ro", + path.as_str(), + ] + }), + "expected missing path mask for {path}: {args:#?}" + ); + } } diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 958d8645bd..8718b7e3fc 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -437,7 +437,7 @@ fn run_bwrap_with_proc_fallback( options, ); apply_inner_command_argv0(&mut bwrap_args.args); - exec_bwrap(bwrap_args.args, bwrap_args.preserved_files); + run_bwrap_command(bwrap_args); } fn bwrap_network_mode( @@ -474,9 +474,46 @@ fn build_bwrap_argv( crate::bwrap::BwrapArgs { args: argv, preserved_files: bwrap_args.preserved_files, + cleanup_mount_points: bwrap_args.cleanup_mount_points, } } +fn run_bwrap_command(bwrap_args: crate::bwrap::BwrapArgs) -> ! { + if bwrap_args.cleanup_mount_points.is_empty() { + exec_bwrap(bwrap_args.args, bwrap_args.preserved_files); + } + run_bwrap_in_child_then_cleanup(bwrap_args) +} + +fn run_bwrap_in_child_then_cleanup(bwrap_args: crate::bwrap::BwrapArgs) -> ! { + let pid = unsafe { libc::fork() }; + if pid < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to fork for bubblewrap: {err}"); + } + + if pid == 0 { + exec_bwrap(bwrap_args.args, bwrap_args.preserved_files); + } + + let mut status: libc::c_int = 0; + let wait_res = unsafe { libc::waitpid(pid, &mut status as *mut libc::c_int, 0) }; + if wait_res < 0 { + let err = std::io::Error::last_os_error(); + panic!("waitpid failed for bubblewrap child: {err}"); + } + + cleanup_bwrap_mount_points(&bwrap_args.cleanup_mount_points); + + if libc::WIFEXITED(status) { + unsafe { libc::_exit(libc::WEXITSTATUS(status)) }; + } + if libc::WIFSIGNALED(status) { + unsafe { libc::_exit(128 + libc::WTERMSIG(status)) }; + } + unsafe { libc::_exit(1) }; +} + fn apply_inner_command_argv0(argv: &mut Vec) { apply_inner_command_argv0_for_launcher( argv, @@ -529,8 +566,8 @@ fn preflight_proc_mount_support( file_system_sandbox_policy, network_mode, ); - let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); - !is_proc_mount_failure(stderr.as_str()) + let output = run_bwrap_in_child_capture_stderr(preflight_argv); + !should_disable_proc_mount_after_preflight(output.status, output.stderr.as_str()) } fn build_preflight_bwrap_argv( @@ -573,7 +610,13 @@ 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(bwrap_args: crate::bwrap::BwrapArgs) -> String { +#[derive(Debug)] +struct BwrapChildOutput { + stderr: String, + status: libc::c_int, +} + +fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> BwrapChildOutput { const MAX_PREFLIGHT_STDERR_BYTES: u64 = 64 * 1024; let mut pipe_fds = [0; 2]; @@ -623,7 +666,42 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str panic!("waitpid failed for bubblewrap child: {err}"); } - String::from_utf8_lossy(&stderr_bytes).into_owned() + cleanup_bwrap_mount_points(&bwrap_args.cleanup_mount_points); + + BwrapChildOutput { + stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), + status, + } +} + +fn should_disable_proc_mount_after_preflight(_status: libc::c_int, stderr: &str) -> bool { + is_proc_mount_failure(stderr) +} + +fn cleanup_bwrap_mount_points(mount_points: &[PathBuf]) { + for mount_point in mount_points { + remove_bwrap_mount_point_if_safe(mount_point); + } +} + +fn remove_bwrap_mount_point_if_safe(mount_point: &Path) { + let Ok(metadata) = std::fs::symlink_metadata(mount_point) else { + return; + }; + + if metadata.file_type().is_file() && metadata.len() == 0 { + let _ = std::fs::remove_file(mount_point); + return; + } + + if metadata.file_type().is_dir() { + let Ok(mut entries) = std::fs::read_dir(mount_point) else { + return; + }; + if entries.next().is_none() { + let _ = std::fs::remove_dir(mount_point); + } + } } /// Close an owned file descriptor and panic with context on failure. 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 0ed10717f2..bf29ab8192 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -37,6 +37,52 @@ fn ignores_non_proc_mount_errors() { assert!(!is_proc_mount_failure(stderr)); } +#[test] +fn non_proc_preflight_failure_keeps_proc_mount_enabled() { + let status = wait_status_for_exit_code(/*exit_code*/ 1); + let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted"; + + assert!(!should_disable_proc_mount_after_preflight(status, stderr)); +} + +#[test] +fn cleanup_removes_empty_synthetic_mount_point() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let git_path = temp_dir.path().join(".git"); + File::create(&git_path).expect("create empty git file"); + + remove_bwrap_mount_point_if_safe(&git_path); + + assert!(!git_path.exists()); +} + +#[test] +fn cleanup_preserves_real_git_file() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let git_path = temp_dir.path().join(".git"); + std::fs::write(&git_path, "gitdir: /tmp/worktree\n").expect("write git file"); + + remove_bwrap_mount_point_if_safe(&git_path); + + assert_eq!( + std::fs::read_to_string(&git_path).expect("read git file"), + "gitdir: /tmp/worktree\n" + ); +} + +#[test] +fn cleanup_preserves_nonempty_git_directory() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let git_path = temp_dir.path().join(".git"); + std::fs::create_dir(&git_path).expect("create git dir"); + std::fs::write(git_path.join("config"), "[core]\n").expect("write git config"); + + remove_bwrap_mount_point_if_safe(&git_path); + + assert!(git_path.exists()); + assert!(git_path.join("config").exists()); +} + #[test] fn inserts_bwrap_argv0_before_command_separator() { let sandbox_policy = SandboxPolicy::new_read_only_policy(); @@ -536,3 +582,8 @@ fn valid_inner_stage_modes_do_not_panic() { /*apply_seccomp_then_exec*/ true, /*use_legacy_landlock*/ false, ); } + +#[cfg(test)] +fn wait_status_for_exit_code(exit_code: libc::c_int) -> libc::c_int { + exit_code << 8 +} diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 3795719f08..d32848a5cb 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -48,6 +48,19 @@ fn create_env_from_core_vars() -> HashMap { create_env(&policy, /*thread_id*/ None) } +fn codex_linux_sandbox_exe() -> PathBuf { + let sandbox_program = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox")); + if sandbox_program.is_absolute() { + return sandbox_program; + } + + if let Ok(current_dir) = std::env::current_dir() { + current_dir.join(sandbox_program) + } else { + sandbox_program + } +} + #[expect(clippy::print_stdout)] async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { let output = run_cmd_output(cmd, writable_roots, timeout_ms).await; @@ -75,12 +88,32 @@ 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_legacy_landlock: bool, network_access: bool, +) -> Result { + run_cmd_result_with_writable_roots_and_cwd( + cmd, + writable_roots, + &AbsolutePathBuf::current_dir().expect("cwd should exist"), + timeout_ms, + use_legacy_landlock, + network_access, + ) + .await +} + +async fn run_cmd_result_with_writable_roots_and_cwd( + cmd: &[&str], + writable_roots: &[PathBuf], + cwd: &AbsolutePathBuf, + timeout_ms: u64, + use_legacy_landlock: bool, + network_access: bool, ) -> Result { let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: writable_roots @@ -102,26 +135,26 @@ async fn run_cmd_result_with_writable_roots( sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + cwd, timeout_ms, use_legacy_landlock, ) .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, + cwd: &AbsolutePathBuf, timeout_ms: u64, use_legacy_landlock: bool, ) -> Result { - let cwd = AbsolutePathBuf::current_dir().expect("cwd should exist"); let sandbox_cwd = cwd.clone(); let params = ExecParams { command: cmd.iter().copied().map(str::to_owned).collect(), - cwd, + cwd: cwd.clone(), expiration: timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, env: create_env_from_core_vars(), @@ -132,8 +165,7 @@ async fn run_cmd_result_with_policies( justification: None, arg0: None, }; - let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); - let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program)); + let codex_linux_sandbox_exe = Some(codex_linux_sandbox_exe()); process_exec_tool_call( params, @@ -394,8 +426,7 @@ async fn assert_network_blocked(cmd: &[&str]) { }; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); - let codex_linux_sandbox_exe: Option = Some(PathBuf::from(sandbox_program)); + let codex_linux_sandbox_exe: Option = Some(codex_linux_sandbox_exe()); let result = process_exec_tool_call( params, &sandbox_policy, @@ -505,6 +536,50 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() { assert_ne!(codex_output.exit_code, 0); } +#[tokio::test] +async fn sandbox_blocks_missing_git_creation_without_host_artifact() { + if should_skip_bwrap_tests().await { + eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable"); + return; + } + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let sandbox_cwd = + AbsolutePathBuf::try_from(tmpdir.path()).expect("tempdir should be an absolute cwd"); + let allowed_target = tmpdir.path().join("allowed.txt"); + let git_path = tmpdir.path().join(".git"); + + let output = expect_denied( + run_cmd_result_with_writable_roots_and_cwd( + &[ + "bash", + "-lc", + &format!( + "printf allowed > {} && git init -q", + allowed_target.to_string_lossy(), + ), + ], + &[tmpdir.path().to_path_buf()], + &sandbox_cwd, + LONG_TIMEOUT_MS, + /*use_legacy_landlock*/ false, + /*network_access*/ true, + ) + .await, + "missing .git should stay blocked under bubblewrap", + ); + + assert_ne!(output.exit_code, 0); + assert_eq!( + std::fs::read_to_string(&allowed_target).expect("read allowed write target"), + "allowed", + ); + assert!( + !git_path.exists(), + "sandbox should not materialize host side .git when the path is missing", + ); +} + #[tokio::test] async fn sandbox_blocks_codex_symlink_replacement_attack() { if should_skip_bwrap_tests().await { @@ -554,7 +629,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() { let blocked_target = blocked.join("secret.txt"); // These tests bypass the usual legacy-policy bridge, so explicitly keep // the sandbox helper binary and minimal runtime paths readable. - let sandbox_helper_dir = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox")) + let sandbox_helper_dir = codex_linux_sandbox_exe() .parent() .expect("sandbox helper should have a parent") .to_path_buf(); @@ -603,6 +678,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() { sandbox_policy, file_system_sandbox_policy, NetworkSandboxPolicy::Enabled, + &AbsolutePathBuf::current_dir().expect("cwd should exist"), LONG_TIMEOUT_MS, /*use_legacy_landlock*/ false, ) @@ -627,7 +703,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() { let allowed_target = allowed.join("note.txt"); // These tests bypass the usual legacy-policy bridge, so explicitly keep // the sandbox helper binary and minimal runtime paths readable. - let sandbox_helper_dir = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox")) + let sandbox_helper_dir = codex_linux_sandbox_exe() .parent() .expect("sandbox helper should have a parent") .to_path_buf(); @@ -685,6 +761,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() { sandbox_policy, file_system_sandbox_policy, NetworkSandboxPolicy::Enabled, + &AbsolutePathBuf::current_dir().expect("cwd should exist"), LONG_TIMEOUT_MS, /*use_legacy_landlock*/ false, ) @@ -736,6 +813,7 @@ async fn sandbox_blocks_root_read_carveouts_under_bwrap() { sandbox_policy, file_system_sandbox_policy, NetworkSandboxPolicy::Enabled, + &AbsolutePathBuf::current_dir().expect("cwd should exist"), LONG_TIMEOUT_MS, /*use_legacy_landlock*/ false, )