use std::io; #[cfg(target_family = "unix")] use std::os::unix::process::ExitStatusExt; use std::path::Path; use std::path::PathBuf; use std::process::ExitStatus; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use std::time::Instant; use tokio::io::AsyncRead; use tokio::io::AsyncReadExt; use tokio::io::BufReader; use tokio::process::Child; use tokio::process::Command; use tokio::sync::Notify; use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; use crate::protocol::SandboxPolicy; // Maximum we send for each stream, which is either: // - 10KiB OR // - 256 lines const MAX_STREAM_OUTPUT: usize = 10 * 1024; const MAX_STREAM_OUTPUT_LINES: usize = 256; const DEFAULT_TIMEOUT_MS: u64 = 10_000; // Hardcode these since it does not seem worth including the libc crate just // for these. const SIGKILL_CODE: i32 = 9; const TIMEOUT_CODE: i32 = 64; const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); /// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` /// to defend against an attacker trying to inject a malicious version on the /// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker /// already has root access. const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; #[derive(Debug, Clone)] pub struct ExecParams { pub command: Vec, pub cwd: PathBuf, pub timeout_ms: Option, } #[derive(Clone, Copy, Debug, PartialEq)] pub enum SandboxType { None, /// Only available on macOS. MacosSeatbelt, /// Only available on Linux. LinuxSeccomp, } #[cfg(target_os = "linux")] async fn exec_linux( params: ExecParams, ctrl_c: Arc, sandbox_policy: &SandboxPolicy, ) -> Result { crate::linux::exec_linux(params, ctrl_c, sandbox_policy).await } #[cfg(not(target_os = "linux"))] async fn exec_linux( _params: ExecParams, _ctrl_c: Arc, _sandbox_policy: &SandboxPolicy, ) -> Result { Err(CodexErr::Io(io::Error::new( io::ErrorKind::InvalidInput, "linux sandbox is not supported on this platform", ))) } pub async fn process_exec_tool_call( params: ExecParams, sandbox_type: SandboxType, ctrl_c: Arc, sandbox_policy: &SandboxPolicy, ) -> Result { let start = Instant::now(); let raw_output_result = match sandbox_type { SandboxType::None => exec(params, ctrl_c).await, SandboxType::MacosSeatbelt => { let ExecParams { command, cwd, timeout_ms, } = params; let seatbelt_command = create_seatbelt_command(command, sandbox_policy, &cwd); exec( ExecParams { command: seatbelt_command, cwd, timeout_ms, }, ctrl_c, ) .await } SandboxType::LinuxSeccomp => exec_linux(params, ctrl_c, sandbox_policy).await, }; let duration = start.elapsed(); match raw_output_result { Ok(raw_output) => { let stdout = String::from_utf8_lossy(&raw_output.stdout).to_string(); let stderr = String::from_utf8_lossy(&raw_output.stderr).to_string(); #[cfg(target_family = "unix")] match raw_output.exit_status.signal() { Some(TIMEOUT_CODE) => return Err(CodexErr::Sandbox(SandboxErr::Timeout)), Some(signal) => { return Err(CodexErr::Sandbox(SandboxErr::Signal(signal))); } None => {} } let exit_code = raw_output.exit_status.code().unwrap_or(-1); // NOTE(ragona): This is much less restrictive than the previous check. If we exec // a command, and it returns anything other than success, we assume that it may have // been a sandboxing error and allow the user to retry. (The user of course may choose // not to retry, or in a non-interactive mode, would automatically reject the approval.) if exit_code != 0 && sandbox_type != SandboxType::None { return Err(CodexErr::Sandbox(SandboxErr::Denied( exit_code, stdout, stderr, ))); } Ok(ExecToolCallOutput { exit_code, stdout, stderr, duration, }) } Err(err) => { tracing::error!("exec error: {err}"); Err(err) } } } pub fn create_seatbelt_command( command: Vec, sandbox_policy: &SandboxPolicy, cwd: &Path, ) -> Vec { let (file_write_policy, extra_cli_args) = { if sandbox_policy.has_full_disk_write_access() { // Allegedly, this is more permissive than `(allow file-write*)`. ( r#"(allow file-write* (regex #"^/"))"#.to_string(), Vec::::new(), ) } else { let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); let (writable_folder_policies, cli_args): (Vec, Vec) = writable_roots .iter() .enumerate() .map(|(index, root)| { let param_name = format!("WRITABLE_ROOT_{index}"); let policy: String = format!("(subpath (param \"{param_name}\"))"); let cli_arg = format!("-D{param_name}={}", root.to_string_lossy()); (policy, cli_arg) }) .unzip(); if writable_folder_policies.is_empty() { ("".to_string(), Vec::::new()) } else { let file_write_policy = format!( "(allow file-write*\n{}\n)", writable_folder_policies.join(" ") ); (file_write_policy, cli_args) } } }; let file_read_policy = if sandbox_policy.has_full_disk_read_access() { "; allow read-only file operations\n(allow file-read*)" } else { "" }; // TODO(mbolin): apply_patch calls must also honor the SandboxPolicy. let network_policy = if sandbox_policy.has_full_network_access() { "(allow network-outbound)\n(allow network-inbound)\n(allow system-socket)" } else { "" }; let full_policy = format!( "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" ); let mut seatbelt_command: Vec = 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 } #[derive(Debug)] pub struct RawExecToolCallOutput { pub exit_status: ExitStatus, pub stdout: Vec, pub stderr: Vec, } #[derive(Debug)] pub struct ExecToolCallOutput { pub exit_code: i32, pub stdout: String, pub stderr: String, pub duration: Duration, } pub async fn exec( ExecParams { command, cwd, timeout_ms, }: ExecParams, ctrl_c: Arc, ) -> Result { let child = spawn_child(command, cwd).await?; consume_truncated_output(child, ctrl_c, timeout_ms).await } /// Spawns the appropriate child process for the ExecParams. async fn spawn_child(command: Vec, cwd: PathBuf) -> std::io::Result { if command.is_empty() { return Err(std::io::Error::new( io::ErrorKind::InvalidInput, "command args are empty", )); } let mut cmd = Command::new(&command[0]); cmd.args(&command[1..]); cmd.current_dir(cwd); // 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()) .kill_on_drop(true) .spawn() } /// Consumes the output of a child process, truncating it so it is suitable for /// use as the output of a `shell` tool call. Also enforces specified timeout. async fn consume_truncated_output( mut child: Child, ctrl_c: Arc, timeout_ms: Option, ) -> Result { let stdout_handle = tokio::spawn(read_capped( BufReader::new(child.stdout.take().expect("stdout is not piped")), MAX_STREAM_OUTPUT, MAX_STREAM_OUTPUT_LINES, )); let stderr_handle = tokio::spawn(read_capped( BufReader::new(child.stderr.take().expect("stderr is not piped")), MAX_STREAM_OUTPUT, MAX_STREAM_OUTPUT_LINES, )); let interrupted = ctrl_c.notified(); let timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)); let exit_status = tokio::select! { result = tokio::time::timeout(timeout, child.wait()) => { match result { Ok(Ok(exit_status)) => exit_status, Ok(e) => e?, Err(_) => { // timeout child.start_kill()?; // Debatable whether `child.wait().await` should be called here. synthetic_exit_status(128 + TIMEOUT_CODE) } } } _ = interrupted => { child.start_kill()?; synthetic_exit_status(128 + SIGKILL_CODE) } }; let stdout = stdout_handle.await??; let stderr = stderr_handle.await??; Ok(RawExecToolCallOutput { exit_status, stdout, stderr, }) } async fn read_capped( mut reader: R, max_output: usize, max_lines: usize, ) -> io::Result> { let mut buf = Vec::with_capacity(max_output.min(8 * 1024)); let mut tmp = [0u8; 8192]; let mut remaining_bytes = max_output; let mut remaining_lines = max_lines; loop { let n = reader.read(&mut tmp).await?; if n == 0 { break; } // Copy into the buffer only while we still have byte and line budget. if remaining_bytes > 0 && remaining_lines > 0 { let mut copy_len = 0; for &b in &tmp[..n] { if remaining_bytes == 0 || remaining_lines == 0 { break; } copy_len += 1; remaining_bytes -= 1; if b == b'\n' { remaining_lines -= 1; } } buf.extend_from_slice(&tmp[..copy_len]); } // Continue reading to EOF to avoid back-pressure, but discard once caps are hit. } Ok(buf) } #[cfg(unix)] fn synthetic_exit_status(code: i32) -> ExitStatus { use std::os::unix::process::ExitStatusExt; std::process::ExitStatus::from_raw(code) } #[cfg(windows)] fn synthetic_exit_status(code: i32) -> ExitStatus { use std::os::windows::process::ExitStatusExt; #[expect(clippy::unwrap_used)] std::process::ExitStatus::from_raw(code.try_into().unwrap()) }