Compare commits

...

1 Commits

Author SHA1 Message Date
celia-oai
1c75b19076 changes 2026-03-17 22:58:08 -07:00
7 changed files with 500 additions and 11 deletions

5
codex-rs/Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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<PathBuf>,
main_execve_wrapper_exe: Option<PathBuf>,
) -> 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<PathBuf>,
_main_execve_wrapper_exe: Option<PathBuf>,
) -> 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<String>,
config_overrides: CliConfigOverrides,
codex_linux_sandbox_exe: Option<PathBuf>,
main_execve_wrapper_exe: Option<PathBuf>,
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<PathBuf>,
main_execve_wrapper_exe: Option<PathBuf>,
full_auto: bool,
) -> anyhow::Result<Config> {
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<PathBuf>,
main_execve_wrapper_exe: Option<PathBuf>,
full_auto: bool,
codex_home: Option<PathBuf>,
) -> anyhow::Result<Config> {
@@ -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(())
}
}

View File

@@ -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<String>,
config: Config,
cwd: PathBuf,
env: HashMap<String, String>,
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<Self> {
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<EscalationDecision> {
tracing::debug!("zsh-fork debug escalation for {program:?} {argv:?} in {workdir:?}");
Ok(EscalationDecision::escalate(
EscalationExecution::TurnDefault,
))
}
}
struct DebugShellCommandExecutor {
command: Vec<String>,
cwd: PathBuf,
env: HashMap<String, String>,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
network: Option<NetworkProxy>,
macos_seatbelt_profile_extensions:
Option<codex_protocol::models::MacOsSeatbeltProfileExtensions>,
sandbox_policy_cwd: PathBuf,
denial_logger: Arc<Mutex<Option<DenialLogger>>>,
}
#[async_trait::async_trait]
impl ShellCommandExecutor for DebugShellCommandExecutor {
async fn run(
&self,
_command: Vec<String>,
_cwd: PathBuf,
env_overlay: HashMap<String, String>,
_cancel_rx: CancellationToken,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> anyhow::Result<ExecResult> {
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<String, String>,
execution: EscalationExecution,
) -> anyhow::Result<PreparedExec> {
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<String>,
cwd: PathBuf,
mut env: HashMap<String, String>,
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<String, String>,
env_overlay: HashMap<String, String>,
) -> HashMap<String, String> {
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<Child> {
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<ExecResult> {
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<String> {
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);
}
}

View File

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

View File

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