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:
20
codex-rs/Cargo.lock
generated
20
codex-rs/Cargo.lock
generated
@@ -491,6 +491,7 @@ dependencies = [
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-exec",
|
||||
"codex-linux-sandbox",
|
||||
"codex-mcp-server",
|
||||
"codex-tui",
|
||||
"serde_json",
|
||||
@@ -562,6 +563,7 @@ dependencies = [
|
||||
"clap",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-linux-sandbox",
|
||||
"mcp-types",
|
||||
"owo-colors 4.2.0",
|
||||
"serde_json",
|
||||
@@ -591,6 +593,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 +626,9 @@ dependencies = [
|
||||
name = "codex-mcp-server"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-core",
|
||||
"codex-linux-sandbox",
|
||||
"mcp-types",
|
||||
"pretty_assertions",
|
||||
"schemars",
|
||||
@@ -629,6 +648,7 @@ dependencies = [
|
||||
"codex-ansi-escape",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-linux-sandbox",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"lazy_static",
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
91
codex-rs/cli/src/debug_sandbox.rs
Normal file
91
codex-rs/cli/src/debug_sandbox.rs
Normal file
@@ -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<String>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>, 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<ExitStatus> {
|
||||
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);
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<PathBuf> = 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<PathBuf>) -> 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?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<String>, 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);
|
||||
}
|
||||
@@ -187,6 +187,7 @@ pub(crate) struct Session {
|
||||
/// sessions can be replayed or inspected later.
|
||||
rollout: Mutex<Option<crate::rollout::RolloutRecorder>>,
|
||||
state: Mutex<State>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -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<Notify>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
codex_linux_sandbox_exe: &Option<PathBuf>,
|
||||
) -> Result<ExecToolCallOutput> {
|
||||
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<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
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<P>(
|
||||
codex_linux_sandbox_exe: P,
|
||||
command: Vec<String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
cwd: PathBuf,
|
||||
stdio_policy: StdioPolicy,
|
||||
env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
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<String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
cwd: &Path,
|
||||
) -> Vec<String> {
|
||||
let mut linux_cmd: Vec<String> = 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<String>,
|
||||
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<String> = 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<String> = 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<Notify>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
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<String>,
|
||||
///
|
||||
/// 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<String>,
|
||||
#[cfg_attr(not(unix), allow(unused_variables))] arg0: Option<&str>,
|
||||
cwd: PathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
stdio_policy: StdioPolicy,
|
||||
env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
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<String>,
|
||||
cwd: PathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
stdio_policy: StdioPolicy,
|
||||
env: HashMap<String, String>,
|
||||
) -> std::io::Result<std::process::Child> {
|
||||
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
|
||||
|
||||
@@ -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<Notify>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
// 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",
|
||||
)))
|
||||
}
|
||||
@@ -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<PathBuf>) -> 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<i64, Vec<SeccompRule>> = 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<String, String> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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<PathBuf> = 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(())
|
||||
})
|
||||
}
|
||||
|
||||
42
codex-rs/linux-sandbox/Cargo.toml
Normal file
42
codex-rs/linux-sandbox/Cargo.toml
Normal file
@@ -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"
|
||||
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
|
||||
139
codex-rs/linux-sandbox/src/landlock.rs
Normal file
139
codex-rs/linux-sandbox/src/landlock.rs
Normal file
@@ -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<PathBuf>) -> 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<i64, Vec<SeccompRule>> = 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(())
|
||||
}
|
||||
63
codex-rs/linux-sandbox/src/lib.rs
Normal file
63
codex-rs/linux-sandbox/src/lib.rs
Normal file
@@ -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<F, Fut>(main_fn: F) -> anyhow::Result<()>
|
||||
where
|
||||
F: FnOnce(Option<PathBuf>) -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<()>>,
|
||||
{
|
||||
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<PathBuf> = 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");
|
||||
}
|
||||
59
codex-rs/linux-sandbox/src/linux_run_main.rs
Normal file
59
codex-rs/linux-sandbox/src/linux_run_main.rs
Normal file
@@ -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<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) = 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<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::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());
|
||||
}
|
||||
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()
|
||||
}
|
||||
209
codex-rs/linux-sandbox/tests/landlock.rs
Normal file
209
codex-rs/linux-sandbox/tests/landlock.rs
Normal file
@@ -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<String, String> {
|
||||
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<PathBuf> = 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;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
@@ -30,5 +31,8 @@ tokio = { version = "1", features = [
|
||||
"signal",
|
||||
] }
|
||||
|
||||
# For unified sandbox/async dispatch helper.
|
||||
codex-linux-sandbox = { path = "../linux-sandbox" }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
|
||||
@@ -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<PathBuf> = 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(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,5 +49,8 @@ tui-markdown = "0.3.3"
|
||||
tui-textarea = "0.7.0"
|
||||
uuid = "1"
|
||||
|
||||
# For unified sandbox/async runtime dispatch logic.
|
||||
codex-linux-sandbox = { path = "../linux-sandbox" }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
||||
@@ -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<PathBuf> = 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(())
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user