diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 30352cbb831..d7c0b2be43b 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 { diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2172caba8a4..44aa78cc76d 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -29,6 +29,7 @@ use crate::sandboxing::CommandSpec; use crate::sandboxing::ExecEnv; 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; @@ -225,7 +226,7 @@ pub(crate) async fn execute_exec_env( let ExecEnv { command, cwd, - mut env, + env, expiration, sandbox, windows_sandbox_level, @@ -234,10 +235,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 +691,7 @@ async fn exec( command, cwd, env, - network: _, + network, arg0, expiration, windows_sandbox_level: _, @@ -708,15 +705,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 ea27f77f75e..f5ed95f14a2 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::has_proxy_url_env_vars; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -33,20 +35,29 @@ where sandbox_policy, sandbox_policy_cwd, use_bwrap_sandbox, + allow_network_for_proxy(sandbox_policy, &env), ); 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: None, stdio_policy, env, - ) + }) .await } +pub(crate) fn allow_network_for_proxy( + sandbox_policy: &SandboxPolicy, + env: &HashMap, +) -> bool { + !sandbox_policy.has_full_network_access() && has_proxy_url_env_vars(env) +} + /// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`. /// /// The helper performs the actual sandboxing (bubblewrap + seccomp) after @@ -56,6 +67,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 +88,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 +113,30 @@ 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 + ); + } } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 0ffede3019d..9f101820733 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -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")] @@ -148,7 +149,7 @@ impl SandboxManager { 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); + create_seatbelt_command_args(command.clone(), policy, sandbox_policy_cwd, &env); 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 +160,13 @@ impl SandboxManager { SandboxType::LinuxSeccomp => { let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; + let allow_proxy_network = allow_network_for_proxy(policy, &env); 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()); diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index a15ebb177bd..56d05222b83 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,13 +1,20 @@ #![cfg(target_os = "macos")] +use codex_network_proxy::ALLOW_LOCAL_BINDING_ENV_KEY; +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; @@ -28,25 +35,116 @@ pub async fn spawn_command_under_seatbelt( stdio_policy: StdioPolicy, 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, &env); 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: None, 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") + }) +} + +fn dynamic_network_policy(sandbox_policy: &SandboxPolicy, env: &HashMap) -> String { + let proxy_ports = proxy_loopback_ports_from_env(env); + if !proxy_ports.is_empty() { + let mut policy = + String::from("; allow outbound access only to configured loopback proxy endpoints\n"); + if local_binding_enabled(env) { + 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 has_proxy_url_env_vars(env) { + // 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 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, + env: &HashMap, ) -> Vec { let (file_write_policy, file_write_dir_params) = { if sandbox_policy.has_full_disk_write_access() { @@ -112,11 +210,7 @@ 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 network_policy = dynamic_network_policy(sandbox_policy, env); let full_policy = format!( "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" @@ -162,12 +256,14 @@ fn macos_dir_params() -> Vec<(String, PathBuf)> { #[cfg(test)] mod tests { + use super::ALLOW_LOCAL_BINDING_ENV_KEY; use super::MACOS_SEATBELT_BASE_POLICY; use super::create_seatbelt_command_args; use super::macos_dir_params; use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -184,6 +280,167 @@ mod tests { ); } + #[test] + fn create_seatbelt_args_routes_network_through_proxy_ports() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + let command = vec!["/bin/echo".to_string(), "ok".to_string()]; + + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43128".to_string(), + ); + env.insert( + "ALL_PROXY".to_string(), + "socks5h://127.0.0.1:48081".to_string(), + ); + + let args = create_seatbelt_command_args(command, &SandboxPolicy::ReadOnly, &cwd, &env); + let policy = args + .get(1) + .expect("seatbelt args should include policy at index 1"); + + 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 tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + let command = vec!["/bin/echo".to_string(), "ok".to_string()]; + + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43128".to_string(), + ); + env.insert(ALLOW_LOCAL_BINDING_ENV_KEY.to_string(), "1".to_string()); + + let args = create_seatbelt_command_args(command, &SandboxPolicy::ReadOnly, &cwd, &env); + let policy = args + .get(1) + .expect("seatbelt args should include policy at index 1"); + + 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 create_seatbelt_args_fails_closed_for_invalid_proxy_env() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + let command = vec!["/bin/echo".to_string(), "ok".to_string()]; + + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "not-a-valid-proxy-url".to_string(), + ); + + let args = create_seatbelt_command_args( + command, + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + &cwd, + &env, + ); + let policy = args + .get(1) + .expect("seatbelt args should include policy at index 1"); + + assert!( + !policy.contains("\n(allow network-outbound)\n"), + "policy should not include blanket outbound allowance when proxy env is invalid:\n{policy}" + ); + assert!( + !policy.contains("(allow network-outbound (remote ip \"localhost:"), + "policy should not include proxy port allowance when proxy env is invalid:\n{policy}" + ); + } + + #[test] + fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + let command = vec!["/bin/echo".to_string(), "ok".to_string()]; + + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43128".to_string(), + ); + + let args = create_seatbelt_command_args( + command, + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + &cwd, + &env, + ); + let policy = args + .get(1) + .expect("seatbelt args should include policy at index 1"); + + 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 +484,8 @@ 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, &HashMap::new()); // Build the expected policy text using a raw string for readability. // Note that the policy includes: @@ -315,7 +573,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, &HashMap::new()); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -346,7 +605,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, &HashMap::new()); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -406,7 +665,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, &HashMap::new()); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -436,7 +695,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, &HashMap::new()); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -492,8 +752,12 @@ 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(), + &HashMap::new(), + ); 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 2a72f95fd32..a0801d093b5 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 b2a507fda7a..67e6ace0447 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/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 0661f92f666..93cdc3aedc0 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -157,10 +157,15 @@ impl ToolRuntime for ShellRuntime { command }; + let mut env = req.env.clone(); + if let Some(network) = req.network.as_ref() { + network.apply_to_env(&mut env); + } + let spec = build_command_spec( &command, &req.cwd, - &req.env, + &env, req.timeout_ms.into(), req.sandbox_permissions, req.justification.clone(), diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index f14fc5f13d8..0abd641708a 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 4a60c65dd3b..a834f41f17a 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -26,11 +26,19 @@ 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, + /// Whether to isolate networking with a dedicated network namespace. + /// + /// When true, bubblewrap adds `--unshare-net`, which removes access to the + /// host network stack and leaves only loopback inside the sandbox. + pub isolate_network: bool, } impl Default for BwrapOptions { fn default() -> Self { - Self { mount_proc: true } + Self { + mount_proc: true, + isolate_network: false, + } } } @@ -65,6 +73,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.isolate_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()); diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index d49491233cd..ee49b142d69 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -35,25 +35,30 @@ use seccompiler::apply_filter; /// /// This function is responsible for: /// - enabling `PR_SET_NO_NEW_PRIVS` when restrictions apply, and -/// - installing the network seccomp filter when network access is disabled. +/// - installing the network seccomp filter when network access is disabled and +/// proxy-based routing is not enabled. /// /// Filesystem restrictions are intentionally handled by bubblewrap. 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 = + !sandbox_policy.has_full_network_access() && !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()?; } diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index f5f0d9887aa..887403059df 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -40,6 +40,11 @@ pub struct LandlockCommand { #[arg(long = "apply-seccomp-then-exec", hide = true, default_value_t = false)] pub apply_seccomp_then_exec: bool, + /// Internal: permit network syscalls in restricted policies when we rely + /// on proxy environment variables for egress routing. + #[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 +69,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 +81,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 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 +112,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 +141,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,7 +150,10 @@ fn run_bwrap_with_proc_fallback( mount_proc = false; } - let options = BwrapOptions { mount_proc }; + let options = BwrapOptions { + mount_proc, + isolate_network: !sandbox_policy.has_full_network_access() && !allow_network_for_proxy, + }; let argv = build_bwrap_argv(inner, sandbox_policy, sandbox_policy_cwd, options); exec_vendored_bwrap(argv); } @@ -164,7 +190,10 @@ fn preflight_proc_mount_support( preflight_command, sandbox_policy, sandbox_policy_cwd, - BwrapOptions { mount_proc: true }, + BwrapOptions { + mount_proc: true, + isolate_network: false, + }, ); let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); !is_proc_mount_failure(stderr.as_str()) @@ -268,6 +297,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 +320,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 +375,10 @@ mod tests { vec!["/bin/true".to_string()], &SandboxPolicy::ReadOnly, Path::new("/"), - BwrapOptions { mount_proc: true }, + BwrapOptions { + mount_proc: true, + isolate_network: false, + }, ); assert_eq!( argv, @@ -366,4 +402,18 @@ 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, + isolate_network: true, + }, + ); + assert_eq!(argv.contains(&"--unshare-net".to_string()), true); + } } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index a974ce13b73..570a8a9945d 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 6bfcf663c12..0b6f29bb17d 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -75,6 +75,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, policy_decider: self.policy_decider, }) @@ -86,6 +88,8 @@ pub struct NetworkProxy { state: Arc, http_addr: SocketAddr, socks_addr: SocketAddr, + socks_enabled: bool, + allow_local_binding: bool, admin_addr: SocketAddr, policy_decider: Option>, } @@ -106,24 +110,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 { @@ -241,3 +372,118 @@ impl Drop for NetworkProxyHandle { }); } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::net::IpAddr; + use std::net::Ipv4Addr; + + #[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()) + ); + } +}