diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 392ebb0cd6d..f494710a4f0 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -30,7 +30,12 @@ pub struct Cli { /// Select the sandbox policy to use when executing model-generated shell /// commands. #[arg(long = "sandbox", short = 's', value_enum)] - pub sandbox_mode: Option, + pub sandbox_mode: Option, + + /// When using `--sandbox external-sandbox`, declare whether outbound + /// network access is available to the external sandbox. + #[arg(long = "network-access", value_enum)] + pub external_sandbox_network_access: Option, /// Configuration profile from config.toml to specify default options. #[arg(long = "profile", short = 'p')] @@ -155,3 +160,30 @@ pub enum Color { #[default] Auto, } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum SandboxCliArg { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, + + /// Indicates the process is already in an external sandbox. + ExternalSandbox, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum NetworkAccessCliArg { + Restricted, + Enabled, +} + +impl NetworkAccessCliArg { + pub fn to_network_access(self) -> codex_core::protocol::NetworkAccess { + match self { + Self::Restricted => codex_core::protocol::NetworkAccess::Restricted, + Self::Enabled => codex_core::protocol::NetworkAccess::Enabled, + } + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 93a481b630e..cfc2568221e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -12,7 +12,9 @@ pub mod exec_events; pub use cli::Cli; pub use cli::Command; +pub use cli::NetworkAccessCliArg; pub use cli::ReviewArgs; +pub use cli::SandboxCliArg; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; use codex_core::AuthManager; @@ -33,6 +35,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; +use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionSource; use codex_protocol::approvals::ElicitationAction; use codex_protocol::config_types::SandboxMode; @@ -88,6 +91,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any last_message_file, json: json_mode, sandbox_mode: sandbox_mode_cli_arg, + external_sandbox_network_access, prompt, output_schema: output_schema_path, config_overrides, @@ -115,12 +119,49 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .with_writer(std::io::stderr) .with_filter(env_filter); + if full_auto && matches!(sandbox_mode_cli_arg, Some(SandboxCliArg::ExternalSandbox)) { + return Err(anyhow::anyhow!( + "--sandbox external-sandbox cannot be used with --full-auto" + )); + } + if dangerously_bypass_approvals_and_sandbox + && matches!(sandbox_mode_cli_arg, Some(SandboxCliArg::ExternalSandbox)) + { + return Err(anyhow::anyhow!( + "--sandbox external-sandbox cannot be used with --dangerously-bypass-approvals-and-sandbox" + )); + } + if external_sandbox_network_access.is_some() + && !matches!(sandbox_mode_cli_arg, Some(SandboxCliArg::ExternalSandbox)) + { + return Err(anyhow::anyhow!( + "--network-access can only be used with --sandbox external-sandbox" + )); + } + + let external_sandbox_override: Option = + if matches!(sandbox_mode_cli_arg, Some(SandboxCliArg::ExternalSandbox)) { + Some(SandboxPolicy::ExternalSandbox { + network_access: external_sandbox_network_access + .unwrap_or(NetworkAccessCliArg::Restricted) + .to_network_access(), + }) + } else { + None + }; + let sandbox_mode = if full_auto { Some(SandboxMode::WorkspaceWrite) } else if dangerously_bypass_approvals_and_sandbox { Some(SandboxMode::DangerFullAccess) } else { - sandbox_mode_cli_arg.map(Into::::into) + match sandbox_mode_cli_arg { + Some(SandboxCliArg::ReadOnly) => Some(SandboxMode::ReadOnly), + Some(SandboxCliArg::WorkspaceWrite) => Some(SandboxMode::WorkspaceWrite), + Some(SandboxCliArg::DangerFullAccess) => Some(SandboxMode::DangerFullAccess), + Some(SandboxCliArg::ExternalSandbox) => None, + None => None, + } }; // Parse `-c` overrides from the CLI. @@ -215,9 +256,16 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any additional_writable_roots: add_dir, }; - let config = + let mut config = Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + if let Some(sandbox_policy) = external_sandbox_override { + config + .sandbox_policy + .set(sandbox_policy) + .map_err(|err| anyhow::anyhow!("Invalid external sandbox policy: {err}"))?; + } + if let Err(err) = enforce_login_restrictions(&config).await { eprintln!("{err}"); std::process::exit(1); diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 77012ee3b77..1ee3e946638 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -6,4 +6,5 @@ mod originator; mod output_schema; mod resume; mod sandbox; +mod sandbox_mode; mod server_error_exit; diff --git a/codex-rs/exec/tests/suite/sandbox_mode.rs b/codex-rs/exec/tests/suite/sandbox_mode.rs new file mode 100644 index 00000000000..62fdd74b779 --- /dev/null +++ b/codex-rs/exec/tests/suite/sandbox_mode.rs @@ -0,0 +1,163 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; +use pretty_assertions::assert_eq; + +async fn run_exec_with_server(args: &[&str], prompt: &str) -> anyhow::Result { + let test = test_codex_exec(); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("response_1"), + responses::ev_assistant_message("response_1", "Task completed"), + responses::ev_completed("response_1"), + ]); + responses::mount_sse_once(&server, body).await; + + let output = { + let mut cmd = test.cmd_with_server(&server); + cmd.arg("--skip-git-repo-check"); + for arg in args { + cmd.arg(arg); + } + cmd.arg(prompt).output()? + }; + + assert!(output.status.success(), "run failed: {output:?}"); + Ok(String::from_utf8(output.stderr)?) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn accepts_read_only_sandbox_flag() -> anyhow::Result<()> { + let stderr = + run_exec_with_server(&["--sandbox", "read-only"], "test read-only sandbox").await?; + assert!(stderr.contains("sandbox: read-only"), "{stderr}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn accepts_workspace_write_sandbox_flag() -> anyhow::Result<()> { + let stderr = run_exec_with_server( + &["--sandbox", "workspace-write"], + "test workspace-write sandbox", + ) + .await?; + assert!(stderr.contains("sandbox: workspace-write"), "{stderr}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn accepts_danger_full_access_sandbox_flag() -> anyhow::Result<()> { + let stderr = run_exec_with_server( + &["--sandbox", "danger-full-access"], + "test danger-full-access sandbox", + ) + .await?; + assert!(stderr.contains("sandbox: danger-full-access"), "{stderr}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn accepts_external_sandbox_flag_defaults_to_restricted_network() -> anyhow::Result<()> { + let stderr = + run_exec_with_server(&["--sandbox", "external-sandbox"], "test external sandbox").await?; + assert!(stderr.contains("sandbox: external-sandbox"), "{stderr}"); + assert!( + !stderr.contains("network access enabled"), + "stderr unexpectedly claims network access enabled: {stderr}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn accepts_external_sandbox_with_enabled_network_access() -> anyhow::Result<()> { + let stderr = run_exec_with_server( + &[ + "--sandbox", + "external-sandbox", + "--network-access", + "enabled", + ], + "test external sandbox network enabled", + ) + .await?; + assert!( + stderr.contains("sandbox: external-sandbox (network access enabled)"), + "{stderr}" + ); + + Ok(()) +} + +#[test] +fn rejects_network_access_without_external_sandbox() -> anyhow::Result<()> { + let test = test_codex_exec(); + + let output = test + .cmd() + .arg("--skip-git-repo-check") + .arg("--network-access") + .arg("enabled") + .arg("test") + .output()?; + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains("--network-access can only be used with --sandbox external-sandbox"), + "{stderr}" + ); + + Ok(()) +} + +#[test] +fn rejects_external_sandbox_with_full_auto() -> anyhow::Result<()> { + let test = test_codex_exec(); + + let output = test + .cmd() + .arg("--skip-git-repo-check") + .arg("--full-auto") + .arg("--sandbox") + .arg("external-sandbox") + .arg("test") + .output()?; + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains("--sandbox external-sandbox cannot be used with --full-auto"), + "{stderr}" + ); + + Ok(()) +} + +#[test] +fn rejects_external_sandbox_with_dangerously_bypass_approvals_and_sandbox() -> anyhow::Result<()> { + let test = test_codex_exec(); + + let output = test + .cmd() + .arg("--skip-git-repo-check") + .arg("--sandbox") + .arg("external-sandbox") + .arg("--dangerously-bypass-approvals-and-sandbox") + .arg("test") + .output()?; + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains( + "--sandbox external-sandbox cannot be used with --dangerously-bypass-approvals-and-sandbox" + ), + "{stderr}" + ); + + Ok(()) +}