Compare commits

...

1 Commits

Author SHA1 Message Date
Eva Wong
253e8dfc2f Reserve missing git sandbox path 2026-04-17 16:32:56 -07:00
4 changed files with 433 additions and 43 deletions

View File

@@ -101,6 +101,7 @@ impl BwrapNetworkMode {
pub(crate) struct BwrapArgs {
pub args: Vec<String>,
pub preserved_files: Vec<File>,
pub cleanup_mount_points: Vec<PathBuf>,
}
/// Wrap a command with bubblewrap so the filesystem is read-only by default,
@@ -126,6 +127,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 +167,7 @@ fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOption
BwrapArgs {
args,
preserved_files: Vec::new(),
cleanup_mount_points: Vec::new(),
}
}
@@ -179,6 +182,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 +220,7 @@ fn create_bwrap_flags(
Ok(BwrapArgs {
args,
preserved_files,
cleanup_mount_points,
})
}
@@ -241,7 +246,8 @@ fn create_filesystem_args(
glob_scan_max_depth: Option<usize>,
) -> Result<BwrapArgs> {
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 +387,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 +408,35 @@ 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<PathBuf> = 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)
{
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<PathBuf> = unreadable_roots
.iter()
@@ -432,6 +452,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 +474,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 +483,7 @@ fn create_filesystem_args(
Ok(BwrapArgs {
args,
preserved_files,
cleanup_mount_points,
})
}
@@ -787,6 +810,7 @@ fn append_mount_target_parent_dir_args(args: &mut Vec<String>, mount_target: &Pa
fn append_read_only_subpath_args(
args: &mut Vec<String>,
cleanup_mount_points: &mut Vec<PathBuf>,
subpath: &Path,
allowed_write_paths: &[PathBuf],
) -> Result<()> {
@@ -808,9 +832,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 +845,51 @@ fn append_read_only_subpath_args(
Ok(())
}
fn track_cleanup_mount_point(cleanup_mount_points: &mut Vec<PathBuf>, mount_point: &Path) {
if cleanup_mount_points
.iter()
.any(|existing| existing == mount_point)
{
return;
}
cleanup_mount_points.push(mount_point.to_path_buf());
}
fn append_empty_file_mask_args(
args: &mut Vec<String>,
preserved_files: &mut Vec<File>,
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<String>,
cleanup_mount_points: &mut Vec<PathBuf>,
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<String>,
preserved_files: &mut Vec<File>,
cleanup_mount_points: &mut Vec<PathBuf>,
unreadable_root: &Path,
allowed_write_paths: &[PathBuf],
) -> Result<()> {
@@ -850,9 +914,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 +963,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.
@@ -965,8 +1018,8 @@ fn first_writable_symlink_component_in_path(
/// Find the first missing path component while walking `target_path`.
///
/// Mounting `/dev/null` on the first missing component prevents the sandboxed
/// process from creating the protected path hierarchy.
/// Masking the first missing component prevents the sandboxed process from
/// creating the protected path hierarchy.
fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
let mut current = PathBuf::new();
@@ -1359,6 +1412,97 @@ 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_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 +1537,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 +1560,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 +1581,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 +2178,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:#?}"
);
}
}

View File

@@ -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<String>) {
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);
bwrap_child_succeeded(output.status) && !is_proc_mount_failure(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 bwrap_child_succeeded(status: libc::c_int) -> bool {
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0
}
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.

View File

@@ -37,6 +37,54 @@ fn ignores_non_proc_mount_errors() {
assert!(!is_proc_mount_failure(stderr));
}
#[test]
fn bwrap_child_success_requires_clean_exit_status() {
assert!(bwrap_child_succeeded(wait_status_for_exit_code(
/*exit_code*/ 0
)));
assert!(!bwrap_child_succeeded(wait_status_for_exit_code(
/*exit_code*/ 1
)));
}
#[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 +584,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
}

View File

@@ -48,6 +48,19 @@ fn create_env_from_core_vars() -> HashMap<String, String> {
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<codex_protocol::exec_output::ExecToolCallOutput> {
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<codex_protocol::exec_output::ExecToolCallOutput> {
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<codex_protocol::exec_output::ExecToolCallOutput> {
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<PathBuf> = Some(PathBuf::from(sandbox_program));
let codex_linux_sandbox_exe: Option<PathBuf> = 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,
)