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/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/core/src/exec.rs b/codex-rs/core/src/exec.rs index 96b601b613..cbe29d0cf6 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -21,7 +21,7 @@ use tokio::sync::Notify; use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; -use crate::exec_linux::exec_linux; +// use crate::exec_linux::exec_linux; // No longer needed – switch to helper binary. use crate::protocol::SandboxPolicy; // Maximum we send for each stream, which is either: @@ -101,7 +101,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 { @@ -155,6 +173,87 @@ pub async fn spawn_command_under_seatbelt( spawn_child_async(seatbelt_command, 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. +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(command, sandbox_policy, &cwd); + spawn_child_async(linux_cmd, cwd, sandbox_policy, stdio_policy, env).await +} + +/// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`. +fn create_linux_sandbox_command( + mut command: Vec, + sandbox_policy: &SandboxPolicy, + cwd: &Path, +) -> Vec { + // Resolve the helper binary path in the following order: + // 1. Explicit override via `CODEX_LINUX_SANDBOX_EXECUTABLE` env var. + // 2. Cargo-provided env var when running tests (`CARGO_BIN_EXE_codex-linux-sandbox`). + // 3. Fallback to just `codex-linux-sandbox` (resolved via PATH). + let helper = std::env::var("CODEX_LINUX_SANDBOX_EXECUTABLE") + .or_else(|_| std::env::var("CARGO_BIN_EXE_codex-linux-sandbox")) + .unwrap_or_else(|_| "codex-linux-sandbox".to_string()); + + let mut linux_cmd: Vec = vec![helper]; + + // 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.append(&mut command); + + linux_cmd +} + fn create_seatbelt_command( command: Vec, sandbox_policy: &SandboxPolicy, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 261ae0a0fd..63ee84aff2 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -21,8 +21,6 @@ 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..396d369a6a 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -1,11 +1,36 @@ 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?; +/// Entry-point for the `codex-exec` binary. +/// +/// When invoked normally it parses the standard `codex-exec` CLI options and +/// launches the non-interactive Codex agent. However, if the executable name is +/// `codex-linux-sandbox`, we instead treat the invocation as a request to run a +/// *sandboxed* command under Landlock + seccomp. This allows us to create a +/// lightweight symlink alias instead of shipping a separate binary — mirroring +/// how macOS uses `/usr/bin/sandbox-exec`. - Ok(()) +// 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(""); + + 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()?; + return 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..b4faaa179b --- /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 codex_linux_sandbox::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..c438f7a343 --- /dev/null +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -0,0 +1,60 @@ +use clap::Parser; +use codex_common::SandboxPermissionOption; +use std::env; +use std::ffi::CString; +use std::io::Error; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process; + +#[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) = landlock::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() +}