diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bd7fd8e28c..cb1a8ce5fe 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -659,6 +659,7 @@ mod tests { parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello".to_string(), }], + network_preflight_only: false, }; let request = ServerRequest::ExecCommandApproval { request_id: RequestId::Integer(7), @@ -680,7 +681,8 @@ mod tests { "type": "unknown", "cmd": "echo hello" } - ] + ], + "networkPreflightOnly": false } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index df39f8809e..4d5fbadb5f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -227,6 +227,8 @@ pub struct ExecCommandApprovalParams { pub cwd: PathBuf, pub reason: Option, pub parsed_cmd: Vec, + #[serde(default)] + pub network_preflight_only: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ab788ff381..7ac0efeafc 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -182,6 +182,7 @@ pub(crate) async fn apply_bespoke_event_handling( reason, proposed_execpolicy_amendment, parsed_cmd, + network_preflight_only, .. }) => match api_version { ApiVersion::V1 => { @@ -192,6 +193,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, parsed_cmd, + network_preflight_only, }; let rx = outgoing .send_request(ServerRequestPayload::ExecCommandApproval(params)) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c228a95c1..ee7c3f9bd1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1172,7 +1172,15 @@ impl CodexMessageProcessor { } let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone()); - let env = create_env(&self.config.shell_environment_policy); + let effective_policy = params + .sandbox_policy + .map(|policy| policy.to_core()) + .unwrap_or_else(|| self.config.sandbox_policy.clone()); + let env = create_env( + &self.config.shell_environment_policy, + &effective_policy, + &self.config.network_proxy, + ); let timeout_ms = params .timeout_ms .and_then(|timeout_ms| u64::try_from(timeout_ms).ok()); @@ -1186,11 +1194,6 @@ impl CodexMessageProcessor { arg0: None, }; - let effective_policy = params - .sandbox_policy - .map(|policy| policy.to_core()) - .unwrap_or_else(|| self.config.sandbox_policy.clone()); - let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); let req_id = request_id; diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index c298a3511a..7fd5725b48 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -130,7 +130,11 @@ 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); + let env = create_env( + &config.shell_environment_policy, + &config.sandbox_policy, + &config.network_proxy, + ); // 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/codex.rs b/codex-rs/core/src/codex.rs index ffd58f8ce5..f6b898b307 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -393,15 +393,6 @@ impl TurnContext { } } -pub(crate) struct CommandApprovalRequest { - pub(crate) call_id: String, - pub(crate) command: Vec, - pub(crate) cwd: PathBuf, - pub(crate) reason: Option, - pub(crate) network_preflight_only: bool, - pub(crate) proposed_execpolicy_amendment: Option, -} - #[allow(dead_code)] #[derive(Clone)] pub(crate) struct SessionConfiguration { @@ -1106,7 +1097,12 @@ impl Session { pub async fn request_command_approval( &self, turn_context: &TurnContext, - request: CommandApprovalRequest, + call_id: String, + command: Vec, + cwd: PathBuf, + reason: Option, + proposed_execpolicy_amendment: Option, + network_preflight_only: bool, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. @@ -1126,16 +1122,16 @@ impl Session { warn!("Overwriting existing pending approval for sub_id: {event_id}"); } - let parsed_cmd = parse_command(&request.command); + let parsed_cmd = parse_command(&command); let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - call_id: request.call_id, + call_id, turn_id: turn_context.sub_id.clone(), - command: request.command, - cwd: request.cwd, - reason: request.reason, - proposed_execpolicy_amendment: request.proposed_execpolicy_amendment, + command, + cwd, + reason, + proposed_execpolicy_amendment, parsed_cmd, - network_preflight_only: request.network_preflight_only, + network_preflight_only, }); self.send_event(turn_context, event).await; rx_approve.await.unwrap_or_default() diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d29c0a3178..fa84f619c2 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -277,14 +277,12 @@ async fn handle_exec_approval( // Race approval with cancellation and timeout to avoid hangs. let approval_fut = parent_session.request_command_approval( parent_ctx, - crate::codex::CommandApprovalRequest { - call_id: parent_ctx.sub_id.clone(), - command: event.command, - cwd: event.cwd, - reason: event.reason, - network_preflight_only: event.network_preflight_only, - proposed_execpolicy_amendment: event.proposed_execpolicy_amendment, - }, + parent_ctx.sub_id.clone(), + event.command, + event.cwd, + event.reason, + event.proposed_execpolicy_amendment, + event.network_preflight_only, ); let decision = await_approval_with_cancel( approval_fut, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8df5c33501..47a6c7daa5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -865,8 +865,18 @@ fn ensure_default_no_proxy_entries(entries: &mut Vec) { } } -fn default_no_proxy_entries() -> [&'static str; 3] { - ["localhost", "127.0.0.1", "::1"] +fn default_no_proxy_entries() -> [&'static str; 9] { + [ + "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", + ] } #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index 8dcbd565c7..037c0a9b4a 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -7,6 +7,8 @@ use crate::protocol::SandboxPolicy; use std::collections::HashMap; use std::collections::HashSet; +const DEFAULT_SOCKS_PROXY_PORT: u16 = 8081; + /// Construct an environment map based on the rules in the specified policy. The /// resulting map can be passed directly to `Command::envs()` after calling /// `env_clear()` to ensure no unintended variables are leaked to the spawned @@ -14,16 +16,12 @@ use std::collections::HashSet; /// /// The derivation follows the algorithm documented in the struct-level comment /// for [`ShellEnvironmentPolicy`]. -pub fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap { - populate_env(std::env::vars(), policy) -} - -pub fn create_env_with_network_proxy( +pub fn create_env( policy: &ShellEnvironmentPolicy, sandbox_policy: &SandboxPolicy, network_proxy: &NetworkProxyConfig, ) -> HashMap { - let mut env_map = create_env(policy); + let mut env_map = populate_env(std::env::vars(), policy); if should_apply_network_proxy(network_proxy, sandbox_policy) { apply_network_proxy_env(&mut env_map, network_proxy); } @@ -97,54 +95,210 @@ fn should_apply_network_proxy( } } -fn apply_network_proxy_env( - env_map: &mut HashMap, +#[derive(Clone, Debug)] +struct ProxyEndpoint { + host: String, + port: u16, +} + +#[derive(Default)] +struct ProxyEndpoints { + http: Option, + socks: Option, +} + +fn proxy_env_entries( network_proxy: &NetworkProxyConfig, -) { - let proxy_url = network_proxy.proxy_url.trim(); - if !proxy_url.is_empty() { + endpoints: &ProxyEndpoints, +) -> Vec { + let mut entries = Vec::new(); + let no_proxy = normalize_no_proxy_value(&network_proxy.no_proxy); + if !no_proxy.is_empty() { + entries.push(format!("NO_PROXY={no_proxy}")); + entries.push(format!("no_proxy={no_proxy}")); + } + + let http_proxy_url = endpoints + .http + .as_ref() + .map(|endpoint| format_proxy_url("http", endpoint)); + let socks_proxy_url = endpoints + .socks + .as_ref() + .map(|endpoint| format_proxy_url("socks5h", endpoint)); + let socks_host_port = endpoints + .socks + .as_ref() + .map(|endpoint| format_host_port(&endpoint.host, endpoint.port)); + + if let Some(http_proxy_url) = http_proxy_url.as_ref() { + for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { + entries.push(format!("{key}={http_proxy_url}")); + } for key in [ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", "YARN_HTTP_PROXY", "YARN_HTTPS_PROXY", "npm_config_http_proxy", "npm_config_https_proxy", "npm_config_proxy", ] { - env_map.insert(key.to_string(), proxy_url.to_string()); + entries.push(format!("{key}={http_proxy_url}")); } - env_map.insert("ELECTRON_GET_USE_PROXY".to_string(), "true".to_string()); + entries.push("ELECTRON_GET_USE_PROXY=true".to_string()); + } - if let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) { - let gradle_opts = format!( - "-Dhttp.proxyHost={host} -Dhttp.proxyPort={port} -Dhttps.proxyHost={host} -Dhttps.proxyPort={port}" - ); - match env_map.get_mut("GRADLE_OPTS") { - Some(existing) => { - if !existing.contains("http.proxyHost") && !existing.contains("https.proxyHost") - { - if !existing.ends_with(' ') { - existing.push(' '); - } - existing.push_str(&gradle_opts); - } - } - None => { - env_map.insert("GRADLE_OPTS".to_string(), gradle_opts); - } - } + if let Some(socks_proxy_url) = socks_proxy_url.as_ref() { + entries.push(format!("ALL_PROXY={socks_proxy_url}")); + entries.push(format!("all_proxy={socks_proxy_url}")); + } + + if let Some(socks_host_port) = socks_host_port.as_ref() { + #[cfg(target_os = "macos")] + entries.push(format!( + "GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x {socks_host_port} %h %p'" + )); + if let Some(socks_proxy_url) = socks_proxy_url.as_ref() { + entries.push(format!("FTP_PROXY={socks_proxy_url}")); + entries.push(format!("ftp_proxy={socks_proxy_url}")); + } + entries.push(format!("RSYNC_PROXY={socks_host_port}")); + } + + let docker_proxy = endpoints.http.as_ref().or(endpoints.socks.as_ref()); + if let Some(endpoint) = docker_proxy { + let docker_proxy_url = format_proxy_url("http", endpoint); + entries.push(format!("DOCKER_HTTP_PROXY={docker_proxy_url}")); + entries.push(format!("DOCKER_HTTPS_PROXY={docker_proxy_url}")); + } + + if let Some(endpoint) = endpoints.http.as_ref() { + entries.push("CLOUDSDK_PROXY_TYPE=https".to_string()); + entries.push("CLOUDSDK_PROXY_ADDRESS=localhost".to_string()); + let port = endpoint.port; + entries.push(format!("CLOUDSDK_PROXY_PORT={port}")); + } + + if let Some(socks_proxy_url) = socks_proxy_url.as_ref() { + entries.push(format!("GRPC_PROXY={socks_proxy_url}")); + entries.push(format!("grpc_proxy={socks_proxy_url}")); + } + + entries +} + +fn resolve_proxy_endpoints(network_proxy: &NetworkProxyConfig) -> ProxyEndpoints { + let proxy_url = network_proxy.proxy_url.trim(); + if proxy_url.is_empty() { + return ProxyEndpoints::default(); + } + + let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else { + return ProxyEndpoints::default(); + }; + let Some(port) = normalize_proxy_port(port) else { + return ProxyEndpoints::default(); + }; + + let (host, is_loopback) = normalize_proxy_host(&host); + let is_socks = proxy_url_scheme(proxy_url) + .map(|scheme| scheme.to_ascii_lowercase().starts_with("socks")) + .unwrap_or(false); + let http = if is_socks { + None + } else { + Some(ProxyEndpoint { + host: host.clone(), + port, + }) + }; + let mut socks = if is_socks { + Some(ProxyEndpoint { + host: host.clone(), + port, + }) + } else { + None + }; + if socks.is_none() && is_loopback { + socks = Some(ProxyEndpoint { + host: "localhost".to_string(), + port: DEFAULT_SOCKS_PROXY_PORT, + }); + } + + ProxyEndpoints { http, socks } +} + +fn proxy_url_scheme(proxy_url: &str) -> Option<&str> { + proxy_url.split_once("://").map(|(scheme, _)| scheme) +} + +fn normalize_proxy_host(host: &str) -> (String, bool) { + let is_loopback = + host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"; + if is_loopback { + ("localhost".to_string(), true) + } else { + (host.to_string(), false) + } +} + +fn normalize_proxy_port(port: i64) -> Option { + if (1..=u16::MAX as i64).contains(&port) { + Some(port as u16) + } else { + None + } +} + +fn format_proxy_url(scheme: &str, endpoint: &ProxyEndpoint) -> String { + let host = &endpoint.host; + let port = endpoint.port; + if endpoint.host.contains(':') { + format!("{scheme}://[{host}]:{port}") + } else { + format!("{scheme}://{host}:{port}") + } +} + +fn format_host_port(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +fn apply_network_proxy_env( + env_map: &mut HashMap, + network_proxy: &NetworkProxyConfig, +) { + let endpoints = resolve_proxy_endpoints(network_proxy); + for entry in proxy_env_entries(network_proxy, &endpoints) { + if let Some((key, value)) = entry.split_once('=') { + env_map.insert(key.to_string(), value.to_string()); } } - let no_proxy = normalize_no_proxy_value(&network_proxy.no_proxy); - if !no_proxy.is_empty() { - env_map.insert("NO_PROXY".to_string(), no_proxy.clone()); - env_map.insert("no_proxy".to_string(), no_proxy); + if let Some(endpoint) = endpoints.http { + let host = &endpoint.host; + let port = endpoint.port; + let gradle_opts = format!( + "-Dhttp.proxyHost={host} -Dhttp.proxyPort={port} -Dhttps.proxyHost={host} -Dhttps.proxyPort={port}" + ); + match env_map.get_mut("GRADLE_OPTS") { + Some(existing) => { + if !existing.contains("http.proxyHost") && !existing.contains("https.proxyHost") { + if !existing.ends_with(' ') { + existing.push(' '); + } + existing.push_str(&gradle_opts); + } + } + None => { + env_map.insert("GRADLE_OPTS".to_string(), gradle_opts); + } + } } network_proxy::apply_mitm_ca_env_if_enabled(env_map, network_proxy); @@ -165,8 +319,10 @@ fn normalize_no_proxy_value(entries: &[String]) -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::types::NetworkProxyMode; use crate::config::types::ShellEnvironmentPolicyInherit; use maplit::hashmap; + use pretty_assertions::assert_eq; fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> { pairs @@ -285,4 +441,55 @@ mod tests { }; assert_eq!(result, expected); } + + #[test] + fn proxy_env_entries_are_deterministic() { + let network_proxy = NetworkProxyConfig { + enabled: true, + proxy_url: "http://localhost:3128".to_string(), + admin_url: "http://localhost:8080".to_string(), + mode: NetworkProxyMode::Full, + no_proxy: vec!["localhost".to_string(), "127.0.0.1".to_string()], + prompt_on_block: true, + poll_interval_ms: 1000, + mitm_ca_cert_path: None, + }; + let endpoints = resolve_proxy_endpoints(&network_proxy); + let entries = proxy_env_entries(&network_proxy, &endpoints); + + let mut expected = vec![ + "NO_PROXY=localhost,127.0.0.1".to_string(), + "no_proxy=localhost,127.0.0.1".to_string(), + "HTTP_PROXY=http://localhost:3128".to_string(), + "HTTPS_PROXY=http://localhost:3128".to_string(), + "http_proxy=http://localhost:3128".to_string(), + "https_proxy=http://localhost:3128".to_string(), + "YARN_HTTP_PROXY=http://localhost:3128".to_string(), + "YARN_HTTPS_PROXY=http://localhost:3128".to_string(), + "npm_config_http_proxy=http://localhost:3128".to_string(), + "npm_config_https_proxy=http://localhost:3128".to_string(), + "npm_config_proxy=http://localhost:3128".to_string(), + "ELECTRON_GET_USE_PROXY=true".to_string(), + "ALL_PROXY=socks5h://localhost:8081".to_string(), + "all_proxy=socks5h://localhost:8081".to_string(), + ]; + #[cfg(target_os = "macos")] + expected.push( + "GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:8081 %h %p'".to_string(), + ); + expected.extend([ + "FTP_PROXY=socks5h://localhost:8081".to_string(), + "ftp_proxy=socks5h://localhost:8081".to_string(), + "RSYNC_PROXY=localhost:8081".to_string(), + "DOCKER_HTTP_PROXY=http://localhost:3128".to_string(), + "DOCKER_HTTPS_PROXY=http://localhost:3128".to_string(), + "CLOUDSDK_PROXY_TYPE=https".to_string(), + "CLOUDSDK_PROXY_ADDRESS=localhost".to_string(), + "CLOUDSDK_PROXY_PORT=3128".to_string(), + "GRPC_PROXY=socks5h://localhost:8081".to_string(), + "grpc_proxy=socks5h://localhost:8081".to_string(), + ]); + + assert_eq!(entries, expected); + } } diff --git a/codex-rs/core/src/network_proxy.rs b/codex-rs/core/src/network_proxy.rs index 37f25bfd5d..dcacf9ed90 100644 --- a/codex-rs/core/src/network_proxy.rs +++ b/codex-rs/core/src/network_proxy.rs @@ -420,14 +420,18 @@ struct NetworkProxySection { } #[derive(Default, Deserialize)] -struct NetworkPolicy { +pub(crate) struct NetworkPolicy { #[serde(default, rename = "allowedDomains")] allowed_domains: Vec, #[serde(default, rename = "deniedDomains")] denied_domains: Vec, + #[serde(default, rename = "allowUnixSockets")] + pub(crate) allow_unix_sockets: Vec, + #[serde(default, rename = "allowLocalBinding")] + pub(crate) allow_local_binding: bool, } -fn load_network_policy(config_path: &Path) -> Result { +pub(crate) fn load_network_policy(config_path: &Path) -> Result { if !config_path.exists() { return Ok(NetworkPolicy::default()); } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index f751287b2d..798a0e8853 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -118,7 +118,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); diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 6958e52219..aacd84213b 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,18 +1,30 @@ #![cfg(target_os = "macos")] +use std::collections::BTreeSet; use std::collections::HashMap; +use std::collections::HashSet; use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; use tokio::process::Child; +use crate::config; +use crate::network_proxy; use crate::protocol::SandboxPolicy; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); -const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); +const MACOS_SEATBELT_NETWORK_POLICY_BASE: &str = include_str!("seatbelt_network_policy.sbpl"); +const PROXY_ENV_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", +]; /// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` /// to defend against an attacker trying to inject a malicious version on the @@ -28,7 +40,7 @@ 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( @@ -43,10 +55,192 @@ pub async fn spawn_command_under_seatbelt( .await } +fn proxy_allowlist_from_env(env: &HashMap) -> Vec { + let mut allowlist = Vec::new(); + let mut seen = HashSet::new(); + + for key in PROXY_ENV_KEYS { + let Some(proxy_url) = env.get(*key) else { + continue; + }; + let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else { + continue; + }; + for entry in proxy_allowlist_entries(&host, port) { + if seen.insert(entry.clone()) { + allowlist.push(entry); + } + } + } + + allowlist +} + +fn proxy_allowlist_entries(host: &str, port: i64) -> Vec { + let mut entries = Vec::new(); + let is_loopback = is_loopback_host(host); + + if is_loopback { + for candidate in ["localhost", "127.0.0.1", "::1"] { + entries.push(format_proxy_host_port(candidate, port)); + } + } else { + entries.push(format_proxy_host_port(host, port)); + } + + entries +} + +fn format_proxy_host_port(host: &str, port: i64) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +fn is_loopback_host(host: &str) -> bool { + let host_lower = host.to_ascii_lowercase(); + host_lower == "localhost" || host == "127.0.0.1" || host == "::1" +} + +#[derive(Default)] +struct ProxyPorts { + http: Vec, + socks: Vec, +} + +fn proxy_ports_from_env(env: &HashMap) -> ProxyPorts { + let mut http_ports = BTreeSet::new(); + let mut socks_ports = BTreeSet::new(); + + for key in PROXY_ENV_KEYS { + let Some(proxy_url) = env.get(*key) else { + continue; + }; + let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else { + continue; + }; + let Some(port) = normalize_proxy_port(port) else { + continue; + }; + if !is_loopback_host(&host) { + continue; + } + let scheme = proxy_url_scheme(proxy_url).unwrap_or("http"); + if scheme.to_ascii_lowercase().starts_with("socks") { + socks_ports.insert(port); + } else { + http_ports.insert(port); + } + } + + ProxyPorts { + http: http_ports.into_iter().collect(), + socks: socks_ports.into_iter().collect(), + } +} + +fn proxy_url_scheme(proxy_url: &str) -> Option<&str> { + proxy_url.split_once("://").map(|(scheme, _)| scheme) +} + +fn normalize_proxy_port(port: i64) -> Option { + if (1..=u16::MAX as i64).contains(&port) { + Some(port as u16) + } else { + None + } +} + +fn normalize_unix_socket_path(path: &str) -> Option { + let trimmed = path.trim(); + if trimmed.is_empty() { + return None; + } + let path_buf = PathBuf::from(trimmed); + let normalized = if path_buf.is_absolute() { + path_buf.canonicalize().unwrap_or(path_buf) + } else { + path_buf + }; + Some(normalized.to_string_lossy().to_string()) +} + +fn escape_sbpl_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn build_network_policy( + proxy_allowlist: &[String], + policy: &network_proxy::NetworkPolicy, + proxy_ports: &ProxyPorts, +) -> String { + let mut network_rules = String::from("; Network\n"); + if proxy_allowlist.is_empty() { + network_rules.push_str("(allow network*)\n"); + return format!("{network_rules}{MACOS_SEATBELT_NETWORK_POLICY_BASE}"); + } + + if policy.allow_local_binding { + network_rules.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); + network_rules.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); + network_rules.push_str("(allow network-outbound (local ip \"localhost:*\"))\n"); + } + + if !policy.allow_unix_sockets.is_empty() { + let mut seen = HashSet::new(); + for socket_path in &policy.allow_unix_sockets { + let Some(normalized) = normalize_unix_socket_path(socket_path) else { + continue; + }; + if !seen.insert(normalized.clone()) { + continue; + } + let escaped = escape_sbpl_string(&normalized); + network_rules.push_str(&format!("(allow network* (subpath \"{escaped}\"))\n")); + } + } + + for port in &proxy_ports.http { + network_rules.push_str(&format!( + "(allow network-bind (local ip \"localhost:{port}\"))\n" + )); + network_rules.push_str(&format!( + "(allow network-inbound (local ip \"localhost:{port}\"))\n" + )); + network_rules.push_str(&format!( + "(allow network-outbound (remote ip \"localhost:{port}\"))\n" + )); + } + + for port in &proxy_ports.socks { + network_rules.push_str(&format!( + "(allow network-bind (local ip \"localhost:{port}\"))\n" + )); + network_rules.push_str(&format!( + "(allow network-inbound (local ip \"localhost:{port}\"))\n" + )); + network_rules.push_str(&format!( + "(allow network-outbound (remote ip \"localhost:{port}\"))\n" + )); + } + + let mut outbound = String::from("(allow network-outbound\n"); + for endpoint in proxy_allowlist { + outbound.push_str(&format!(" (remote tcp \"{endpoint}\")\n")); + } + outbound.push_str(")\n"); + network_rules.push_str(&outbound); + + format!("{network_rules}{MACOS_SEATBELT_NETWORK_POLICY_BASE}") +} + 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() { @@ -113,9 +307,15 @@ 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 + let proxy_allowlist = proxy_allowlist_from_env(env); + let proxy_ports = proxy_ports_from_env(env); + let policy = config::default_config_path() + .ok() + .and_then(|path| network_proxy::load_network_policy(&path).ok()) + .unwrap_or_default(); + build_network_policy(&proxy_allowlist, &policy, &proxy_ports) } else { - "" + String::new() }; let full_policy = format!( @@ -168,17 +368,42 @@ mod tests { use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use pretty_assertions::assert_eq; + use serial_test::serial; use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; + struct CodexHomeGuard { + previous: Option, + } + + impl CodexHomeGuard { + fn new(path: &Path) -> Self { + let previous = std::env::var("CODEX_HOME").ok(); + std::env::set_var("CODEX_HOME", path); + Self { previous } + } + } + + impl Drop for CodexHomeGuard { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + std::env::set_var("CODEX_HOME", previous); + } else { + std::env::remove_var("CODEX_HOME"); + } + } + } + #[test] + #[serial] fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { // Create a temporary workspace with two writable roots: one containing // top-level .git and .codex directories and one without them. let tmp = TempDir::new().expect("tempdir"); + let _codex_home_guard = CodexHomeGuard::new(tmp.path()); let PopulatedTmp { vulnerable_root, vulnerable_root_canonical, @@ -189,6 +414,7 @@ mod tests { } = populate_tmpdir(tmp.path()); let cwd = tmp.path().join("cwd"); fs::create_dir_all(&cwd).expect("create cwd"); + let env = std::collections::HashMap::new(); // Build a policy that only includes the two test roots as writable and // does not automatically include defaults TMPDIR or /tmp. @@ -217,7 +443,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, &env); // Build the expected policy text using a raw string for readability. // Note that the policy includes: @@ -308,7 +534,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, &env); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -345,7 +572,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, &env); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -365,10 +592,12 @@ mod tests { } #[test] + #[serial] fn create_seatbelt_args_for_cwd_as_git_repo() { // Create a temporary workspace with two writable roots: one containing // top-level .git and .codex directories and one without them. let tmp = TempDir::new().expect("tempdir"); + let _codex_home_guard = CodexHomeGuard::new(tmp.path()); let PopulatedTmp { vulnerable_root, vulnerable_root_canonical, @@ -376,6 +605,7 @@ mod tests { dot_codex_canonical, .. } = populate_tmpdir(tmp.path()); + let env = std::collections::HashMap::new(); // Build a policy that does not specify any writable_roots, but does // use the default ones (cwd and TMPDIR) and verifies the `.git` and @@ -400,8 +630,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(), + &env, + ); let tmpdir_env_var = std::env::var("TMPDIR") .ok() @@ -470,6 +704,41 @@ mod tests { assert_eq!(expected_args, args); } + #[test] + #[serial] + fn create_seatbelt_args_with_proxy_allowlist() { + let tmp = TempDir::new().expect("tempdir"); + let _codex_home_guard = CodexHomeGuard::new(tmp.path()); + let policy = SandboxPolicy::DangerFullAccess; + let cwd = std::env::current_dir().expect("getcwd"); + let env = std::collections::HashMap::from([( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:3128".to_string(), + )]); + let args = create_seatbelt_command_args(vec!["true".to_string()], &policy, &cwd, &env); + let policy_text = &args[1]; + assert!( + policy_text.contains("(allow network-bind (local ip \"localhost:3128\"))"), + "expected seatbelt policy to allow local proxy binding" + ); + assert!( + policy_text.contains("(allow network-inbound (local ip \"localhost:3128\"))"), + "expected seatbelt policy to allow local proxy inbound" + ); + assert!( + policy_text.contains("(allow network-outbound (remote ip \"localhost:3128\"))"), + "expected seatbelt policy to allow local proxy outbound" + ); + assert!( + policy_text.contains("(remote tcp \"127.0.0.1:3128\")"), + "expected seatbelt policy to include the proxy allowlist" + ); + assert!( + !policy_text.contains("localhost:*"), + "proxy-restricted policy should not allow all localhost ports" + ); + } + struct PopulatedTmp { /// Path containing a .git and .codex subfolder. /// For the purposes of this test, we consider this a "vulnerable" root diff --git a/codex-rs/core/src/seatbelt_network_policy.sbpl b/codex-rs/core/src/seatbelt_network_policy.sbpl index 2a72f95fd3..a87b848316 100644 --- a/codex-rs/core/src/seatbelt_network_policy.sbpl +++ b/codex-rs/core/src/seatbelt_network_policy.sbpl @@ -1,8 +1,7 @@ ; when network access is enabled, these policies are added after those in seatbelt_base_policy.sbpl +; network allow rules are injected by codex-core based on proxy settings. ; 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 mach-lookup diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 1d480615e1..84823e87b8 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -15,7 +15,7 @@ use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::StreamOutput; use crate::exec::execute_exec_env; -use crate::exec_env::create_env_with_network_proxy; +use crate::exec_env::create_env; use crate::parse_command::parse_command; use crate::protocol::EventMsg; use crate::protocol::ExecCommandBeginEvent; @@ -97,7 +97,7 @@ impl SessionTask for UserShellCommandTask { let exec_env = ExecEnv { command: command.clone(), cwd: cwd.clone(), - env: create_env_with_network_proxy( + env: create_env( &turn_context.shell_environment_policy, &sandbox_policy, &turn_context.network_proxy, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 2bd77639b3..0cc2d7304d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use crate::codex::TurnContext; use crate::exec::ExecParams; -use crate::exec_env::create_env_with_network_proxy; +use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; @@ -37,7 +37,7 @@ impl ShellHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), - env: create_env_with_network_proxy( + env: create_env( &turn_context.shell_environment_policy, &turn_context.sandbox_policy, &turn_context.network_proxy, @@ -67,7 +67,7 @@ impl ShellCommandHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), - env: create_env_with_network_proxy( + env: create_env( &turn_context.shell_environment_policy, &turn_context.sandbox_policy, &turn_context.network_proxy, @@ -407,7 +407,11 @@ mod tests { let expected_command = session.user_shell().derive_exec_args(&command, true); let expected_cwd = turn_context.resolve_path(workdir.clone()); - let expected_env = create_env(&turn_context.shell_environment_policy); + let expected_env = create_env( + &turn_context.shell_environment_policy, + &turn_context.sandbox_policy, + &turn_context.network_proxy, + ); let params = ShellCommandToolCallParams { command, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 8ea07bee02..9f9dad13c4 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -114,14 +114,12 @@ impl Approvable for ApplyPatchRuntime { session .request_command_approval( turn, - crate::codex::CommandApprovalRequest { - call_id, - command: vec!["apply_patch".to_string()], - cwd, - reason: Some(reason), - proposed_execpolicy_amendment: None, - network_preflight_only: false, - }, + call_id, + vec!["apply_patch".to_string()], + cwd, + Some(reason), + None, + false, ) .await } else if user_explicitly_approved { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 39c311bca1..4c07db706f 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -100,17 +100,14 @@ impl Approvable for ShellRuntime { session .request_command_approval( turn, - crate::codex::CommandApprovalRequest { - call_id, - command, - cwd, - reason, - proposed_execpolicy_amendment: req - .exec_approval_requirement - .proposed_execpolicy_amendment() - .cloned(), - network_preflight_only: req.network_preflight_only, - }, + call_id, + command, + cwd, + reason, + req.exec_approval_requirement + .proposed_execpolicy_amendment() + .cloned(), + req.network_preflight_only, ) .await }) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index bda04ee9d6..b14c70aabd 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -120,17 +120,14 @@ impl Approvable for UnifiedExecRuntime<'_> { session .request_command_approval( turn, - crate::codex::CommandApprovalRequest { - call_id, - command, - cwd, - reason, - proposed_execpolicy_amendment: req - .exec_approval_requirement - .proposed_execpolicy_amendment() - .cloned(), - network_preflight_only: req.network_preflight_only, - }, + call_id, + command, + cwd, + reason, + req.exec_approval_requirement + .proposed_execpolicy_amendment() + .cloned(), + req.network_preflight_only, ) .await }) diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index e3f7b12a6b..a61d229967 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken; use crate::bash::extract_bash_command; use crate::codex::Session; use crate::codex::TurnContext; -use crate::exec_env::create_env_with_network_proxy; +use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::network_proxy; use crate::protocol::BackgroundEventEvent; @@ -481,7 +481,7 @@ impl UnifiedExecSessionManager { justification: Option, context: &UnifiedExecContext, ) -> Result { - let env = apply_unified_exec_env(create_env_with_network_proxy( + let env = apply_unified_exec_env(create_env( &context.turn.shell_environment_policy, &context.turn.sandbox_policy, &context.turn.network_proxy, diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index a4868ec057..8fe8522ea0 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -1,4 +1,5 @@ #![cfg(target_os = "linux")] +use codex_core::config::types::NetworkProxyConfig; use codex_core::config::types::ShellEnvironmentPolicy; use codex_core::error::CodexErr; use codex_core::error::SandboxErr; @@ -29,25 +30,16 @@ const NETWORK_TIMEOUT_MS: u64 = 2_000; #[cfg(target_arch = "aarch64")] const NETWORK_TIMEOUT_MS: u64 = 10_000; -fn create_env_from_core_vars() -> HashMap { +fn create_env_from_core_vars(sandbox_policy: &SandboxPolicy) -> HashMap { let policy = ShellEnvironmentPolicy::default(); - create_env(&policy) + let network_proxy = NetworkProxyConfig::default(); + create_env(&policy, sandbox_policy, &network_proxy) } #[expect(clippy::print_stdout, clippy::expect_used, clippy::unwrap_used)] async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { let cwd = std::env::current_dir().expect("cwd should exist"); let sandbox_cwd = cwd.clone(); - let params = ExecParams { - command: cmd.iter().copied().map(str::to_owned).collect(), - cwd, - expiration: timeout_ms.into(), - env: create_env_from_core_vars(), - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, - arg0: None, - }; - let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: writable_roots .iter() @@ -60,6 +52,15 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; + let params = ExecParams { + command: cmd.iter().copied().map(str::to_owned).collect(), + cwd, + expiration: timeout_ms.into(), + env: create_env_from_core_vars(&sandbox_policy), + sandbox_permissions: SandboxPermissions::UseDefault, + justification: None, + arg0: None, + }; let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program)); let res = process_exec_tool_call( diff --git a/docs/config.md b/docs/config.md index 69c0073e3c..629712b678 100644 --- a/docs/config.md +++ b/docs/config.md @@ -359,7 +359,17 @@ enabled = true proxy_url = "http://127.0.0.1:3128" admin_url = "http://127.0.0.1:8080" mode = "limited" # limited | full (default) -no_proxy = ["localhost", "127.0.0.1"] +no_proxy = [ + "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", +] prompt_on_block = true poll_interval_ms = 1000 ``` @@ -367,8 +377,10 @@ poll_interval_ms = 1000 Notes: - Proxy settings are injected only when sandbox network access is enabled (or full access mode). If the sandbox blocks network access, requests are blocked at the OS layer. -- `proxy_url` is used for `HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY` env vars. -- `no_proxy` entries bypass the proxy; use sparingly because bypassed traffic is not filtered by the proxy policy. +- `proxy_url` is used for HTTP proxy envs (`HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, `https_proxy`, plus npm/yarn variants). Docker and Cloud SDK proxy envs are derived from the HTTP proxy when present. +- When `proxy_url` points at localhost, Codex also assumes a SOCKS5 proxy on `localhost:8081` for `ALL_PROXY`, `GRPC_PROXY`, `FTP_PROXY`, `RSYNC_PROXY`, and (macOS only) `GIT_SSH_COMMAND`. +- `no_proxy` entries bypass the proxy; defaults include localhost + private network ranges. Use sparingly because bypassed traffic is not filtered by the proxy policy. +- `[network_proxy.policy]` can optionally allow localhost binding or Unix socket access (macOS only) when proxy-restricted network access is active. - When `prompt_on_block = true`, Codex polls the proxy admin API (`/blocked`) and surfaces a prompt to allow for the session, allow always (add to allowlist), or deny (add to denylist). Allow/deny decisions update `~/.codex/config.toml` under `[network_proxy.policy]`, then Codex calls `/reload`. ### tools.\* @@ -963,14 +975,16 @@ Valid values: | `sandbox_workspace_write.exclude_tmpdir_env_var` | boolean | Exclude `$TMPDIR` from writable roots (default: false). | | `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). | | `network_proxy.enabled` | boolean | Enable proxy environment injection + admin polling (default: false). | -| `network_proxy.proxy_url` | string | Proxy URL used for `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` (default: `http://127.0.0.1:3128`). | +| `network_proxy.proxy_url` | string | Proxy URL used for HTTP proxy envs (`HTTP_PROXY`/`HTTPS_PROXY`); when it points at localhost, Codex also assumes SOCKS5 on `localhost:8081` for `ALL_PROXY` (default: `http://127.0.0.1:3128`). | | `network_proxy.admin_url` | string | Proxy admin API base URL (default: `http://127.0.0.1:8080`). | | `network_proxy.mode` | `limited` \| `full` | Default proxy mode for policy hints (default: `full`). | -| `network_proxy.no_proxy` | array | Hosts/IPs that bypass the proxy (default includes `localhost`, `127.0.0.1`, `::1`). | +| `network_proxy.no_proxy` | array | Hosts/IPs that bypass the proxy (default includes localhost + private network ranges). | | `network_proxy.prompt_on_block` | boolean | Poll `/blocked` and prompt on denied requests (default: true). | | `network_proxy.poll_interval_ms` | number | Admin poll interval in ms (default: 1000). | | `network_proxy.policy.allowedDomains` | array | Allowlist of domain patterns (denylist takes precedence). | | `network_proxy.policy.deniedDomains` | array | Denylist of domain patterns (takes precedence over allowlist). | +| `network_proxy.policy.allowLocalBinding` | boolean | Allow localhost binding when proxy-restricted (macOS only, default: false). | +| `network_proxy.policy.allowUnixSockets` | array | Allow specific Unix socket paths when proxy-restricted (macOS only, default: []). | | `network_proxy.mitm.enabled` | boolean | Enable HTTPS MITM for read-only enforcement in limited mode (default: false). | | `network_proxy.mitm.inspect` | boolean | Enable body inspection in MITM mode (default: false). | | `network_proxy.mitm.max_body_bytes` | number | Max body bytes to buffer when inspection is enabled (default: 4096). | diff --git a/docs/example-config.md b/docs/example-config.md index 10f910efaf..57fa660d49 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -106,19 +106,38 @@ exclude_slash_tmp = false [network_proxy] # Enable proxy env injection + approval prompts for blocked domains. Default: false enabled = false -# HTTP/HTTPS/ALL proxy URL. Default: "http://127.0.0.1:3128" +# HTTP/HTTPS proxy URL. Default: "http://127.0.0.1:3128" proxy_url = "http://127.0.0.1:3128" # Admin API for the proxy (for /blocked, /reload, /mode). Default: "http://127.0.0.1:8080" admin_url = "http://127.0.0.1:8080" # limited | full (default: full) mode = "full" -# Hosts/IPs that bypass the proxy. Default includes localhost + loopback. -no_proxy = ["localhost", "127.0.0.1", "::1"] +# Hosts/IPs that bypass the proxy. Default includes localhost + private networks. +no_proxy = [ + "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", +] # Poll proxy /blocked and prompt the user. Default: true prompt_on_block = true # Poll interval in milliseconds. Default: 1000 poll_interval_ms = 1000 +[network_proxy.policy] +# Allow localhost binds inside the sandbox (macOS only). Default: false +allowLocalBinding = false +# Allow Unix socket paths inside the sandbox (macOS only). Default: [] +allowUnixSockets = [] +# Optional domain allow/deny lists (denylist wins) +allowedDomains = [] +deniedDomains = [] + ################################################################################ # Shell Environment Policy for spawned processes ################################################################################ diff --git a/docs/sandbox.md b/docs/sandbox.md index fa264bd0bd..b68bf10d52 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -76,7 +76,9 @@ Network access is controlled through a proxy server running outside the sandbox: - **Custom proxy support:** Advanced users can implement custom rules on outgoing traffic. - **Comprehensive coverage:** Restrictions apply to all scripts, programs, and subprocesses spawned by Codex. -`NO_PROXY` is supported via `[network_proxy].no_proxy` and is passed to subprocesses as `NO_PROXY/no_proxy`. Any domains or IPs in that list bypass the proxy and are not filtered by proxy policy. +`NO_PROXY` is supported via `[network_proxy].no_proxy` and is passed to subprocesses as `NO_PROXY/no_proxy`. Defaults include localhost and private network ranges; any entries in that list bypass the proxy and are not filtered by proxy policy. + +On macOS, `[network_proxy.policy]` can also allow localhost binding or Unix socket paths when proxy-restricted network access is active. These settings influence the Seatbelt profile. When MITM is enabled in the proxy config, Codex injects common CA environment variables (for example `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`, `PIP_CERT`, and `NPM_CONFIG_CAFILE`) pointing at the proxy CA cert to reduce per‑tool configuration.