diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a164ccca3..b87f76384 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1524,6 +1524,7 @@ dependencies = [ "codex-utils-absolute-path", "landlock", "libc", + "pretty_assertions", "seccompiler", "tempfile", "tokio", diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 1009791aa..2b63d6b30 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -24,6 +24,7 @@ libc = { workspace = true } seccompiler = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] +pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = [ "io-std", diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 9d46c08df..dab5a6bfc 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -37,6 +37,10 @@ pub(crate) fn apply_sandbox_policy_to_current_thread( apply_read_only_mounts(sandbox_policy, cwd)?; } + if !sandbox_policy.has_full_disk_write_access() || !sandbox_policy.has_full_network_access() { + set_no_new_privs()?; + } + if !sandbox_policy.has_full_network_access() { install_network_seccomp_filter_on_current_thread()?; } @@ -56,6 +60,14 @@ pub(crate) fn apply_sandbox_policy_to_current_thread( Ok(()) } +fn set_no_new_privs() -> Result<()> { + let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; + if result != 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) +} + /// Installs Landlock file-system rules on the current thread allowing read /// access to the entire file-system while restricting write access to /// `/dev/null` and the provided list of `writable_roots`. diff --git a/codex-rs/linux-sandbox/src/mounts.rs b/codex-rs/linux-sandbox/src/mounts.rs index 8018814cf..bed732da1 100644 --- a/codex-rs/linux-sandbox/src/mounts.rs +++ b/codex-rs/linux-sandbox/src/mounts.rs @@ -253,3 +253,85 @@ fn bind_mount_read_only(path: &Path) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn collect_read_only_mount_targets_errors_on_missing_path() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let missing = AbsolutePathBuf::try_from(tempdir.path().join("missing").as_path()) + .expect("missing path"); + let root = AbsolutePathBuf::try_from(tempdir.path()).expect("root"); + let writable_root = WritableRoot { + root, + read_only_subpaths: vec![missing], + }; + + let err = collect_read_only_mount_targets(&[writable_root]) + .expect_err("expected missing path error"); + let message = match err { + CodexErr::UnsupportedOperation(message) => message, + other => panic!("unexpected error: {other:?}"), + }; + assert_eq!( + message, + format!( + "Sandbox expected to protect {path}, but it does not exist. Ensure the repository contains this path or create it before running Codex.", + path = tempdir.path().join("missing").display() + ) + ); + } + + #[test] + fn collect_read_only_mount_targets_adds_gitdir_for_pointer_file() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let gitdir = tempdir.path().join("actual-gitdir"); + std::fs::create_dir_all(&gitdir).expect("create gitdir"); + let dot_git = tempdir.path().join(".git"); + std::fs::write(&dot_git, format!("gitdir: {}\n", gitdir.display())) + .expect("write gitdir pointer"); + let root = AbsolutePathBuf::try_from(tempdir.path()).expect("root"); + let writable_root = WritableRoot { + root, + read_only_subpaths: vec![ + AbsolutePathBuf::try_from(dot_git.as_path()).expect("dot git"), + ], + }; + + let targets = collect_read_only_mount_targets(&[writable_root]).expect("collect targets"); + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].as_path(), dot_git.as_path()); + assert_eq!(targets[1].as_path(), gitdir.as_path()); + } + + #[test] + fn collect_read_only_mount_targets_errors_on_invalid_gitdir_pointer() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let dot_git = tempdir.path().join(".git"); + std::fs::write(&dot_git, "not-a-pointer\n").expect("write invalid pointer"); + let root = AbsolutePathBuf::try_from(tempdir.path()).expect("root"); + let writable_root = WritableRoot { + root, + read_only_subpaths: vec![ + AbsolutePathBuf::try_from(dot_git.as_path()).expect("dot git"), + ], + }; + + let err = collect_read_only_mount_targets(&[writable_root]) + .expect_err("expected invalid pointer error"); + let message = match err { + CodexErr::UnsupportedOperation(message) => message, + other => panic!("unexpected error: {other:?}"), + }; + assert_eq!( + message, + format!( + "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: `.", + path = dot_git.display() + ) + ); + } +} diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index c02b08cd0..cca049e04 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -9,6 +9,7 @@ use codex_core::exec_env::create_env; use codex_core::protocol::SandboxPolicy; use codex_core::sandboxing::SandboxPermissions; use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::PathBuf; use tempfile::NamedTempFile; @@ -36,8 +37,22 @@ fn create_env_from_core_vars() -> HashMap { create_env(&policy) } -#[expect(clippy::print_stdout, clippy::expect_used, clippy::unwrap_used)] +#[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; + if output.exit_code != 0 { + println!("stdout:\n{}", output.stdout.text); + println!("stderr:\n{}", output.stderr.text); + panic!("exit code: {}", output.exit_code); + } +} + +#[expect(clippy::expect_used, clippy::unwrap_used)] +async fn run_cmd_output( + cmd: &[&str], + writable_roots: &[PathBuf], + timeout_ms: u64, +) -> codex_core::exec::ExecToolCallOutput { let cwd = std::env::current_dir().expect("cwd should exist"); let sandbox_cwd = cwd.clone(); let params = ExecParams { @@ -64,7 +79,8 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { }; let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program)); - let res = process_exec_tool_call( + + process_exec_tool_call( params, &sandbox_policy, sandbox_cwd.as_path(), @@ -72,13 +88,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { None, ) .await - .unwrap(); - - if res.exit_code != 0 { - println!("stdout:\n{}", res.stdout.text); - println!("stderr:\n{}", res.stderr.text); - panic!("exit code: {}", res.exit_code); - } + .unwrap() } #[expect(clippy::expect_used)] @@ -174,6 +184,23 @@ async fn test_writable_root() { .await; } +#[tokio::test] +async fn test_no_new_privs_is_enabled() { + let output = run_cmd_output( + &["bash", "-lc", "grep '^NoNewPrivs:' /proc/self/status"], + &[], + SHORT_TIMEOUT_MS, + ) + .await; + let line = output + .stdout + .text + .lines() + .find(|line| line.starts_with("NoNewPrivs:")) + .unwrap_or(""); + assert_eq!(line.trim(), "NoNewPrivs:\t1"); +} + #[tokio::test] async fn test_git_dir_write_blocked() { let tmpdir = tempfile::tempdir().unwrap();