From e476fc16ce810495cbfabe4f815d20947da0ba79 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 23 Jun 2026 20:07:09 +0100 Subject: [PATCH] Prepare managed network sandbox context (#29456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Managed network configures commands to use local HTTP and SOCKS proxies. For commands delegated to the exec server, the proxy environment and the sandbox policy were prepared separately. On macOS, that meant a command could receive `HTTPS_PROXY=http://127.0.0.1:43123` while Seatbelt still denied access to port `43123`. ## What changed `NetworkProxy` now prepares the command environment and sandbox context together from the same runtime snapshot: ```text Prepared managed network ├── command environment: HTTPS_PROXY=http://127.0.0.1:43123 └── sandbox context: allow outbound to 127.0.0.1:43123 ``` That context travels with remote exec requests. The exec server preserves the managed proxy and CA environment, and macOS Seatbelt allows only the prepared loopback proxy ports without enabling broad network access or local binding. The protocol field is optional and the existing enforcement flag remains in place, preserving compatibility with callers that do not send the new context. --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/debug_sandbox.rs | 1 + codex-rs/core/src/exec.rs | 2 + codex-rs/core/src/sandboxing/mod.rs | 4 + codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/tools/runtimes/mod.rs | 31 ++--- .../tools/runtimes/shell/unix_escalation.rs | 3 + .../core/src/tools/runtimes/unified_exec.rs | 39 ++++-- codex-rs/core/src/tools/sandboxing.rs | 2 + codex-rs/core/src/tools/sandboxing_tests.rs | 32 ++++- .../core/src/unified_exec/process_manager.rs | 16 ++- .../src/unified_exec/process_manager_tests.rs | 33 ++++- codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/environment.rs | 2 + codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec-server/src/local_process.rs | 1 + codex-rs/exec-server/src/process_sandbox.rs | 18 +++ .../exec-server/src/process_sandbox_tests.rs | 47 +++++++ codex-rs/exec-server/src/protocol.rs | 55 ++++++++ .../exec-server/src/server/handler/tests.rs | 1 + codex-rs/exec-server/src/server/processor.rs | 1 + codex-rs/exec-server/tests/exec_process.rs | 12 ++ codex-rs/exec-server/tests/relay.rs | 1 + codex-rs/network-proxy/src/lib.rs | 2 + codex-rs/network-proxy/src/proxy.rs | 123 ++++++++++++++++-- .../rmcp-client/src/stdio_server_launcher.rs | 1 + codex-rs/sandboxing/src/manager.rs | 5 + codex-rs/sandboxing/src/manager_tests.rs | 5 + codex-rs/sandboxing/src/seatbelt.rs | 72 ++++++---- codex-rs/sandboxing/src/seatbelt_tests.rs | 37 ++++++ 30 files changed, 472 insertions(+), 78 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d4eaf1dd2..39f16431a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2886,6 +2886,7 @@ dependencies = [ "codex-app-server-protocol", "codex-client", "codex-file-system", + "codex-network-proxy", "codex-protocol", "codex-sandboxing", "codex-shell-command", diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index dd2d646aa..9561dde36 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -383,6 +383,7 @@ async fn run_command_under_sandbox( network_sandbox_policy, sandbox_policy_cwd: sandbox_policy_cwd.as_path(), enforce_managed_network, + managed_network: None, environment_id: None, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 647203db2..015b20715 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -377,6 +377,7 @@ pub fn build_exec_request( args: args.to_vec(), cwd, env, + managed_network: None, additional_permissions: None, }; let options = ExecOptions { @@ -459,6 +460,7 @@ pub(crate) async fn execute_exec_request( arg0, exec_server_sandbox: _, exec_server_enforce_managed_network: _, + exec_server_managed_network: _, } = exec_request; // TODO(anp): Keep PathUri through the local process launch boundary. diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index d0006f865..1c570e1bb 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -15,6 +15,7 @@ use crate::exec::execute_exec_request; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_file_system::FileSystemSandboxContext; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::exec_output::ExecToolCallOutput; @@ -63,6 +64,7 @@ pub struct ExecRequest { pub arg0: Option, pub(crate) exec_server_sandbox: Option, pub(crate) exec_server_enforce_managed_network: bool, + pub(crate) exec_server_managed_network: Option, } impl ExecRequest { @@ -107,6 +109,7 @@ impl ExecRequest { arg0, exec_server_sandbox: None, exec_server_enforce_managed_network: false, + exec_server_managed_network: None, } } @@ -165,6 +168,7 @@ impl ExecRequest { arg0, exec_server_sandbox: None, exec_server_enforce_managed_network: false, + exec_server_managed_network: None, } } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 844e327bd..d588e5a35 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -226,6 +226,7 @@ pub(crate) async fn execute_user_shell_command( arg0: None, exec_server_sandbox: None, exec_server_enforce_managed_network: false, + exec_server_managed_network: None, }; let stdout_stream = Some(StdoutStream { diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 27f4ee594..db55cc2bb 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -50,6 +50,7 @@ pub(crate) fn build_sandbox_command( args: args.to_vec(), cwd, env: env.clone(), + managed_network: None, additional_permissions, }) } @@ -67,26 +68,26 @@ pub(crate) fn exec_env_for_sandbox_permissions( env } -pub(crate) fn strip_managed_proxy_env(env: &mut HashMap) { - for key in PROXY_ENV_KEYS { - env.remove(*key); +pub(crate) fn is_managed_proxy_env_var(key: &str, value: &str) -> bool { + if PROXY_ENV_KEYS.contains(&key) { + return true; } - for key in CUSTOM_CA_ENV_KEYS { - if env - .get(key) - .is_some_and(|value| is_managed_mitm_ca_trust_bundle_path(value)) - { - env.remove(key); - } + if CUSTOM_CA_ENV_KEYS.contains(&key) { + return is_managed_mitm_ca_trust_bundle_path(value); } - // Only macOS injects a Codex-owned SSH wrapper for the managed SOCKS proxy. #[cfg(target_os = "macos")] - if env - .get(PROXY_GIT_SSH_COMMAND_ENV_KEY) - .is_some_and(|command| command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER)) { - env.remove(PROXY_GIT_SSH_COMMAND_ENV_KEY); + key == PROXY_GIT_SSH_COMMAND_ENV_KEY + && value.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER) } + #[cfg(not(target_os = "macos"))] + { + false + } +} + +pub(crate) fn strip_managed_proxy_env(env: &mut HashMap) { + env.retain(|key, value| !is_managed_proxy_env_var(key, value)); } /// Prepends `path_entry` to `PATH`, removing duplicate and empty existing diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index a77645f94..10cb46020 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -168,6 +168,7 @@ pub(super) async fn try_run_zsh_fork( arg0, exec_server_sandbox: _, exec_server_enforce_managed_network: _, + exec_server_managed_network: _, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; let effective_timeout = Duration::from_millis( @@ -902,6 +903,7 @@ impl CoreShellCommandExecutor { arg0: self.arg0.clone(), exec_server_sandbox: None, exec_server_enforce_managed_network: false, + exec_server_managed_network: None, }, /*stdout_stream*/ None, after_spawn, @@ -1010,6 +1012,7 @@ impl CoreShellCommandExecutor { args: args.to_vec(), cwd, env, + managed_network: None, additional_permissions, }; let options = ExecOptions { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index c1d519120..5df42cc97 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -41,6 +41,7 @@ use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -117,6 +118,7 @@ fn build_unified_exec_sandbox_command( command: &[String], cwd: &PathUri, env: &HashMap, + managed_network: Option, additional_permissions: Option, ) -> Result { let (program, args) = command @@ -127,6 +129,7 @@ fn build_unified_exec_sandbox_command( args: args.to_vec(), cwd: cwd.clone(), env: env.clone(), + managed_network, additional_permissions, }) } @@ -321,22 +324,28 @@ impl<'a> ToolRuntime for UnifiedExecRunt req.network.as_ref(), launch_sandbox_permissions, ); - let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); - if let Some(network) = managed_network { - network - .apply_to_env_for_optional_environment( - &mut env, - Some(&req.turn_environment.environment_id), - ) - .map_err(|err| { - ToolError::Codex(CodexErr::Io(io::Error::other(format!( - "failed to prepare network proxy for environment `{}`: {err}", - req.turn_environment.environment_id - )))) - })?; - } + let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); + let (env, managed_network_context) = match managed_network { + Some(network) => { + let prepared = network + .prepare_for_optional_environment( + env, + Some(&req.turn_environment.environment_id), + ) + .map_err(|err| { + ToolError::Codex(CodexErr::Io(io::Error::other(format!( + "failed to prepare network proxy for environment `{}`: {err}", + req.turn_environment.environment_id + )))) + })?; + (prepared.env, Some(prepared.sandbox_context)) + } + None => (env, None), + }; let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] + let mut env = env; + #[cfg(unix)] let runtime_path_prepends = { let mut runtime_path_prepends = RuntimePathPrepends::default(); if !environment_is_remote { @@ -385,6 +394,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &command, &req.cwd, &env, + managed_network_context.clone(), req.additional_permissions.clone(), ) .map_err(|error| match error { @@ -450,6 +460,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &command, &req.cwd, &env, + managed_network_context, req.additional_permissions.clone(), ) .map_err(|error| match error { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 77f68d96b..ee398dae5 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -466,6 +466,7 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let managed_network = command.managed_network.clone(); let exec_server_permissions = effective_permission_profile( self.exec_server_permissions, command.additional_permissions.as_ref(), @@ -492,6 +493,7 @@ impl<'a> SandboxAttempt<'a> { options, self.workspace_roots.to_vec(), ); + exec_request.exec_server_managed_network = managed_network; if self.sandbox_requested { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index c647e1c40..28b9de9ce 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::sandboxing::SandboxPermissions; use crate::tools::hook_names::HookToolName; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -212,7 +213,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { .clone() .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)); let manager = SandboxManager::new(); - let attempt = SandboxAttempt { + let mut attempt = SandboxAttempt { sandbox: SandboxType::None, sandbox_requested: true, permissions: &permissions, @@ -227,20 +228,24 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { windows_sandbox_private_desktop: false, network_denial_cancellation_token: None, }; - let command = SandboxCommand { + let managed_network = ManagedNetworkSandboxContext { + loopback_ports: vec![43123], + allow_local_binding: false, + }; + let command = || SandboxCommand { program: "/bin/bash".into(), args: vec!["-lc".to_string(), "pwd".to_string()], cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: Some(managed_network.clone()), additional_permissions: None, }; - let options = crate::sandboxing::ExecOptions { + let options = || crate::sandboxing::ExecOptions { expiration: crate::exec::ExecExpiration::DefaultTimeout, capture_policy: crate::exec::ExecCapturePolicy::ShellTool, }; - let request = attempt - .env_for_exec_server(command, options, /*network*/ None, Some("remote")) + .env_for_exec_server(command(), options(), /*network*/ None, Some("remote")) .expect("prepare remote exec request"); assert_eq!( @@ -256,8 +261,8 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { assert_eq!( request.exec_server_sandbox, Some(codex_exec_server::FileSystemSandboxContext { - permissions: exec_server_permissions.into(), - cwd: Some(cwd_uri), + permissions: exec_server_permissions.clone().into(), + cwd: Some(cwd_uri.clone()), workspace_roots: Vec::new(), windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, @@ -265,4 +270,17 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { }) ); assert!(request.exec_server_enforce_managed_network); + assert_eq!( + request.exec_server_managed_network, + Some(managed_network.clone()) + ); + + attempt.sandbox_requested = false; + let request = attempt + .env_for_exec_server(command(), options(), /*network*/ None, Some("remote")) + .expect("prepare unsandboxed remote exec request"); + + assert_eq!(request.exec_server_sandbox, None); + assert!(!request.exec_server_enforce_managed_network); + assert_eq!(request.exec_server_managed_network, Some(managed_network)); } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 2cb62a6d4..5e8944576 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -26,6 +26,7 @@ use crate::tools::events::ToolEventStage; use crate::tools::network_approval::DeferredNetworkApproval; use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::orchestrator::ToolOrchestrator; +use crate::tools::runtimes::is_managed_proxy_env_var; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; use crate::tools::sandboxing::SandboxAttempt; @@ -143,10 +144,16 @@ fn exec_server_env_for_request( HashMap, ) { if let Some(exec_server_env_config) = &request.exec_server_env_config { - ( - Some(exec_server_env_config.policy.clone()), - env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env), - ) + let mut env = + env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env); + if request.exec_server_managed_network.is_some() { + for (key, value) in &request.env { + if is_managed_proxy_env_var(key, value) { + env.insert(key.clone(), value.clone()); + } + } + } + (Some(exec_server_env_config.policy.clone()), env) } else { (None, request.env.clone()) } @@ -175,6 +182,7 @@ fn exec_server_params_for_request( arg0: request.arg0.clone(), sandbox: request.exec_server_sandbox.clone(), enforce_managed_network: request.exec_server_enforce_managed_network, + managed_network: request.exec_server_managed_network.clone(), } } diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index cd48d01c7..93ffd0ced 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::unified_exec::clamp_yield_time; +use codex_network_proxy::ManagedNetworkSandboxContext; use pretty_assertions::assert_eq; use tokio::time::Duration; use tokio::time::Instant; @@ -76,6 +77,10 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; let permission_profile = codex_protocol::models::PermissionProfile::Disabled; + let managed_network = ManagedNetworkSandboxContext { + loopback_ports: vec![43123], + allow_local_binding: false, + }; let mut request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone().into(), @@ -83,6 +88,15 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/sandbox-path".to_string()), ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), + ( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43123".to_string(), + ), + ("CODEX_NETWORK_PROXY_ACTIVE".to_string(), "1".to_string()), + ( + "SSL_CERT_FILE".to_string(), + "/client/custom-ca.pem".to_string(), + ), ]), exec_server_env_config: Some(ExecServerEnvConfig { policy: codex_exec_server::ExecEnvPolicy { @@ -95,6 +109,15 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { local_policy_env: HashMap::from([ ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/client-path".to_string()), + ( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43123".to_string(), + ), + ("CODEX_NETWORK_PROXY_ACTIVE".to_string(), "1".to_string()), + ( + "SSL_CERT_FILE".to_string(), + "/client/custom-ca.pem".to_string(), + ), ]), }), network: None, @@ -112,7 +135,8 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { windows_sandbox_filesystem_overrides: None, arg0: None, exec_server_sandbox: None, - exec_server_enforce_managed_network: false, + exec_server_enforce_managed_network: true, + exec_server_managed_network: Some(managed_network.clone()), }; let params = @@ -120,12 +144,19 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { assert_eq!(params.process_id.as_str(), "123"); assert_eq!(params.cwd, request.cwd); + assert!(params.enforce_managed_network); + assert_eq!(params.managed_network, Some(managed_network)); assert!(params.env_policy.is_some()); assert_eq!( params.env, HashMap::from([ ("PATH".to_string(), "/sandbox-path".to_string()), ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), + ( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43123".to_string(), + ), + ("CODEX_NETWORK_PROXY_ACTIVE".to_string(), "1".to_string(),), ]) ); request.exec_server_sandbox = Some( diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 31fc4ea0a..02f23dfc4 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -20,6 +20,7 @@ codex-app-server-protocol = { workspace = true } codex-api = { workspace = true } codex-client = { workspace = true } codex-file-system = { workspace = true } +codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-command = { workspace = true } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 40d73cade..ee0e303ca 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1162,6 +1162,7 @@ mod tests { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await .expect("start process"); @@ -1201,6 +1202,7 @@ mod tests { arg0: None, sandbox: Some(sandbox), enforce_managed_network: false, + managed_network: None, }) .await; let Err(err) = result else { diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 3dbcf73c1..6db3804ae 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -115,6 +115,7 @@ impl FileSystemSandboxRunner { args: vec![CODEX_FS_HELPER_ARG1.to_string()], cwd: cwd.uri.clone(), env: self.helper_env.clone(), + managed_network: None, additional_permissions: None, }; let native_workspace_roots = sandbox_context diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 1f0dc5fec..d77209e7c 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -965,6 +965,7 @@ mod tests { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, } } diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index d157f4c6d..e3bffeaa6 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use codex_app_server_protocol::JSONRPCErrorError; +use codex_network_proxy::CUSTOM_CA_ENV_KEYS; +use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path; use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxDirectSpawnTransformRequest; @@ -8,6 +10,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::with_managed_mitm_ca_readable_root; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -59,6 +62,20 @@ pub(crate) fn prepare_exec_request( native_workspace_roots.as_slice() }; let permissions = permissions.materialize_project_roots_with_workspace_roots(workspace_roots); + let managed_mitm_ca_trust_bundle_path = params.managed_network.as_ref().and_then(|_| { + CUSTOM_CA_ENV_KEYS.iter().find_map(|key| { + let path = env.get(*key)?; + if !is_managed_mitm_ca_trust_bundle_path(path) { + return None; + } + AbsolutePathBuf::from_absolute_path(path).ok() + }) + }); + let permissions = with_managed_mitm_ca_readable_root( + permissions, + managed_mitm_ca_trust_bundle_path.as_ref(), + native_sandbox_policy_cwd.as_path(), + ); let (file_system_policy, network_policy) = permissions.to_runtime_permissions(); let sandbox_manager = SandboxManager::new(); let sandbox = sandbox_manager.select_initial( @@ -98,6 +115,7 @@ pub(crate) fn prepare_exec_request( args: args.to_vec(), cwd: params.cwd.clone(), env, + managed_network: params.managed_network.clone(), additional_permissions: None, }, permissions: &permissions, diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 1b0408afd..bc71b5732 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +#[cfg(target_os = "macos")] +use codex_network_proxy::ManagedNetworkSandboxContext; #[cfg(unix)] use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -44,6 +46,7 @@ fn sandbox_request_wraps_native_argv_on_executor() { arg0: None, sandbox: Some(sandbox), enforce_managed_network: false, + managed_network: None, }; let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) @@ -78,6 +81,49 @@ fn sandbox_request_wraps_native_argv_on_executor() { ); } +#[cfg(target_os = "macos")] +#[test] +fn sandbox_request_allows_prepared_managed_proxy_port() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let self_exe = std::env::current_exe().expect("current executable"); + let runtime_paths = + ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::workspace_write(), + cwd_uri.clone(), + ); + let params = ExecParams { + process_id: ProcessId::from("process-managed-network"), + argv: vec!["/usr/bin/true".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: true, + managed_network: Some(ManagedNetworkSandboxContext { + loopback_ports: vec![43123], + allow_local_binding: false, + }), + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .expect("prepare managed-network sandbox request"); + let policy = prepared + .command + .windows(2) + .find_map(|args| (args[0] == "-p").then_some(args[1].as_str())) + .expect("Seatbelt policy argument"); + + assert!(policy.contains("(allow network-outbound (remote ip \"localhost:43123\"))")); +} + #[test] fn native_request_preserves_native_launch_fields() { let cwd: AbsolutePathBuf = std::env::current_dir() @@ -97,6 +143,7 @@ fn native_request_preserves_native_launch_fields() { arg0: Some("custom-arg0".to_string()), sandbox: None, enforce_managed_network: false, + managed_network: None, }; let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None) diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 8771e869d..921223e31 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_file_system::FileSystemSandboxContext; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use codex_utils_path_uri::PathUri; use serde::Deserialize; @@ -109,6 +110,12 @@ pub struct ExecParams { /// Whether the eventual executor-side sandbox must enforce managed networking. #[serde(default)] pub enforce_managed_network: bool, + /// Optional details for enforcing managed networking without a live proxy object. + /// + /// When `enforce_managed_network` is true and these details are absent, the executor must + /// continue to fail closed. This preserves compatibility with older clients. + #[serde(default)] + pub managed_network: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -504,12 +511,60 @@ mod base64_bytes { #[cfg(test)] mod tests { + use super::ExecParams; use super::FsReadFileParams; use super::HttpRequestParams; + use super::ProcessId; use codex_file_system::FileSystemSandboxContext; + use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::models::PermissionProfile; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; + use std::collections::HashMap; + + #[test] + fn exec_params_managed_network_context_round_trips_and_defaults_for_legacy_peers() { + let cwd = + PathUri::from_host_native_path(std::env::current_dir().expect("current directory")) + .expect("cwd URI"); + let params = ExecParams { + process_id: ProcessId::from("managed-network"), + argv: vec!["true".to_string()], + cwd, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: None, + enforce_managed_network: true, + managed_network: Some(ManagedNetworkSandboxContext { + loopback_ports: vec![43123, 48081], + allow_local_binding: false, + }), + }; + + let mut serialized = serde_json::to_value(¶ms).expect("serialize exec params"); + assert_eq!( + serialized["managedNetwork"], + serde_json::json!({ + "loopbackPorts": [43123, 48081], + "allowLocalBinding": false, + }) + ); + let round_trip: ExecParams = + serde_json::from_value(serialized.clone()).expect("deserialize exec params"); + assert_eq!(round_trip, params); + + serialized + .as_object_mut() + .expect("exec params object") + .remove("managedNetwork"); + let legacy: ExecParams = + serde_json::from_value(serialized).expect("deserialize legacy exec params"); + assert!(legacy.enforce_managed_network); + assert_eq!(legacy.managed_network, None); + } #[test] fn filesystem_protocol_accepts_legacy_absolute_paths_and_serializes_path_uris() { diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index ebb2b6e6e..2cea99b21 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -36,6 +36,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, } } diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index e9316e0d5..d960e53ca 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -441,6 +441,7 @@ mod tests { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 2f9b9c1db..8fe5fe0be 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -83,6 +83,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -226,6 +227,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -259,6 +261,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -308,6 +311,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -358,6 +362,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -433,6 +438,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -472,6 +478,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -507,6 +514,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -543,6 +551,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -598,6 +607,7 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; @@ -640,6 +650,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; @@ -700,6 +711,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 30ce1459b..918cba739 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -152,6 +152,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await?; assert_eq!( diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index c8950f1f5..5bce99030 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -47,6 +47,7 @@ pub use proxy::Args; #[cfg(target_os = "macos")] pub use proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; pub use proxy::DEFAULT_NO_PROXY_VALUE; +pub use proxy::ManagedNetworkSandboxContext; pub use proxy::NO_PROXY_ENV_KEYS; pub use proxy::NetworkProxy; pub use proxy::NetworkProxyBuilder; @@ -56,6 +57,7 @@ pub use proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] pub use proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; pub use proxy::PROXY_URL_ENV_KEYS; +pub use proxy::PreparedManagedNetwork; pub use proxy::has_proxy_url_env_vars; pub use proxy::proxy_url_env_value; pub use runtime::BlockedRequest; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 13998dd44..7d2372f17 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -10,6 +10,8 @@ use anyhow::Context; use anyhow::Result; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; use std::collections::HashMap; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; @@ -328,6 +330,27 @@ struct EnvironmentProxyAddrs { socks_addr: SocketAddr, } +/// Portable managed-network facts needed by an operating-system sandbox. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedNetworkSandboxContext { + /// Loopback proxy ports that sandboxed commands may connect to. + #[serde(default)] + pub loopback_ports: Vec, + /// Whether the command may bind local sockets and exchange loopback traffic. + #[serde(default)] + pub allow_local_binding: bool, +} + +/// Environment-specific managed-network settings prepared for one command launch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedManagedNetwork { + /// Complete command environment with managed proxy variables applied. + pub env: HashMap, + /// Matching portable sandbox inputs for the command environment. + pub sandbox_context: ManagedNetworkSandboxContext, +} + struct EnvironmentProxy { addrs: EnvironmentProxyAddrs, http_task: JoinHandle>, @@ -653,22 +676,49 @@ impl NetworkProxy { }) } - fn apply_to_env_for_addrs( + fn prepare_for_addrs( &self, - env: &mut HashMap, + mut env: HashMap, addrs: EnvironmentProxyAddrs, - ) { + ) -> PreparedManagedNetwork { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; // managed MITM CA vars preserve child-scoped overrides after proxy startup. apply_proxy_env_overrides( - env, + &mut env, addrs.http_addr, addrs.socks_addr, self.socks_enabled, runtime_settings.allow_local_binding, runtime_settings.mitm_ca_trust_bundle.as_ref(), ); + let mut loopback_ports = [ + Some(addrs.http_addr), + self.socks_enabled.then_some(addrs.socks_addr), + ] + .into_iter() + .flatten() + .filter(|addr| addr.ip().is_loopback()) + .map(|addr| addr.port()) + .collect::>(); + loopback_ports.sort_unstable(); + loopback_ports.dedup(); + PreparedManagedNetwork { + env, + sandbox_context: ManagedNetworkSandboxContext { + loopback_ports, + allow_local_binding: runtime_settings.allow_local_binding, + }, + } + } + + fn apply_to_env_for_addrs( + &self, + env: &mut HashMap, + addrs: EnvironmentProxyAddrs, + ) { + let prepared = self.prepare_for_addrs(std::mem::take(env), addrs); + *env = prepared.env; } pub fn apply_to_env(&self, env: &mut HashMap) { @@ -705,6 +755,23 @@ impl NetworkProxy { } } + /// Applies the environment-specific proxy settings and returns the matching portable sandbox + /// projection from the same runtime configuration snapshot. + pub fn prepare_for_optional_environment( + &self, + env: HashMap, + environment_id: Option<&str>, + ) -> Result { + let addrs = match environment_id { + Some(environment_id) => self.environment_proxy_addrs(environment_id)?, + None => EnvironmentProxyAddrs { + http_addr: self.http_addr, + socks_addr: self.socks_addr, + }, + }; + Ok(self.prepare_for_addrs(env, addrs)) + } + fn environment_proxy_addrs(&self, environment_id: &str) -> Result { let mut proxies = self .environment_proxies @@ -1071,27 +1138,59 @@ mod tests { } #[tokio::test] - async fn apply_to_env_for_environment_uses_distinct_proxy_ports() -> Result<()> { + async fn prepare_for_environment_keeps_env_and_sandbox_ports_in_sync() -> Result<()> { let state = Arc::new(network_proxy_state_for_policy( NetworkProxySettings::default(), )); let proxy = NetworkProxy::builder().state(state).build().await?; let handle = proxy.run().await?; - let mut local_env = HashMap::new(); - proxy.apply_to_env_for_environment(&mut local_env, "local")?; - let mut remote_env = HashMap::new(); - proxy.apply_to_env_for_environment(&mut remote_env, "remote")?; + let base_env = HashMap::from([("PRESERVED".to_string(), "value".to_string())]); + let local = proxy.prepare_for_optional_environment(base_env.clone(), Some("local"))?; + let remote = proxy.prepare_for_optional_environment(HashMap::new(), Some("remote"))?; - assert_ne!(local_env.get("HTTP_PROXY"), remote_env.get("HTTP_PROXY")); + assert_eq!( + local.env.get("PRESERVED").map(String::as_str), + Some("value") + ); + assert_ne!(local.env.get("HTTP_PROXY"), remote.env.get("HTTP_PROXY")); assert_ne!( - local_env.get("HTTP_PROXY"), + local.env.get("HTTP_PROXY"), Some(&format!("http://{}", proxy.http_addr())) ); assert_ne!( - remote_env.get("HTTP_PROXY"), + remote.env.get("HTTP_PROXY"), Some(&format!("http://{}", proxy.http_addr())) ); + for prepared in [&local, &remote] { + let http_port = prepared + .env + .get("HTTP_PROXY") + .and_then(|value| value.strip_prefix("http://")) + .and_then(|value| value.parse::().ok()) + .map(|addr| addr.port()) + .expect("managed HTTP proxy address"); + let socks_port = prepared + .env + .get("ALL_PROXY") + .and_then(|value| value.strip_prefix("socks5h://")) + .and_then(|value| value.parse::().ok()) + .map(|addr| addr.port()) + .expect("managed SOCKS proxy address"); + let mut expected_ports = vec![http_port, socks_port]; + expected_ports.sort_unstable(); + expected_ports.dedup(); + assert_eq!( + prepared.sandbox_context, + ManagedNetworkSandboxContext { + loopback_ports: expected_ports, + allow_local_binding: false, + } + ); + } + let mut legacy_env = base_env; + proxy.apply_to_env_for_environment(&mut legacy_env, "local")?; + assert_eq!(legacy_env, local.env); handle.shutdown().await?; Ok(()) diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index bc1a8924d..7d03e1437 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -509,6 +509,7 @@ impl ExecutorStdioServerLauncher { arg0: None, sandbox: None, enforce_managed_network: false, + managed_network: None, }) .await .map_err(io::Error::other)?; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 29b1177b1..30870417f 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -13,6 +13,7 @@ use crate::resolve_windows_elevated_filesystem_overrides; use crate::resolve_windows_restricted_token_filesystem_overrides; #[cfg(target_os = "windows")] use crate::windows_sandbox_uses_elevated_backend; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; @@ -99,6 +100,7 @@ pub struct SandboxCommand { pub args: Vec, pub cwd: PathUri, pub env: HashMap, + pub managed_network: Option, pub additional_permissions: Option, } @@ -332,6 +334,8 @@ impl SandboxManager { windows_sandbox_level, windows_sandbox_private_desktop, } = request; + #[cfg(target_os = "macos")] + let managed_network = command.managed_network.as_ref(); let additional_permissions = command.additional_permissions.take(); let managed_mitm_ca_trust_bundle_path = network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); @@ -364,6 +368,7 @@ impl SandboxManager { network_sandbox_policy: pending.effective_network_policy, sandbox_policy_cwd: pending.native_sandbox_policy_cwd.as_path(), enforce_managed_network, + managed_network, environment_id, network, extra_allow_unix_sockets: &[], diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 64fc87b6e..547155c63 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -92,6 +92,7 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli args: Vec::new(), cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: None, additional_permissions: None, }, permissions: &permissions, @@ -139,6 +140,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { args: Vec::new(), cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: None, additional_permissions: Some(AdditionalPermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), @@ -211,6 +213,7 @@ fn transform_additional_permissions_preserves_denied_entries() { args: Vec::new(), cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: None, additional_permissions: Some(AdditionalPermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( /*read*/ None, @@ -314,6 +317,7 @@ fn transform_linux_seccomp_request( args: Vec::new(), cwd: cwd_uri.clone(), env: HashMap::new(), + managed_network: None, additional_permissions: None, }, permissions: &permissions, @@ -504,6 +508,7 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { "Path".to_string(), r"C:\Windows\System32".to_string(), )]), + managed_network: None, additional_permissions: None, }, permissions: &permissions, diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index d3233e705..f53569de0 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -1,3 +1,4 @@ +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_network_proxy::PROXY_URL_ENV_KEYS; use codex_network_proxy::has_proxy_url_env_vars; @@ -103,6 +104,7 @@ struct UnixSocketPathParam { } fn proxy_policy_inputs( + managed_network: Option<&ManagedNetworkSandboxContext>, network: Option<&NetworkProxy>, environment_id: Option<&str>, extra_allow_unix_sockets: &[AbsolutePathBuf], @@ -112,33 +114,47 @@ fn proxy_policy_inputs( .filter_map(|socket_path| normalize_path_for_sandbox(socket_path.as_path())) .collect::>(); + let unix_domain_socket_policy = match network { + Some(network) if network.dangerously_allow_all_unix_sockets() => { + UnixDomainSocketPolicy::AllowAll + } + Some(network) => { + let mut allowed = network + .allow_unix_sockets() + .iter() + .filter_map(|socket_path| { + match normalize_path_for_sandbox(Path::new(socket_path)) { + Some(path) => Some(path), + None => { + warn!( + "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" + ); + None + } + } + }) + .collect::>(); + allowed.extend(extra_allowed); + UnixDomainSocketPolicy::Restricted { allowed } + } + None => UnixDomainSocketPolicy::Restricted { + allowed: extra_allowed, + }, + }; + if let Some(managed_network) = managed_network { + return Ok(ProxyPolicyInputs { + ports: managed_network.loopback_ports.clone(), + has_proxy_config: true, + allow_local_binding: managed_network.allow_local_binding, + unix_domain_socket_policy, + }); + } match network { Some(network) => { let mut env = HashMap::new(); network .apply_to_env_for_optional_environment(&mut env, environment_id) .map_err(|err| err.to_string())?; - let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { - UnixDomainSocketPolicy::AllowAll - } else { - let mut allowed = network - .allow_unix_sockets() - .iter() - .filter_map(|socket_path| { - match normalize_path_for_sandbox(Path::new(socket_path)) { - Some(path) => Some(path), - None => { - warn!( - "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" - ); - None - } - } - }) - .collect::>(); - allowed.extend(extra_allowed); - UnixDomainSocketPolicy::Restricted { allowed } - }; Ok(ProxyPolicyInputs { ports: proxy_loopback_ports_from_env(&env), has_proxy_config: has_proxy_url_env_vars(&env), @@ -147,9 +163,7 @@ fn proxy_policy_inputs( }) } None => Ok(ProxyPolicyInputs { - unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { - allowed: extra_allowed, - }, + unix_domain_socket_policy, ..Default::default() }), } @@ -586,6 +600,7 @@ fn create_seatbelt_command_args_for_legacy_policy( network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, enforce_managed_network, + managed_network: None, environment_id: None, network, extra_allow_unix_sockets: &[], @@ -599,6 +614,7 @@ pub struct CreateSeatbeltCommandArgsParams<'a> { pub network_sandbox_policy: NetworkSandboxPolicy, pub sandbox_policy_cwd: &'a Path, pub enforce_managed_network: bool, + pub managed_network: Option<&'a ManagedNetworkSandboxContext>, pub environment_id: Option<&'a str>, pub network: Option<&'a NetworkProxy>, pub extra_allow_unix_sockets: &'a [AbsolutePathBuf], @@ -613,6 +629,7 @@ pub fn create_seatbelt_command_args( network_sandbox_policy, sandbox_policy_cwd, enforce_managed_network, + managed_network, environment_id, network, extra_allow_unix_sockets, @@ -709,7 +726,12 @@ pub fn create_seatbelt_command_args( } }; - let proxy = proxy_policy_inputs(network, environment_id, extra_allow_unix_sockets)?; + let proxy = proxy_policy_inputs( + managed_network, + network, + environment_id, + extra_allow_unix_sockets, + )?; let network_policy = dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index e0ff0e665..b15019a8c 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -14,6 +14,7 @@ use super::unix_socket_policy; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigReloaderFuture; use codex_network_proxy::ConfigState; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxyConfig; @@ -205,6 +206,7 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, + managed_network: None, environment_id: None, network: None, extra_allow_unix_sockets: &[], @@ -258,6 +260,37 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() ); } +#[test] +fn prepared_managed_network_context_allows_only_its_proxy_ports() { + let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &SandboxPolicy::new_read_only_policy(), + Path::new("/"), + ); + let managed_network = ManagedNetworkSandboxContext { + loopback_ports: vec![43123, 48081], + allow_local_binding: false, + }; + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: vec!["/bin/true".to_string()], + file_system_sandbox_policy: &file_system_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: Path::new("/"), + enforce_managed_network: true, + managed_network: Some(&managed_network), + environment_id: None, + network: None, + extra_allow_unix_sockets: &[], + }) + .unwrap(); + + let policy = seatbelt_policy_arg(&args); + assert!(policy.contains("(allow network-outbound (remote ip \"localhost:43123\"))")); + assert!(policy.contains("(allow network-outbound (remote ip \"localhost:48081\"))")); + assert!(!policy.contains("(allow network-outbound (remote ip \"localhost:9999\"))")); + assert!(!policy.contains("(allow network-bind (local ip \"*:*\"))")); + assert!(!policy.contains("(allow network-outbound)\n")); +} + #[test] fn explicit_unreadable_paths_are_excluded_from_readable_roots() { let root = absolute_path("/tmp/codex-readable"); @@ -279,6 +312,7 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() { network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: Path::new("/"), enforce_managed_network: false, + managed_network: None, environment_id: None, network: None, extra_allow_unix_sockets: &[], @@ -585,6 +619,7 @@ fn create_seatbelt_args_allowlists_explicit_unix_socket_paths_without_proxy() { network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + managed_network: None, environment_id: None, network: None, extra_allow_unix_sockets: &extra_allow_unix_sockets, @@ -645,6 +680,7 @@ async fn create_seatbelt_args_merges_proxy_and_explicit_unix_socket_paths() -> a network_sandbox_policy: NetworkSandboxPolicy::Restricted, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + managed_network: None, environment_id: None, network: Some(&network_proxy), extra_allow_unix_sockets: &extra_allow_unix_sockets, @@ -688,6 +724,7 @@ fn create_seatbelt_args_preserves_full_network_with_explicit_unix_socket_paths() network_sandbox_policy: NetworkSandboxPolicy::Enabled, sandbox_policy_cwd: cwd.path(), enforce_managed_network: false, + managed_network: None, environment_id: None, network: None, extra_allow_unix_sockets: &extra_allow_unix_sockets,