Files
codex/codex-rs/linux-sandbox/src/linux_run_main_tests.rs
T
evawong-oai 74f06dcdfb Enforce workspace metadata protections in Linux sandbox (#19852)
## Summary

Enforce FileSystemSandboxPolicy protected metadata names in the Linux
bubblewrap adapter so `.git`, `.agents`, and `.codex` remain read only
inside writable workspace roots unless the policy grants an explicit
write carveout.

## Scope

1. Translate protected metadata names from FileSystemSandboxPolicy into
bubblewrap masks for existing metadata paths.
2. Represent missing protected metadata paths as guarded mount targets
so agents cannot create `.git`, `.agents`, or `.codex` under writable
roots.
3. Preserve normal git discovery for existing repos, worktrees, and
parent repos.
4. Keep explicit user write grants working when policy allows a
protected metadata path directly.

## Not in scope

1. No shell preflight UX.
2. No TUI runtime profile propagation.
3. No macOS Seatbelt changes in this PR.

## Reviewer focus

1. This should be reviewed as the Linux enforcement adapter for the
policy primitive from PR 19846.
2. macOS enforcement already landed in PR 19847.
3. The important invariant is that `FileSystemSandboxPolicy` is the
source of truth for `.git`, `.agents`, and `.codex`.

## Validation

1. `git diff` whitespace check passed.
2. `cargo fmt` check passed with the existing stable rustfmt warning
about `imports_granularity`.
3. Full Linux sandbox Cargo test suite passed on the devbox.
4. Devbox forty six case suite passed at head
`012accb703c13bd28df5b40079a9bf183036336a`.
5. Devbox summary: pass 46, fail 0.
6. The devbox suite was run through `just c sandbox linux`.
7. Focused repo test for Viyat parent repo case passed on the devbox.
2026-04-29 16:14:14 -07:00

626 lines
22 KiB
Rust

#[cfg(test)]
use super::*;
#[cfg(test)]
use crate::linux_run_main::install_bwrap_signal_forwarders;
#[cfg(test)]
use crate::linux_run_main::wait_for_bwrap_child;
#[cfg(test)]
use codex_protocol::models::PermissionProfile;
#[cfg(test)]
use codex_protocol::protocol::FileSystemSandboxPolicy;
#[cfg(test)]
use codex_protocol::protocol::NetworkSandboxPolicy;
#[cfg(test)]
use codex_utils_absolute_path::AbsolutePathBuf;
#[cfg(test)]
use pretty_assertions::assert_eq;
fn read_only_permission_profile() -> PermissionProfile {
PermissionProfile::read_only()
}
fn read_only_file_system_policy() -> FileSystemSandboxPolicy {
read_only_permission_profile().file_system_sandbox_policy()
}
#[test]
fn detects_proc_mount_invalid_argument_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument";
assert!(is_proc_mount_failure(stderr));
}
#[test]
fn detects_proc_mount_operation_not_permitted_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted";
assert!(is_proc_mount_failure(stderr));
}
#[test]
fn detects_proc_mount_permission_denied_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied";
assert!(is_proc_mount_failure(stderr));
}
#[test]
fn ignores_non_proc_mount_errors() {
let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted";
assert!(!is_proc_mount_failure(stderr));
}
#[test]
fn inserts_bwrap_argv0_before_command_separator() {
let file_system_sandbox_policy = read_only_file_system_policy();
let mut argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&file_system_sandbox_policy,
Path::new("/"),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
..Default::default()
},
)
.args;
apply_inner_command_argv0_for_launcher(
&mut argv,
/*supports_argv0*/ true,
"/tmp/codex-arg0-session/codex-linux-sandbox".to_string(),
);
assert_eq!(
argv,
vec![
"bwrap".to_string(),
"--new-session".to_string(),
"--die-with-parent".to_string(),
"--ro-bind".to_string(),
"/".to_string(),
"/".to_string(),
"--dev".to_string(),
"/dev".to_string(),
"--unshare-user".to_string(),
"--unshare-pid".to_string(),
"--proc".to_string(),
"/proc".to_string(),
"--argv0".to_string(),
"codex-linux-sandbox".to_string(),
"--".to_string(),
"/bin/true".to_string(),
]
);
}
#[test]
fn rewrites_inner_command_path_when_bwrap_lacks_argv0() {
let file_system_sandbox_policy = read_only_file_system_policy();
let mut argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&file_system_sandbox_policy,
Path::new("/"),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
..Default::default()
},
)
.args;
apply_inner_command_argv0_for_launcher(
&mut argv,
/*supports_argv0*/ false,
"/tmp/codex-arg0-session/codex-linux-sandbox".to_string(),
);
assert!(!argv.iter().any(|arg| arg == "--argv0"));
assert!(
argv.windows(2)
.any(|window| { window == ["--", "/tmp/codex-arg0-session/codex-linux-sandbox"] })
);
}
#[test]
fn rewrites_bwrap_helper_command_not_nested_user_command_when_current_exe_appears_later() {
let nested_current_exe = std::env::current_exe()
.expect("current exe")
.to_string_lossy()
.into_owned();
let mut argv = vec![
"bwrap".to_string(),
"--".to_string(),
"/tmp/helper-symlink".to_string(),
"--sandbox-policy-cwd".to_string(),
"/tmp/cwd".to_string(),
"--".to_string(),
nested_current_exe.clone(),
"--codex-run-as-apply-patch".to_string(),
"patch".to_string(),
];
apply_inner_command_argv0_for_launcher(
&mut argv,
/*supports_argv0*/ false,
"/tmp/argv0-fallback-helper".to_string(),
);
assert_eq!(
argv,
vec![
"bwrap".to_string(),
"--".to_string(),
"/tmp/argv0-fallback-helper".to_string(),
"--sandbox-policy-cwd".to_string(),
"/tmp/cwd".to_string(),
"--".to_string(),
nested_current_exe,
"--codex-run-as-apply-patch".to_string(),
"patch".to_string(),
]
);
}
#[test]
fn inserts_unshare_net_when_network_isolation_requested() {
let file_system_sandbox_policy = read_only_file_system_policy();
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&file_system_sandbox_policy,
Path::new("/"),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::Isolated,
..Default::default()
},
)
.args;
assert!(argv.contains(&"--unshare-net".to_string()));
}
#[test]
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
let file_system_sandbox_policy = read_only_file_system_policy();
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&file_system_sandbox_policy,
Path::new("/"),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::ProxyOnly,
..Default::default()
},
)
.args;
assert!(argv.contains(&"--unshare-net".to_string()));
}
#[test]
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
let mode = bwrap_network_mode(
NetworkSandboxPolicy::Enabled,
/*allow_network_for_proxy*/ true,
);
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
}
#[test]
fn split_only_filesystem_policy_requires_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 policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(
/*subpath*/ None,
),
},
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!(
policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, temp_dir.path(),)
);
}
#[test]
fn root_write_read_only_carveout_requires_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 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!(
policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, temp_dir.path(),)
);
}
#[test]
fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() {
let mode = bwrap_network_mode(
NetworkSandboxPolicy::Enabled,
/*allow_network_for_proxy*/ true,
);
let argv = build_preflight_bwrap_argv(
Path::new("/"),
Path::new("/"),
&FileSystemSandboxPolicy::unrestricted(),
mode,
)
.args;
assert!(argv.iter().any(|arg| arg == "--"));
}
#[test]
fn cleanup_synthetic_mount_targets_removes_only_empty_mount_targets() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let empty_file = temp_dir.path().join(".git");
let empty_dir = temp_dir.path().join(".agents");
let non_empty_file = temp_dir.path().join("non-empty");
let missing_file = temp_dir.path().join(".missing");
std::fs::write(&empty_file, "").expect("write empty file");
std::fs::create_dir(&empty_dir).expect("create empty dir");
std::fs::write(&non_empty_file, "keep").expect("write nonempty file");
let registrations = register_synthetic_mount_targets(&[
crate::bwrap::SyntheticMountTarget::missing(&empty_file),
crate::bwrap::SyntheticMountTarget::missing_empty_directory(&empty_dir),
crate::bwrap::SyntheticMountTarget::missing(&non_empty_file),
crate::bwrap::SyntheticMountTarget::missing(&missing_file),
]);
cleanup_synthetic_mount_targets(&registrations);
assert!(!empty_file.exists());
assert!(!empty_dir.exists());
assert_eq!(
std::fs::read_to_string(&non_empty_file).expect("read nonempty file"),
"keep"
);
assert!(!missing_file.exists());
}
#[test]
fn cleanup_synthetic_mount_targets_waits_for_other_active_registrations() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let empty_file = temp_dir.path().join(".git");
std::fs::write(&empty_file, "").expect("write empty file");
let target = crate::bwrap::SyntheticMountTarget::missing(&empty_file);
let registrations = register_synthetic_mount_targets(std::slice::from_ref(&target));
let active_marker = registrations[0].marker_dir.join("1");
std::fs::write(&active_marker, "").expect("write active marker");
cleanup_synthetic_mount_targets(&registrations);
assert!(empty_file.exists());
std::fs::remove_file(active_marker).expect("remove active marker");
let registrations = register_synthetic_mount_targets(std::slice::from_ref(&target));
cleanup_synthetic_mount_targets(&registrations);
assert!(!empty_file.exists());
}
#[test]
fn cleanup_synthetic_mount_targets_removes_transient_file_after_concurrent_owner_exits() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let empty_file = temp_dir.path().join(".git");
let first_target = crate::bwrap::SyntheticMountTarget::missing(&empty_file);
let first_registrations = register_synthetic_mount_targets(&[first_target]);
std::fs::write(&empty_file, "").expect("write transient empty file");
let active_marker = first_registrations[0].marker_dir.join("1");
std::fs::write(&active_marker, SYNTHETIC_MOUNT_MARKER_SYNTHETIC).expect("write active marker");
let metadata = std::fs::symlink_metadata(&empty_file).expect("stat empty file");
let second_target =
crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata);
let second_registrations = register_synthetic_mount_targets(&[second_target]);
cleanup_synthetic_mount_targets(&first_registrations);
assert!(empty_file.exists());
std::fs::remove_file(active_marker).expect("remove active marker");
cleanup_synthetic_mount_targets(&second_registrations);
assert!(!empty_file.exists());
}
#[test]
fn cleanup_synthetic_mount_targets_preserves_real_pre_existing_empty_file() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let empty_file = temp_dir.path().join(".git");
std::fs::write(&empty_file, "").expect("write pre-existing empty file");
let metadata = std::fs::symlink_metadata(&empty_file).expect("stat empty file");
let first_target =
crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata);
let second_target =
crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata);
let first_registrations = register_synthetic_mount_targets(&[first_target]);
let second_registrations = register_synthetic_mount_targets(&[second_target]);
cleanup_synthetic_mount_targets(&first_registrations);
cleanup_synthetic_mount_targets(&second_registrations);
assert!(empty_file.exists());
}
#[test]
fn cleanup_protected_create_targets_removes_created_path_and_reports_violation() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let dot_git = temp_dir.path().join(".git");
let target = crate::bwrap::ProtectedCreateTarget::missing(&dot_git);
let registrations = register_protected_create_targets(&[target]);
std::fs::create_dir(&dot_git).expect("create protected path");
let violation = cleanup_protected_create_targets(&registrations);
assert!(violation);
assert!(!dot_git.exists());
}
#[test]
fn cleanup_protected_create_targets_waits_for_other_active_registrations() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let dot_git = temp_dir.path().join(".git");
let target = crate::bwrap::ProtectedCreateTarget::missing(&dot_git);
let registrations = register_protected_create_targets(std::slice::from_ref(&target));
let active_marker = registrations[0].marker_dir.join("1");
std::fs::write(&active_marker, PROTECTED_CREATE_MARKER).expect("write active marker");
std::fs::write(&dot_git, "").expect("create protected path");
let violation = cleanup_protected_create_targets(&registrations);
assert!(violation);
assert!(dot_git.exists());
std::fs::remove_file(active_marker).expect("remove active marker");
let registrations = register_protected_create_targets(std::slice::from_ref(&target));
let violation = cleanup_protected_create_targets(&registrations);
assert!(violation);
assert!(!dot_git.exists());
}
#[test]
fn bwrap_signal_forwarder_terminates_child_and_keeps_parent_alive() {
let supervisor_pid = unsafe { libc::fork() };
assert!(supervisor_pid >= 0, "failed to fork supervisor");
if supervisor_pid == 0 {
run_bwrap_signal_forwarder_test_supervisor();
}
let status = wait_for_bwrap_child(supervisor_pid);
assert!(libc::WIFEXITED(status), "supervisor status: {status}");
assert_eq!(libc::WEXITSTATUS(status), 0);
}
#[cfg(test)]
fn run_bwrap_signal_forwarder_test_supervisor() -> ! {
let child_pid = unsafe { libc::fork() };
if child_pid < 0 {
unsafe {
libc::_exit(2);
}
}
if child_pid == 0 {
loop {
unsafe {
libc::pause();
}
}
}
install_bwrap_signal_forwarders(child_pid);
unsafe {
libc::raise(libc::SIGTERM);
}
let status = wait_for_bwrap_child(child_pid);
let child_terminated_by_sigterm =
libc::WIFSIGNALED(status) && libc::WTERMSIG(status) == libc::SIGTERM;
unsafe {
libc::_exit(if child_terminated_by_sigterm { 0 } else { 1 });
}
}
#[test]
fn managed_proxy_inner_command_includes_route_spec() {
let permission_profile = read_only_permission_profile();
let args = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
allow_network_for_proxy: true,
proxy_route_spec: Some("{\"routes\":[]}".to_string()),
command: vec!["/bin/true".to_string()],
});
assert!(args.iter().any(|arg| arg == "--proxy-route-spec"));
assert!(args.iter().any(|arg| arg == "{\"routes\":[]}"));
}
#[test]
fn inner_command_includes_permission_profile_flag() {
let permission_profile = read_only_permission_profile();
let args = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
allow_network_for_proxy: false,
proxy_route_spec: None,
command: vec!["/bin/true".to_string()],
});
assert!(args.iter().any(|arg| arg == "--permission-profile"));
assert!(
args.windows(2)
.any(|window| { window == ["--command-cwd", "/tmp/link"] })
);
}
#[test]
fn non_managed_inner_command_omits_route_spec() {
let permission_profile = read_only_permission_profile();
let args = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
allow_network_for_proxy: false,
proxy_route_spec: None,
command: vec!["/bin/true".to_string()],
});
assert!(!args.iter().any(|arg| arg == "--proxy-route-spec"));
}
#[test]
fn managed_proxy_inner_command_requires_route_spec() {
let result = std::panic::catch_unwind(|| {
let permission_profile = read_only_permission_profile();
build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: Path::new("/tmp"),
command_cwd: Some(Path::new("/tmp/link")),
permission_profile: &permission_profile,
allow_network_for_proxy: true,
proxy_route_spec: None,
command: vec!["/bin/true".to_string()],
})
});
assert!(result.is_err());
}
#[test]
fn resolve_permission_profile_derives_runtime_policies() {
let permission_profile = read_only_permission_profile();
let resolved = resolve_permission_profile(Some(permission_profile.clone()))
.expect("profile should resolve");
assert_eq!(resolved.permission_profile, permission_profile);
assert_eq!(
resolved.file_system_sandbox_policy,
read_only_file_system_policy()
);
assert_eq!(
resolved.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn resolve_permission_profile_preserves_direct_runtime_profile() {
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 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 permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
let resolved = resolve_permission_profile(Some(permission_profile.clone()))
.expect("profile should resolve");
assert_eq!(resolved.permission_profile, permission_profile);
assert_eq!(
resolved.file_system_sandbox_policy,
file_system_sandbox_policy
);
assert_eq!(
resolved.network_sandbox_policy,
NetworkSandboxPolicy::Restricted
);
}
#[test]
fn resolve_permission_profile_rejects_missing_configuration() {
let err = resolve_permission_profile(/*permission_profile*/ None)
.expect_err("missing profile should fail");
assert_eq!(err, ResolvePermissionProfileError::MissingConfiguration);
}
#[test]
fn apply_seccomp_then_exec_with_legacy_landlock_panics() {
let result = std::panic::catch_unwind(|| {
ensure_inner_stage_mode_is_valid(
/*apply_seccomp_then_exec*/ true, /*use_legacy_landlock*/ true,
)
});
assert!(result.is_err());
}
#[test]
fn legacy_landlock_rejects_split_only_filesystem_policies() {
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::Read,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path { path: docs },
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
]);
let result = std::panic::catch_unwind(|| {
ensure_legacy_landlock_mode_supports_policy(
/*use_legacy_landlock*/ true,
&policy,
NetworkSandboxPolicy::Restricted,
temp_dir.path(),
);
});
assert!(result.is_err());
}
#[test]
fn valid_inner_stage_modes_do_not_panic() {
ensure_inner_stage_mode_is_valid(
/*apply_seccomp_then_exec*/ false, /*use_legacy_landlock*/ false,
);
ensure_inner_stage_mode_is_valid(
/*apply_seccomp_then_exec*/ false, /*use_legacy_landlock*/ true,
);
ensure_inner_stage_mode_is_valid(
/*apply_seccomp_then_exec*/ true, /*use_legacy_landlock*/ false,
);
}