mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
fix: overhaul how we spawn commands under seccomp/landlock on Linux
This commit is contained in:
13
codex-rs/Cargo.lock
generated
13
codex-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
});
|
||||
}
|
||||
|
||||
25
codex-rs/linux-sandbox/Cargo.toml
Normal file
25
codex-rs/linux-sandbox/Cargo.toml
Normal 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"
|
||||
8
codex-rs/linux-sandbox/README.md
Normal file
8
codex-rs/linux-sandbox/README.md
Normal 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
|
||||
@@ -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;
|
||||
12
codex-rs/linux-sandbox/src/lib.rs
Normal file
12
codex-rs/linux-sandbox/src/lib.rs
Normal 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");
|
||||
}
|
||||
60
codex-rs/linux-sandbox/src/linux_run_main.rs
Normal file
60
codex-rs/linux-sandbox/src/linux_run_main.rs
Normal 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}");
|
||||
}
|
||||
6
codex-rs/linux-sandbox/src/main.rs
Normal file
6
codex-rs/linux-sandbox/src/main.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user