diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 5745f4816c..254ae2bfa3 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -82,7 +82,8 @@ commands that would enter the bubblewrap path. writable roots are blocked by mounting `/dev/null` on the symlink or first missing component. - When bubblewrap is active, the helper explicitly isolates the user namespace via - `--unshare-user` and the PID namespace via `--unshare-pid`. + `--unshare-user`, the PID namespace via `--unshare-pid`, and the IPC namespace + via `--unshare-ipc`. - When bubblewrap is active and network is restricted without proxy routing, the helper also isolates the network namespace via `--unshare-net`. - In managed proxy mode, the helper uses `--unshare-net` plus an internal diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 22e7d72531..003ebf97c0 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -275,6 +275,7 @@ fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOption // not need ambient CAP_SYS_ADMIN to create the remaining namespaces. "--unshare-user".to_string(), "--unshare-pid".to_string(), + "--unshare-ipc".to_string(), ]; if options.network_mode.should_unshare_network() { args.push("--unshare-net".to_string()); @@ -322,6 +323,7 @@ fn create_bwrap_flags( // auto-enable behavior, which is skipped when the caller runs as uid 0. args.push("--unshare-user".to_string()); args.push("--unshare-pid".to_string()); + args.push("--unshare-ipc".to_string()); if options.network_mode.should_unshare_network() { args.push("--unshare-net".to_string()); } @@ -1402,6 +1404,7 @@ mod tests { "/".to_string(), "--unshare-user".to_string(), "--unshare-pid".to_string(), + "--unshare-ipc".to_string(), "--unshare-net".to_string(), "--proc".to_string(), "/proc".to_string(), 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 228cea6e5d..92ee4ee5d1 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -80,6 +80,7 @@ fn inserts_bwrap_argv0_before_command_separator() { "/dev".to_string(), "--unshare-user".to_string(), "--unshare-pid".to_string(), + "--unshare-ipc".to_string(), "--proc".to_string(), "/proc".to_string(), "--argv0".to_string(), diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index efbcd0b486..f46cb89b09 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -41,6 +41,51 @@ const NETWORK_TIMEOUT_MS: u64 = 10_000; const NETWORK_TIMEOUT_MS: u64 = 10_000; const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build."; +const SYSV_IPC_PROBE_SHMID_ENV: &str = "CODEX_SYSV_IPC_PROBE_SHMID"; +const SYSV_IPC_PROBE_SECRET_ENV: &str = "CODEX_SYSV_IPC_PROBE_SECRET"; + +struct HostSysvSharedMemory { + shmid: libc::c_int, + addr: *mut libc::c_void, +} + +impl HostSysvSharedMemory { + fn new(contents: &[u8]) -> std::io::Result { + let shmid = + unsafe { libc::shmget(libc::IPC_PRIVATE, contents.len(), libc::IPC_CREAT | 0o600) }; + if shmid == -1 { + return Err(std::io::Error::last_os_error()); + } + + let addr = unsafe { libc::shmat(shmid, std::ptr::null(), 0) }; + if shmat_failed(addr) { + let err = std::io::Error::last_os_error(); + unsafe { + libc::shmctl(shmid, libc::IPC_RMID, std::ptr::null_mut()); + } + return Err(err); + } + + let shared_bytes = + unsafe { std::slice::from_raw_parts_mut(addr.cast::(), contents.len()) }; + shared_bytes.copy_from_slice(contents); + + Ok(Self { shmid, addr }) + } +} + +impl Drop for HostSysvSharedMemory { + fn drop(&mut self) { + unsafe { + libc::shmdt(self.addr); + libc::shmctl(self.shmid, libc::IPC_RMID, std::ptr::null_mut()); + } + } +} + +fn shmat_failed(addr: *mut libc::c_void) -> bool { + addr == (-1_isize) as *mut libc::c_void +} fn create_env_from_core_vars() -> HashMap { let policy = ShellEnvironmentPolicy::default(); @@ -245,7 +290,7 @@ fn expect_denied( #[tokio::test] async fn test_root_read() { - run_cmd(&["ls", "-l", "/bin"], &[], SHORT_TIMEOUT_MS).await; + run_cmd(&["ls", "-l", "/bin"], &[], LONG_TIMEOUT_MS).await; } #[tokio::test] @@ -349,6 +394,109 @@ async fn bwrap_preserves_writable_dev_shm_bind_mount() { ); } +#[test] +fn sysv_ipc_probe_helper() { + let Ok(shmid) = std::env::var(SYSV_IPC_PROBE_SHMID_ENV) else { + return; + }; + let expected_secret = + std::env::var(SYSV_IPC_PROBE_SECRET_ENV).expect("expected probe secret env var"); + let shmid = shmid + .parse::() + .expect("probe shmid should be an integer"); + + let addr = unsafe { libc::shmat(shmid, std::ptr::null(), 0) }; + if shmat_failed(addr) { + let err = std::io::Error::last_os_error(); + assert_eq!( + err.raw_os_error(), + Some(libc::EINVAL), + "expected private IPC namespace to hide host SysV shared memory segment {shmid}, got {err}" + ); + return; + } + + let mut segment_metadata = unsafe { std::mem::zeroed::() }; + let shmctl_result = unsafe { libc::shmctl(shmid, libc::IPC_STAT, &mut segment_metadata) }; + if shmctl_result == -1 { + let err = std::io::Error::last_os_error(); + unsafe { + libc::shmdt(addr); + } + panic!("failed to stat SysV shared memory segment {shmid}: {err}"); + } + let segment_size = segment_metadata.shm_segsz; + if expected_secret.len() > segment_size { + unsafe { + libc::shmdt(addr); + } + panic!( + "SysV shared memory segment {shmid} size {segment_size} is smaller than probe secret size {}", + expected_secret.len() + ); + } + + let observed = unsafe { std::slice::from_raw_parts(addr.cast::(), expected_secret.len()) }; + let observed_secret = String::from_utf8_lossy(observed).into_owned(); + unsafe { + libc::shmdt(addr); + } + + panic!( + "sandboxed command attached to host SysV shared memory segment {shmid} and read {observed_secret:?}" + ); +} + +#[tokio::test] +async fn bwrap_uses_private_ipc_namespace() { + if should_skip_bwrap_tests().await { + eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable"); + return; + } + + let secret = b"codex-sysv-ipc-host-secret"; + let host_segment = match HostSysvSharedMemory::new(secret) { + Ok(host_segment) => host_segment, + Err(err) => { + eprintln!("skipping bwrap test: failed to create SysV shared memory segment: {err}"); + return; + } + }; + + let test_exe = std::env::current_exe() + .expect("current test executable") + .to_string_lossy() + .into_owned(); + let shmid_env = format!("{SYSV_IPC_PROBE_SHMID_ENV}={}", host_segment.shmid); + let secret_env = format!( + "{SYSV_IPC_PROBE_SECRET_ENV}={}", + String::from_utf8_lossy(secret) + ); + let output = run_cmd_result_with_writable_roots( + &[ + "env", + shmid_env.as_str(), + secret_env.as_str(), + test_exe.as_str(), + "sysv_ipc_probe_helper", + "--exact", + "--nocapture", + ], + &[], + NETWORK_TIMEOUT_MS, + /*use_legacy_landlock*/ false, + /*network_access*/ true, + ) + .await + .expect("sandboxed SysV IPC probe should execute"); + + assert_eq!( + output.exit_code, 0, + "sandboxed SysV IPC probe failed\nstdout:\n{}\nstderr:\n{}", + output.stdout.text, output.stderr.text + ); +} + #[tokio::test] async fn test_writable_root() { let tmpdir = tempfile::tempdir().unwrap();