diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 30352cbb8..308e83583 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -130,7 +130,10 @@ async fn run_command_under_sandbox( let sandbox_policy_cwd = cwd.clone(); let stdio_policy = StdioPolicy::Inherit; - let env = create_env(&config.shell_environment_policy, None); + let mut env = create_env(&config.shell_environment_policy, None); + if let Some(network) = config.network.as_ref() { + network.apply_to_env(&mut env); + } // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. if let SandboxType::Windows = sandbox_type { @@ -222,6 +225,7 @@ async fn run_command_under_sandbox( config.sandbox_policy.get(), sandbox_policy_cwd.as_path(), stdio_policy, + None, env, ) .await? @@ -241,6 +245,7 @@ async fn run_command_under_sandbox( sandbox_policy_cwd.as_path(), use_bwrap_sandbox, stdio_policy, + None, env, ) .await? diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2172caba8..e627ebec0 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -26,9 +26,10 @@ use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecOutputStream; use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; -use crate::sandboxing::ExecEnv; +use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxPermissions; +use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; @@ -157,9 +158,18 @@ pub async fn process_exec_tool_call( stdout_stream: Option, ) -> Result { let windows_sandbox_level = params.windows_sandbox_level; + let enforce_managed_network = params.network.is_some(); let sandbox_type = match &sandbox_policy { SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - SandboxType::None + if enforce_managed_network { + get_platform_sandbox( + windows_sandbox_level + != codex_protocol::config_types::WindowsSandboxLevel::Disabled, + ) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None + } } _ => get_platform_sandbox( windows_sandbox_level != codex_protocol::config_types::WindowsSandboxLevel::Disabled, @@ -200,11 +210,13 @@ pub async fn process_exec_tool_call( }; let manager = SandboxManager::new(); - let exec_env = manager + let exec_req = manager .transform(crate::sandboxing::SandboxTransformRequest { spec, policy: sandbox_policy, sandbox: sandbox_type, + enforce_managed_network, + network: network.as_ref(), sandbox_policy_cwd: sandbox_cwd, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(), use_linux_sandbox_bwrap, @@ -213,19 +225,19 @@ pub async fn process_exec_tool_call( .map_err(CodexErr::from)?; // Route through the sandboxing module for a single, unified execution path. - crate::sandboxing::execute_env(exec_env, sandbox_policy, network, stdout_stream).await + crate::sandboxing::execute_env(exec_req, sandbox_policy, stdout_stream).await } pub(crate) async fn execute_exec_env( - env: ExecEnv, + env: ExecRequest, sandbox_policy: &SandboxPolicy, stdout_stream: Option, - network: Option, ) -> Result { - let ExecEnv { + let ExecRequest { command, cwd, - mut env, + env, + network, expiration, sandbox, windows_sandbox_level, @@ -234,10 +246,6 @@ pub(crate) async fn execute_exec_env( arg0, } = env; - if let Some(network) = network.as_ref() { - network.apply_to_env(&mut env); - } - let params = ExecParams { command, cwd, @@ -694,7 +702,7 @@ async fn exec( command, cwd, env, - network: _, + network, arg0, expiration, windows_sandbox_level: _, @@ -708,15 +716,16 @@ async fn exec( )) })?; let arg0_ref = arg0.as_deref(); - let child = spawn_child_async( - PathBuf::from(program), - args.into(), - arg0_ref, + let child = spawn_child_async(SpawnChildRequest { + program: PathBuf::from(program), + args: args.into(), + arg0: arg0_ref, cwd, sandbox_policy, - StdioPolicy::RedirectForShellTool, + network: network.as_ref(), + stdio_policy: StdioPolicy::RedirectForShellTool, env, - ) + }) .await?; consume_truncated_output(child, expiration, stdout_stream).await } diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index ea27f77f7..51a466193 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -1,6 +1,8 @@ use crate::protocol::SandboxPolicy; +use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; +use codex_network_proxy::NetworkProxy; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -23,6 +25,7 @@ pub async fn spawn_command_under_linux_sandbox

( sandbox_policy_cwd: &Path, use_bwrap_sandbox: bool, stdio_policy: StdioPolicy, + network: Option<&NetworkProxy>, env: HashMap, ) -> std::io::Result where @@ -33,20 +36,29 @@ where sandbox_policy, sandbox_policy_cwd, use_bwrap_sandbox, + allow_network_for_proxy(false), ); let arg0 = Some("codex-linux-sandbox"); - spawn_child_async( - codex_linux_sandbox_exe.as_ref().to_path_buf(), + spawn_child_async(SpawnChildRequest { + program: codex_linux_sandbox_exe.as_ref().to_path_buf(), args, arg0, - command_cwd, + cwd: command_cwd, sandbox_policy, + network, stdio_policy, env, - ) + }) .await } +pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool { + // When managed network requirements are active, request proxy-only + // networking from the Linux sandbox helper. Without managed requirements, + // preserve existing behavior. + enforce_managed_network +} + /// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`. /// /// The helper performs the actual sandboxing (bubblewrap + seccomp) after @@ -56,6 +68,7 @@ pub(crate) fn create_linux_sandbox_command_args( sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, use_bwrap_sandbox: bool, + allow_network_for_proxy: bool, ) -> Vec { #[expect(clippy::expect_used)] let sandbox_policy_cwd = sandbox_policy_cwd @@ -76,6 +89,9 @@ pub(crate) fn create_linux_sandbox_command_args( if use_bwrap_sandbox { linux_cmd.push("--use-bwrap-sandbox".to_string()); } + if allow_network_for_proxy { + linux_cmd.push("--allow-network-for-proxy".to_string()); + } // Separator so that command arguments starting with `-` are not parsed as // options of the helper itself. @@ -98,16 +114,36 @@ mod tests { let cwd = Path::new("/tmp"); let policy = SandboxPolicy::ReadOnly; - let with_bwrap = create_linux_sandbox_command_args(command.clone(), &policy, cwd, true); + let with_bwrap = + create_linux_sandbox_command_args(command.clone(), &policy, cwd, true, false); assert_eq!( with_bwrap.contains(&"--use-bwrap-sandbox".to_string()), true ); - let without_bwrap = create_linux_sandbox_command_args(command, &policy, cwd, false); + let without_bwrap = create_linux_sandbox_command_args(command, &policy, cwd, false, false); assert_eq!( without_bwrap.contains(&"--use-bwrap-sandbox".to_string()), false ); } + + #[test] + fn proxy_flag_is_included_when_requested() { + let command = vec!["/bin/true".to_string()]; + let cwd = Path::new("/tmp"); + let policy = SandboxPolicy::ReadOnly; + + let args = create_linux_sandbox_command_args(command, &policy, cwd, true, true); + assert_eq!( + args.contains(&"--allow-network-for-proxy".to_string()), + true + ); + } + + #[test] + fn proxy_network_requires_managed_requirements() { + assert_eq!(allow_network_for_proxy(false), false); + assert_eq!(allow_network_for_proxy(true), true); + } } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 0ffede301..9a72b6e24 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -1,7 +1,7 @@ /* Module: sandboxing -Build platform wrappers and produce ExecEnv for execution. Owns low‑level +Build platform wrappers and produce ExecRequest for execution. Owns low-level sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ @@ -11,6 +11,7 @@ use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::execute_exec_env; +use crate::landlock::allow_network_for_proxy; use crate::landlock::create_linux_sandbox_command_args; use crate::protocol::SandboxPolicy; #[cfg(target_os = "macos")] @@ -40,10 +41,11 @@ pub struct CommandSpec { } #[derive(Debug)] -pub struct ExecEnv { +pub struct ExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, + pub network: Option, pub expiration: ExecExpiration, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, @@ -59,6 +61,10 @@ pub(crate) struct SandboxTransformRequest<'a> { pub spec: CommandSpec, pub policy: &'a SandboxPolicy, pub sandbox: SandboxType, + pub enforce_managed_network: bool, + // TODO(viyatb): Evaluate switching this to Option> + // to make shared ownership explicit across runtime/sandbox plumbing. + pub network: Option<&'a NetworkProxy>, pub sandbox_policy_cwd: &'a Path, pub codex_linux_sandbox_exe: Option<&'a PathBuf>, pub use_linux_sandbox_bwrap: bool, @@ -93,6 +99,7 @@ impl SandboxManager { policy: &SandboxPolicy, pref: SandboxablePreference, windows_sandbox_level: WindowsSandboxLevel, + has_managed_network_requirements: bool, ) -> SandboxType { match pref { SandboxablePreference::Forbid => SandboxType::None, @@ -106,7 +113,14 @@ impl SandboxManager { } SandboxablePreference::Auto => match policy { SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - SandboxType::None + if has_managed_network_requirements { + crate::safety::get_platform_sandbox( + windows_sandbox_level != WindowsSandboxLevel::Disabled, + ) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None + } } _ => crate::safety::get_platform_sandbox( windows_sandbox_level != WindowsSandboxLevel::Disabled, @@ -119,11 +133,13 @@ impl SandboxManager { pub(crate) fn transform( &self, request: SandboxTransformRequest<'_>, - ) -> Result { + ) -> Result { let SandboxTransformRequest { mut spec, policy, sandbox, + enforce_managed_network, + network, sandbox_policy_cwd, codex_linux_sandbox_exe, use_linux_sandbox_bwrap, @@ -147,8 +163,13 @@ impl SandboxManager { SandboxType::MacosSeatbelt => { let mut seatbelt_env = HashMap::new(); seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); - let mut args = - create_seatbelt_command_args(command.clone(), policy, sandbox_policy_cwd); + let mut args = create_seatbelt_command_args( + command.clone(), + policy, + sandbox_policy_cwd, + enforce_managed_network, + network, + ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); full_command.append(&mut args); @@ -159,11 +180,13 @@ impl SandboxManager { SandboxType::LinuxSeccomp => { let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; + let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); let mut args = create_linux_sandbox_command_args( command.clone(), policy, sandbox_policy_cwd, use_linux_sandbox_bwrap, + allow_proxy_network, ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(exe.to_string_lossy().to_string()); @@ -186,10 +209,11 @@ impl SandboxManager { env.extend(sandbox_env); - Ok(ExecEnv { + Ok(ExecRequest { command, cwd: spec.cwd, env, + network: network.cloned(), expiration: spec.expiration, sandbox, windows_sandbox_level, @@ -205,10 +229,44 @@ impl SandboxManager { } pub async fn execute_env( - env: ExecEnv, + env: ExecRequest, policy: &SandboxPolicy, - network: Option, stdout_stream: Option, ) -> crate::error::Result { - execute_exec_env(env, policy, stdout_stream, network).await + execute_exec_env(env, policy, stdout_stream).await +} + +#[cfg(test)] +mod tests { + use super::SandboxManager; + use crate::exec::SandboxType; + use crate::protocol::SandboxPolicy; + use crate::tools::sandboxing::SandboxablePreference; + use codex_protocol::config_types::WindowsSandboxLevel; + use pretty_assertions::assert_eq; + + #[test] + fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { + let manager = SandboxManager::new(); + let sandbox = manager.select_initial( + &SandboxPolicy::DangerFullAccess, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + false, + ); + assert_eq!(sandbox, SandboxType::None); + } + + #[test] + fn danger_full_access_uses_platform_sandbox_with_network_requirements() { + let manager = SandboxManager::new(); + let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); + let sandbox = manager.select_initial( + &SandboxPolicy::DangerFullAccess, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + true, + ); + assert_eq!(sandbox, expected); + } } diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index a15ebb177..c08f70b1e 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,13 +1,21 @@ #![cfg(target_os = "macos")] +use codex_network_proxy::ALLOW_LOCAL_BINDING_ENV_KEY; +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::PROXY_URL_ENV_KEYS; +use codex_network_proxy::has_proxy_url_env_vars; +use codex_network_proxy::proxy_url_env_value; +use std::collections::BTreeSet; use std::collections::HashMap; use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; use tokio::process::Child; +use url::Url; use crate::protocol::SandboxPolicy; use crate::spawn::CODEX_SANDBOX_ENV_VAR; +use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; @@ -26,27 +34,151 @@ pub async fn spawn_command_under_seatbelt( sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, stdio_policy: StdioPolicy, + network: Option<&NetworkProxy>, mut env: HashMap, ) -> std::io::Result { - let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd); + let args = + create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd, false, network); let arg0 = None; env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); - spawn_child_async( - PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE), + spawn_child_async(SpawnChildRequest { + program: PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE), args, arg0, - command_cwd, + cwd: command_cwd, sandbox_policy, + network, stdio_policy, env, - ) + }) .await } +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" +} + +fn proxy_scheme_default_port(scheme: &str) -> u16 { + match scheme { + "https" => 443, + "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, + _ => 80, + } +} + +fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { + let mut ports = BTreeSet::new(); + for key in PROXY_URL_ENV_KEYS { + let Some(proxy_url) = proxy_url_env_value(env, key) else { + continue; + }; + let trimmed = proxy_url.trim(); + if trimmed.is_empty() { + continue; + } + + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + let Ok(parsed) = Url::parse(&candidate) else { + continue; + }; + let Some(host) = parsed.host_str() else { + continue; + }; + if !is_loopback_host(host) { + continue; + } + + let scheme = parsed.scheme().to_ascii_lowercase(); + let port = parsed + .port() + .unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str())); + ports.insert(port); + } + ports.into_iter().collect() +} + +fn local_binding_enabled(env: &HashMap) -> bool { + env.get(ALLOW_LOCAL_BINDING_ENV_KEY).is_some_and(|value| { + let trimmed = value.trim(); + trimmed == "1" || trimmed.eq_ignore_ascii_case("true") + }) +} + +#[derive(Debug, Default)] +struct ProxyPolicyInputs { + ports: Vec, + has_proxy_config: bool, + allow_local_binding: bool, +} + +fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { + if let Some(network) = network { + let mut env = HashMap::new(); + network.apply_to_env(&mut env); + return ProxyPolicyInputs { + ports: proxy_loopback_ports_from_env(&env), + has_proxy_config: has_proxy_url_env_vars(&env), + allow_local_binding: local_binding_enabled(&env), + }; + } + + ProxyPolicyInputs::default() +} + +fn dynamic_network_policy( + sandbox_policy: &SandboxPolicy, + enforce_managed_network: bool, + proxy: &ProxyPolicyInputs, +) -> String { + if !proxy.ports.is_empty() { + let mut policy = + String::from("; allow outbound access only to configured loopback proxy endpoints\n"); + if proxy.allow_local_binding { + policy.push_str("; allow localhost-only binding and loopback traffic\n"); + policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); + policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); + policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n"); + } + for port in &proxy.ports { + policy.push_str(&format!( + "(allow network-outbound (remote ip \"localhost:{port}\"))\n" + )); + } + return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); + } + + if proxy.has_proxy_config { + // Proxy configuration is present but we could not infer any valid loopback endpoints. + // Fail closed to avoid silently widening network access in proxy-enforced sessions. + return String::new(); + } + + if enforce_managed_network { + // Managed network requirements are active but no usable proxy endpoints + // are available. Fail closed for network access. + return String::new(); + } + + if sandbox_policy.has_full_network_access() { + // No proxy env is configured: retain the existing full-network behavior. + format!( + "(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}" + ) + } else { + String::new() + } +} + pub(crate) fn create_seatbelt_command_args( command: Vec, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, ) -> Vec { let (file_write_policy, file_write_dir_params) = { if sandbox_policy.has_full_disk_write_access() { @@ -112,11 +244,8 @@ pub(crate) fn create_seatbelt_command_args( }; // TODO(mbolin): apply_patch calls must also honor the SandboxPolicy. - let network_policy = if sandbox_policy.has_full_network_access() { - MACOS_SEATBELT_NETWORK_POLICY - } else { - "" - }; + let proxy = proxy_policy_inputs(network); + let network_policy = dynamic_network_policy(sandbox_policy, enforce_managed_network, &proxy); let full_policy = format!( "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" @@ -163,7 +292,9 @@ fn macos_dir_params() -> Vec<(String, PathBuf)> { #[cfg(test)] mod tests { use super::MACOS_SEATBELT_BASE_POLICY; + use super::ProxyPolicyInputs; use super::create_seatbelt_command_args; + use super::dynamic_network_policy; use super::macos_dir_params; use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; @@ -184,6 +315,148 @@ mod tests { ); } + #[test] + fn create_seatbelt_args_routes_network_through_proxy_ports() { + let policy = dynamic_network_policy( + &SandboxPolicy::ReadOnly, + false, + &ProxyPolicyInputs { + ports: vec![43128, 48081], + has_proxy_config: true, + allow_local_binding: false, + }, + ); + + assert!( + policy.contains("(allow network-outbound (remote ip \"localhost:43128\"))"), + "expected HTTP proxy port allow rule in policy:\n{policy}" + ); + assert!( + policy.contains("(allow network-outbound (remote ip \"localhost:48081\"))"), + "expected SOCKS proxy port allow rule in policy:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should not include blanket outbound allowance when proxy ports are present:\n{policy}" + ); + assert!( + !policy.contains("(allow network-bind (local ip \"localhost:*\"))"), + "policy should not allow loopback binding unless explicitly enabled:\n{policy}" + ); + assert!( + !policy.contains("(allow network-inbound (local ip \"localhost:*\"))"), + "policy should not allow loopback inbound unless explicitly enabled:\n{policy}" + ); + } + + #[test] + fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() { + let policy = dynamic_network_policy( + &SandboxPolicy::ReadOnly, + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: true, + }, + ); + + assert!( + policy.contains("(allow network-bind (local ip \"localhost:*\"))"), + "policy should allow loopback binding when explicitly enabled:\n{policy}" + ); + assert!( + policy.contains("(allow network-inbound (local ip \"localhost:*\"))"), + "policy should allow loopback inbound when explicitly enabled:\n{policy}" + ); + assert!( + policy.contains("(allow network-outbound (remote ip \"localhost:*\"))"), + "policy should allow loopback outbound when explicitly enabled:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should keep proxy-routed behavior without blanket outbound allowance:\n{policy}" + ); + } + + #[test] + fn dynamic_network_policy_fails_closed_when_proxy_config_without_ports() { + let policy = dynamic_network_policy( + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + false, + &ProxyPolicyInputs { + ports: vec![], + has_proxy_config: true, + allow_local_binding: false, + }, + ); + + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should not include blanket outbound allowance when proxy config is present without ports:\n{policy}" + ); + assert!( + !policy.contains("(allow network-outbound (remote ip \"localhost:"), + "policy should not include proxy port allowance when proxy config is present without ports:\n{policy}" + ); + } + + #[test] + fn dynamic_network_policy_fails_closed_for_managed_network_without_proxy_config() { + let policy = dynamic_network_policy( + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + true, + &ProxyPolicyInputs { + ports: vec![], + has_proxy_config: false, + allow_local_binding: false, + }, + ); + + assert_eq!(policy, ""); + } + + #[test] + fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { + let policy = dynamic_network_policy( + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + }, + ); + + assert!( + policy.contains("(allow network-outbound (remote ip \"localhost:43128\"))"), + "expected proxy endpoint allow rule in policy:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should not include blanket outbound allowance when proxy is configured:\n{policy}" + ); + assert!( + !policy.contains("\n(allow network-inbound)\n"), + "policy should not include blanket inbound allowance when proxy is configured:\n{policy}" + ); + } + #[test] fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { // Create a temporary workspace with two writable roots: one containing @@ -227,7 +500,7 @@ mod tests { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd); + let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None); // Build the expected policy text using a raw string for readability. // Note that the policy includes: @@ -315,7 +588,8 @@ mod tests { .iter() .map(std::string::ToString::to_string) .collect(); - let write_hooks_file_args = create_seatbelt_command_args(shell_command_git, &policy, &cwd); + let write_hooks_file_args = + create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -346,7 +620,7 @@ mod tests { .map(std::string::ToString::to_string) .collect(); let write_allowed_file_args = - create_seatbelt_command_args(shell_command_allowed, &policy, &cwd); + create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -406,7 +680,7 @@ mod tests { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command, &policy, &cwd); + let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -436,7 +710,8 @@ mod tests { .iter() .map(std::string::ToString::to_string) .collect(); - let gitdir_args = create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd); + let gitdir_args = + create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -492,8 +767,13 @@ mod tests { .iter() .map(std::string::ToString::to_string) .collect(); - let args = - create_seatbelt_command_args(shell_command.clone(), &policy, vulnerable_root.as_path()); + let args = create_seatbelt_command_args( + shell_command.clone(), + &policy, + vulnerable_root.as_path(), + false, + None, + ); let tmpdir_env_var = std::env::var("TMPDIR") .ok() diff --git a/codex-rs/core/src/seatbelt_network_policy.sbpl b/codex-rs/core/src/seatbelt_network_policy.sbpl index 2a72f95fd..a0801d093 100644 --- a/codex-rs/core/src/seatbelt_network_policy.sbpl +++ b/codex-rs/core/src/seatbelt_network_policy.sbpl @@ -1,9 +1,14 @@ ; when network access is enabled, these policies are added after those in seatbelt_base_policy.sbpl +; proxy-specific allow rules are injected by codex-core based on environment. ; Ref https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/network.sb;drc=f8f264d5e4e7509c913f4c60c2639d15905a07e4 -(allow network-outbound) -(allow network-inbound) -(allow system-socket) +; allow only safe AF_SYSTEM sockets used for local platform services. +(allow system-socket + (require-all + (socket-domain AF_SYSTEM) + (socket-protocol 2) + ) +) (allow mach-lookup ; Used to look up the _CS_DARWIN_USER_CACHE_DIR in the sandbox. diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index b2a507fda..67e6ace04 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -1,3 +1,4 @@ +use codex_network_proxy::NetworkProxy; use std::collections::HashMap; use std::path::PathBuf; use std::process::Stdio; @@ -35,15 +36,29 @@ pub enum StdioPolicy { /// 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. -pub(crate) async fn spawn_child_async( - program: PathBuf, - args: Vec, - #[cfg_attr(not(unix), allow(unused_variables))] arg0: Option<&str>, - cwd: PathBuf, - sandbox_policy: &SandboxPolicy, - stdio_policy: StdioPolicy, - env: HashMap, -) -> std::io::Result { +pub(crate) struct SpawnChildRequest<'a> { + pub program: PathBuf, + pub args: Vec, + pub arg0: Option<&'a str>, + pub cwd: PathBuf, + pub sandbox_policy: &'a SandboxPolicy, + pub network: Option<&'a NetworkProxy>, + pub stdio_policy: StdioPolicy, + pub env: HashMap, +} + +pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io::Result { + let SpawnChildRequest { + program, + args, + arg0, + cwd, + sandbox_policy, + network, + stdio_policy, + mut env, + } = request; + trace!( "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}" ); @@ -53,6 +68,9 @@ pub(crate) async fn spawn_child_async( cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from)); cmd.args(args); cmd.current_dir(cwd); + if let Some(network) = network { + network.apply_to_env(&mut env); + } cmd.env_clear(); cmd.envs(env); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index c034e26f4..02dd5fdf2 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -23,7 +23,7 @@ use crate::protocol::ExecCommandEndEvent; use crate::protocol::ExecCommandSource; use crate::protocol::SandboxPolicy; use crate::protocol::TurnStartedEvent; -use crate::sandboxing::ExecEnv; +use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; @@ -140,13 +140,14 @@ pub(crate) async fn execute_user_shell_command( ) .await; - let exec_env = ExecEnv { + let exec_env = ExecRequest { command: exec_command.clone(), cwd: cwd.clone(), env: create_env( &turn_context.shell_environment_policy, Some(session.conversation_id), ), + network: turn_context.config.network.clone(), // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), @@ -164,14 +165,9 @@ pub(crate) async fn execute_user_shell_command( }); let sandbox_policy = SandboxPolicy::DangerFullAccess; - let exec_result = execute_exec_env( - exec_env, - &sandbox_policy, - stdout_stream, - turn_context.config.network.clone(), - ) - .or_cancel(&cancellation_token) - .await; + let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream) + .or_cancel(&cancellation_token) + .await; match exec_result { Err(CancelErr::Cancelled) => { diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 1eb51dd73..0a88283fe 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -192,6 +192,7 @@ impl ToolHandler for UnifiedExecHandler { yield_time_ms, max_output_tokens, workdir, + network: context.turn.config.network.clone(), tty, sandbox_permissions, justification, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 381f8ce13..3f410e1b2 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -3,7 +3,8 @@ Module: orchestrator Central place for approvals + sandbox selection + retry semantics. Drives a simple sequence for any ToolRuntime: approval → select sandbox → attempt → -retry without sandbox on denial (no re‑approval thanks to caching). +retry with an escalated sandbox strategy on denial (no re‑approval thanks to +caching). */ use crate::error::CodexErr; use crate::error::SandboxErr; @@ -87,12 +88,19 @@ impl ToolOrchestrator { } // 2) First attempt under the selected sandbox. + let has_managed_network_requirements = turn_ctx + .config + .config_layer_stack + .requirements_toml() + .network + .is_some(); let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) { SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None, SandboxOverride::NoOverride => self.sandbox.select_initial( &turn_ctx.sandbox_policy, tool.sandbox_preference(), turn_ctx.windows_sandbox_level, + has_managed_network_requirements, ), }; @@ -102,6 +110,7 @@ impl ToolOrchestrator { let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, policy: &turn_ctx.sandbox_policy, + enforce_managed_network: has_managed_network_requirements, manager: &self.sandbox, sandbox_cwd: &turn_ctx.cwd, codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), @@ -128,7 +137,7 @@ impl ToolOrchestrator { }))); } - // Ask for approval before retrying without sandbox. + // Ask for approval before retrying with the escalated sandbox. if !tool.should_bypass_approval(approval_policy, already_approved) { let reason_msg = build_denial_reason_from_output(output.as_ref()); let approval_ctx = ApprovalCtx { @@ -154,6 +163,7 @@ impl ToolOrchestrator { let escalated_attempt = SandboxAttempt { sandbox: crate::exec::SandboxType::None, policy: &turn_ctx.sandbox_policy, + enforce_managed_network: has_managed_network_requirements, manager: &self.sandbox, sandbox_cwd: &turn_ctx.cwd, codex_linux_sandbox_exe: None, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index e87b40661..d16f9b836 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -151,9 +151,9 @@ impl ToolRuntime for ApplyPatchRuntime { ) -> Result { let spec = Self::build_command_spec(req)?; let env = attempt - .env_for(spec) + .env_for(spec, None) .map_err(|err| ToolError::Codex(err.into()))?; - let out = execute_env(env, attempt.policy, None, Self::stdout_stream(ctx)) + let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; Ok(out) diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 106092ca0..f5e1ea17e 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -167,16 +167,11 @@ impl ToolRuntime for ShellRuntime { req.justification.clone(), )?; let env = attempt - .env_for(spec) + .env_for(spec, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; - let out = execute_env( - env, - attempt.policy, - req.network.clone(), - Self::stdout_stream(ctx), - ) - .await - .map_err(ToolError::Codex)?; + let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) + .await + .map_err(ToolError::Codex)?; Ok(out) } } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index a36ee68ed..f75c8f757 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -2,7 +2,7 @@ Runtime: unified exec Handles approval + sandbox orchestration for unified exec requests, delegating to -the process manager to spawn PTYs once an ExecEnv is prepared. +the process manager to spawn PTYs once an ExecRequest is prepared. */ use crate::error::CodexErr; use crate::error::SandboxErr; @@ -176,7 +176,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; let exec_env = attempt - .env_for(spec) + .env_for(spec, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; self.manager .open_session_with_exec_env(&exec_env, req.tty) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index d50e59253..56e54e62f 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -12,6 +12,7 @@ use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxTransformError; use crate::state::SessionServices; +use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; @@ -271,6 +272,7 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { pub(crate) struct SandboxAttempt<'a> { pub sandbox: crate::exec::SandboxType, pub policy: &'a crate::protocol::SandboxPolicy, + pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a Path, pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>, @@ -282,12 +284,15 @@ impl<'a> SandboxAttempt<'a> { pub fn env_for( &self, spec: CommandSpec, - ) -> Result { + network: Option<&NetworkProxy>, + ) -> Result { self.manager .transform(crate::sandboxing::SandboxTransformRequest { spec, policy: self.policy, sandbox: self.sandbox, + enforce_managed_network: self.enforce_managed_network, + network, sandbox_policy_cwd: self.sandbox_cwd, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe, use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 4c45f1cf4..c1c7b8707 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -4,7 +4,7 @@ //! - Manages interactive processes (create, reuse, buffer output with caps). //! - Uses the shared ToolOrchestrator to handle approval, sandbox selection, and //! retry semantics in a single, descriptive flow. -//! - Spawns the PTY from a sandbox‑transformed `ExecEnv`; on sandbox denial, +//! - Spawns the PTY from a sandbox-transformed `ExecRequest`; on sandbox denial, //! retries without sandbox when policy allows (no re‑prompt thanks to caching). //! - Uses the shared `is_likely_sandbox_denied` heuristic to keep denial messages //! consistent with other exec paths. @@ -12,7 +12,7 @@ //! Flow at a glance (open process) //! 1) Build a small request `{ command, cwd }`. //! 2) Orchestrator: approval (bypass/cache/prompt) → select sandbox → run. -//! 3) Runtime: transform `CommandSpec` → `ExecEnv` → spawn PTY. +//! 3) Runtime: transform `CommandSpec` -> `ExecRequest` -> spawn PTY. //! 4) If denial, orchestrator retries with `SandboxType::None`. //! 5) Process handle is returned with streaming output + metadata. //! @@ -27,6 +27,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use codex_network_proxy::NetworkProxy; use rand::Rng; use rand::rng; use tokio::sync::Mutex; @@ -79,6 +80,7 @@ pub(crate) struct ExecCommandRequest { pub yield_time_ms: u64, pub max_output_tokens: Option, pub workdir: Option, + pub network: Option, pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub justification: Option, @@ -203,6 +205,7 @@ mod tests { yield_time_ms, max_output_tokens: None, workdir: None, + network: None, tty: true, sandbox_permissions: SandboxPermissions::UseDefault, justification: None, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index cee73fae2..8576bf77c 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -14,7 +14,7 @@ use tokio_util::sync::CancellationToken; use crate::exec_env::create_env; use crate::exec_policy::ExecApprovalRequest; use crate::protocol::ExecCommandSource; -use crate::sandboxing::ExecEnv; +use crate::sandboxing::ExecRequest; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::events::ToolEventStage; @@ -460,7 +460,7 @@ impl UnifiedExecProcessManager { pub(crate) async fn open_session_with_exec_env( &self, - env: &ExecEnv, + env: &ExecRequest, tty: bool, ) -> Result { let (program, args) = env @@ -520,7 +520,7 @@ impl UnifiedExecProcessManager { command: request.command.clone(), cwd, env, - network: context.turn.config.network.clone(), + network: request.network.clone(), tty: request.tty, sandbox_permissions: request.sandbox_permissions, justification: request.justification.clone(), diff --git a/codex-rs/core/tests/suite/seatbelt.rs b/codex-rs/core/tests/suite/seatbelt.rs index 286bc8791..614e87367 100644 --- a/codex-rs/core/tests/suite/seatbelt.rs +++ b/codex-rs/core/tests/suite/seatbelt.rs @@ -190,6 +190,7 @@ assert os.read(master, 4) == b"ping""# &policy, sandbox_cwd.as_path(), StdioPolicy::RedirectForShellTool, + None, HashMap::new(), ) .await @@ -242,6 +243,7 @@ async fn java_home_finds_runtime_under_seatbelt() { &policy, sandbox_cwd.as_path(), StdioPolicy::RedirectForShellTool, + None, env, ) .await @@ -298,6 +300,7 @@ async fn touch(path: &Path, policy: &SandboxPolicy) -> bool { policy, sandbox_cwd.as_path(), StdioPolicy::RedirectForShellTool, + None, HashMap::new(), ) .await diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index ab8d3868d..45de9c223 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -27,6 +27,7 @@ async fn spawn_command_under_sandbox( sandbox_policy, sandbox_cwd, stdio_policy, + None, env, ) .await @@ -52,6 +53,7 @@ async fn spawn_command_under_sandbox( sandbox_cwd, false, stdio_policy, + None, env, ) .await diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index f14fc5f13..0abd64170 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -26,6 +26,8 @@ into this binary. writable roots are blocked by mounting `/dev/null` on the symlink or first missing component. - When enabled, the helper isolates the PID namespace via `--unshare-pid`. +- When enabled and network is restricted without proxy routing, the helper also + isolates the network namespace via `--unshare-net`. - When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but you can skip this in restrictive container environments with `--no-proc`. diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 4a60c65dd..1a835fd5e 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -26,19 +26,47 @@ pub(crate) struct BwrapOptions { /// This is the secure default, but some restrictive container environments /// deny `--proc /proc` even when PID namespaces are available. pub mount_proc: bool, + /// How networking should be configured inside the bubblewrap sandbox. + pub network_mode: BwrapNetworkMode, } impl Default for BwrapOptions { fn default() -> Self { - Self { mount_proc: true } + Self { + mount_proc: true, + network_mode: BwrapNetworkMode::FullAccess, + } + } +} + +/// Network policy modes for bubblewrap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum BwrapNetworkMode { + /// Keep access to the host network namespace. + #[default] + FullAccess, + /// Remove access to the host network namespace. + Isolated, + /// Intended proxy-only mode. + /// + /// Bubblewrap does not currently enforce proxy-only egress, so this is + /// treated as isolated for fail-closed behavior. + ProxyOnly, +} + +impl BwrapNetworkMode { + fn should_unshare_network(self) -> bool { + !matches!(self, Self::FullAccess) } } /// Wrap a command with bubblewrap so the filesystem is read-only by default, /// with explicit writable roots and read-only subpaths layered afterward. /// -/// When the policy grants full disk write access, this returns `command` -/// unchanged so we avoid unnecessary sandboxing overhead. +/// When the policy grants full disk write access and full network access, this +/// returns `command` unchanged so we avoid unnecessary sandboxing overhead. +/// If network isolation is requested, we still wrap with bubblewrap so network +/// namespace restrictions apply while preserving full filesystem access. pub(crate) fn create_bwrap_command_args( command: Vec, sandbox_policy: &SandboxPolicy, @@ -46,12 +74,37 @@ pub(crate) fn create_bwrap_command_args( options: BwrapOptions, ) -> Result> { if sandbox_policy.has_full_disk_write_access() { - return Ok(command); + return if options.network_mode == BwrapNetworkMode::FullAccess { + Ok(command) + } else { + Ok(create_bwrap_flags_full_filesystem(command, options)) + }; } create_bwrap_flags(command, sandbox_policy, cwd, options) } +fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOptions) -> Vec { + let mut args = vec![ + "--new-session".to_string(), + "--die-with-parent".to_string(), + "--bind".to_string(), + "/".to_string(), + "/".to_string(), + "--unshare-pid".to_string(), + ]; + if options.network_mode.should_unshare_network() { + args.push("--unshare-net".to_string()); + } + if options.mount_proc { + args.push("--proc".to_string()); + args.push("/proc".to_string()); + } + args.push("--".to_string()); + args.extend(command); + args +} + /// Build the bubblewrap flags (everything after `argv[0]`). fn create_bwrap_flags( command: Vec, @@ -65,6 +118,9 @@ fn create_bwrap_flags( args.extend(create_filesystem_args(sandbox_policy, cwd)?); // Isolate the PID namespace. args.push("--unshare-pid".to_string()); + if options.network_mode.should_unshare_network() { + args.push("--unshare-net".to_string()); + } // Mount a fresh /proc unless the caller explicitly disables it. if options.mount_proc { args.push("--proc".to_string()); @@ -250,3 +306,59 @@ fn find_first_non_existent_component(target_path: &Path) -> Option { None } + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + + #[test] + fn full_disk_write_full_network_returns_unwrapped_command() { + let command = vec!["/bin/true".to_string()]; + let args = create_bwrap_command_args( + command.clone(), + &SandboxPolicy::DangerFullAccess, + Path::new("/"), + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::FullAccess, + }, + ) + .expect("create bwrap args"); + + assert_eq!(args, command); + } + + #[test] + fn full_disk_write_proxy_only_keeps_full_filesystem_but_unshares_network() { + let command = vec!["/bin/true".to_string()]; + let args = create_bwrap_command_args( + command, + &SandboxPolicy::DangerFullAccess, + Path::new("/"), + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::ProxyOnly, + }, + ) + .expect("create bwrap args"); + + assert_eq!( + args, + vec![ + "--new-session".to_string(), + "--die-with-parent".to_string(), + "--bind".to_string(), + "/".to_string(), + "/".to_string(), + "--unshare-pid".to_string(), + "--unshare-net".to_string(), + "--proc".to_string(), + "/proc".to_string(), + "--".to_string(), + "/bin/true".to_string(), + ] + ); + } +} diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index d49491233..d86fafd29 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -42,18 +42,22 @@ pub(crate) fn apply_sandbox_policy_to_current_thread( sandbox_policy: &SandboxPolicy, cwd: &Path, apply_landlock_fs: bool, + allow_network_for_proxy: bool, ) -> Result<()> { + let install_network_seccomp = + should_install_network_seccomp(sandbox_policy, allow_network_for_proxy); + // `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents // setuid privilege elevation. Many `bwrap` deployments rely on setuid, so // we avoid this unless we need seccomp or we are explicitly using the // legacy Landlock filesystem pipeline. - if !sandbox_policy.has_full_network_access() + if install_network_seccomp || (apply_landlock_fs && !sandbox_policy.has_full_disk_write_access()) { set_no_new_privs()?; } - if !sandbox_policy.has_full_network_access() { + if install_network_seccomp { install_network_seccomp_filter_on_current_thread()?; } @@ -72,6 +76,15 @@ pub(crate) fn apply_sandbox_policy_to_current_thread( Ok(()) } +fn should_install_network_seccomp( + sandbox_policy: &SandboxPolicy, + allow_network_for_proxy: bool, +) -> bool { + // Managed-network sessions should remain fail-closed even for policies that + // would normally grant full network access (for example, DangerFullAccess). + !sandbox_policy.has_full_network_access() || allow_network_for_proxy +} + /// Enable `PR_SET_NO_NEW_PRIVS` so seccomp can be applied safely. fn set_no_new_privs() -> Result<()> { let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; @@ -183,3 +196,38 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), Ok(()) } + +#[cfg(test)] +mod tests { + use super::should_install_network_seccomp; + use codex_core::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + + #[test] + fn managed_network_enforces_seccomp_even_for_full_network_policy() { + assert_eq!( + should_install_network_seccomp(&SandboxPolicy::DangerFullAccess, true), + true + ); + } + + #[test] + fn full_network_policy_without_managed_network_skips_seccomp() { + assert_eq!( + should_install_network_seccomp(&SandboxPolicy::DangerFullAccess, false), + false + ); + } + + #[test] + fn restricted_network_policy_always_installs_seccomp() { + assert!(should_install_network_seccomp( + &SandboxPolicy::ReadOnly, + false + )); + assert!(should_install_network_seccomp( + &SandboxPolicy::ReadOnly, + true + )); + } +} diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index f5f0d9887..2978efae2 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -6,6 +6,7 @@ use std::os::fd::FromRawFd; use std::path::Path; use std::path::PathBuf; +use crate::bwrap::BwrapNetworkMode; use crate::bwrap::BwrapOptions; use crate::bwrap::create_bwrap_command_args; use crate::landlock::apply_sandbox_policy_to_current_thread; @@ -40,6 +41,14 @@ pub struct LandlockCommand { #[arg(long = "apply-seccomp-then-exec", hide = true, default_value_t = false)] pub apply_seccomp_then_exec: bool, + /// Internal compatibility flag. + /// + /// By default, restricted-network sandboxing uses isolated networking. + /// If set, sandbox setup switches to proxy-only network mode + /// (currently enforced the same as isolated networking). + #[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)] + pub allow_network_for_proxy: bool, + /// When set, skip mounting a fresh `/proc` even though PID isolation is /// still enabled. This is primarily intended for restrictive container /// environments that deny `--proc /proc`. @@ -64,6 +73,7 @@ pub fn run_main() -> ! { sandbox_policy, use_bwrap_sandbox, apply_seccomp_then_exec, + allow_network_for_proxy, no_proc, command, } = LandlockCommand::parse(); @@ -75,18 +85,24 @@ pub fn run_main() -> ! { // Inner stage: apply seccomp/no_new_privs after bubblewrap has already // established the filesystem view. if apply_seccomp_then_exec { - if let Err(e) = - apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd, false) - { + if let Err(e) = apply_sandbox_policy_to_current_thread( + &sandbox_policy, + &sandbox_policy_cwd, + false, + allow_network_for_proxy, + ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } exec_or_panic(command); } - if sandbox_policy.has_full_disk_write_access() { - if let Err(e) = - apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd, false) - { + if sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy { + if let Err(e) = apply_sandbox_policy_to_current_thread( + &sandbox_policy, + &sandbox_policy_cwd, + false, + allow_network_for_proxy, + ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } exec_or_panic(command); @@ -100,15 +116,25 @@ pub fn run_main() -> ! { &sandbox_policy_cwd, &sandbox_policy, use_bwrap_sandbox, + allow_network_for_proxy, command, ); - run_bwrap_with_proc_fallback(&sandbox_policy_cwd, &sandbox_policy, inner, !no_proc); + run_bwrap_with_proc_fallback( + &sandbox_policy_cwd, + &sandbox_policy, + inner, + !no_proc, + allow_network_for_proxy, + ); } // Legacy path: Landlock enforcement only, when bwrap sandboxing is not enabled. - if let Err(e) = - apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd, true) - { + if let Err(e) = apply_sandbox_policy_to_current_thread( + &sandbox_policy, + &sandbox_policy_cwd, + true, + allow_network_for_proxy, + ) { panic!("error applying legacy Linux sandbox restrictions: {e:?}"); } exec_or_panic(command); @@ -119,6 +145,7 @@ fn run_bwrap_with_proc_fallback( sandbox_policy: &codex_core::protocol::SandboxPolicy, inner: Vec, mount_proc: bool, + allow_network_for_proxy: bool, ) -> ! { let mut mount_proc = mount_proc; @@ -127,11 +154,28 @@ fn run_bwrap_with_proc_fallback( mount_proc = false; } - let options = BwrapOptions { mount_proc }; + let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy); + let options = BwrapOptions { + mount_proc, + network_mode, + }; let argv = build_bwrap_argv(inner, sandbox_policy, sandbox_policy_cwd, options); exec_vendored_bwrap(argv); } +fn bwrap_network_mode( + sandbox_policy: &codex_core::protocol::SandboxPolicy, + allow_network_for_proxy: bool, +) -> BwrapNetworkMode { + if allow_network_for_proxy { + BwrapNetworkMode::ProxyOnly + } else if sandbox_policy.has_full_network_access() { + BwrapNetworkMode::FullAccess + } else { + BwrapNetworkMode::Isolated + } +} + fn build_bwrap_argv( inner: Vec, sandbox_policy: &codex_core::protocol::SandboxPolicy, @@ -164,7 +208,10 @@ fn preflight_proc_mount_support( preflight_command, sandbox_policy, sandbox_policy_cwd, - BwrapOptions { mount_proc: true }, + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::FullAccess, + }, ); let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); !is_proc_mount_failure(stderr.as_str()) @@ -268,6 +315,7 @@ fn build_inner_seccomp_command( sandbox_policy_cwd: &Path, sandbox_policy: &codex_core::protocol::SandboxPolicy, use_bwrap_sandbox: bool, + allow_network_for_proxy: bool, command: Vec, ) -> Vec { let current_exe = match std::env::current_exe() { @@ -290,6 +338,9 @@ fn build_inner_seccomp_command( inner.push("--use-bwrap-sandbox".to_string()); inner.push("--apply-seccomp-then-exec".to_string()); } + if allow_network_for_proxy { + inner.push("--allow-network-for-proxy".to_string()); + } inner.push("--".to_string()); inner.extend(command); inner @@ -342,7 +393,10 @@ mod tests { vec!["/bin/true".to_string()], &SandboxPolicy::ReadOnly, Path::new("/"), - BwrapOptions { mount_proc: true }, + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::FullAccess, + }, ); assert_eq!( argv, @@ -366,4 +420,38 @@ mod tests { ] ); } + + #[test] + fn inserts_unshare_net_when_network_isolation_requested() { + let argv = build_bwrap_argv( + vec!["/bin/true".to_string()], + &SandboxPolicy::ReadOnly, + Path::new("/"), + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::Isolated, + }, + ); + assert_eq!(argv.contains(&"--unshare-net".to_string()), true); + } + + #[test] + fn inserts_unshare_net_when_proxy_only_network_mode_requested() { + let argv = build_bwrap_argv( + vec!["/bin/true".to_string()], + &SandboxPolicy::ReadOnly, + Path::new("/"), + BwrapOptions { + mount_proc: true, + network_mode: BwrapNetworkMode::ProxyOnly, + }, + ); + assert_eq!(argv.contains(&"--unshare-net".to_string()), true); + } + + #[test] + fn proxy_only_mode_takes_precedence_over_full_network_policy() { + let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true); + assert_eq!(mode, BwrapNetworkMode::ProxyOnly); + } } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index a974ce13b..570a8a994 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -20,10 +20,17 @@ pub use network_policy::NetworkPolicyDecider; pub use network_policy::NetworkPolicyRequest; pub use network_policy::NetworkPolicyRequestArgs; pub use network_policy::NetworkProtocol; +pub use proxy::ALL_PROXY_ENV_KEYS; +pub use proxy::ALLOW_LOCAL_BINDING_ENV_KEY; pub use proxy::Args; +pub use proxy::DEFAULT_NO_PROXY_VALUE; +pub use proxy::NO_PROXY_ENV_KEYS; pub use proxy::NetworkProxy; pub use proxy::NetworkProxyBuilder; pub use proxy::NetworkProxyHandle; +pub use proxy::PROXY_URL_ENV_KEYS; +pub use proxy::has_proxy_url_env_vars; +pub use proxy::proxy_url_env_value; pub use runtime::ConfigReloader; pub use runtime::ConfigState; pub use runtime::NetworkProxyState; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 46936dd53..6388854b4 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -176,6 +176,8 @@ impl NetworkProxyBuilder { state, http_addr, socks_addr, + socks_enabled: current_cfg.network.enable_socks5, + allow_local_binding: current_cfg.network.allow_local_binding, admin_addr, reserved_listeners, policy_decider: self.policy_decider, @@ -202,6 +204,8 @@ pub struct NetworkProxy { state: Arc, http_addr: SocketAddr, socks_addr: SocketAddr, + socks_enabled: bool, + allow_local_binding: bool, admin_addr: SocketAddr, reserved_listeners: Option>, policy_decider: Option>, @@ -223,24 +227,151 @@ impl PartialEq for NetworkProxy { fn eq(&self, other: &Self) -> bool { self.http_addr == other.http_addr && self.socks_addr == other.socks_addr + && self.allow_local_binding == other.allow_local_binding && self.admin_addr == other.admin_addr } } impl Eq for NetworkProxy {} +pub const PROXY_URL_ENV_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "FTP_PROXY", + "YARN_HTTP_PROXY", + "YARN_HTTPS_PROXY", + "NPM_CONFIG_HTTP_PROXY", + "NPM_CONFIG_HTTPS_PROXY", + "NPM_CONFIG_PROXY", + "BUNDLE_HTTP_PROXY", + "BUNDLE_HTTPS_PROXY", + "PIP_PROXY", + "DOCKER_HTTP_PROXY", + "DOCKER_HTTPS_PROXY", +]; + +pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; +pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; + +const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"]; + +pub const NO_PROXY_ENV_KEYS: &[&str] = &[ + "NO_PROXY", + "no_proxy", + "npm_config_noproxy", + "NPM_CONFIG_NOPROXY", + "YARN_NO_PROXY", + "BUNDLE_NO_PROXY", +]; + +pub const DEFAULT_NO_PROXY_VALUE: &str = concat!( + "localhost,127.0.0.1,::1,", + "*.local,.local,", + "169.254.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" +); + +pub fn proxy_url_env_value<'a>( + env: &'a HashMap, + canonical_key: &str, +) -> Option<&'a str> { + if let Some(value) = env.get(canonical_key) { + return Some(value.as_str()); + } + let lower_key = canonical_key.to_ascii_lowercase(); + env.get(lower_key.as_str()).map(String::as_str) +} + +pub fn has_proxy_url_env_vars(env: &HashMap) -> bool { + PROXY_URL_ENV_KEYS + .iter() + .any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty())) +} + +fn set_env_keys(env: &mut HashMap, keys: &[&str], value: &str) { + for key in keys { + env.insert((*key).to_string(), value.to_string()); + } +} + +fn apply_proxy_env_overrides( + env: &mut HashMap, + http_addr: SocketAddr, + socks_addr: SocketAddr, + socks_enabled: bool, + allow_local_binding: bool, +) { + let http_proxy_url = format!("http://{http_addr}"); + let socks_proxy_url = format!("socks5h://{socks_addr}"); + env.insert( + ALLOW_LOCAL_BINDING_ENV_KEY.to_string(), + if allow_local_binding { + "1".to_string() + } else { + "0".to_string() + }, + ); + + // HTTP-based clients are best served by explicit HTTP proxy URLs. + set_env_keys( + env, + &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "YARN_HTTP_PROXY", + "YARN_HTTPS_PROXY", + "npm_config_http_proxy", + "npm_config_https_proxy", + "npm_config_proxy", + "NPM_CONFIG_HTTP_PROXY", + "NPM_CONFIG_HTTPS_PROXY", + "NPM_CONFIG_PROXY", + "BUNDLE_HTTP_PROXY", + "BUNDLE_HTTPS_PROXY", + "PIP_PROXY", + "DOCKER_HTTP_PROXY", + "DOCKER_HTTPS_PROXY", + ], + &http_proxy_url, + ); + + // Keep local/private targets direct so local IPC and metadata endpoints avoid the proxy. + set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE); + + env.insert("ELECTRON_GET_USE_PROXY".to_string(), "true".to_string()); + + if socks_enabled { + set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url); + set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url); + #[cfg(target_os = "macos")] + { + // Preserve existing SSH wrappers (for example: Secretive/Teleport setups) + // and only provide a SOCKS ProxyCommand fallback when one is not present. + env.entry("GIT_SSH_COMMAND".to_string()) + .or_insert_with(|| format!("ssh -o ProxyCommand='nc -X 5 -x {socks_addr} %h %p'")); + } + } else { + set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url); + } +} + impl NetworkProxy { pub fn builder() -> NetworkProxyBuilder { NetworkProxyBuilder::default() } pub fn apply_to_env(&self, env: &mut HashMap) { - // Enforce proxying for all child processes when configured. We always override to ensure - // the proxy is actually used even if the caller passed conflicting environment variables. - let proxy_url = format!("http://{}", self.http_addr); - for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { - env.insert(key.to_string(), proxy_url.clone()); - } + // Enforce proxying for child processes. We intentionally override existing values so + // command-level environment cannot bypass the managed proxy endpoint. + apply_proxy_env_overrides( + env, + self.http_addr, + self.socks_addr, + self.socks_enabled, + self.allow_local_binding, + ); } pub async fn run(&self) -> Result { @@ -406,6 +537,9 @@ mod tests { use super::*; use crate::config::NetworkProxySettings; use crate::state::network_proxy_state_for_policy; + use pretty_assertions::assert_eq; + use std::net::IpAddr; + use std::net::Ipv4Addr; #[tokio::test] async fn managed_proxy_builder_uses_loopback_ephemeral_ports() { @@ -462,4 +596,111 @@ mod tests { "127.0.0.1:48080".parse::().unwrap() ); } + + #[test] + fn proxy_url_env_value_resolves_lowercase_aliases() { + let mut env = HashMap::new(); + env.insert( + "http_proxy".to_string(), + "http://127.0.0.1:3128".to_string(), + ); + + assert_eq!( + proxy_url_env_value(&env, "HTTP_PROXY"), + Some("http://127.0.0.1:3128") + ); + } + + #[test] + fn has_proxy_url_env_vars_detects_lowercase_aliases() { + let mut env = HashMap::new(); + env.insert( + "all_proxy".to_string(), + "socks5h://127.0.0.1:8081".to_string(), + ); + + assert_eq!(has_proxy_url_env_vars(&env), true); + } + + #[test] + fn apply_proxy_env_overrides_sets_common_tool_vars() { + let mut env = HashMap::new(); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + true, + false, + ); + + assert_eq!( + env.get("HTTP_PROXY"), + Some(&"http://127.0.0.1:3128".to_string()) + ); + assert_eq!( + env.get("npm_config_proxy"), + Some(&"http://127.0.0.1:3128".to_string()) + ); + assert_eq!( + env.get("ALL_PROXY"), + Some(&"socks5h://127.0.0.1:8081".to_string()) + ); + assert_eq!( + env.get("FTP_PROXY"), + Some(&"socks5h://127.0.0.1:8081".to_string()) + ); + assert_eq!( + env.get("NO_PROXY"), + Some(&DEFAULT_NO_PROXY_VALUE.to_string()) + ); + assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string())); + assert_eq!(env.get("ELECTRON_GET_USE_PROXY"), Some(&"true".to_string())); + #[cfg(target_os = "macos")] + assert_eq!( + env.get("GIT_SSH_COMMAND"), + Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string()) + ); + #[cfg(not(target_os = "macos"))] + assert_eq!(env.get("GIT_SSH_COMMAND"), None); + } + + #[test] + fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() { + let mut env = HashMap::new(); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + false, + true, + ); + + assert_eq!( + env.get("ALL_PROXY"), + Some(&"http://127.0.0.1:3128".to_string()) + ); + assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"1".to_string())); + } + + #[cfg(target_os = "macos")] + #[test] + fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() { + let mut env = HashMap::new(); + env.insert( + "GIT_SSH_COMMAND".to_string(), + "ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(), + ); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + true, + false, + ); + + assert_eq!( + env.get("GIT_SSH_COMMAND"), + Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string()) + ); + } }