diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6408e8de..309c671e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -491,6 +491,7 @@ dependencies = [ "codex-common", "codex-core", "codex-exec", + "codex-linux-sandbox", "codex-mcp-server", "codex-tui", "serde_json", @@ -525,7 +526,6 @@ dependencies = [ "fs2", "futures", "landlock", - "libc", "maplit", "mcp-types", "mime_guess", @@ -562,6 +562,7 @@ dependencies = [ "clap", "codex-common", "codex-core", + "codex-linux-sandbox", "mcp-types", "owo-colors 4.2.0", "serde_json", @@ -591,6 +592,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-linux-sandbox" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-common", + "codex-core", + "landlock", + "libc", + "seccompiler", + "tempfile", + "tokio", +] + [[package]] name = "codex-mcp-client" version = "0.0.0" @@ -609,7 +625,9 @@ dependencies = [ name = "codex-mcp-server" version = "0.0.0" dependencies = [ + "anyhow", "codex-core", + "codex-linux-sandbox", "mcp-types", "pretty_assertions", "schemars", @@ -629,6 +647,7 @@ dependencies = [ "codex-ansi-escape", "codex-common", "codex-core", + "codex-linux-sandbox", "color-eyre", "crossterm", "lazy_static", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e95942cb..5af55f45 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -8,6 +8,7 @@ members = [ "core", "exec", "execpolicy", + "linux-sandbox", "mcp-client", "mcp-server", "mcp-types", @@ -23,7 +24,7 @@ version = "0.0.0" edition = "2024" [workspace.lints] -rust = { } +rust = {} [workspace.lints.clippy] expect_used = "deny" diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index f7ad70e9..a1474d8e 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -7,10 +7,6 @@ edition = "2024" name = "codex" path = "src/main.rs" -[[bin]] -name = "codex-linux-sandbox" -path = "src/linux-sandbox/main.rs" - [lib] name = "codex_cli" path = "src/lib.rs" @@ -24,6 +20,7 @@ clap = { version = "4", features = ["derive"] } codex-core = { path = "../core" } codex-common = { path = "../common", features = ["cli"] } codex-exec = { path = "../exec" } +codex-linux-sandbox = { path = "../linux-sandbox" } codex-mcp-server = { path = "../mcp-server" } codex-tui = { path = "../tui" } serde_json = "1" diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs new file mode 100644 index 00000000..a7388b29 --- /dev/null +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -0,0 +1,91 @@ +use std::path::PathBuf; + +use codex_common::SandboxPermissionOption; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::exec::StdioPolicy; +use codex_core::exec::spawn_command_under_linux_sandbox; +use codex_core::exec::spawn_command_under_seatbelt; +use codex_core::exec_env::create_env; +use codex_core::protocol::SandboxPolicy; + +use crate::LandlockCommand; +use crate::SeatbeltCommand; +use crate::exit_status::handle_exit_status; + +pub async fn run_command_under_seatbelt(command: SeatbeltCommand) -> anyhow::Result<()> { + let SeatbeltCommand { + full_auto, + sandbox, + command, + } = command; + run_command_under_sandbox(full_auto, sandbox, command, None, SandboxType::Seatbelt).await +} + +pub async fn run_command_under_landlock(command: LandlockCommand) -> anyhow::Result<()> { + let LandlockCommand { + full_auto, + sandbox, + command, + } = command; + run_command_under_sandbox(full_auto, sandbox, command, None, SandboxType::Landlock).await +} + +enum SandboxType { + Seatbelt, + Landlock, +} + +async fn run_command_under_sandbox( + full_auto: bool, + sandbox: SandboxPermissionOption, + command: Vec, + codex_linux_sandbox_exe: Option, + sandbox_type: SandboxType, +) -> anyhow::Result<()> { + let sandbox_policy = create_sandbox_policy(full_auto, sandbox); + let cwd = std::env::current_dir()?; + let config = Config::load_with_overrides(ConfigOverrides { + sandbox_policy: Some(sandbox_policy), + codex_linux_sandbox_exe, + ..Default::default() + })?; + let stdio_policy = StdioPolicy::Inherit; + let env = create_env(&config.shell_environment_policy); + + let mut child = match sandbox_type { + SandboxType::Seatbelt => { + spawn_command_under_seatbelt(command, &config.sandbox_policy, cwd, stdio_policy, env) + .await? + } + SandboxType::Landlock => { + #[expect(clippy::expect_used)] + let codex_linux_sandbox_exe = config + .codex_linux_sandbox_exe + .expect("codex-linux-sandbox executable not found"); + spawn_command_under_linux_sandbox( + codex_linux_sandbox_exe, + command, + &config.sandbox_policy, + cwd, + stdio_policy, + env, + ) + .await? + } + }; + let status = child.wait().await?; + + handle_exit_status(status); +} + +pub fn create_sandbox_policy(full_auto: bool, sandbox: SandboxPermissionOption) -> SandboxPolicy { + if full_auto { + SandboxPolicy::new_full_auto_policy() + } else { + match sandbox.permissions.map(Into::into) { + Some(sandbox_policy) => sandbox_policy, + None => SandboxPolicy::new_read_only_policy(), + } + } +} diff --git a/codex-rs/cli/src/landlock.rs b/codex-rs/cli/src/landlock.rs deleted file mode 100644 index 5a65fcbc..00000000 --- a/codex-rs/cli/src/landlock.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! `debug landlock` implementation for the Codex CLI. -//! -//! On Linux the command is executed inside a Landlock + seccomp sandbox by -//! calling the low-level `exec_linux` helper from `codex_core::linux`. - -use codex_core::config::Config; -use codex_core::exec::StdioPolicy; -use codex_core::exec::spawn_child_sync; -use codex_core::exec_linux::apply_sandbox_policy_to_current_thread; -use std::process::ExitStatus; - -use crate::exit_status::handle_exit_status; - -/// Execute `command` in a Linux sandbox (Landlock + seccomp) the way Codex -/// would. -pub fn run_landlock(command: Vec, config: &Config) -> anyhow::Result<()> { - if command.is_empty() { - anyhow::bail!("command args are empty"); - } - - // Spawn a new thread and apply the sandbox policies there. - let env = codex_core::exec_env::create_env(&config.shell_environment_policy); - let sandbox_policy = config.sandbox_policy.clone(); - let handle = std::thread::spawn(move || -> anyhow::Result { - let cwd = std::env::current_dir()?; - - apply_sandbox_policy_to_current_thread(&sandbox_policy, &cwd)?; - let mut child = spawn_child_sync(command, cwd, &sandbox_policy, StdioPolicy::Inherit, env)?; - let status = child.wait()?; - Ok(status) - }); - let status = handle - .join() - .map_err(|e| anyhow::anyhow!("Failed to join thread: {e:?}"))??; - - handle_exit_status(status); -} diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index b5ce03c5..bf85c98c 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,12 +1,9 @@ +pub mod debug_sandbox; mod exit_status; -#[cfg(unix)] -pub mod landlock; pub mod proto; -pub mod seatbelt; use clap::Parser; use codex_common::SandboxPermissionOption; -use codex_core::protocol::SandboxPolicy; #[derive(Debug, Parser)] pub struct SeatbeltCommand { @@ -35,14 +32,3 @@ pub struct LandlockCommand { #[arg(trailing_var_arg = true)] pub command: Vec, } - -pub fn create_sandbox_policy(full_auto: bool, sandbox: SandboxPermissionOption) -> SandboxPolicy { - if full_auto { - SandboxPolicy::new_full_auto_policy() - } else { - match sandbox.permissions.map(Into::into) { - Some(sandbox_policy) => sandbox_policy, - None => SandboxPolicy::new_read_only_policy(), - } - } -} diff --git a/codex-rs/cli/src/linux-sandbox/main.rs b/codex-rs/cli/src/linux-sandbox/main.rs deleted file mode 100644 index 31416565..00000000 --- a/codex-rs/cli/src/linux-sandbox/main.rs +++ /dev/null @@ -1,28 +0,0 @@ -#[cfg(not(target_os = "linux"))] -fn main() -> anyhow::Result<()> { - eprintln!("codex-linux-sandbox is not supported on this platform."); - std::process::exit(1); -} - -#[cfg(target_os = "linux")] -fn main() -> anyhow::Result<()> { - use clap::Parser; - use codex_cli::LandlockCommand; - use codex_cli::create_sandbox_policy; - use codex_cli::landlock; - use codex_core::config::Config; - use codex_core::config::ConfigOverrides; - - let LandlockCommand { - full_auto, - sandbox, - command, - } = LandlockCommand::parse(); - let sandbox_policy = create_sandbox_policy(full_auto, sandbox); - let config = Config::load_with_overrides(ConfigOverrides { - sandbox_policy: Some(sandbox_policy), - ..Default::default() - })?; - landlock::run_landlock(command, &config)?; - Ok(()) -} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 725a82c2..4c46967b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,15 +1,10 @@ -use std::path::PathBuf; - use clap::Parser; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; -use codex_cli::create_sandbox_policy; use codex_cli::proto; -use codex_cli::seatbelt; -use codex_core::config::Config; -use codex_core::config::ConfigOverrides; use codex_exec::Cli as ExecCli; use codex_tui::Cli as TuiCli; +use std::path::PathBuf; use crate::proto::ProtoCli; @@ -66,14 +61,14 @@ enum DebugCommand { #[derive(Debug, Parser)] struct ReplProto {} -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let codex_linux_sandbox_exe: Option = if cfg!(target_os = "linux") { - std::env::current_exe().ok() - } else { - None - }; +fn main() -> anyhow::Result<()> { + codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { + cli_main(codex_linux_sandbox_exe).await?; + Ok(()) + }) +} +async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { let cli = MultitoolCli::parse(); match cli.subcommand { @@ -90,34 +85,11 @@ async fn main() -> anyhow::Result<()> { proto::run_main(proto_cli).await?; } Some(Subcommand::Debug(debug_args)) => match debug_args.cmd { - DebugCommand::Seatbelt(SeatbeltCommand { - command, - sandbox, - full_auto, - }) => { - let sandbox_policy = create_sandbox_policy(full_auto, sandbox); - let config = Config::load_with_overrides(ConfigOverrides { - sandbox_policy: Some(sandbox_policy), - ..Default::default() - })?; - seatbelt::run_seatbelt(command, &config).await?; + DebugCommand::Seatbelt(seatbelt_command) => { + codex_cli::debug_sandbox::run_command_under_seatbelt(seatbelt_command).await?; } - #[cfg(unix)] - DebugCommand::Landlock(LandlockCommand { - command, - sandbox, - full_auto, - }) => { - let sandbox_policy = create_sandbox_policy(full_auto, sandbox); - let config = Config::load_with_overrides(ConfigOverrides { - sandbox_policy: Some(sandbox_policy), - ..Default::default() - })?; - codex_cli::landlock::run_landlock(command, &config)?; - } - #[cfg(not(unix))] - DebugCommand::Landlock(_) => { - anyhow::bail!("Landlock is only supported on Linux."); + DebugCommand::Landlock(landlock_command) => { + codex_cli::debug_sandbox::run_command_under_landlock(landlock_command).await?; } }, } diff --git a/codex-rs/cli/src/seatbelt.rs b/codex-rs/cli/src/seatbelt.rs deleted file mode 100644 index d4a78404..00000000 --- a/codex-rs/cli/src/seatbelt.rs +++ /dev/null @@ -1,21 +0,0 @@ -use codex_core::config::Config; -use codex_core::exec::StdioPolicy; -use codex_core::exec::spawn_command_under_seatbelt; -use codex_core::exec_env::create_env; - -use crate::exit_status::handle_exit_status; - -pub async fn run_seatbelt(command: Vec, config: &Config) -> anyhow::Result<()> { - let cwd = std::env::current_dir()?; - let env = create_env(&config.shell_environment_policy); - let mut child = spawn_command_under_seatbelt( - command, - &config.sandbox_policy, - cwd, - StdioPolicy::Inherit, - env, - ) - .await?; - let status = child.wait().await?; - handle_exit_status(status); -} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2d4ed8f3..46872949 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -49,7 +49,6 @@ uuid = { version = "1", features = ["serde", "v4"] } wildmatch = "2.4.0" [target.'cfg(target_os = "linux")'.dependencies] -libc = "0.2.172" landlock = "0.4.1" seccompiler = "0.5.0" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 69e50478..2699a9ce 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -187,6 +187,7 @@ pub(crate) struct Session { /// sessions can be replayed or inspected later. rollout: Mutex>, state: Mutex, + codex_linux_sandbox_exe: Option, } impl Session { @@ -644,6 +645,7 @@ async fn submission_loop( notify, state: Mutex::new(state), rollout: Mutex::new(rollout_recorder), + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), })); // Gather history metadata for SessionConfiguredEvent. @@ -1244,6 +1246,7 @@ async fn handle_container_exec_with_params( sandbox_type, sess.ctrl_c.clone(), &sess.sandbox_policy, + &sess.codex_linux_sandbox_exe, ) .await; @@ -1348,6 +1351,7 @@ async fn handle_sanbox_error( SandboxType::None, sess.ctrl_c.clone(), &sess.sandbox_policy, + &sess.codex_linux_sandbox_exe, ) .await; diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 35b099e6..9cdc4eb5 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -74,6 +74,9 @@ pub enum CodexErr { #[error("sandbox error: {0}")] Sandbox(#[from] SandboxErr), + #[error("codex-linux-sandbox was required but not provided")] + LandlockSandboxExecutableNotProvided, + // ----------------------------------------------------------------- // Automatic conversions for common external error types // ----------------------------------------------------------------- diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 96b601b6..bf724048 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -21,7 +21,6 @@ use tokio::sync::Notify; use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; -use crate::exec_linux::exec_linux; use crate::protocol::SandboxPolicy; // Maximum we send for each stream, which is either: @@ -79,6 +78,7 @@ pub async fn process_exec_tool_call( sandbox_type: SandboxType, ctrl_c: Arc, sandbox_policy: &SandboxPolicy, + codex_linux_sandbox_exe: &Option, ) -> Result { let start = Instant::now(); @@ -101,7 +101,29 @@ pub async fn process_exec_tool_call( .await?; consume_truncated_output(child, ctrl_c, timeout_ms).await } - SandboxType::LinuxSeccomp => exec_linux(params, ctrl_c, sandbox_policy), + SandboxType::LinuxSeccomp => { + let ExecParams { + command, + cwd, + timeout_ms, + env, + } = params; + + let codex_linux_sandbox_exe = codex_linux_sandbox_exe + .as_ref() + .ok_or(CodexErr::LandlockSandboxExecutableNotProvided)?; + let child = spawn_command_under_linux_sandbox( + codex_linux_sandbox_exe, + command, + sandbox_policy, + cwd, + StdioPolicy::RedirectForShellTool, + env, + ) + .await?; + + consume_truncated_output(child, ctrl_c, timeout_ms).await + } }; let duration = start.elapsed(); match raw_output_result { @@ -151,11 +173,101 @@ pub async fn spawn_command_under_seatbelt( stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { - let seatbelt_command = create_seatbelt_command(command, sandbox_policy, &cwd); - spawn_child_async(seatbelt_command, cwd, sandbox_policy, stdio_policy, env).await + let args = create_seatbelt_command_args(command, sandbox_policy, &cwd); + let arg0 = None; + spawn_child_async( + PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE), + args, + arg0, + cwd, + sandbox_policy, + stdio_policy, + env, + ) + .await } -fn create_seatbelt_command( +/// Spawn a shell tool command under the Linux Landlock+seccomp sandbox helper +/// (codex-linux-sandbox). +/// +/// Unlike macOS Seatbelt where we directly embed the policy text, the Linux +/// helper accepts a list of `--sandbox-permission`/`-s` flags mirroring the +/// public CLI. We convert the internal [`SandboxPolicy`] representation into +/// the equivalent CLI options. +pub async fn spawn_command_under_linux_sandbox

( + codex_linux_sandbox_exe: P, + command: Vec, + sandbox_policy: &SandboxPolicy, + cwd: PathBuf, + stdio_policy: StdioPolicy, + env: HashMap, +) -> std::io::Result +where + P: AsRef, +{ + let args = create_linux_sandbox_command_args(command, sandbox_policy, &cwd); + let arg0 = Some("codex-linux-sandbox"); + spawn_child_async( + codex_linux_sandbox_exe.as_ref().to_path_buf(), + args, + arg0, + cwd, + sandbox_policy, + stdio_policy, + env, + ) + .await +} + +/// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`. +fn create_linux_sandbox_command_args( + command: Vec, + sandbox_policy: &SandboxPolicy, + cwd: &Path, +) -> Vec { + let mut linux_cmd: Vec = vec![]; + + // Translate individual permissions. + // Use high-level helper methods to infer flags when we cannot see the + // exact permission list. + if sandbox_policy.has_full_disk_read_access() { + linux_cmd.extend(["-s", "disk-full-read-access"].map(String::from)); + } + + if sandbox_policy.has_full_disk_write_access() { + linux_cmd.extend(["-s", "disk-full-write-access"].map(String::from)); + } else { + // Derive granular writable paths (includes cwd if `DiskWriteCwd` is + // present). + for root in sandbox_policy.get_writable_roots_with_cwd(cwd) { + // Check if this path corresponds exactly to cwd to map to + // `disk-write-cwd`, otherwise use the generic folder rule. + if root == cwd { + linux_cmd.extend(["-s", "disk-write-cwd"].map(String::from)); + } else { + linux_cmd.extend([ + "-s".to_string(), + format!("disk-write-folder={}", root.to_string_lossy()), + ]); + } + } + } + + if sandbox_policy.has_full_network_access() { + linux_cmd.extend(["-s", "network-full-access"].map(String::from)); + } + + // Separator so that command arguments starting with `-` are not parsed as + // options of the helper itself. + linux_cmd.push("--".to_string()); + + // Append the original tool command. + linux_cmd.extend(command); + + linux_cmd +} + +fn create_seatbelt_command_args( command: Vec, sandbox_policy: &SandboxPolicy, cwd: &Path, @@ -207,15 +319,11 @@ fn create_seatbelt_command( let full_policy = format!( "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" ); - let mut seatbelt_command: Vec = vec![ - MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string(), - "-p".to_string(), - full_policy, - ]; - seatbelt_command.extend(extra_cli_args); - seatbelt_command.push("--".to_string()); - seatbelt_command.extend(command); - seatbelt_command + let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; + seatbelt_args.extend(extra_cli_args); + seatbelt_args.push("--".to_string()); + seatbelt_args.extend(command); + seatbelt_args } #[derive(Debug)] @@ -243,8 +351,17 @@ async fn exec( sandbox_policy: &SandboxPolicy, ctrl_c: Arc, ) -> Result { + let (program, args) = command.split_first().ok_or_else(|| { + CodexErr::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "command args are empty", + )) + })?; + let arg0 = None; let child = spawn_child_async( - command, + PathBuf::from(program), + args.into(), + arg0, cwd, sandbox_policy, StdioPolicy::RedirectForShellTool, @@ -260,124 +377,53 @@ pub enum StdioPolicy { Inherit, } -macro_rules! configure_command { - ( - $cmd_type: path, - $command: expr, - $cwd: expr, - $sandbox_policy: expr, - $stdio_policy: expr, - $env_map: expr - ) => {{ - // For now, we take `SandboxPolicy` as a parameter to spawn_child() because - // we need to determine whether to set the - // `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` environment variable. - // Ultimately, we should be stricter about the environment variables that - // are set for the command (as we are when spawning an MCP server), so - // instead of SandboxPolicy, we should take the exact env to use for the - // Command (i.e., `env_clear().envs(env)`). - if $command.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "command args are empty", - )); - } - - let mut cmd = <$cmd_type>::new(&$command[0]); - cmd.args(&$command[1..]); - cmd.current_dir($cwd); - - // Previously, to update the env for `cmd`, we did the straightforward - // thing of calling `env_clear()` followed by `envs(&env_map)` so - // that the spawned process inherited *only* the variables explicitly - // provided by the caller. On Linux, the combination of `env_clear()` - // and Landlock/seccomp caused a permission error whereas this more - // "surgical" approach of setting variables individually appears to - // work fine. More time with `strace` and friends is merited to fully - // debug thus, though we will soon use a helper binary like we do for - // Seatbelt, which will simplify this logic. - - // Iterate through the current process environment first so we can - // decide, for every variable that already exists, whether we need to - // override its value. - let mut remaining_overrides = $env_map.clone(); - for (key, current_val) in std::env::vars() { - if let Some(desired_val) = remaining_overrides.remove(&key) { - // The caller provided a value for this variable. Override it - // only if the value differs from what is currently set. - if desired_val != current_val { - cmd.env(&key, desired_val); - } - } - // If the variable was not in `env_map`, we leave it unchanged. - } - - // Any entries still left in `remaining_overrides` were not present in - // the parent environment. Add them now so that the child process sees - // the complete set requested by the caller. - for (key, val) in remaining_overrides { - cmd.env(key, val); - } - - if !$sandbox_policy.has_full_network_access() { - cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1"); - } - - match $stdio_policy { - StdioPolicy::RedirectForShellTool => { - // Do not create a file descriptor for stdin because otherwise some - // commands may hang forever waiting for input. For example, ripgrep has - // a heuristic where it may try to read from stdin as explained here: - // https://github.com/BurntSushi/ripgrep/blob/e2362d4d5185d02fa857bf381e7bd52e66fafc73/crates/core/flags/hiargs.rs#L1101-L1103 - cmd.stdin(Stdio::null()); - - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - } - StdioPolicy::Inherit => { - // Inherit stdin, stdout, and stderr from the parent process. - cmd.stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - } - } - - std::io::Result::<$cmd_type>::Ok(cmd) - }}; -} - /// Spawns the appropriate child process for the ExecParams and SandboxPolicy, /// ensuring the args and environment variables used to create the `Command` /// (and `Child`) honor the configuration. -pub(crate) async fn spawn_child_async( - command: Vec, +/// +/// For now, we take `SandboxPolicy` as a parameter to spawn_child() because +/// we need to determine whether to set the +/// `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` environment variable. +async fn spawn_child_async( + program: PathBuf, + args: Vec, + #[cfg_attr(not(unix), allow(unused_variables))] arg0: Option<&str>, cwd: PathBuf, sandbox_policy: &SandboxPolicy, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { - let mut cmd = configure_command!(Command, command, cwd, sandbox_policy, stdio_policy, env)?; - cmd.kill_on_drop(true).spawn() -} + let mut cmd = Command::new(&program); + #[cfg(unix)] + cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from)); + cmd.args(args); + cmd.current_dir(cwd); + cmd.env_clear(); + cmd.envs(env); -/// Alternative version of `spawn_child_async()` that returns -/// `std::process::Child` instead of `tokio::process::Child`. This is useful for -/// spawning a child process in a thread that is not running a Tokio runtime. -pub fn spawn_child_sync( - command: Vec, - cwd: PathBuf, - sandbox_policy: &SandboxPolicy, - stdio_policy: StdioPolicy, - env: HashMap, -) -> std::io::Result { - let mut cmd = configure_command!( - std::process::Command, - command, - cwd, - sandbox_policy, - stdio_policy, - env - )?; - cmd.spawn() + if !sandbox_policy.has_full_network_access() { + cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1"); + } + + match stdio_policy { + StdioPolicy::RedirectForShellTool => { + // Do not create a file descriptor for stdin because otherwise some + // commands may hang forever waiting for input. For example, ripgrep has + // a heuristic where it may try to read from stdin as explained here: + // https://github.com/BurntSushi/ripgrep/blob/e2362d4d5185d02fa857bf381e7bd52e66fafc73/crates/core/flags/hiargs.rs#L1101-L1103 + cmd.stdin(Stdio::null()); + + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + } + StdioPolicy::Inherit => { + // Inherit stdin, stdout, and stderr from the parent process. + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + } + } + + cmd.kill_on_drop(true).spawn() } /// Consumes the output of a child process, truncating it so it is suitable for diff --git a/codex-rs/core/src/exec_linux.rs b/codex-rs/core/src/exec_linux.rs deleted file mode 100644 index 76bd428a..00000000 --- a/codex-rs/core/src/exec_linux.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::io; -use std::path::Path; -use std::sync::Arc; - -use crate::error::CodexErr; -use crate::error::Result; -use crate::exec::ExecParams; -use crate::exec::RawExecToolCallOutput; -use crate::exec::StdioPolicy; -use crate::exec::consume_truncated_output; -use crate::exec::spawn_child_async; -use crate::protocol::SandboxPolicy; - -use tokio::sync::Notify; - -pub fn exec_linux( - params: ExecParams, - ctrl_c: Arc, - sandbox_policy: &SandboxPolicy, -) -> Result { - // Allow READ on / - // Allow WRITE on /dev/null - let ctrl_c_copy = ctrl_c.clone(); - let sandbox_policy = sandbox_policy.clone(); - - // Isolate thread to run the sandbox from - let tool_call_output = std::thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - - rt.block_on(async { - let ExecParams { - command, - cwd, - timeout_ms, - env, - } = params; - apply_sandbox_policy_to_current_thread(&sandbox_policy, &cwd)?; - let child = spawn_child_async( - command, - cwd, - &sandbox_policy, - StdioPolicy::RedirectForShellTool, - env, - ) - .await?; - consume_truncated_output(child, ctrl_c_copy, timeout_ms).await - }) - }) - .join(); - - match tool_call_output { - Ok(Ok(output)) => Ok(output), - Ok(Err(e)) => Err(e), - Err(e) => Err(CodexErr::Io(io::Error::other(format!( - "thread join failed: {e:?}" - )))), - } -} - -#[cfg(target_os = "linux")] -pub fn apply_sandbox_policy_to_current_thread( - sandbox_policy: &SandboxPolicy, - cwd: &Path, -) -> Result<()> { - crate::landlock::apply_sandbox_policy_to_current_thread(sandbox_policy, cwd) -} - -#[cfg(not(target_os = "linux"))] -pub fn apply_sandbox_policy_to_current_thread( - _sandbox_policy: &SandboxPolicy, - _cwd: &Path, -) -> Result<()> { - Err(CodexErr::Io(io::Error::new( - io::ErrorKind::InvalidInput, - "linux sandbox is not supported on this platform", - ))) -} diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs deleted file mode 100644 index 07c56815..00000000 --- a/codex-rs/core/src/landlock.rs +++ /dev/null @@ -1,336 +0,0 @@ -use std::collections::BTreeMap; -use std::path::Path; -use std::path::PathBuf; - -use crate::error::CodexErr; -use crate::error::Result; -use crate::error::SandboxErr; -use crate::protocol::SandboxPolicy; - -use landlock::ABI; -use landlock::Access; -use landlock::AccessFs; -use landlock::CompatLevel; -use landlock::Compatible; -use landlock::Ruleset; -use landlock::RulesetAttr; -use landlock::RulesetCreatedAttr; -use seccompiler::BpfProgram; -use seccompiler::SeccompAction; -use seccompiler::SeccompCmpArgLen; -use seccompiler::SeccompCmpOp; -use seccompiler::SeccompCondition; -use seccompiler::SeccompFilter; -use seccompiler::SeccompRule; -use seccompiler::TargetArch; -use seccompiler::apply_filter; - -/// Apply sandbox policies inside this thread so only the child inherits -/// them, not the entire CLI process. -pub(crate) fn apply_sandbox_policy_to_current_thread( - sandbox_policy: &SandboxPolicy, - cwd: &Path, -) -> Result<()> { - if !sandbox_policy.has_full_network_access() { - install_network_seccomp_filter_on_current_thread()?; - } - - if !sandbox_policy.has_full_disk_write_access() { - let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); - install_filesystem_landlock_rules_on_current_thread(writable_roots)?; - } - - // TODO(ragona): Add appropriate restrictions if - // `sandbox_policy.has_full_disk_read_access()` is `false`. - - 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`. -/// -/// # Errors -/// Returns [`CodexErr::Sandbox`] variants when the ruleset fails to apply. -fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec) -> Result<()> { - let abi = ABI::V5; - let access_rw = AccessFs::from_all(abi); - let access_ro = AccessFs::from_read(abi); - - let mut ruleset = Ruleset::default() - .set_compatibility(CompatLevel::BestEffort) - .handle_access(access_rw)? - .create()? - .add_rules(landlock::path_beneath_rules(&["/"], access_ro))? - .add_rules(landlock::path_beneath_rules(&["/dev/null"], access_rw))? - .set_no_new_privs(true); - - if !writable_roots.is_empty() { - ruleset = ruleset.add_rules(landlock::path_beneath_rules(&writable_roots, access_rw))?; - } - - let status = ruleset.restrict_self()?; - - if status.ruleset == landlock::RulesetStatus::NotEnforced { - return Err(CodexErr::Sandbox(SandboxErr::LandlockRestrict)); - } - - Ok(()) -} - -/// Installs a seccomp filter that blocks outbound network access except for -/// AF_UNIX domain sockets. -fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> { - // Build rule map. - let mut rules: BTreeMap> = BTreeMap::new(); - - // Helper – insert unconditional deny rule for syscall number. - let mut deny_syscall = |nr: i64| { - rules.insert(nr, vec![]); // empty rule vec = unconditional match - }; - - deny_syscall(libc::SYS_connect); - deny_syscall(libc::SYS_accept); - deny_syscall(libc::SYS_accept4); - deny_syscall(libc::SYS_bind); - deny_syscall(libc::SYS_listen); - deny_syscall(libc::SYS_getpeername); - deny_syscall(libc::SYS_getsockname); - deny_syscall(libc::SYS_shutdown); - deny_syscall(libc::SYS_sendto); - deny_syscall(libc::SYS_sendmsg); - deny_syscall(libc::SYS_sendmmsg); - deny_syscall(libc::SYS_recvfrom); - deny_syscall(libc::SYS_recvmsg); - deny_syscall(libc::SYS_recvmmsg); - deny_syscall(libc::SYS_getsockopt); - deny_syscall(libc::SYS_setsockopt); - deny_syscall(libc::SYS_ptrace); - - // For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny everything else. - let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new( - 0, // first argument (domain) - SeccompCmpArgLen::Dword, - SeccompCmpOp::Eq, - libc::AF_UNIX as u64, - )?])?; - - rules.insert(libc::SYS_socket, vec![unix_only_rule]); - rules.insert(libc::SYS_socketpair, vec![]); // always deny (Unix can use socketpair but fine, keep open?) - - let filter = SeccompFilter::new( - rules, - SeccompAction::Allow, // default – allow - SeccompAction::Errno(libc::EPERM as u32), // when rule matches – return EPERM - if cfg!(target_arch = "x86_64") { - TargetArch::x86_64 - } else if cfg!(target_arch = "aarch64") { - TargetArch::aarch64 - } else { - unimplemented!("unsupported architecture for seccomp filter"); - }, - )?; - - let prog: BpfProgram = filter.try_into()?; - - apply_filter(&prog)?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - #![expect(clippy::unwrap_used, clippy::expect_used)] - - use super::*; - use crate::config_types::ShellEnvironmentPolicy; - use crate::exec::ExecParams; - use crate::exec::SandboxType; - use crate::exec::process_exec_tool_call; - use crate::exec_env::create_env; - use crate::protocol::SandboxPolicy; - use std::collections::HashMap; - use std::sync::Arc; - use tempfile::NamedTempFile; - use tokio::sync::Notify; - - fn create_env_from_core_vars() -> HashMap { - let policy = ShellEnvironmentPolicy::default(); - create_env(&policy) - } - - #[allow(clippy::print_stdout)] - async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { - let params = ExecParams { - command: cmd.iter().map(|elm| elm.to_string()).collect(), - cwd: std::env::current_dir().expect("cwd should exist"), - timeout_ms: Some(timeout_ms), - env: create_env_from_core_vars(), - }; - - let sandbox_policy = - SandboxPolicy::new_read_only_policy_with_writable_roots(writable_roots); - let ctrl_c = Arc::new(Notify::new()); - let res = - process_exec_tool_call(params, SandboxType::LinuxSeccomp, ctrl_c, &sandbox_policy) - .await - .unwrap(); - - if res.exit_code != 0 { - println!("stdout:\n{}", res.stdout); - println!("stderr:\n{}", res.stderr); - panic!("exit code: {}", res.exit_code); - } - } - - #[tokio::test] - async fn test_root_read() { - run_cmd(&["ls", "-l", "/bin"], &[], 200).await; - } - - #[tokio::test] - #[should_panic] - async fn test_root_write() { - let tmpfile = NamedTempFile::new().unwrap(); - let tmpfile_path = tmpfile.path().to_string_lossy(); - run_cmd( - &["bash", "-lc", &format!("echo blah > {}", tmpfile_path)], - &[], - 200, - ) - .await; - } - - #[tokio::test] - async fn test_dev_null_write() { - run_cmd( - &["bash", "-lc", "echo blah > /dev/null"], - &[], - // We have seen timeouts when running this test in CI on GitHub, - // so we are using a generous timeout until we can diagnose further. - 1_000, - ) - .await; - } - - #[tokio::test] - async fn test_writable_root() { - let tmpdir = tempfile::tempdir().unwrap(); - let file_path = tmpdir.path().join("test"); - run_cmd( - &[ - "bash", - "-lc", - &format!("echo blah > {}", file_path.to_string_lossy()), - ], - &[tmpdir.path().to_path_buf()], - // We have seen timeouts when running this test in CI on GitHub, - // so we are using a generous timeout until we can diagnose further. - 1_000, - ) - .await; - } - - #[tokio::test] - #[should_panic(expected = "Sandbox(Timeout)")] - async fn test_timeout() { - run_cmd(&["sleep", "2"], &[], 50).await; - } - - /// Helper that runs `cmd` under the Linux sandbox and asserts that the command - /// does NOT succeed (i.e. returns a non‑zero exit code) **unless** the binary - /// is missing in which case we silently treat it as an accepted skip so the - /// suite remains green on leaner CI images. - async fn assert_network_blocked(cmd: &[&str]) { - let params = ExecParams { - command: cmd.iter().map(|s| s.to_string()).collect(), - cwd: std::env::current_dir().expect("cwd should exist"), - // Give the tool a generous 2-second timeout so even slow DNS timeouts - // do not stall the suite. - timeout_ms: Some(2_000), - env: create_env_from_core_vars(), - }; - - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let ctrl_c = Arc::new(Notify::new()); - let result = - process_exec_tool_call(params, SandboxType::LinuxSeccomp, ctrl_c, &sandbox_policy) - .await; - - let (exit_code, stdout, stderr) = match result { - Ok(output) => (output.exit_code, output.stdout, output.stderr), - Err(CodexErr::Sandbox(SandboxErr::Denied(exit_code, stdout, stderr))) => { - (exit_code, stdout, stderr) - } - _ => { - panic!("expected sandbox denied error, got: {:?}", result); - } - }; - - dbg!(&stderr); - dbg!(&stdout); - dbg!(&exit_code); - - // A completely missing binary exits with 127. Anything else should also - // be non‑zero (EPERM from seccomp will usually bubble up as 1, 2, 13…) - // If—*and only if*—the command exits 0 we consider the sandbox breached. - - if exit_code == 0 { - panic!( - "Network sandbox FAILED - {:?} exited 0\nstdout:\n{}\nstderr:\n{}", - cmd, stdout, stderr - ); - } - } - - #[tokio::test] - async fn sandbox_blocks_curl() { - assert_network_blocked(&["curl", "-I", "http://openai.com"]).await; - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn sandbox_blocks_wget() { - assert_network_blocked(&["wget", "-qO-", "http://openai.com"]).await; - } - - #[tokio::test] - async fn sandbox_blocks_ping() { - // ICMP requires raw socket – should be denied quickly with EPERM. - assert_network_blocked(&["ping", "-c", "1", "8.8.8.8"]).await; - } - - #[tokio::test] - async fn sandbox_blocks_nc() { - // Zero‑length connection attempt to localhost. - assert_network_blocked(&["nc", "-z", "127.0.0.1", "80"]).await; - } - - #[tokio::test] - async fn sandbox_blocks_ssh() { - // Force ssh to attempt a real TCP connection but fail quickly. `BatchMode` - // avoids password prompts, and `ConnectTimeout` keeps the hang time low. - assert_network_blocked(&[ - "ssh", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=1", - "github.com", - ]) - .await; - } - - #[tokio::test] - async fn sandbox_blocks_getent() { - assert_network_blocked(&["getent", "ahosts", "openai.com"]).await; - } - - #[tokio::test] - async fn sandbox_blocks_dev_tcp_redirection() { - // This syntax is only supported by bash and zsh. We try bash first. - // Fallback generic socket attempt using /bin/sh with bash‑style /dev/tcp. Not - // all images ship bash, so we guard against 127 as well. - assert_network_blocked(&["bash", "-c", "echo hi > /dev/tcp/127.0.0.1/80"]).await; - } -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 261ae0a0..8398ff76 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -18,11 +18,8 @@ mod conversation_history; pub mod error; pub mod exec; pub mod exec_env; -pub mod exec_linux; mod flags; mod is_safe_command; -#[cfg(target_os = "linux")] -pub mod landlock; mod mcp_connection_manager; mod mcp_tool_call; mod message_history; diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 13ceb9ec..c3bde697 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -20,6 +20,7 @@ chrono = "0.4.40" clap = { version = "4", features = ["derive"] } codex-core = { path = "../core" } codex-common = { path = "../common", features = ["cli", "elapsed"] } +codex-linux-sandbox = { path = "../linux-sandbox" } mcp-types = { path = "../mcp-types" } owo-colors = "4.2.0" serde_json = "1" diff --git a/codex-rs/exec/src/main.rs b/codex-rs/exec/src/main.rs index 3cb7bd0b..17aa5377 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -1,19 +1,22 @@ -use std::path::PathBuf; - +//! Entry-point for the `codex-exec` binary. +//! +//! When this CLI is invoked normally, it parses the standard `codex-exec` CLI +//! options and launches the non-interactive Codex agent. However, if it is +//! invoked with arg0 as `codex-linux-sandbox`, we instead treat the invocation +//! as a request to run the logic for the standalone `codex-linux-sandbox` +//! executable (i.e., parse any -s args and then run a *sandboxed* command under +//! Landlock + seccomp. +//! +//! This allows us to ship a completely separate set of functionality as part +//! of the `codex-exec` binary. use clap::Parser; use codex_exec::Cli; use codex_exec::run_main; -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let codex_linux_sandbox_exe: Option = if cfg!(target_os = "linux") { - std::env::current_exe().ok() - } else { - None - }; - - let cli = Cli::parse(); - run_main(cli, codex_linux_sandbox_exe).await?; - - Ok(()) +fn main() -> anyhow::Result<()> { + codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { + let cli = Cli::parse(); + run_main(cli, codex_linux_sandbox_exe).await?; + Ok(()) + }) } diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml new file mode 100644 index 00000000..8d1e3a1c --- /dev/null +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "codex-linux-sandbox" +version = { workspace = true } +edition = "2024" + +[[bin]] +name = "codex-linux-sandbox" +path = "src/main.rs" + +[lib] +name = "codex_linux_sandbox" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +clap = { version = "4", features = ["derive"] } +codex-core = { path = "../core" } +codex-common = { path = "../common", features = ["cli"] } + +# Used for error handling in the helper that unifies runtime dispatch across +# binaries. +anyhow = "1" +# Required to construct a Tokio runtime for async execution of the caller's +# entry-point. +tokio = { version = "1", features = ["rt-multi-thread"] } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", + "signal", +] } + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2.172" +landlock = "0.4.1" +seccompiler = "0.5.0" diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md new file mode 100644 index 00000000..676f2349 --- /dev/null +++ b/codex-rs/linux-sandbox/README.md @@ -0,0 +1,8 @@ +# codex-linux-sandbox + +This crate is responsible for producing: + +- a `codex-linux-sandbox` standalone executable for Linux that is bundled with the Node.js version of the Codex CLI +- a lib crate that exposes the business logic of the executable as `run_main()` so that + - the `codex-exec` CLI can check if its arg0 is `codex-linux-sandbox` and, if so, execute as if it were `codex-linux-sandbox` + - this should also be true of the `codex` multitool CLI diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs new file mode 100644 index 00000000..326e2cb4 --- /dev/null +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -0,0 +1,139 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; + +use codex_core::error::CodexErr; +use codex_core::error::Result; +use codex_core::error::SandboxErr; +use codex_core::protocol::SandboxPolicy; + +use landlock::ABI; +use landlock::Access; +use landlock::AccessFs; +use landlock::CompatLevel; +use landlock::Compatible; +use landlock::Ruleset; +use landlock::RulesetAttr; +use landlock::RulesetCreatedAttr; +use seccompiler::BpfProgram; +use seccompiler::SeccompAction; +use seccompiler::SeccompCmpArgLen; +use seccompiler::SeccompCmpOp; +use seccompiler::SeccompCondition; +use seccompiler::SeccompFilter; +use seccompiler::SeccompRule; +use seccompiler::TargetArch; +use seccompiler::apply_filter; + +/// Apply sandbox policies inside this thread so only the child inherits +/// them, not the entire CLI process. +pub(crate) fn apply_sandbox_policy_to_current_thread( + sandbox_policy: &SandboxPolicy, + cwd: &Path, +) -> Result<()> { + if !sandbox_policy.has_full_network_access() { + install_network_seccomp_filter_on_current_thread()?; + } + + if !sandbox_policy.has_full_disk_write_access() { + let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); + install_filesystem_landlock_rules_on_current_thread(writable_roots)?; + } + + // TODO(ragona): Add appropriate restrictions if + // `sandbox_policy.has_full_disk_read_access()` is `false`. + + 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`. +/// +/// # Errors +/// Returns [`CodexErr::Sandbox`] variants when the ruleset fails to apply. +fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec) -> Result<()> { + let abi = ABI::V5; + let access_rw = AccessFs::from_all(abi); + let access_ro = AccessFs::from_read(abi); + + let mut ruleset = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(access_rw)? + .create()? + .add_rules(landlock::path_beneath_rules(&["/"], access_ro))? + .add_rules(landlock::path_beneath_rules(&["/dev/null"], access_rw))? + .set_no_new_privs(true); + + if !writable_roots.is_empty() { + ruleset = ruleset.add_rules(landlock::path_beneath_rules(&writable_roots, access_rw))?; + } + + let status = ruleset.restrict_self()?; + + if status.ruleset == landlock::RulesetStatus::NotEnforced { + return Err(CodexErr::Sandbox(SandboxErr::LandlockRestrict)); + } + + Ok(()) +} + +/// Installs a seccomp filter that blocks outbound network access except for +/// AF_UNIX domain sockets. +fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> { + // Build rule map. + let mut rules: BTreeMap> = BTreeMap::new(); + + // Helper – insert unconditional deny rule for syscall number. + let mut deny_syscall = |nr: i64| { + rules.insert(nr, vec![]); // empty rule vec = unconditional match + }; + + deny_syscall(libc::SYS_connect); + deny_syscall(libc::SYS_accept); + deny_syscall(libc::SYS_accept4); + deny_syscall(libc::SYS_bind); + deny_syscall(libc::SYS_listen); + deny_syscall(libc::SYS_getpeername); + deny_syscall(libc::SYS_getsockname); + deny_syscall(libc::SYS_shutdown); + deny_syscall(libc::SYS_sendto); + deny_syscall(libc::SYS_sendmsg); + deny_syscall(libc::SYS_sendmmsg); + deny_syscall(libc::SYS_recvfrom); + deny_syscall(libc::SYS_recvmsg); + deny_syscall(libc::SYS_recvmmsg); + deny_syscall(libc::SYS_getsockopt); + deny_syscall(libc::SYS_setsockopt); + deny_syscall(libc::SYS_ptrace); + + // For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny everything else. + let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new( + 0, // first argument (domain) + SeccompCmpArgLen::Dword, + SeccompCmpOp::Eq, + libc::AF_UNIX as u64, + )?])?; + + rules.insert(libc::SYS_socket, vec![unix_only_rule]); + rules.insert(libc::SYS_socketpair, vec![]); // always deny (Unix can use socketpair but fine, keep open?) + + let filter = SeccompFilter::new( + rules, + SeccompAction::Allow, // default – allow + SeccompAction::Errno(libc::EPERM as u32), // when rule matches – return EPERM + if cfg!(target_arch = "x86_64") { + TargetArch::x86_64 + } else if cfg!(target_arch = "aarch64") { + TargetArch::aarch64 + } else { + unimplemented!("unsupported architecture for seccomp filter"); + }, + )?; + + let prog: BpfProgram = filter.try_into()?; + + apply_filter(&prog)?; + + Ok(()) +} diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs new file mode 100644 index 00000000..568f0158 --- /dev/null +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -0,0 +1,63 @@ +#[cfg(target_os = "linux")] +mod landlock; +#[cfg(target_os = "linux")] +mod linux_run_main; + +#[cfg(target_os = "linux")] +pub use linux_run_main::run_main; + +use std::future::Future; +use std::path::PathBuf; + +/// Helper that consolidates the common boilerplate found in several Codex +/// binaries (`codex`, `codex-exec`, `codex-tui`) around dispatching to the +/// `codex-linux-sandbox` sub-command. +/// +/// When the current executable is invoked through the hard-link or alias +/// named `codex-linux-sandbox` we *directly* execute [`run_main`](crate::run_main) +/// (which never returns). Otherwise we: +/// 1. Construct a Tokio multi-thread runtime. +/// 2. Derive the path to the current executable (so children can re-invoke +/// the sandbox) when running on Linux. +/// 3. Execute the provided async `main_fn` inside that runtime, forwarding +/// any error. +/// +/// This function eliminates duplicated code across the various `main.rs` +/// entry-points. +pub fn run_with_sandbox(main_fn: F) -> anyhow::Result<()> +where + F: FnOnce(Option) -> Fut, + Fut: Future>, +{ + use std::path::Path; + + // Determine if we were invoked via the special alias. + let argv0 = std::env::args().next().unwrap_or_default(); + let exe_name = Path::new(&argv0) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + if exe_name == "codex-linux-sandbox" { + // Safety: [`run_main`] never returns. + crate::run_main(); + } + + // Regular invocation – create a Tokio runtime and execute the provided + // async entry-point. + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(async move { + let codex_linux_sandbox_exe: Option = if cfg!(target_os = "linux") { + std::env::current_exe().ok() + } else { + None + }; + + main_fn(codex_linux_sandbox_exe).await + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn run_main() -> ! { + panic!("codex-linux-sandbox is only supported on Linux"); +} diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs new file mode 100644 index 00000000..a8c73aa7 --- /dev/null +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -0,0 +1,59 @@ +use clap::Parser; +use codex_common::SandboxPermissionOption; +use std::ffi::CString; + +use crate::landlock::apply_sandbox_policy_to_current_thread; + +#[derive(Debug, Parser)] +pub struct LandlockCommand { + #[clap(flatten)] + pub sandbox: SandboxPermissionOption, + + /// Full command args to run under landlock. + #[arg(trailing_var_arg = true)] + pub command: Vec, +} + +pub fn run_main() -> ! { + let LandlockCommand { sandbox, command } = LandlockCommand::parse(); + + let sandbox_policy = match sandbox.permissions.map(Into::into) { + Some(sandbox_policy) => sandbox_policy, + None => codex_core::protocol::SandboxPolicy::new_read_only_policy(), + }; + + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(e) => { + panic!("failed to getcwd(): {e:?}"); + } + }; + + if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &cwd) { + panic!("error running landlock: {e:?}"); + } + + if command.is_empty() { + panic!("No command specified to execute."); + } + + #[expect(clippy::expect_used)] + let c_command = + CString::new(command[0].as_str()).expect("Failed to convert command to CString"); + #[expect(clippy::expect_used)] + let c_args: Vec = command + .iter() + .map(|arg| CString::new(arg.as_str()).expect("Failed to convert arg to CString")) + .collect(); + + let mut c_args_ptrs: Vec<*const libc::c_char> = c_args.iter().map(|arg| arg.as_ptr()).collect(); + c_args_ptrs.push(std::ptr::null()); + + unsafe { + libc::execvp(c_command.as_ptr(), c_args_ptrs.as_ptr()); + } + + // If execvp returns, there was an error. + let err = std::io::Error::last_os_error(); + panic!("Failed to execvp {}: {err}", command[0].as_str()); +} diff --git a/codex-rs/linux-sandbox/src/main.rs b/codex-rs/linux-sandbox/src/main.rs new file mode 100644 index 00000000..83602b50 --- /dev/null +++ b/codex-rs/linux-sandbox/src/main.rs @@ -0,0 +1,6 @@ +/// Note that the cwd, env, and command args are preserved in the ultimate call +/// to `execv`, so the caller is responsible for ensuring those values are +/// correct. +fn main() -> ! { + codex_linux_sandbox::run_main() +} diff --git a/codex-rs/linux-sandbox/tests/landlock.rs b/codex-rs/linux-sandbox/tests/landlock.rs new file mode 100644 index 00000000..95ca11a2 --- /dev/null +++ b/codex-rs/linux-sandbox/tests/landlock.rs @@ -0,0 +1,209 @@ +#![cfg(target_os = "linux")] +#![expect(clippy::unwrap_used, clippy::expect_used)] + +use codex_core::config_types::ShellEnvironmentPolicy; +use codex_core::error::CodexErr; +use codex_core::error::SandboxErr; +use codex_core::exec::ExecParams; +use codex_core::exec::SandboxType; +use codex_core::exec::process_exec_tool_call; +use codex_core::exec_env::create_env; +use codex_core::protocol::SandboxPolicy; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::NamedTempFile; +use tokio::sync::Notify; + +fn create_env_from_core_vars() -> HashMap { + let policy = ShellEnvironmentPolicy::default(); + create_env(&policy) +} + +#[allow(clippy::print_stdout)] +async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { + let params = ExecParams { + command: cmd.iter().map(|elm| elm.to_string()).collect(), + cwd: std::env::current_dir().expect("cwd should exist"), + timeout_ms: Some(timeout_ms), + env: create_env_from_core_vars(), + }; + + let sandbox_policy = SandboxPolicy::new_read_only_policy_with_writable_roots(writable_roots); + let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); + let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program)); + let ctrl_c = Arc::new(Notify::new()); + let res = process_exec_tool_call( + params, + SandboxType::LinuxSeccomp, + ctrl_c, + &sandbox_policy, + &codex_linux_sandbox_exe, + ) + .await + .unwrap(); + + if res.exit_code != 0 { + println!("stdout:\n{}", res.stdout); + println!("stderr:\n{}", res.stderr); + panic!("exit code: {}", res.exit_code); + } +} + +#[tokio::test] +async fn test_root_read() { + run_cmd(&["ls", "-l", "/bin"], &[], 200).await; +} + +#[tokio::test] +#[should_panic] +async fn test_root_write() { + let tmpfile = NamedTempFile::new().unwrap(); + let tmpfile_path = tmpfile.path().to_string_lossy(); + run_cmd( + &["bash", "-lc", &format!("echo blah > {}", tmpfile_path)], + &[], + 200, + ) + .await; +} + +#[tokio::test] +async fn test_dev_null_write() { + run_cmd( + &["bash", "-lc", "echo blah > /dev/null"], + &[], + // We have seen timeouts when running this test in CI on GitHub, + // so we are using a generous timeout until we can diagnose further. + 1_000, + ) + .await; +} + +#[tokio::test] +async fn test_writable_root() { + let tmpdir = tempfile::tempdir().unwrap(); + let file_path = tmpdir.path().join("test"); + run_cmd( + &[ + "bash", + "-lc", + &format!("echo blah > {}", file_path.to_string_lossy()), + ], + &[tmpdir.path().to_path_buf()], + // We have seen timeouts when running this test in CI on GitHub, + // so we are using a generous timeout until we can diagnose further. + 1_000, + ) + .await; +} + +#[tokio::test] +#[should_panic(expected = "Sandbox(Timeout)")] +async fn test_timeout() { + run_cmd(&["sleep", "2"], &[], 50).await; +} + +/// Helper that runs `cmd` under the Linux sandbox and asserts that the command +/// does NOT succeed (i.e. returns a non‑zero exit code) **unless** the binary +/// is missing in which case we silently treat it as an accepted skip so the +/// suite remains green on leaner CI images. +async fn assert_network_blocked(cmd: &[&str]) { + let cwd = std::env::current_dir().expect("cwd should exist"); + let params = ExecParams { + command: cmd.iter().map(|s| s.to_string()).collect(), + cwd, + // Give the tool a generous 2-second timeout so even slow DNS timeouts + // do not stall the suite. + timeout_ms: Some(2_000), + env: create_env_from_core_vars(), + }; + + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let ctrl_c = Arc::new(Notify::new()); + let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); + let codex_linux_sandbox_exe: Option = Some(PathBuf::from(sandbox_program)); + let result = process_exec_tool_call( + params, + SandboxType::LinuxSeccomp, + ctrl_c, + &sandbox_policy, + &codex_linux_sandbox_exe, + ) + .await; + + let (exit_code, stdout, stderr) = match result { + Ok(output) => (output.exit_code, output.stdout, output.stderr), + Err(CodexErr::Sandbox(SandboxErr::Denied(exit_code, stdout, stderr))) => { + (exit_code, stdout, stderr) + } + _ => { + panic!("expected sandbox denied error, got: {:?}", result); + } + }; + + dbg!(&stderr); + dbg!(&stdout); + dbg!(&exit_code); + + // A completely missing binary exits with 127. Anything else should also + // be non‑zero (EPERM from seccomp will usually bubble up as 1, 2, 13…) + // If—*and only if*—the command exits 0 we consider the sandbox breached. + + if exit_code == 0 { + panic!( + "Network sandbox FAILED - {:?} exited 0\nstdout:\n{}\nstderr:\n{}", + cmd, stdout, stderr + ); + } +} + +#[tokio::test] +async fn sandbox_blocks_curl() { + assert_network_blocked(&["curl", "-I", "http://openai.com"]).await; +} + +#[tokio::test] +async fn sandbox_blocks_wget() { + assert_network_blocked(&["wget", "-qO-", "http://openai.com"]).await; +} + +#[tokio::test] +async fn sandbox_blocks_ping() { + // ICMP requires raw socket – should be denied quickly with EPERM. + assert_network_blocked(&["ping", "-c", "1", "8.8.8.8"]).await; +} + +#[tokio::test] +async fn sandbox_blocks_nc() { + // Zero‑length connection attempt to localhost. + assert_network_blocked(&["nc", "-z", "127.0.0.1", "80"]).await; +} + +#[tokio::test] +async fn sandbox_blocks_ssh() { + // Force ssh to attempt a real TCP connection but fail quickly. `BatchMode` + // avoids password prompts, and `ConnectTimeout` keeps the hang time low. + assert_network_blocked(&[ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=1", + "github.com", + ]) + .await; +} + +#[tokio::test] +async fn sandbox_blocks_getent() { + assert_network_blocked(&["getent", "ahosts", "openai.com"]).await; +} + +#[tokio::test] +async fn sandbox_blocks_dev_tcp_redirection() { + // This syntax is only supported by bash and zsh. We try bash first. + // Fallback generic socket attempt using /bin/sh with bash‑style /dev/tcp. Not + // all images ship bash, so we guard against 127 as well. + assert_network_blocked(&["bash", "-c", "echo hi > /dev/tcp/127.0.0.1/80"]).await; +} diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 9b5153a5..968222c9 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -15,7 +15,9 @@ path = "src/lib.rs" workspace = true [dependencies] +anyhow = "1" codex-core = { path = "../core" } +codex-linux-sandbox = { path = "../linux-sandbox" } mcp-types = { path = "../mcp-types" } schemars = "0.8.22" serde = { version = "1", features = ["derive"] } diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index 8ce727e9..51c46c44 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -1,15 +1,8 @@ -use std::path::PathBuf; - use codex_mcp_server::run_main; -#[tokio::main] -async fn main() -> std::io::Result<()> { - let codex_linux_sandbox_exe: Option = if cfg!(target_os = "linux") { - std::env::current_exe().ok() - } else { - None - }; - - run_main(codex_linux_sandbox_exe).await?; - Ok(()) +fn main() -> anyhow::Result<()> { + codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { + run_main(codex_linux_sandbox_exe).await?; + Ok(()) + }) } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c09baf28..c7a8361f 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -20,6 +20,7 @@ clap = { version = "4", features = ["derive"] } codex-ansi-escape = { path = "../ansi-escape" } codex-core = { path = "../core" } codex-common = { path = "../common", features = ["cli", "elapsed"] } +codex-linux-sandbox = { path = "../linux-sandbox" } color-eyre = "0.6.3" crossterm = { version = "0.28.1", features = ["bracketed-paste"] } lazy_static = "1" diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 08738ba2..7e55f2af 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -1,18 +1,11 @@ -use std::path::PathBuf; - use clap::Parser; use codex_tui::Cli; use codex_tui::run_main; -#[tokio::main] -async fn main() -> std::io::Result<()> { - let codex_linux_sandbox_exe: Option = if cfg!(target_os = "linux") { - std::env::current_exe().ok() - } else { - None - }; - - let cli = Cli::parse(); - run_main(cli, codex_linux_sandbox_exe)?; - Ok(()) +fn main() -> anyhow::Result<()> { + codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { + let cli = Cli::parse(); + run_main(cli, codex_linux_sandbox_exe)?; + Ok(()) + }) }