From c992c438d8d26a8629dfc9526236cbb36595290b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 22 May 2025 14:47:02 -0700 Subject: [PATCH] fix: overhaul how we spawn commands under seccomp/landlock on Linux --- codex-rs/Cargo.lock | 13 + codex-rs/Cargo.toml | 3 +- codex-rs/cli/Cargo.toml | 4 - codex-rs/cli/src/landlock.rs | 37 --- codex-rs/cli/src/lib.rs | 2 - codex-rs/cli/src/linux-sandbox/main.rs | 28 -- codex-rs/cli/src/main.rs | 23 +- codex-rs/core/src/exec.rs | 276 +++++++++++------- codex-rs/core/src/exec_linux.rs | 79 ----- codex-rs/core/src/lib.rs | 3 - codex-rs/exec/Cargo.toml | 1 + codex-rs/exec/src/main.rs | 37 ++- codex-rs/linux-sandbox/Cargo.toml | 25 ++ codex-rs/linux-sandbox/README.md | 8 + .../{core => linux-sandbox}/src/landlock.rs | 8 +- codex-rs/linux-sandbox/src/lib.rs | 12 + codex-rs/linux-sandbox/src/linux_run_main.rs | 57 ++++ codex-rs/linux-sandbox/src/main.rs | 6 + 18 files changed, 342 insertions(+), 280 deletions(-) delete mode 100644 codex-rs/cli/src/landlock.rs delete mode 100644 codex-rs/cli/src/linux-sandbox/main.rs delete mode 100644 codex-rs/core/src/exec_linux.rs create mode 100644 codex-rs/linux-sandbox/Cargo.toml create mode 100644 codex-rs/linux-sandbox/README.md rename codex-rs/{core => linux-sandbox}/src/landlock.rs (98%) create mode 100644 codex-rs/linux-sandbox/src/lib.rs create mode 100644 codex-rs/linux-sandbox/src/linux_run_main.rs create mode 100644 codex-rs/linux-sandbox/src/main.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6408e8de6f..6a785665a7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-linux-sandbox" +version = "0.0.0" +dependencies = [ + "clap", + "codex-common", + "codex-core", + "landlock", + "libc", + "seccompiler", +] + [[package]] name = "codex-mcp-client" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e95942cbf5..5af55f45ce 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 f7ad70e9df..9fa80929f1 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" diff --git a/codex-rs/cli/src/landlock.rs b/codex-rs/cli/src/landlock.rs deleted file mode 100644 index 5a65fcbca4..0000000000 --- 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 b5ce03c59a..40016c13f0 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,6 +1,4 @@ mod exit_status; -#[cfg(unix)] -pub mod landlock; pub mod proto; pub mod seatbelt; 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 3141656595..0000000000 --- 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 b2b1b8cf9a..8d5a768658 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -6,6 +6,7 @@ use codex_cli::proto; use codex_cli::seatbelt; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::exec_env::create_env; use codex_exec::Cli as ExecCli; use codex_tui::Cli as TuiCli; @@ -94,22 +95,32 @@ async fn main() -> anyhow::Result<()> { })?; seatbelt::run_seatbelt(command, &config).await?; } - #[cfg(unix)] DebugCommand::Landlock(LandlockCommand { command, sandbox, full_auto, }) => { 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), ..Default::default() })?; - codex_cli::landlock::run_landlock(command, &config)?; - } - #[cfg(not(unix))] - DebugCommand::Landlock(_) => { - anyhow::bail!("Landlock is only supported on Linux."); + let full_args = codex_core::exec::create_linux_sandbox_command_args( + command, + &config.sandbox_policy, + &cwd, + ); + + let env = create_env(&config.shell_environment_policy); + codex_core::exec::spawn_command_under_linux_sandbox( + full_args, + &config.sandbox_policy, + cwd, + codex_core::exec::StdioPolicy::Inherit, + env, + ) + .await?; } }, } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 96b601b613..878afa717a 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: @@ -101,7 +100,25 @@ 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 child = spawn_command_under_linux_sandbox( + 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 { @@ -152,7 +169,104 @@ pub async fn spawn_command_under_seatbelt( 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 arg0 = None; + spawn_child_async( + seatbelt_command, + arg0, + cwd, + sandbox_policy, + stdio_policy, + env, + ) + .await +} + +/// 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 so that front-ends and the business-logic layer +/// remain decoupled from the platform-specific implementation. +pub async fn spawn_command_under_linux_sandbox( + command: Vec, + sandbox_policy: &SandboxPolicy, + cwd: PathBuf, + stdio_policy: StdioPolicy, + env: HashMap, +) -> std::io::Result { + let linux_cmd = create_linux_sandbox_command_args(command, sandbox_policy, &cwd); + let arg0 = Some("codex-linux-sandbox"); + spawn_child_async(linux_cmd, arg0, cwd, sandbox_policy, stdio_policy, env).await +} + +/// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`. +pub fn create_linux_sandbox_command_args( + command: Vec, + sandbox_policy: &SandboxPolicy, + cwd: &Path, +) -> Vec { + // TODO(mbolin): Require the client to pass codex_linux_sandbox_exe as a + // parameter to this function because code in `codex_core` should assume it + // is bundled in a binary that special-cases arg0 when it is + // "codex-linux-sandbox". + #[expect(clippy::expect_used)] + let codex_linux_sandbox_exe = + std::env::current_exe().expect("failed to get current executable"); + + #[expect(clippy::expect_used)] + let mut linux_cmd: Vec = vec![ + codex_linux_sandbox_exe + .to_str() + .expect("failed to convert path to str") + .to_string(), + ]; + + // If the policy matches the built-in “full-auto” setting, use the concise flag. + if *sandbox_policy == SandboxPolicy::new_full_auto_policy() { + linux_cmd.push("--full-auto".to_string()); + } else { + // Otherwise, translate individual permissions. + // Use high-level helper methods to infer flags when we cannot see the + // exact permission list (private field). + + 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( @@ -243,8 +357,10 @@ async fn exec( sandbox_policy: &SandboxPolicy, ctrl_c: Arc, ) -> Result { + let arg0 = None; let child = spawn_child_async( command, + arg0, cwd, sandbox_policy, StdioPolicy::RedirectForShellTool, @@ -260,124 +376,62 @@ 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( +async fn spawn_child_async( command: Vec, + 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() -} + // 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", + )); + } -/// 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() + let mut cmd = Command::new(&command[0]); + #[cfg(unix)] + cmd.arg0(arg0.unwrap_or_else(|| &command[0])); + cmd.args(&command[1..]); + cmd.current_dir(cwd); + cmd.env_clear(); + cmd.envs(env); + + 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 76bd428a7f..0000000000 --- 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/lib.rs b/codex-rs/core/src/lib.rs index 261ae0a0fd..8398ff7650 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 13ceb9ece6..c3bde69719 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 3a40da2336..6d7efaf43f 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -1,11 +1,38 @@ +//! 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; +use std::path::Path; -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - run_main(cli).await?; +// No #[tokio::main]! If arg0 is `codex-linux-sandbox`, we delegate to +// `codex_linux_sandbox::run_main()` and do not want to start the Tokio runtime. +fn main() -> anyhow::Result<()> { + // 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(""); - Ok(()) + if exe_name == "codex-linux-sandbox" { + codex_linux_sandbox::run_main() + } + + // Regular `codex-exec` invocation – parse the normal CLI. + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(async { + let cli = Cli::parse(); + run_main(cli).await?; + Ok(()) + }) } diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml new file mode 100644 index 0000000000..10371dd3ab --- /dev/null +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -0,0 +1,25 @@ +[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"] } + +[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 0000000000..676f234954 --- /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/core/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs similarity index 98% rename from codex-rs/core/src/landlock.rs rename to codex-rs/linux-sandbox/src/landlock.rs index 07c568151a..9ee0762e3b 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -2,10 +2,10 @@ 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 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; diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs new file mode 100644 index 0000000000..8e00b6110f --- /dev/null +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -0,0 +1,12 @@ +#[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; + +#[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 0000000000..b19cb593b1 --- /dev/null +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -0,0 +1,57 @@ +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."); + } + + let c_command = + CString::new(command[0].as_str()).expect("Failed to convert command to CString"); + 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::execv(c_command.as_ptr(), c_args_ptrs.as_ptr()); + } + + // If execv returns, there was an error. + let err = std::io::Error::last_os_error(); + panic!("Failed to execv: {err}"); +} diff --git a/codex-rs/linux-sandbox/src/main.rs b/codex-rs/linux-sandbox/src/main.rs new file mode 100644 index 0000000000..83602b508e --- /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() +}