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 5a0a555e8e..cf831ad100 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -81,6 +81,7 @@ use crate::config::Constrained; use crate::config::ConstraintError; use crate::config::ConstraintResult; use crate::config::GhostSnapshotConfig; +use crate::config::types::NetworkProxyConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; use crate::environment_context::EnvironmentContext; @@ -368,6 +369,7 @@ pub(crate) struct TurnContext { pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, + pub(crate) network_proxy: NetworkProxyConfig, pub(crate) tools_config: ToolsConfig, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, @@ -530,6 +532,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.clone(), shell_environment_policy: per_turn_config.shell_environment_policy.clone(), + network_proxy: per_turn_config.network_proxy.clone(), tools_config, ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, @@ -2182,6 +2185,7 @@ async fn spawn_review_thread( approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), + network_proxy: parent_turn_context.network_proxy.clone(), cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dae8a9575b..4325f7710d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2,6 +2,9 @@ use crate::auth::AuthCredentialsStoreMode; use crate::config::types::DEFAULT_OTEL_ENVIRONMENT; use crate::config::types::History; use crate::config::types::McpServerConfig; +use crate::config::types::NetworkProxyConfig; +use crate::config::types::NetworkProxyConfigToml; +use crate::config::types::NetworkProxyMode; use crate::config::types::Notice; use crate::config::types::Notifications; use crate::config::types::OtelConfig; @@ -87,6 +90,11 @@ pub(crate) fn test_config() -> Config { .expect("load default test config") } +pub fn default_config_path() -> std::io::Result { + let codex_home = find_codex_home()?; + Ok(codex_home.join(CONFIG_TOML_FILE)) +} + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Config { @@ -122,6 +130,8 @@ pub struct Config { pub forced_auto_mode_downgraded_on_windows: bool, pub shell_environment_policy: ShellEnvironmentPolicy, + /// Network proxy settings for routing sandboxed network access. + pub network_proxy: NetworkProxyConfig, /// When `true`, `AgentReasoning` events emitted by the backend will be /// suppressed from the frontend output. This can reduce visual noise when @@ -563,6 +573,9 @@ pub struct ConfigToml { /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. pub sandbox_workspace_write: Option, + /// Network proxy settings for sandboxed network access. + pub network_proxy: Option, + /// Optional external command to spawn for end-user notifications. #[serde(default)] pub notify: Option>, @@ -725,6 +738,143 @@ impl From for UserSavedConfig { } } +fn default_network_proxy_config() -> NetworkProxyConfig { + NetworkProxyConfig { + enabled: false, + proxy_url: "http://127.0.0.1:3128".to_string(), + admin_url: "http://127.0.0.1:8080".to_string(), + mode: NetworkProxyMode::Full, + no_proxy: default_no_proxy_entries() + .iter() + .map(|entry| (*entry).to_string()) + .collect(), + poll_interval_ms: 1000, + mitm_ca_cert_path: None, + } +} + +fn resolve_network_proxy_config(cfg: &ConfigToml, codex_home: &Path) -> NetworkProxyConfig { + let mut resolved = default_network_proxy_config(); + let Some(network_proxy) = cfg.network_proxy.clone() else { + return resolved; + }; + let mitm_ca_cert_path = resolve_network_proxy_mitm_ca_path(&network_proxy, codex_home); + + if let Some(enabled) = network_proxy.enabled { + resolved.enabled = enabled; + } + if let Some(proxy_url) = network_proxy.proxy_url { + let trimmed = proxy_url.trim(); + if !trimmed.is_empty() { + resolved.proxy_url = trimmed.to_string(); + } + } + if let Some(admin_url) = network_proxy.admin_url { + let trimmed = admin_url.trim(); + if !trimmed.is_empty() { + resolved.admin_url = trimmed.to_string(); + } + } + if let Some(mode) = network_proxy.mode { + resolved.mode = mode; + } + if let Some(no_proxy) = network_proxy.no_proxy { + resolved.no_proxy = normalize_no_proxy_entries(no_proxy); + } + ensure_default_no_proxy_entries(&mut resolved.no_proxy); + if let Some(poll_interval_ms) = network_proxy.poll_interval_ms + && poll_interval_ms > 0 + { + resolved.poll_interval_ms = poll_interval_ms; + } + resolved.mitm_ca_cert_path = mitm_ca_cert_path; + resolved +} + +fn resolve_network_proxy_mitm_ca_path( + network_proxy: &NetworkProxyConfigToml, + codex_home: &Path, +) -> Option { + let mitm = network_proxy.mitm.as_ref()?; + if !mitm.enabled.unwrap_or(false) { + return None; + } + let ca_cert_path = mitm + .ca_cert_path + .clone() + .unwrap_or_else(|| PathBuf::from("network_proxy/mitm/ca.pem")); + if ca_cert_path.as_os_str().is_empty() { + return None; + } + Some(resolve_network_proxy_path(&ca_cert_path, codex_home)) +} + +fn resolve_network_proxy_path(path: &Path, codex_home: &Path) -> PathBuf { + let expanded = expand_tilde_path(path); + if expanded.is_absolute() { + expanded + } else { + codex_home.join(expanded) + } +} + +fn expand_tilde_path(path: &Path) -> PathBuf { + let path_str = path.to_string_lossy(); + if path_str == "~" { + return home_dir().unwrap_or_else(|| path.to_path_buf()); + } + if let Some(rest) = path_str.strip_prefix("~/") + && let Some(home) = home_dir() + { + return home.join(rest); + } + path.to_path_buf() +} + +fn normalize_no_proxy_entries(entries: Vec) -> Vec { + let mut out: Vec = Vec::new(); + for entry in entries { + let trimmed = entry.trim(); + if trimmed.is_empty() { + continue; + } + if out + .iter() + .any(|existing| existing.eq_ignore_ascii_case(trimmed)) + { + continue; + } + out.push(trimmed.to_string()); + } + out +} + +fn ensure_default_no_proxy_entries(entries: &mut Vec) { + for entry in default_no_proxy_entries() { + if entries + .iter() + .any(|existing| existing.eq_ignore_ascii_case(entry)) + { + continue; + } + entries.push(entry.to_string()); + } +} + +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)] pub struct ProjectConfig { pub trust_level: Option, @@ -1047,6 +1197,11 @@ impl Config { || config_profile.sandbox_mode.is_some() || cfg.sandbox_mode.is_some(); + let mut network_proxy = resolve_network_proxy_config(&cfg, &codex_home); + if !features.enabled(Feature::NetworkProxy) { + network_proxy.enabled = false; + } + let mut model_providers = built_in_model_providers(); // Merge user-defined providers into the built-in list. for (key, provider) in cfg.model_providers.into_iter() { @@ -1168,6 +1323,7 @@ impl Config { did_user_set_custom_approval_policy_or_sandbox_mode, forced_auto_mode_downgraded_on_windows, shell_environment_policy, + network_proxy, notify: cfg.notify, user_instructions, base_instructions, @@ -1346,12 +1502,6 @@ pub fn find_codex_home() -> std::io::Result { Ok(p) } -/// Returns the default path to the Codex config file (`config.toml`). -pub fn default_config_path() -> std::io::Result { - let codex_home = find_codex_home()?; - Ok(codex_home.join(CONFIG_TOML_FILE)) -} - /// Returns the path to the folder where Codex logs are stored. Does not verify /// that the directory exists. pub fn log_dir(cfg: &Config) -> std::io::Result { @@ -1409,6 +1559,51 @@ persistence = "none" ); } + #[test] + fn network_proxy_is_rollout_gated_by_feature_flag() -> std::io::Result<()> { + let cfg_without_feature_flag = r#" +[network_proxy] +enabled = true +"#; + let parsed_without_feature_flag = + toml::from_str::(cfg_without_feature_flag).expect("TOML parse should work"); + let cwd_temp_dir = TempDir::new().unwrap(); + std::fs::write(cwd_temp_dir.path().join(".git"), "gitdir: nowhere")?; + let codex_home_temp_dir = TempDir::new().unwrap(); + let config_without_feature_flag = Config::load_from_base_config_with_overrides( + parsed_without_feature_flag, + ConfigOverrides { + cwd: Some(cwd_temp_dir.path().to_path_buf()), + ..Default::default() + }, + codex_home_temp_dir.path().to_path_buf(), + )?; + assert!(!config_without_feature_flag.network_proxy.enabled); + + let cfg_with_feature_flag = r#" +[features] +network_proxy = true + +[network_proxy] +enabled = true +"#; + let parsed_with_feature_flag = + toml::from_str::(cfg_with_feature_flag).expect("TOML parse should work"); + let cwd_temp_dir = TempDir::new().unwrap(); + std::fs::write(cwd_temp_dir.path().join(".git"), "gitdir: nowhere")?; + let codex_home_temp_dir = TempDir::new().unwrap(); + let config_with_feature_flag = Config::load_from_base_config_with_overrides( + parsed_with_feature_flag, + ConfigOverrides { + cwd: Some(cwd_temp_dir.path().to_path_buf()), + ..Default::default() + }, + codex_home_temp_dir.path().to_path_buf(), + )?; + assert!(config_with_feature_flag.network_proxy.enabled); + Ok(()) + } + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2965,6 +3160,7 @@ model_verbosity = "high" did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), + network_proxy: default_network_proxy_config(), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -3040,6 +3236,7 @@ model_verbosity = "high" did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), + network_proxy: default_network_proxy_config(), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -3130,6 +3327,7 @@ model_verbosity = "high" did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), + network_proxy: default_network_proxy_config(), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -3206,6 +3404,7 @@ model_verbosity = "high" did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), + network_proxy: default_network_proxy_config(), user_instructions: None, notify: None, cwd: fixture.cwd(), diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 9243e9878a..f83597fc67 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -435,6 +435,47 @@ impl From for codex_app_server_protocol::SandboxSettings } } +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum NetworkProxyMode { + Limited, + #[default] + Full, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +pub struct NetworkProxyConfigToml { + pub enabled: Option, + pub proxy_url: Option, + pub admin_url: Option, + pub mode: Option, + pub no_proxy: Option>, + pub poll_interval_ms: Option, + #[serde(default)] + pub mitm: Option, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +pub struct NetworkProxyMitmConfigToml { + pub enabled: Option, + pub inspect: Option, + pub max_body_bytes: Option, + pub ca_cert_path: Option, + pub ca_key_path: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NetworkProxyConfig { + pub enabled: bool, + pub proxy_url: String, + pub admin_url: String, + pub mode: NetworkProxyMode, + pub no_proxy: Vec, + pub poll_interval_ms: i64, + pub mitm_ca_cert_path: Option, +} + #[derive(Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub enum ShellEnvironmentPolicyInherit { diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index 11334896bf..79cd9bbfc0 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -1,9 +1,14 @@ use crate::config::types::EnvironmentVariablePattern; +use crate::config::types::NetworkProxyConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyInherit; +use crate::network_proxy; +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 @@ -11,8 +16,16 @@ 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( + policy: &ShellEnvironmentPolicy, + sandbox_policy: &SandboxPolicy, + network_proxy: &NetworkProxyConfig, +) -> HashMap { + 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); + } + env_map } fn populate_env(vars: I, policy: &ShellEnvironmentPolicy) -> HashMap @@ -68,11 +81,245 @@ where env_map } +fn should_apply_network_proxy( + network_proxy: &NetworkProxyConfig, + sandbox_policy: &SandboxPolicy, +) -> bool { + if !network_proxy.enabled { + return false; + } + match sandbox_policy { + SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, + SandboxPolicy::DangerFullAccess => true, + SandboxPolicy::ReadOnly => false, + } +} + +#[derive(Clone, Debug)] +struct ProxyEndpoint { + host: String, + port: u16, +} + +#[derive(Default)] +struct ProxyEndpoints { + http: Option, + socks: Option, +} + +fn proxy_env_entries( + network_proxy: &NetworkProxyConfig, + 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 [ + "YARN_HTTP_PROXY", + "YARN_HTTPS_PROXY", + "npm_config_http_proxy", + "npm_config_https_proxy", + "npm_config_proxy", + ] { + entries.push(format!("{key}={http_proxy_url}")); + } + entries.push("ELECTRON_GET_USE_PROXY=true".to_string()); + } + + 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, 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()); + } + } + + 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); +} + +fn normalize_no_proxy_value(entries: &[String]) -> String { + let mut out = Vec::new(); + for entry in entries { + let trimmed = entry.trim(); + if trimmed.is_empty() { + continue; + } + out.push(trimmed.to_string()); + } + out.join(",") +} + #[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 @@ -191,4 +438,54 @@ 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()], + 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/features.rs b/codex-rs/core/src/features.rs index 3239adc109..7fc52f3244 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -85,6 +85,8 @@ pub enum Feature { ShellSnapshot, /// Experimental TUI v2 (viewport) implementation. Tui2, + /// Route subprocess network access through the Codex network proxy and surface approvals. + NetworkProxy, } impl Feature { @@ -405,4 +407,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::NetworkProxy, + key: "network_proxy", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index f78c19328f..5ffe2c47bf 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -33,6 +33,7 @@ pub mod git_info; pub mod landlock; pub mod mcp; mod mcp_connection_manager; +pub mod network_proxy; pub mod openai_models; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_NOTIFICATION; diff --git a/codex-rs/core/src/network_proxy.rs b/codex-rs/core/src/network_proxy.rs new file mode 100644 index 0000000000..1fd06fbee9 --- /dev/null +++ b/codex-rs/core/src/network_proxy.rs @@ -0,0 +1,903 @@ +use crate::config; +use crate::config::types::NetworkProxyConfig; +use crate::config::types::NetworkProxyMode; +use crate::protocol::ReviewDecision; +use crate::protocol::SandboxPolicy; +use crate::tools::sandboxing::ApprovalStore; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_client::CodexHttpClient; +use serde::Deserialize; +use serde::Serialize; +use shlex::split as shlex_split; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use toml_edit::Array as TomlArray; +use toml_edit::DocumentMut; +use toml_edit::InlineTable; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; +use wildmatch::WildMatchPattern; + +const NETWORK_PROXY_TABLE: &str = "network_proxy"; +const NETWORK_PROXY_POLICY_TABLE: &str = "policy"; +const ALLOWED_DOMAINS_KEY: &str = "allowed_domains"; +const DENIED_DOMAINS_KEY: &str = "denied_domains"; +const ALLOW_UNIX_SOCKETS_KEY: &str = "allow_unix_sockets"; + +#[derive(Debug, Clone, Deserialize)] +pub struct NetworkProxyBlockedRequest { + pub host: String, + pub reason: String, + #[serde(default)] + pub call_id: Option, + pub client: Option, + pub method: Option, + pub mode: Option, + pub protocol: String, + pub timestamp: i64, +} + +#[derive(Debug, Deserialize)] +struct BlockedResponse { + blocked: Vec, +} + +#[derive(Serialize)] +struct ModeUpdate { + mode: NetworkProxyMode, +} + +pub async fn fetch_blocked( + client: &CodexHttpClient, + admin_url: &str, +) -> Result> { + let base = admin_url.trim_end_matches('/'); + let url = format!("{base}/blocked"); + let response = client + .get(url) + .send() + .await + .context("network proxy /blocked request failed")? + .error_for_status() + .context("network proxy /blocked returned error")?; + let payload: BlockedResponse = response + .json() + .await + .context("network proxy /blocked returned invalid JSON")?; + Ok(payload.blocked) +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize)] +pub(crate) struct NetworkApprovalKey { + host: String, +} + +impl NetworkApprovalKey { + #[allow(dead_code)] + fn new(host: &str) -> Option { + let host = host.trim(); + if host.is_empty() { + return None; + } + Some(Self { + host: host.to_ascii_lowercase(), + }) + } +} + +#[allow(dead_code)] +pub(crate) fn cache_network_approval( + store: &mut ApprovalStore, + host: &str, + decision: ReviewDecision, +) -> bool { + if !matches!( + decision, + ReviewDecision::Approved | ReviewDecision::ApprovedForSession + ) { + return false; + } + let Some(key) = NetworkApprovalKey::new(host) else { + return false; + }; + store.put(key, decision); + true +} + +pub async fn set_mode( + client: &CodexHttpClient, + admin_url: &str, + mode: NetworkProxyMode, +) -> Result<()> { + let base = admin_url.trim_end_matches('/'); + let url = format!("{base}/mode"); + let request = ModeUpdate { mode }; + client + .post(url) + .json(&request) + .send() + .await + .context("network proxy /mode request failed")? + .error_for_status() + .context("network proxy /mode returned error")?; + Ok(()) +} + +pub async fn reload(client: &CodexHttpClient, admin_url: &str) -> Result<()> { + let base = admin_url.trim_end_matches('/'); + let url = format!("{base}/reload"); + client + .post(url) + .send() + .await + .context("network proxy /reload request failed")? + .error_for_status() + .context("network proxy /reload returned error")?; + Ok(()) +} + +pub fn add_allowed_domain(config_path: &Path, host: &str) -> Result { + update_domain_list(config_path, host, DomainListKind::Allow) +} + +pub fn add_denied_domain(config_path: &Path, host: &str) -> Result { + update_domain_list(config_path, host, DomainListKind::Deny) +} + +pub fn add_allowed_unix_socket(config_path: &Path, socket: &str) -> Result { + update_unix_socket_list(config_path, socket, UnixSocketListKind::Allow) +} + +pub fn remove_allowed_unix_socket(config_path: &Path, socket: &str) -> Result { + update_unix_socket_list(config_path, socket, UnixSocketListKind::Remove) +} + +#[derive(Debug, Clone, Copy)] +pub struct DomainState { + pub allowed: bool, + pub denied: bool, +} + +pub fn domain_state(config_path: &Path, host: &str) -> Result { + let host = host.trim(); + if host.is_empty() { + return Err(anyhow!("host is empty")); + } + let policy = load_network_policy(config_path)?; + Ok(DomainState { + allowed: list_contains(&policy.allowed_domains, host), + denied: list_contains(&policy.denied_domains, host), + }) +} + +pub fn set_domain_state(config_path: &Path, host: &str, state: DomainState) -> Result { + let host = host.trim(); + if host.is_empty() { + return Err(anyhow!("host is empty")); + } + let mut doc = load_document(config_path)?; + let policy = ensure_policy_table(&mut doc); + let mut changed = false; + { + let allowed = ensure_array(policy, ALLOWED_DOMAINS_KEY); + if state.allowed { + changed |= add_domain(allowed, host); + } else { + changed |= remove_domain(allowed, host); + } + } + { + let denied = ensure_array(policy, DENIED_DOMAINS_KEY); + if state.denied { + changed |= add_domain(denied, host); + } else { + changed |= remove_domain(denied, host); + } + } + + if changed { + write_document(config_path, &doc)?; + } + Ok(changed) +} + +pub fn unix_socket_allowed(config_path: &Path, socket_path: &Path) -> Result { + let policy = load_network_policy(config_path)?; + let allowed = resolve_unix_socket_allowlist(&policy.allow_unix_sockets); + if allowed.is_empty() { + return Ok(false); + } + let canonical_socket = socket_path + .canonicalize() + .unwrap_or_else(|_| socket_path.to_path_buf()); + Ok(allowed + .iter() + .any(|allowed_path| canonical_socket.starts_with(allowed_path))) +} + +pub fn should_preflight_network( + network_proxy: &NetworkProxyConfig, + sandbox_policy: &SandboxPolicy, +) -> bool { + if !network_proxy.enabled { + return false; + } + match sandbox_policy { + SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, + SandboxPolicy::DangerFullAccess => true, + SandboxPolicy::ReadOnly => false, + } +} + +pub fn preflight_blocked_host_if_enabled( + network_proxy: &NetworkProxyConfig, + sandbox_policy: &SandboxPolicy, + command: &[String], +) -> Result> { + if !should_preflight_network(network_proxy, sandbox_policy) { + return Ok(None); + } + let config_path = config::default_config_path()?; + preflight_blocked_host(&config_path, command) +} + +pub fn preflight_blocked_request_if_enabled( + network_proxy: &NetworkProxyConfig, + sandbox_policy: &SandboxPolicy, + command: &[String], +) -> Result> { + match preflight_blocked_host_if_enabled(network_proxy, sandbox_policy, command)? { + Some(hit) => Ok(Some(NetworkProxyBlockedRequest { + host: hit.host, + reason: hit.reason, + call_id: None, + client: None, + method: None, + mode: Some(network_proxy.mode), + protocol: "preflight".to_string(), + timestamp: 0, + })), + None => Ok(None), + } +} + +#[derive(Debug, Clone)] +pub struct UnixSocketPreflightMatch { + /// Socket path that needs to be allowed (canonicalized when possible). + pub socket_path: PathBuf, + /// Suggested config entry to add for a persistent allow (e.g. `$SSH_AUTH_SOCK`). + pub suggested_allow_entry: String, + pub reason: String, +} + +pub fn preflight_blocked_unix_socket_if_enabled( + network_proxy: &NetworkProxyConfig, + sandbox_policy: &SandboxPolicy, + command: &[String], +) -> Result> { + if !cfg!(target_os = "macos") { + return Ok(None); + } + if !should_preflight_network(network_proxy, sandbox_policy) { + return Ok(None); + } + + let Some(socket_path) = ssh_auth_sock_if_needed(command) else { + return Ok(None); + }; + + let config_path = config::default_config_path()?; + if unix_socket_allowed(&config_path, &socket_path)? { + return Ok(None); + } + + Ok(Some(UnixSocketPreflightMatch { + socket_path, + suggested_allow_entry: "$SSH_AUTH_SOCK".to_string(), + reason: "not_allowed_unix_socket".to_string(), + })) +} + +pub fn apply_mitm_ca_env_if_enabled( + env_map: &mut HashMap, + network_proxy: &NetworkProxyConfig, +) { + let Some(ca_cert_path) = network_proxy.mitm_ca_cert_path.as_ref() else { + return; + }; + let ca_value = ca_cert_path.to_string_lossy().to_string(); + for key in [ + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "PIP_CERT", + "NPM_CONFIG_CAFILE", + "npm_config_cafile", + "CODEX_PROXY_CERT", + "PROXY_CA_CERT_PATH", + ] { + env_map + .entry(key.to_string()) + .or_insert_with(|| ca_value.clone()); + } +} + +pub fn proxy_host_port(proxy_url: &str) -> Option<(String, i64)> { + let trimmed = proxy_url.trim(); + if trimmed.is_empty() { + return None; + } + let without_scheme = trimmed + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(trimmed); + let mut host_port = without_scheme.split('/').next().unwrap_or(""); + if let Some((_, rest)) = host_port.rsplit_once('@') { + host_port = rest; + } + if host_port.is_empty() { + return None; + } + let (host, port_str) = if host_port.starts_with('[') { + let end = host_port.find(']')?; + let host = &host_port[1..end]; + let port = host_port[end + 1..].strip_prefix(':')?; + (host, port) + } else { + host_port.rsplit_once(':')? + }; + if host.is_empty() { + return None; + } + let port: i64 = port_str.parse().ok()?; + if port <= 0 { + return None; + } + Some((host.to_string(), port)) +} + +#[derive(Debug, Clone)] +pub struct PreflightMatch { + pub host: String, + pub reason: String, +} + +pub fn preflight_blocked_host( + config_path: &Path, + command: &[String], +) -> Result> { + let policy = load_network_policy(config_path)?; + let hosts = extract_hosts_from_command(command); + for host in hosts { + if policy + .denied_domains + .iter() + .any(|pattern| host_matches(pattern, &host)) + { + return Ok(Some(PreflightMatch { + host, + reason: "denied".to_string(), + })); + } + if policy.allowed_domains.is_empty() + || !policy + .allowed_domains + .iter() + .any(|pattern| host_matches(pattern, &host)) + { + return Ok(Some(PreflightMatch { + host, + reason: "not_allowed".to_string(), + })); + } + } + Ok(None) +} + +pub fn preflight_host(config_path: &Path, host: &str) -> Result> { + let host = host.trim(); + if host.is_empty() { + return Err(anyhow!("host is empty")); + } + let policy = load_network_policy(config_path)?; + if policy + .denied_domains + .iter() + .any(|pattern| host_matches(pattern, host)) + { + return Ok(Some("denied".to_string())); + } + if policy.allowed_domains.is_empty() + || !policy + .allowed_domains + .iter() + .any(|pattern| host_matches(pattern, host)) + { + return Ok(Some("not_allowed".to_string())); + } + Ok(None) +} + +#[derive(Copy, Clone)] +enum DomainListKind { + Allow, + Deny, +} + +fn update_domain_list(config_path: &Path, host: &str, list: DomainListKind) -> Result { + let host = host.trim(); + if host.is_empty() { + return Err(anyhow!("host is empty")); + } + let mut doc = load_document(config_path)?; + let policy = ensure_policy_table(&mut doc); + let (target_key, other_key) = match list { + DomainListKind::Allow => (ALLOWED_DOMAINS_KEY, DENIED_DOMAINS_KEY), + DomainListKind::Deny => (DENIED_DOMAINS_KEY, ALLOWED_DOMAINS_KEY), + }; + + let mut changed = { + let target = ensure_array(policy, target_key); + add_domain(target, host) + }; + let removed = { + let other = ensure_array(policy, other_key); + remove_domain(other, host) + }; + if removed { + changed = true; + } + + if changed { + write_document(config_path, &doc)?; + } + Ok(changed) +} + +#[derive(Copy, Clone)] +enum UnixSocketListKind { + Allow, + Remove, +} + +fn update_unix_socket_list( + config_path: &Path, + socket: &str, + action: UnixSocketListKind, +) -> Result { + let socket = socket.trim(); + if socket.is_empty() { + return Err(anyhow!("socket is empty")); + } + let mut doc = load_document(config_path)?; + let policy = ensure_policy_table(&mut doc); + let list = ensure_array(policy, ALLOW_UNIX_SOCKETS_KEY); + let changed = match action { + UnixSocketListKind::Allow => add_domain(list, socket), + UnixSocketListKind::Remove => remove_domain(list, socket), + }; + if changed { + write_document(config_path, &doc)?; + } + Ok(changed) +} + +fn load_document(path: &Path) -> Result { + if !path.exists() { + return Ok(DocumentMut::new()); + } + let raw = std::fs::read_to_string(path) + .with_context(|| format!("failed to read network proxy config at {}", path.display()))?; + raw.parse::() + .with_context(|| format!("failed to parse network proxy config at {}", path.display())) +} + +#[derive(Default, Deserialize)] +struct NetworkPolicyConfig { + #[serde(default, rename = "network_proxy")] + network_proxy: NetworkProxySection, +} + +#[derive(Default, Deserialize)] +struct NetworkProxySection { + #[serde(default)] + policy: NetworkPolicy, +} + +#[derive(Default, Deserialize)] +pub(crate) struct NetworkPolicy { + #[serde(default, rename = "allowed_domains", alias = "allowedDomains")] + allowed_domains: Vec, + #[serde(default, rename = "denied_domains", alias = "deniedDomains")] + denied_domains: Vec, + #[serde(default, rename = "allow_unix_sockets", alias = "allowUnixSockets")] + pub(crate) allow_unix_sockets: Vec, + #[serde(default, rename = "allow_local_binding", alias = "allowLocalBinding")] + pub(crate) allow_local_binding: bool, +} + +pub(crate) fn resolve_unix_socket_allowlist(entries: &[String]) -> Vec { + let mut resolved = Vec::new(); + let mut seen = HashSet::new(); + + for entry in entries { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + for candidate in resolve_unix_socket_entry(entry) { + if !seen.insert(candidate.clone()) { + continue; + } + resolved.push(candidate); + } + } + + resolved.sort(); + resolved +} + +fn resolve_unix_socket_entry(entry: &str) -> Vec { + // Presets are intentionally simple: they resolve to a path (or set of paths) + // and are ultimately translated into Seatbelt `subpath` rules. + let entry = entry.trim(); + if entry.is_empty() { + return Vec::new(); + } + + let mut candidates: Vec = Vec::new(); + match entry { + "ssh-agent" | "ssh_auth_sock" | "ssh_auth_socket" => { + if let Some(value) = std::env::var_os("SSH_AUTH_SOCK") { + candidates.push(value.to_string_lossy().to_string()); + } + } + _ => { + if let Some(var) = entry.strip_prefix('$') { + candidates.extend(resolve_env_unix_socket(var)); + } else if entry.starts_with("${") && entry.ends_with('}') { + candidates.extend(resolve_env_unix_socket(&entry[2..entry.len() - 1])); + } else { + candidates.push(entry.to_string()); + } + } + } + + candidates + .into_iter() + .filter_map(|candidate| parse_unix_socket_candidate(&candidate)) + .collect() +} + +fn resolve_env_unix_socket(var: &str) -> Vec { + let var = var.trim(); + if var.is_empty() { + return Vec::new(); + } + std::env::var_os(var) + .map(|value| vec![value.to_string_lossy().to_string()]) + .unwrap_or_default() +} + +fn parse_unix_socket_candidate(candidate: &str) -> Option { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return None; + } + let path = if let Some(rest) = trimmed.strip_prefix("unix://") { + rest + } else if let Some(rest) = trimmed.strip_prefix("unix:") { + rest + } else { + trimmed + }; + let path = PathBuf::from(path); + if !path.is_absolute() { + return None; + } + Some(path.canonicalize().unwrap_or(path)) +} + +pub(crate) fn load_network_policy(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(NetworkPolicy::default()); + } + let raw = std::fs::read_to_string(config_path).with_context(|| { + format!( + "failed to read network proxy config at {}", + config_path.display() + ) + })?; + let config: NetworkPolicyConfig = toml::from_str(&raw).with_context(|| { + format!( + "failed to parse network proxy config at {}", + config_path.display() + ) + })?; + Ok(config.network_proxy.policy) +} + +fn list_contains(domains: &[String], host: &str) -> bool { + domains.iter().any(|value| value.eq_ignore_ascii_case(host)) +} + +fn host_matches(pattern: &str, host: &str) -> bool { + let pattern = pattern.trim(); + if pattern.is_empty() { + return false; + } + let matcher: WildMatchPattern<'*', '?'> = WildMatchPattern::new_case_insensitive(pattern); + if matcher.matches(host) { + return true; + } + if let Some(apex) = pattern.strip_prefix("*.") { + return apex.eq_ignore_ascii_case(host); + } + false +} + +fn extract_hosts_from_command(command: &[String]) -> Vec { + let mut hosts = HashSet::new(); + extract_hosts_from_tokens(command, &mut hosts); + for tokens in extract_shell_script_commands(command) { + extract_hosts_from_tokens(&tokens, &mut hosts); + } + hosts.into_iter().collect() +} + +fn ssh_auth_sock_if_needed(command: &[String]) -> Option { + let Some(cmd0) = command.first() else { + return None; + }; + let cmd = std::path::Path::new(cmd0) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + let needs_sock = match cmd { + "ssh" | "scp" | "sftp" | "ssh-add" => true, + "git" => command + .iter() + .skip(1) + .any(|arg| arg.contains("ssh://") || looks_like_scp_host(arg)), + _ => false, + }; + if !needs_sock { + return None; + } + let sock = std::env::var_os("SSH_AUTH_SOCK")?; + let sock = sock.to_string_lossy().to_string(); + parse_unix_socket_candidate(&sock) +} + +fn looks_like_scp_host(value: &str) -> bool { + // e.g. git@github.com:owner/repo.git + let value = value.trim(); + if value.is_empty() || value.starts_with('-') { + return false; + } + value.contains('@') && value.contains(':') && !value.contains("://") +} + +fn extract_hosts_from_tokens(tokens: &[String], hosts: &mut HashSet) { + let (cmd0, args) = match tokens.split_first() { + Some((cmd0, args)) => (cmd0.as_str(), args), + None => return, + }; + let cmd = std::path::Path::new(cmd0) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + let (_tool, tool_args) = match cmd { + "curl" | "wget" | "git" | "gh" | "ssh" | "scp" | "rsync" => (cmd, args), + "npm" | "yarn" | "pnpm" | "pip" | "pip3" | "pipx" | "cargo" | "go" => (cmd, args), + "python" | "python3" + if matches!( + (args.first(), args.get(1)), + (Some(flag), Some(module)) if flag == "-m" && module == "pip" + ) => + { + ("pip", &args[2..]) + } + _ => return, + }; + + if tool_args.is_empty() { + return; + } + for arg in tool_args { + if let Some(host) = extract_host_from_url(arg) { + hosts.insert(host); + } + } +} + +fn extract_shell_script_commands(command: &[String]) -> Vec> { + let Some(cmd0) = command.first() else { + return Vec::new(); + }; + let cmd = std::path::Path::new(cmd0) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if !matches!(cmd, "bash" | "zsh" | "sh") { + return Vec::new(); + } + let Some(flag) = command.get(1) else { + return Vec::new(); + }; + if !matches!(flag.as_str(), "-lc" | "-c") { + return Vec::new(); + } + let Some(script) = command.get(2) else { + return Vec::new(); + }; + let tokens = shlex_split(script) + .unwrap_or_else(|| script.split_whitespace().map(ToString::to_string).collect()); + split_shell_tokens_into_commands(&tokens) +} + +fn split_shell_tokens_into_commands(tokens: &[String]) -> Vec> { + let mut commands = Vec::new(); + let mut current: Vec = Vec::new(); + for token in tokens { + if is_shell_separator(token) { + if !current.is_empty() { + commands.push(std::mem::take(&mut current)); + } + continue; + } + current.push(token.clone()); + } + if !current.is_empty() { + commands.push(current); + } + commands +} + +fn is_shell_separator(token: &str) -> bool { + matches!(token, "&&" | "||" | ";" | "|") +} + +fn extract_host_from_url(value: &str) -> Option { + let trimmed = value + .trim() + .trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | ';' | ',')); + if trimmed.is_empty() { + return None; + } + for scheme in ["http://", "https://", "ssh://", "git://", "git+ssh://"] { + if let Some(rest) = trimmed.strip_prefix(scheme) { + return normalize_host(rest); + } + } + None +} + +fn normalize_host(value: &str) -> Option { + let mut host = value.split('/').next().unwrap_or(""); + if let Some((_, tail)) = host.rsplit_once('@') { + host = tail; + } + if let Some((head, _)) = host.split_once(':') { + host = head; + } + let host = host.trim_matches(|c: char| matches!(c, '.' | ',' | ';')); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +fn write_document(path: &Path, doc: &DocumentMut) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let mut output = doc.to_string(); + if !output.ends_with('\n') { + output.push('\n'); + } + std::fs::write(path, output) + .with_context(|| format!("failed to write network proxy config at {}", path.display()))?; + Ok(()) +} + +fn ensure_network_proxy_table(doc: &mut DocumentMut) -> &mut TomlTable { + let entry = doc + .entry(NETWORK_PROXY_TABLE) + .or_insert_with(|| TomlItem::Table(TomlTable::new())); + let table = ensure_table_for_write(entry); + table.set_implicit(false); + table +} + +fn ensure_policy_table(doc: &mut DocumentMut) -> &mut TomlTable { + let network_proxy = ensure_network_proxy_table(doc); + let entry = network_proxy + .entry(NETWORK_PROXY_POLICY_TABLE) + .or_insert_with(|| TomlItem::Table(TomlTable::new())); + let table = ensure_table_for_write(entry); + table.set_implicit(false); + table +} + +fn ensure_table_for_write(item: &mut TomlItem) -> &mut TomlTable { + loop { + match item { + TomlItem::Table(table) => return table, + TomlItem::Value(value) => { + if let Some(inline) = value.as_inline_table() { + *item = TomlItem::Table(table_from_inline(inline)); + } else { + *item = TomlItem::Table(TomlTable::new()); + } + } + _ => { + *item = TomlItem::Table(TomlTable::new()); + } + } + } +} + +fn table_from_inline(inline: &InlineTable) -> TomlTable { + let mut table = TomlTable::new(); + table.set_implicit(false); + for (key, value) in inline.iter() { + table.insert(key, TomlItem::Value(value.clone())); + } + table +} + +fn ensure_array<'a>(table: &'a mut TomlTable, key: &str) -> &'a mut TomlArray { + let entry = table + .entry(key) + .or_insert_with(|| TomlItem::Value(TomlArray::new().into())); + if entry.as_array().is_none() { + *entry = TomlItem::Value(TomlArray::new().into()); + } + match entry { + TomlItem::Value(value) => value + .as_array_mut() + .unwrap_or_else(|| unreachable!("array should exist after normalization")), + _ => unreachable!("array should be a value after normalization"), + } +} + +fn add_domain(array: &mut TomlArray, host: &str) -> bool { + if array + .iter() + .filter_map(|item| item.as_str()) + .any(|existing| existing.eq_ignore_ascii_case(host)) + { + return false; + } + array.push(host); + true +} + +fn remove_domain(array: &mut TomlArray, host: &str) -> bool { + let mut removed = false; + let mut updated = TomlArray::new(); + for item in array.iter() { + let should_remove = item + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case(host)); + if should_remove { + removed = true; + } else { + updated.push(item.clone()); + } + } + if removed { + *array = updated; + } + removed +} 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..e1c9f2e9c5 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,18 +1,29 @@ #![cfg(target_os = "macos")] +use std::collections::BTreeSet; use std::collections::HashMap; use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; use tokio::process::Child; +use 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 +39,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 +54,133 @@ pub async fn spawn_command_under_seatbelt( .await } +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, + has_proxy_env: bool, + has_non_loopback_proxy_env: bool, +} + +fn proxy_ports_from_env(env: &HashMap) -> ProxyPorts { + let mut http_ports = BTreeSet::new(); + let mut socks_ports = BTreeSet::new(); + let mut has_proxy_env = false; + let mut has_non_loopback_proxy_env = false; + + for key in PROXY_ENV_KEYS { + let Some(proxy_url) = env.get(*key) else { + continue; + }; + has_proxy_env = true; + 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) { + has_non_loopback_proxy_env = true; + 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(), + has_proxy_env, + has_non_loopback_proxy_env, + } +} + +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 escape_sbpl_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn build_network_policy(policy: &network_proxy::NetworkPolicy, proxy_ports: &ProxyPorts) -> String { + let mut network_rules = String::from("; Network\n"); + // On macOS, `sandbox-exec` only accepts `localhost` or `*` in network + // addresses. We use loopback proxy ports + the network proxy itself to + // enforce per-domain policy and prompting. + if !proxy_ports.has_proxy_env { + 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() { + for socket_path in network_proxy::resolve_unix_socket_allowlist(&policy.allow_unix_sockets) + { + let escaped = escape_sbpl_string(&socket_path.to_string_lossy()); + 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" + )); + } + + if proxy_ports.has_non_loopback_proxy_env { + network_rules + .push_str("; NOTE: Non-loopback proxies are not supported under `sandbox-exec`.\n"); + } + + 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 +247,14 @@ 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_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(&policy, &proxy_ports) } else { - "" + String::new() }; let full_policy = format!( @@ -168,17 +307,49 @@ 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(); + // SAFETY: these tests execute serially, and we restore the original value in Drop. + unsafe { + std::env::set_var("CODEX_HOME", path); + } + Self { previous } + } + } + + impl Drop for CodexHomeGuard { + fn drop(&mut self) { + // SAFETY: these tests execute serially, and we restore the original value before other + // tests run. + unsafe { + 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 +360,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 +389,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: @@ -280,6 +452,7 @@ mod tests { .current_dir(&cwd) .output() .expect("execute seatbelt command"); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert_eq!( "sandbox_mode = \"read-only\"\n", String::from_utf8_lossy(&fs::read(&config_toml).expect("read config.toml")), @@ -290,8 +463,14 @@ mod tests { "command to write {} should fail under seatbelt", &config_toml.display() ); + if stderr.starts_with("sandbox-exec: sandbox_apply:") { + // Some environments (including Codex's own test harness) run the process under a + // Seatbelt sandbox already, which prevents nested `sandbox-exec` usage. In that case, + // we can still validate policy generation but cannot validate enforcement. + return; + } assert_eq!( - String::from_utf8_lossy(&output.stderr), + stderr, format!("bash: {}: Operation not permitted\n", config_toml.display()), ); @@ -308,7 +487,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 +525,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 +545,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 +558,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 +583,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 +657,45 @@ 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"), + "`sandbox-exec` network addresses only support `localhost` or `*`, so we must not emit host allowlists" + ); + assert!( + !policy_text.contains("127.0.0.1:3128"), + "seatbelt policy must not include numeric loopback hosts (it will fail to parse)" + ); + 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 aec09514ca..37a4bb0a4a 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -21,7 +21,6 @@ use crate::protocol::EventMsg; use crate::protocol::ExecCommandBeginEvent; use crate::protocol::ExecCommandEndEvent; use crate::protocol::ExecCommandSource; -use crate::protocol::SandboxPolicy; use crate::protocol::TaskStartedEvent; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; @@ -96,9 +95,11 @@ impl SessionTask for UserShellCommandTask { let exec_env = ExecEnv { command: command.clone(), cwd: cwd.clone(), - env: create_env(&turn_context.shell_environment_policy), - // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we - // should use that instead of an "arbitrarily large" timeout here. + env: create_env( + &turn_context.shell_environment_policy, + &turn_context.sandbox_policy, + &turn_context.network_proxy, + ), expiration: USER_SHELL_TIMEOUT_MS.into(), sandbox: SandboxType::None, sandbox_permissions: SandboxPermissions::UseDefault, @@ -112,8 +113,7 @@ impl SessionTask for UserShellCommandTask { tx_event: session.get_tx_event(), }); - let sandbox_policy = SandboxPolicy::DangerFullAccess; - let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream) + let exec_result = execute_exec_env(exec_env, &turn_context.sandbox_policy, stdout_stream) .or_cancel(&cancellation_token) .await; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index bcc4ed9309..aed4e36d8f 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -34,7 +34,11 @@ impl ShellHandler { command: params.command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), - env: create_env(&turn_context.shell_environment_policy), + env: create_env( + &turn_context.shell_environment_policy, + &turn_context.sandbox_policy, + &turn_context.network_proxy, + ), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, @@ -60,7 +64,11 @@ impl ShellCommandHandler { command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), - env: create_env(&turn_context.shell_environment_policy), + env: create_env( + &turn_context.shell_environment_policy, + &turn_context.sandbox_policy, + &turn_context.network_proxy, + ), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, @@ -371,7 +379,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/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 17d5ef671a..083de7e885 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -480,7 +480,11 @@ impl UnifiedExecSessionManager { justification: Option, context: &UnifiedExecContext, ) -> Result { - let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy)); + let env = apply_unified_exec_env(create_env( + &context.turn.shell_environment_policy, + &context.turn.sandbox_policy, + &context.turn.network_proxy, + )); let features = context.session.features(); let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); 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 8d4cfe349e..e83ff74fbe 100644 --- a/docs/config.md +++ b/docs/config.md @@ -349,6 +349,47 @@ This is reasonable to use if Codex is running in an environment that provides it Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows. +### network_proxy + +Codex can route subprocess network traffic through an external proxy (for example, `codex-network-proxy`) to enforce domain allow/deny policies for sandboxed subprocesses. + +```toml +[features] +network_proxy = true + +[network_proxy] +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", + "::1", + "*.local", + ".local", + "169.254.0.0/16", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", +] +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. +- Network proxy integration is rollout-gated behind `[features].network_proxy = true`. +- `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. +- On macOS, `network_proxy.policy.allow_unix_sockets` is useful for local IPC that relies on Unix domain sockets (most commonly the SSH agent). Entries can be: + - absolute socket paths (or directories containing sockets), + - `$SSH_AUTH_SOCK` / `${SSH_AUTH_SOCK}`, + - the preset `ssh-agent` (alias: `ssh_auth_sock`, `ssh_auth_socket`). + If you use `git` over SSH, a common setting is `allow_unix_sockets = ["$SSH_AUTH_SOCK"]`. + ### tools.\* These `[tools]` configuration options are deprecated. Use `[features]` instead (see [Feature flags](#feature-flags)). @@ -928,67 +969,82 @@ Valid values: ## Config reference -| Key | Type / Values | Notes | -| ------------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `model` | string | Model to use (e.g., `gpt-5.1-codex-max`). | -| `model_provider` | string | Provider id from `model_providers` (default: `openai`). | -| `model_context_window` | number | Context window tokens. | -| `tool_output_token_limit` | number | Token budget for stored function/tool outputs in history (default: 2,560 tokens). | -| `approval_policy` | `untrusted` \| `on-failure` \| `on-request` \| `never` | When to prompt for approval. | -| `sandbox_mode` | `read-only` \| `workspace-write` \| `danger-full-access` | OS sandbox policy. | -| `sandbox_workspace_write.writable_roots` | array | Extra writable roots in workspace‑write. | -| `sandbox_workspace_write.network_access` | boolean | Allow network in workspace‑write (default: false). | -| `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). | -| `notify` | array | External program for notifications. | -| `tui.animations` | boolean | Enable terminal animations (welcome screen, shimmer, spinner). Defaults to true; set to `false` to disable visual motion. | -| `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. | -| `features.` | boolean | See [feature flags](#feature-flags) for details | -| `ghost_snapshot.disable_warnings` | boolean | Disable every warnings around ghost snapshot (large files, directory, ...) | -| `ghost_snapshot.ignore_large_untracked_files` | number | Exclude untracked files larger than this many bytes from ghost snapshots (default: 10 MiB). Set to `0` to disable. | -| `ghost_snapshot.ignore_large_untracked_dirs` | number | Ignore untracked directories with at least this many files (default: 200). Set to `0` to disable. | -| `mcp_servers..command` | string | MCP server launcher command (stdio servers only). | -| `mcp_servers..args` | array | MCP server args (stdio servers only). | -| `mcp_servers..env` | map | MCP server env vars (stdio servers only). | -| `mcp_servers..url` | string | MCP server url (streamable http servers only). | -| `mcp_servers..bearer_token_env_var` | string | environment variable containing a bearer token to use for auth (streamable http servers only). | -| `mcp_servers..enabled` | boolean | When false, Codex skips starting the server (default: true). | -| `mcp_servers..startup_timeout_sec` | number | Startup timeout in seconds (default: 10). Timeout is applied both for initializing MCP server and initially listing tools. | -| `mcp_servers..tool_timeout_sec` | number | Per-tool timeout in seconds (default: 60). Accepts fractional values; omit to use the default. | -| `mcp_servers..enabled_tools` | array | Restrict the server to the listed tool names. | -| `mcp_servers..disabled_tools` | array | Remove the listed tool names after applying `enabled_tools`, if any. | -| `model_providers..name` | string | Display name. | -| `model_providers..base_url` | string | API base URL. | -| `model_providers..env_key` | string | Env var for API key. | -| `model_providers..wire_api` | `chat` \| `responses` | Protocol used (default: `chat`). | -| `model_providers..query_params` | map | Extra query params (e.g., Azure `api-version`). | -| `model_providers..http_headers` | map | Additional static headers. | -| `model_providers..env_http_headers` | map | Headers sourced from env vars. | -| `model_providers..request_max_retries` | number | Per‑provider HTTP retry count (default: 4). | -| `model_providers..stream_max_retries` | number | SSE stream retry count (default: 5). | -| `model_providers..stream_idle_timeout_ms` | number | SSE idle timeout (ms) (default: 300000). | -| `project_doc_max_bytes` | number | Max bytes to read from `AGENTS.md`. | -| `profile` | string | Active profile name. | -| `profiles..*` | various | Profile‑scoped overrides of the same keys. | -| `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). | -| `history.max_bytes` | number | Maximum size of `history.jsonl` in bytes; when exceeded, history is compacted to ~80% of this limit by dropping oldest entries. | -| `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). | -| `tui` | table | TUI‑specific options. | -| `tui.notifications` | boolean \| array | Enable desktop notifications in the tui (default: true). | -| `hide_agent_reasoning` | boolean | Hide model reasoning events. | -| `check_for_update_on_startup` | boolean | Check for Codex updates on startup (default: true). Set to `false` only if updates are centrally managed. | -| `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). | -| `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high`\|`xhigh` | Responses API reasoning effort. | -| `model_reasoning_summary` | `auto` \| `concise` \| `detailed` \| `none` | Reasoning summaries. | -| `model_verbosity` | `low` \| `medium` \| `high` | GPT‑5 text verbosity (Responses API). | -| `model_supports_reasoning_summaries` | boolean | Force‑enable reasoning summaries. | -| `model_reasoning_summary_format` | `none` \| `experimental` | Force reasoning summary format. | -| `chatgpt_base_url` | string | Base URL for ChatGPT auth flow. | -| `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). | -| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | -| `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | -| `tools.web_search` | boolean | Enable web search tool (deprecated) (default: false). | -| `tools.view_image` | boolean | Enable or disable the `view_image` tool so Codex can attach local image files from the workspace (default: true). | -| `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. | -| `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. | -| `cli_auth_credentials_store` | `file` \| `keyring` \| `auto` | Where to store CLI login credentials (default: `file`). | +| Key | Type / Values | Notes | +| -------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `model` | string | Model to use (e.g., `gpt-5.1-codex-max`). | +| `model_provider` | string | Provider id from `model_providers` (default: `openai`). | +| `model_context_window` | number | Context window tokens. | +| `tool_output_token_limit` | number | Token budget for stored function/tool outputs in history (default: 2,560 tokens). | +| `approval_policy` | `untrusted` \| `on-failure` \| `on-request` \| `never` | When to prompt for approval. | +| `sandbox_mode` | `read-only` \| `workspace-write` \| `danger-full-access` | OS sandbox policy. | +| `sandbox_workspace_write.writable_roots` | array | Extra writable roots in workspace‑write. | +| `sandbox_workspace_write.network_access` | boolean | Allow network in workspace‑write (default: false). | +| `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 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 + private network ranges). | +| `network_proxy.poll_interval_ms` | number | Admin poll interval in ms (default: 1000). | +| `network_proxy.policy.allowed_domains` | array | Allowlist of domain patterns (denylist takes precedence). | +| `network_proxy.policy.denied_domains` | array | Denylist of domain patterns (takes precedence over allowlist). | +| `network_proxy.policy.allow_local_binding` | boolean | Allow localhost binding when proxy-restricted (macOS only, default: false). | +| `network_proxy.policy.allow_unix_sockets` | array | Allow Unix socket paths when proxy-restricted (macOS only, default: []). Supports `$SSH_AUTH_SOCK` and the `ssh-agent` preset. | +| `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). | +| `network_proxy.mitm.ca_cert_path` | string (path) | CA cert path (default: `network_proxy/mitm/ca.pem` under `$CODEX_HOME`). | +| `network_proxy.mitm.ca_key_path` | string (path) | CA key path (default: `network_proxy/mitm/ca.key` under `$CODEX_HOME`). | +| `notify` | array | External program for notifications. | +| `tui.animations` | boolean | Enable terminal animations (welcome screen, shimmer, spinner). Defaults to true; set to `false` to disable visual motion. | +| `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. | +| `features.` | boolean | See [feature flags](#feature-flags) for details | +| `ghost_snapshot.disable_warnings` | boolean | Disable every warnings around ghost snapshot (large files, directory, ...) | +| `ghost_snapshot.ignore_large_untracked_files` | number | Exclude untracked files larger than this many bytes from ghost snapshots (default: 10 MiB). Set to `0` to disable. | +| `ghost_snapshot.ignore_large_untracked_dirs` | number | Ignore untracked directories with at least this many files (default: 200). Set to `0` to disable. | +| `mcp_servers..command` | string | MCP server launcher command (stdio servers only). | +| `mcp_servers..args` | array | MCP server args (stdio servers only). | +| `mcp_servers..env` | map | MCP server env vars (stdio servers only). | +| `mcp_servers..url` | string | MCP server url (streamable http servers only). | +| `mcp_servers..bearer_token_env_var` | string | environment variable containing a bearer token to use for auth (streamable http servers only). | +| `mcp_servers..enabled` | boolean | When false, Codex skips starting the server (default: true). | +| `mcp_servers..startup_timeout_sec` | number | Startup timeout in seconds (default: 10). Timeout is applied both for initializing MCP server and initially listing tools. | +| `mcp_servers..tool_timeout_sec` | number | Per-tool timeout in seconds (default: 60). Accepts fractional values; omit to use the default. | +| `mcp_servers..enabled_tools` | array | Restrict the server to the listed tool names. | +| `mcp_servers..disabled_tools` | array | Remove the listed tool names after applying `enabled_tools`, if any. | +| `model_providers..name` | string | Display name. | +| `model_providers..base_url` | string | API base URL. | +| `model_providers..env_key` | string | Env var for API key. | +| `model_providers..wire_api` | `chat` \| `responses` | Protocol used (default: `chat`). | +| `model_providers..query_params` | map | Extra query params (e.g., Azure `api-version`). | +| `model_providers..http_headers` | map | Additional static headers. | +| `model_providers..env_http_headers` | map | Headers sourced from env vars. | +| `model_providers..request_max_retries` | number | Per‑provider HTTP retry count (default: 4). | +| `model_providers..stream_max_retries` | number | SSE stream retry count (default: 5). | +| `model_providers..stream_idle_timeout_ms` | number | SSE idle timeout (ms) (default: 300000). | +| `project_doc_max_bytes` | number | Max bytes to read from `AGENTS.md`. | +| `profile` | string | Active profile name. | +| `profiles..*` | various | Profile‑scoped overrides of the same keys. | +| `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). | +| `history.max_bytes` | number | Maximum size of `history.jsonl` in bytes; when exceeded, history is compacted to ~80% of this limit by dropping oldest entries. | +| `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). | +| `tui` | table | TUI‑specific options. | +| `tui.notifications` | boolean \| array | Enable desktop notifications in the tui (default: true). | +| `hide_agent_reasoning` | boolean | Hide model reasoning events. | +| `check_for_update_on_startup` | boolean | Check for Codex updates on startup (default: true). Set to `false` only if updates are centrally managed. | +| `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). | +| `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high`\|`xhigh` | Responses API reasoning effort. | +| `model_reasoning_summary` | `auto` \| `concise` \| `detailed` \| `none` | Reasoning summaries. | +| `model_verbosity` | `low` \| `medium` \| `high` | GPT‑5 text verbosity (Responses API). | +| `model_supports_reasoning_summaries` | boolean | Force‑enable reasoning summaries. | +| `model_reasoning_summary_format` | `none` \| `experimental` | Force reasoning summary format. | +| `chatgpt_base_url` | string | Base URL for ChatGPT auth flow. | +| `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). | +| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | +| `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | +| `tools.web_search` | boolean | Enable web search tool (deprecated) (default: false). | +| `tools.view_image` | boolean | Enable or disable the `view_image` tool so Codex can attach local image files from the workspace (default: true). | +| `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. | +| `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. | +| `cli_auth_credentials_store` | `file` \| `keyring` \| `auto` | Where to store CLI login credentials (default: `file`). | diff --git a/docs/example-config.md b/docs/example-config.md index fd69faddde..a64637bfce 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -99,6 +99,48 @@ exclude_tmpdir_env_var = false # Exclude /tmp from writable roots. Default: false exclude_slash_tmp = false +################################################################################ +# Network Proxy (optional) +################################################################################ + +[network_proxy] +# Enable proxy env injection + approval prompts for blocked domains. Default: false +# NOTE: This is rollout-gated behind `[features].network_proxy = true`. +enabled = false +# 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 + 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 interval (ms) for checking proxy /blocked. Default: 1000 +poll_interval_ms = 1000 + +[network_proxy.policy] +# Allow localhost binds inside the sandbox (macOS only). Default: false +allow_local_binding = false +# Allow Unix socket paths inside the sandbox (macOS only). Default: [] +# Common values: +# - "$SSH_AUTH_SOCK" (recommended) or "${SSH_AUTH_SOCK}" +# - "ssh-agent" (alias: "ssh_auth_sock", "ssh_auth_socket") +# - an absolute socket path like "/private/tmp/..." (or a directory containing sockets) +allow_unix_sockets = [] +# Optional domain allow/deny lists (denylist wins) +allowed_domains = [] +denied_domains = [] + ################################################################################ # Shell Environment Policy for spawned processes ################################################################################ @@ -218,6 +260,7 @@ rmcp_client = false apply_patch_freeform = false view_image_tool = true web_search_request = false +network_proxy = false ghost_commit = false enable_experimental_windows_sandbox = false skills = false diff --git a/docs/sandbox.md b/docs/sandbox.md index 94f9c8280c..4fc667c2ea 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -54,6 +54,30 @@ approval_policy = "never" sandbox_mode = "read-only" ``` +### Network proxy + +Codex can optionally route outbound subprocess network traffic through a proxy running outside the sandbox. This lets you enforce an allow-only network policy for sandboxed commands. + +Key behaviors: + +- The OS sandbox is still the first line of defense. If network access is disabled, outbound requests are blocked at the OS level. +- When network is enabled and both `[features].network_proxy = true` and `network_proxy.enabled = true`, Codex injects proxy environment variables into subprocesses and configures the OS sandbox to allow only connections to local proxy ports. +- The proxy enforces `allowed_domains` / `denied_domains` (denylist takes precedence). Requests that are not allowed are rejected at the proxy. +- Update policy in `~/.codex/config.toml` under `[network_proxy.policy]`, then call the proxy admin `/reload` to apply changes. + +`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. + +Unix sockets are deny-by-default. If you run tools that rely on local IPC (most commonly the SSH agent via `SSH_AUTH_SOCK`), you can allow them via: + +```toml +[network_proxy.policy] +allow_unix_sockets = ["$SSH_AUTH_SOCK"] +``` + +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. + ### Sandbox mechanics by platform The mechanism Codex uses to enforce the sandbox policy depends on your OS: