From 1c75b190764f3b5cd24d434fbd06fa7f80c2f975 Mon Sep 17 00:00:00 2001 From: celia-oai Date: Tue, 17 Mar 2026 22:58:08 -0700 Subject: [PATCH] changes --- codex-rs/Cargo.lock | 5 + codex-rs/README.md | 3 + codex-rs/cli/Cargo.toml | 5 + codex-rs/cli/src/debug_sandbox.rs | 84 ++++- codex-rs/cli/src/debug_sandbox/zsh_fork.rs | 409 +++++++++++++++++++++ codex-rs/cli/src/lib.rs | 4 + codex-rs/cli/src/main.rs | 1 + 7 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 codex-rs/cli/src/debug_sandbox/zsh_fork.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 74609ca058..82426a5fb6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1643,6 +1643,7 @@ dependencies = [ "anyhow", "assert_cmd", "assert_matches", + "async-trait", "clap", "clap_complete", "codex-app-server", @@ -1657,13 +1658,16 @@ dependencies = [ "codex-execpolicy", "codex-login", "codex-mcp-server", + "codex-network-proxy", "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-shell-escalation", "codex-state", "codex-stdio-to-uds", "codex-tui", "codex-tui-app-server", + "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", "codex-windows-sandbox", @@ -1677,6 +1681,7 @@ dependencies = [ "supports-color 3.0.2", "tempfile", "tokio", + "tokio-util", "toml 0.9.11+spec-1.1.0", "tracing", "tracing-appender", diff --git a/codex-rs/README.md b/codex-rs/README.md index ad8a0506ff..90a10c7449 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -60,6 +60,7 @@ To test to see what happens when a command is run under the sandbox provided by ``` # macOS codex sandbox macos [--full-auto] [--log-denials] [COMMAND]... +codex sandbox macos [--zsh-fork] [--log-denials] [COMMAND]... # Linux codex sandbox linux [--full-auto] [COMMAND]... @@ -72,6 +73,8 @@ codex debug seatbelt [--full-auto] [--log-denials] [COMMAND]... codex debug landlock [--full-auto] [COMMAND]... ``` +Use `codex sandbox macos --zsh-fork` when you need to reproduce behavior from the zsh-fork exec-interception path. The command must be a `zsh -c ...` or `zsh -lc ...` invocation; intercepted child execs are then re-run under the same turn sandbox. + ### Selecting a sandbox policy via `--sandbox` The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option: diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index a7e88cd1b4..fb1e96898f 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -16,6 +16,7 @@ path = "src/lib.rs" workspace = true [dependencies] +async-trait = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } @@ -32,13 +33,16 @@ codex-exec = { workspace = true } codex-execpolicy = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } +codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } +codex-shell-escalation = { workspace = true } codex-state = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-tui = { workspace = true } codex-tui-app-server = { workspace = true } +codex-utils-absolute-path = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } @@ -52,6 +56,7 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } +tokio-util = { workspace = true } toml = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 64169327f5..1ab3559827 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -2,6 +2,8 @@ mod pid_tracker; #[cfg(target_os = "macos")] mod seatbelt; +#[cfg(target_os = "macos")] +mod zsh_fork; use std::path::PathBuf; use std::process::Stdio; @@ -36,20 +38,26 @@ use seatbelt::DenialLogger; pub async fn run_command_under_seatbelt( command: SeatbeltCommand, codex_linux_sandbox_exe: Option, + main_execve_wrapper_exe: Option, ) -> anyhow::Result<()> { let SeatbeltCommand { full_auto, + zsh_fork, log_denials, config_overrides, command, } = command; run_command_under_sandbox( - full_auto, + SandboxRunOptions { + full_auto, + zsh_fork, + log_denials, + }, command, config_overrides, codex_linux_sandbox_exe, + main_execve_wrapper_exe, SandboxType::Seatbelt, - log_denials, ) .await } @@ -58,6 +66,7 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_seatbelt( _command: SeatbeltCommand, _codex_linux_sandbox_exe: Option, + _main_execve_wrapper_exe: Option, ) -> anyhow::Result<()> { anyhow::bail!("Seatbelt sandbox is only available on macOS"); } @@ -72,12 +81,16 @@ pub async fn run_command_under_landlock( command, } = command; run_command_under_sandbox( - full_auto, + SandboxRunOptions { + full_auto, + zsh_fork: false, + log_denials: false, + }, command, config_overrides, codex_linux_sandbox_exe, + None, SandboxType::Landlock, - /*log_denials*/ false, ) .await } @@ -92,12 +105,16 @@ pub async fn run_command_under_windows( command, } = command; run_command_under_sandbox( - full_auto, + SandboxRunOptions { + full_auto, + zsh_fork: false, + log_denials: false, + }, command, config_overrides, codex_linux_sandbox_exe, + None, SandboxType::Windows, - /*log_denials*/ false, ) .await } @@ -109,20 +126,27 @@ enum SandboxType { Windows, } -async fn run_command_under_sandbox( +struct SandboxRunOptions { full_auto: bool, + zsh_fork: bool, + log_denials: bool, +} + +async fn run_command_under_sandbox( + options: SandboxRunOptions, command: Vec, config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, + main_execve_wrapper_exe: Option, sandbox_type: SandboxType, - log_denials: bool, ) -> anyhow::Result<()> { let config = load_debug_sandbox_config( config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?, codex_linux_sandbox_exe, - full_auto, + main_execve_wrapper_exe, + options.full_auto, ) .await?; @@ -139,6 +163,18 @@ async fn run_command_under_sandbox( /*thread_id*/ None, ); + #[cfg(target_os = "macos")] + if matches!(sandbox_type, SandboxType::Seatbelt) && options.zsh_fork { + return zsh_fork::run_command_under_zsh_fork( + command, + config, + cwd, + env, + options.log_denials, + ) + .await; + } + // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] @@ -218,9 +254,9 @@ async fn run_command_under_sandbox( } #[cfg(target_os = "macos")] - let mut denial_logger = log_denials.then(DenialLogger::new).flatten(); + let mut denial_logger = options.log_denials.then(DenialLogger::new).flatten(); #[cfg(not(target_os = "macos"))] - let _ = log_denials; + let _ = options.log_denials; let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); @@ -374,11 +410,13 @@ async fn spawn_debug_sandbox_child( async fn load_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, + main_execve_wrapper_exe: Option, full_auto: bool, ) -> anyhow::Result { load_debug_sandbox_config_with_codex_home( cli_overrides, codex_linux_sandbox_exe, + main_execve_wrapper_exe, full_auto, /*codex_home*/ None, ) @@ -388,6 +426,7 @@ async fn load_debug_sandbox_config( async fn load_debug_sandbox_config_with_codex_home( cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, + main_execve_wrapper_exe: Option, full_auto: bool, codex_home: Option, ) -> anyhow::Result { @@ -395,6 +434,7 @@ async fn load_debug_sandbox_config_with_codex_home( cli_overrides.clone(), ConfigOverrides { codex_linux_sandbox_exe: codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: main_execve_wrapper_exe.clone(), ..Default::default() }, codex_home.clone(), @@ -415,6 +455,7 @@ async fn load_debug_sandbox_config_with_codex_home( ConfigOverrides { sandbox_mode: Some(create_sandbox_mode(full_auto)), codex_linux_sandbox_exe, + main_execve_wrapper_exe, ..Default::default() }, codex_home, @@ -506,6 +547,7 @@ mod tests { let config = load_debug_sandbox_config_with_codex_home( Vec::new(), None, + None, false, Some(codex_home_path), ) @@ -540,6 +582,7 @@ mod tests { let err = load_debug_sandbox_config_with_codex_home( Vec::new(), None, + None, true, Some(codex_home.path().to_path_buf()), ) @@ -553,4 +596,23 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn debug_sandbox_threads_execve_wrapper_override() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let wrapper = codex_home.path().join("codex-execve-wrapper"); + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + None, + Some(wrapper.clone()), + false, + Some(codex_home.path().to_path_buf()), + ) + .await?; + + assert_eq!(config.main_execve_wrapper_exe, Some(wrapper)); + + Ok(()) + } } diff --git a/codex-rs/cli/src/debug_sandbox/zsh_fork.rs b/codex-rs/cli/src/debug_sandbox/zsh_fork.rs new file mode 100644 index 0000000000..d7820fb594 --- /dev/null +++ b/codex-rs/cli/src/debug_sandbox/zsh_fork.rs @@ -0,0 +1,409 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Context as _; +use codex_core::config::Config; +use codex_core::config::NetworkProxyAuditMetadata; +use codex_core::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; +use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_network_proxy::NetworkProxy; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_shell_escalation::EscalateServer; +use codex_shell_escalation::EscalationDecision; +use codex_shell_escalation::EscalationExecution; +use codex_shell_escalation::EscalationPermissions; +use codex_shell_escalation::EscalationPolicy; +use codex_shell_escalation::ExecParams; +use codex_shell_escalation::ExecResult; +use codex_shell_escalation::PreparedExec; +use codex_shell_escalation::ShellCommandExecutor; +use codex_utils_absolute_path::AbsolutePathBuf; +use tokio::process::Child; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; + +use super::seatbelt::DenialLogger; + +pub(crate) async fn run_command_under_zsh_fork( + command: Vec, + config: Config, + cwd: PathBuf, + env: HashMap, + log_denials: bool, +) -> anyhow::Result<()> { + let parsed = ParsedShellCommand::extract(&command)?; + let shell_command = config + .zsh_path + .as_ref() + .map(|zsh_path| { + vec![ + zsh_path.to_string_lossy().to_string(), + parsed.flag().to_string(), + parsed.script.clone(), + ] + }) + .unwrap_or(command); + + let main_execve_wrapper_exe = config + .main_execve_wrapper_exe + .clone() + .context("`codex sandbox macos --zsh-fork` requires main_execve_wrapper_exe")?; + + let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); + let network_proxy = match config.permissions.network.as_ref() { + Some(spec) => Some( + spec.start_proxy( + config.permissions.sandbox_policy.get(), + /*policy_decider*/ None, + /*blocked_request_observer*/ None, + managed_network_requirements_enabled, + NetworkProxyAuditMetadata::default(), + ) + .await + .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?, + ), + None => None, + }; + let network = network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy); + + let denial_logger = Arc::new(Mutex::new(log_denials.then(DenialLogger::new).flatten())); + let executor = DebugShellCommandExecutor { + command: shell_command, + cwd: cwd.clone(), + env, + file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: config.permissions.network_sandbox_policy, + network, + macos_seatbelt_profile_extensions: config + .permissions + .macos_seatbelt_profile_extensions + .clone(), + sandbox_policy_cwd: cwd, + denial_logger: Arc::clone(&denial_logger), + }; + let zsh_path = config.zsh_path.clone().unwrap_or_else(|| { + PathBuf::from( + executor + .command + .first() + .cloned() + .unwrap_or_else(|| "/bin/zsh".to_string()), + ) + }); + let escalation_server = EscalateServer::new( + zsh_path, + main_execve_wrapper_exe, + TurnDefaultEscalationPolicy, + ); + let exec_result = escalation_server + .exec( + ExecParams { + command: parsed.script, + workdir: executor.cwd.to_string_lossy().to_string(), + timeout_ms: None, + login: Some(parsed.login), + }, + CancellationToken::new(), + Arc::new(executor), + ) + .await?; + + print_exec_result(&exec_result); + + let denial_logger = denial_logger + .lock() + .ok() + .and_then(|mut logger| logger.take()); + if let Some(denial_logger) = denial_logger { + let denials = denial_logger.finish().await; + eprintln!("\n=== Sandbox denials ==="); + if denials.is_empty() { + eprintln!("None found."); + } else { + for denial in denials { + eprintln!("({}) {}", denial.name, denial.capability); + } + } + } + + std::process::exit(exec_result.exit_code); +} + +struct ParsedShellCommand { + script: String, + login: bool, +} + +impl ParsedShellCommand { + fn extract(command: &[String]) -> anyhow::Result { + if let Some((login, script)) = command.windows(3).find_map(|parts| match parts { + [_, flag, script] if flag == "-c" => Some((false, script.clone())), + [_, flag, script] if flag == "-lc" => Some((true, script.clone())), + _ => None, + }) { + return Ok(Self { script, login }); + } + + anyhow::bail!( + "`codex sandbox macos --zsh-fork` expects a `zsh -c ...` or `zsh -lc ...` command" + ) + } + + fn flag(&self) -> &'static str { + if self.login { "-lc" } else { "-c" } + } +} + +struct TurnDefaultEscalationPolicy; + +#[async_trait::async_trait] +impl EscalationPolicy for TurnDefaultEscalationPolicy { + async fn determine_action( + &self, + program: &AbsolutePathBuf, + argv: &[String], + workdir: &AbsolutePathBuf, + ) -> anyhow::Result { + tracing::debug!("zsh-fork debug escalation for {program:?} {argv:?} in {workdir:?}"); + Ok(EscalationDecision::escalate( + EscalationExecution::TurnDefault, + )) + } +} + +struct DebugShellCommandExecutor { + command: Vec, + cwd: PathBuf, + env: HashMap, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + network: Option, + macos_seatbelt_profile_extensions: + Option, + sandbox_policy_cwd: PathBuf, + denial_logger: Arc>>, +} + +#[async_trait::async_trait] +impl ShellCommandExecutor for DebugShellCommandExecutor { + async fn run( + &self, + _command: Vec, + _cwd: PathBuf, + env_overlay: HashMap, + _cancel_rx: CancellationToken, + after_spawn: Option>, + ) -> anyhow::Result { + let prepared = self.prepare_seatbelt_exec( + self.command.clone(), + self.cwd.clone(), + overlay_env(&self.env, env_overlay), + &self.file_system_sandbox_policy, + self.network_sandbox_policy, + self.macos_seatbelt_profile_extensions.as_ref(), + ); + let child = spawn_prepared_command(&prepared)?; + if let Ok(mut logger) = self.denial_logger.lock() + && let Some(denial_logger) = logger.as_mut() + { + denial_logger.on_child_spawn(&child); + } + if let Some(after_spawn) = after_spawn { + after_spawn(); + } + wait_for_output(child).await + } + + async fn prepare_escalated_exec( + &self, + program: &AbsolutePathBuf, + argv: &[String], + workdir: &AbsolutePathBuf, + env: HashMap, + execution: EscalationExecution, + ) -> anyhow::Result { + let command = join_program_and_argv(program, argv); + let Some(first_arg) = argv.first() else { + anyhow::bail!("intercepted exec request must contain argv[0]"); + }; + + match execution { + EscalationExecution::TurnDefault => Ok(self.prepare_seatbelt_exec( + command, + workdir.to_path_buf(), + env, + &self.file_system_sandbox_policy, + self.network_sandbox_policy, + self.macos_seatbelt_profile_extensions.as_ref(), + )), + EscalationExecution::Unsandboxed => Ok(PreparedExec { + command, + cwd: workdir.to_path_buf(), + env, + arg0: Some(first_arg.clone()), + }), + EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => { + Ok(self.prepare_seatbelt_exec( + command, + workdir.to_path_buf(), + env, + &permissions.file_system_sandbox_policy, + permissions.network_sandbox_policy, + permissions.macos_seatbelt_profile_extensions.as_ref(), + )) + } + EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(_)) => { + anyhow::bail!( + "`codex sandbox macos --zsh-fork` does not yet support permission-profile escalations" + ) + } + } + } +} + +impl DebugShellCommandExecutor { + fn prepare_seatbelt_exec( + &self, + command: Vec, + cwd: PathBuf, + mut env: HashMap, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + macos_seatbelt_profile_extensions: Option< + &codex_protocol::models::MacOsSeatbeltProfileExtensions, + >, + ) -> PreparedExec { + let args = create_seatbelt_command_args_for_policies_with_extensions( + command, + file_system_sandbox_policy, + network_sandbox_policy, + self.sandbox_policy_cwd.as_path(), + false, + self.network.as_ref(), + macos_seatbelt_profile_extensions, + ); + + env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); + if let Some(network) = self.network.as_ref() { + network.apply_to_env(&mut env); + } + if !network_sandbox_policy.is_enabled() { + env.insert( + CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), + "1".to_string(), + ); + } + + PreparedExec { + command: std::iter::once("/usr/bin/sandbox-exec".to_string()) + .chain(args) + .collect(), + cwd, + env, + arg0: None, + } + } +} + +fn overlay_env( + base_env: &HashMap, + env_overlay: HashMap, +) -> HashMap { + let mut env = base_env.clone(); + for (key, value) in env_overlay { + env.insert(key, value); + } + env +} + +fn spawn_prepared_command(prepared: &PreparedExec) -> std::io::Result { + let (program, args) = prepared.command.split_first().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "prepared command must not be empty", + ) + })?; + + let mut command = Command::new(program); + command + .args(args) + .arg0(prepared.arg0.clone().unwrap_or_else(|| program.to_string())) + .env_clear() + .envs(prepared.env.clone()) + .current_dir(&prepared.cwd) + .stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() +} + +async fn wait_for_output(child: Child) -> anyhow::Result { + let output = child.wait_with_output().await?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + let exit_code = output.status.code().unwrap_or(1); + + Ok(ExecResult { + exit_code, + output: format!("{stdout}{stderr}"), + stdout, + stderr, + duration: Default::default(), + timed_out: false, + }) +} + +fn print_exec_result(exec_result: &ExecResult) { + if !exec_result.stdout.is_empty() { + print!("{}", exec_result.stdout); + } + if !exec_result.stderr.is_empty() { + eprint!("{}", exec_result.stderr); + } +} + +fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec { + std::iter::once(program.to_string_lossy().to_string()) + .chain(argv.iter().skip(1).cloned()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::ParsedShellCommand; + use pretty_assertions::assert_eq; + + #[test] + fn extract_accepts_non_login_zsh_command() { + let parsed = ParsedShellCommand::extract(&[ + "/bin/zsh".to_string(), + "-c".to_string(), + "echo hi".to_string(), + ]) + .expect("parse zsh command"); + + assert_eq!(parsed.script, "echo hi"); + assert!(!parsed.login); + } + + #[test] + fn extract_accepts_login_zsh_command() { + let parsed = ParsedShellCommand::extract(&[ + "/bin/zsh".to_string(), + "-lc".to_string(), + "echo hi".to_string(), + ]) + .expect("parse zsh command"); + + assert_eq!(parsed.script, "echo hi"); + assert!(parsed.login); + } +} diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index b6174efa3a..a8c2f2fde4 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -11,6 +11,10 @@ pub struct SeatbeltCommand { #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, + /// Route intercepted child execs through the zsh-fork execve wrapper using the turn sandbox. + #[arg(long = "zsh-fork", default_value_t = false)] + pub zsh_fork: bool, + /// While the command runs, capture macOS sandbox denials via `log stream` and print them after exit #[arg(long = "log-denials", default_value_t = false)] pub log_denials: bool, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 05b568b7b7..fb6c9be563 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -795,6 +795,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_cli::debug_sandbox::run_command_under_seatbelt( seatbelt_cli, arg0_paths.codex_linux_sandbox_exe.clone(), + arg0_paths.main_execve_wrapper_exe.clone(), ) .await?; }