fix: overhaul how we spawn commands under seccomp/landlock on Linux

This commit is contained in:
Michael Bolin
2025-05-22 14:47:02 -07:00
parent cb379d7797
commit 7ae2f80bb7
14 changed files with 262 additions and 46 deletions

13
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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(())
}

View File

@@ -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<String>,
sandbox_policy: &SandboxPolicy,
cwd: PathBuf,
stdio_policy: StdioPolicy,
env: HashMap<String, String>,
) -> std::io::Result<Child> {
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<String>,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
) -> Vec<String> {
// 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<String> = 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<String>,
sandbox_policy: &SandboxPolicy,

View File

@@ -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;

View File

@@ -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"

View File

@@ -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(())
});
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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<String>,
}
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<CString> = 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}");
}

View File

@@ -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()
}