diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f754ece562..633baa9a0a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -21,6 +21,10 @@ use serde::Serialize; use strum_macros::Display; use ts_rs::TS; +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[ts(type = "string")] pub struct GitSha(pub String); @@ -459,6 +463,8 @@ pub struct ExecCommandApprovalParams { pub reason: Option, pub risk: Option, pub parsed_cmd: Vec, + #[serde(default, skip_serializing_if = "is_false")] + pub network_preflight_only: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -623,6 +629,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), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 23fbe59a90..7402cd203e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2688,6 +2688,7 @@ async fn apply_bespoke_event_handling( reason, risk, parsed_cmd, + network_preflight_only, }) => { let params = ExecCommandApprovalParams { conversation_id, @@ -2697,6 +2698,7 @@ async fn apply_bespoke_event_handling( reason, risk, parsed_cmd, + network_preflight_only, }; let rx = outgoing .send_request(ServerRequestPayload::ExecCommandApproval(params)) diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 1feda42841..594f5a51d4 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -278,6 +278,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { parsed_cmd: vec![ParsedCommand::Unknown { cmd: "python3 -c 'print(42)'".to_string() }], + network_preflight_only: false, }, params ); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 273f7a4975..0357d72660 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -58,6 +58,7 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::config::Config; use crate::config::types::McpServerTransportConfig; +use crate::config::types::NetworkProxyConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; use crate::environment_context::EnvironmentContext; @@ -275,6 +276,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) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, @@ -431,6 +433,7 @@ impl Session { approval_policy: session_configuration.approval_policy, sandbox_policy: session_configuration.sandbox_policy.clone(), shell_environment_policy: config.shell_environment_policy.clone(), + network_proxy: config.network_proxy.clone(), tools_config, final_output_json_schema: None, codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), @@ -868,6 +871,7 @@ impl Session { cwd: PathBuf, reason: Option, risk: 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. @@ -895,6 +899,7 @@ impl Session { reason, risk, parsed_cmd, + network_preflight_only, }); self.send_event(turn_context, event).await; rx_approve.await.unwrap_or_default() @@ -1759,6 +1764,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/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4cb4d4a06a..95618226be 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -235,6 +235,7 @@ async fn handle_exec_approval( event.cwd, event.reason, event.risk, + 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 0dc9d12667..1c4031ca48 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; @@ -112,6 +115,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 @@ -535,6 +540,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>, @@ -684,6 +692,175 @@ impl From for UserSavedConfig { } } +fn default_network_proxy_config(codex_home: &Path) -> NetworkProxyConfig { + NetworkProxyConfig { + enabled: false, + proxy_url: "http://127.0.0.1:3128".to_string(), + admin_url: "http://127.0.0.1:8080".to_string(), + config_path: codex_home.join("network_proxy").join(CONFIG_TOML_FILE), + mode: NetworkProxyMode::Full, + no_proxy: default_no_proxy_entries() + .iter() + .map(|entry| (*entry).to_string()) + .collect(), + prompt_on_block: true, + 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(codex_home); + let Some(network_proxy) = cfg.network_proxy.clone() else { + return resolved; + }; + + 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(config_path) = network_proxy.config_path { + resolved.config_path = resolve_network_proxy_path(&config_path, codex_home); + } + 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(prompt_on_block) = network_proxy.prompt_on_block { + resolved.prompt_on_block = prompt_on_block; + } + 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 = resolve_network_proxy_mitm_ca_path(&resolved.config_path); + resolved +} + +#[derive(Default, Deserialize)] +struct NetworkProxyFileConfig { + #[serde(default, rename = "network")] + network: NetworkProxyFileNetwork, +} + +#[derive(Default, Deserialize)] +struct NetworkProxyFileNetwork { + #[serde(default, rename = "mitm")] + mitm: NetworkProxyFileMitm, +} + +#[derive(Default, Deserialize)] +struct NetworkProxyFileMitm { + #[serde(default)] + enabled: bool, + #[serde(default, rename = "ca_cert_path")] + ca_cert_path: Option, +} + +fn resolve_network_proxy_mitm_ca_path(config_path: &Path) -> Option { + if !config_path.exists() { + return None; + } + let raw = match std::fs::read_to_string(config_path) { + Ok(raw) => raw, + Err(err) => { + tracing::debug!(error = %err, "failed to read network proxy config"); + return None; + } + }; + let config: NetworkProxyFileConfig = match toml::from_str(&raw) { + Ok(config) => config, + Err(err) => { + tracing::debug!(error = %err, "failed to parse network proxy config"); + return None; + } + }; + if !config.network.mitm.enabled { + return None; + } + let ca_cert_path = config.network.mitm.ca_cert_path?; + if ca_cert_path.as_os_str().is_empty() { + return None; + } + let base = config_path.parent().unwrap_or_else(|| Path::new(".")); + if ca_cert_path.is_absolute() { + Some(ca_cert_path) + } else { + Some(base.join(ca_cert_path)) + } +} + +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; 3] { + ["localhost", "127.0.0.1", "::1"] +} + #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ProjectConfig { pub trust_level: Option, @@ -970,6 +1147,8 @@ impl Config { || config_profile.sandbox_mode.is_some() || cfg.sandbox_mode.is_some(); + let network_proxy = resolve_network_proxy_config(&cfg, &codex_home); + 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() { @@ -1098,6 +1277,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, @@ -2868,6 +3048,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(&fixture.codex_home()), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -2939,6 +3120,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(&fixture.codex_home()), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -3025,6 +3207,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(&fixture.codex_home()), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -3097,6 +3280,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(&fixture.codex_home()), 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 be47b20b06..b7ae640235 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -394,6 +394,40 @@ 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 config_path: Option, + pub mode: Option, + pub no_proxy: Option>, + pub prompt_on_block: Option, + pub poll_interval_ms: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NetworkProxyConfig { + pub enabled: bool, + pub proxy_url: String, + pub admin_url: String, + pub config_path: PathBuf, + pub mode: NetworkProxyMode, + pub no_proxy: Vec, + pub prompt_on_block: bool, + 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..8dcbd565c7 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -1,6 +1,9 @@ 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; @@ -15,6 +18,18 @@ pub fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap { populate_env(std::env::vars(), policy) } +pub fn create_env_with_network_proxy( + policy: &ShellEnvironmentPolicy, + sandbox_policy: &SandboxPolicy, + network_proxy: &NetworkProxyConfig, +) -> HashMap { + let mut env_map = create_env(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 where I: IntoIterator, @@ -68,6 +83,85 @@ 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, + } +} + +fn apply_network_proxy_env( + env_map: &mut HashMap, + network_proxy: &NetworkProxyConfig, +) { + let proxy_url = network_proxy.proxy_url.trim(); + if !proxy_url.is_empty() { + 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()); + } + env_map.insert("ELECTRON_GET_USE_PROXY".to_string(), "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); + } + } + } + } + + 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); + } + + 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::*; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 5229d00606..f70c3dd9d1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -56,6 +56,7 @@ pub use auth::AuthManager; pub use auth::CodexAuth; pub mod default_client; pub mod model_family; +pub mod network_proxy; mod openai_model_info; pub mod project_doc; mod rollout; diff --git a/codex-rs/core/src/network_proxy.rs b/codex-rs/core/src/network_proxy.rs new file mode 100644 index 0000000000..3843b2e955 --- /dev/null +++ b/codex-rs/core/src/network_proxy.rs @@ -0,0 +1,604 @@ +use crate::config::types::NetworkProxyConfig; +use crate::config::types::NetworkProxyMode; +use crate::default_client::CodexHttpClient; +use crate::protocol::SandboxPolicy; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +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 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_TABLE: &str = "network"; +const ALLOWED_DOMAINS_KEY: &str = "allowedDomains"; +const DENIED_DOMAINS_KEY: &str = "deniedDomains"; + +#[derive(Debug, Clone, Deserialize)] +pub struct NetworkProxyBlockedRequest { + pub host: String, + pub reason: String, + 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 AllowOnceRequest<'a> { + host: &'a str, +} + +#[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) +} + +pub async fn allow_once(client: &CodexHttpClient, admin_url: &str, host: &str) -> Result<()> { + let host = host.trim(); + if host.is_empty() { + return Err(anyhow!("host is empty")); + } + let base = admin_url.trim_end_matches('/'); + let url = format!("{base}/allow_once"); + let request = AllowOnceRequest { host }; + client + .post(url) + .json(&request) + .send() + .await + .context("network proxy /allow_once request failed")? + .error_for_status() + .context("network proxy /allow_once returned error")?; + Ok(()) +} + +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 should_preflight_network( + network_proxy: &NetworkProxyConfig, + sandbox_policy: &SandboxPolicy, +) -> bool { + if !network_proxy.enabled || !network_proxy.prompt_on_block { + 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); + } + preflight_blocked_host(&network_proxy.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, + client: None, + method: None, + mode: Some(network_proxy.mode), + protocol: "preflight".to_string(), + timestamp: 0, + })), + None => Ok(None), + } +} + +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 network = ensure_network_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(network, target_key); + add_domain(target, host) + }; + let removed = { + let other = ensure_array(network, other_key); + remove_domain(other, host) + }; + if removed { + changed = true; + } + + 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")] + network: NetworkPolicy, +} + +#[derive(Default, Deserialize)] +struct NetworkPolicy { + #[serde(default, rename = "allowedDomains")] + allowed_domains: Vec, + #[serde(default, rename = "deniedDomains")] + denied_domains: Vec, +} + +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) +} + +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 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_table(doc: &mut DocumentMut) -> &mut TomlTable { + let entry = doc + .entry(NETWORK_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/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index e894a08444..422904aba7 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; +use crate::exec_env::create_env_with_network_proxy; use crate::parse_command::parse_command; use crate::protocol::EventMsg; use crate::protocol::ExecCommandBeginEvent; @@ -85,10 +85,15 @@ impl SessionTask for UserShellCommandTask { ) .await; + let sandbox_policy = SandboxPolicy::DangerFullAccess; let exec_env = ExecEnv { command: shell_invocation, cwd: turn_context.cwd.clone(), - env: create_env(&turn_context.shell_environment_policy), + env: create_env_with_network_proxy( + &turn_context.shell_environment_policy, + &sandbox_policy, + &turn_context.network_proxy, + ), timeout_ms: None, sandbox: SandboxType::None, with_escalated_permissions: None, @@ -102,7 +107,6 @@ 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) .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 3a5115f6ee..dd1f86276e 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -7,10 +7,12 @@ use crate::apply_patch; use crate::apply_patch::InternalApplyPatchInvocation; use crate::apply_patch::convert_apply_patch_to_protocol; use crate::codex::TurnContext; +use crate::command_safety::is_dangerous_command::requires_initial_appoval; use crate::exec::ExecParams; -use crate::exec_env::create_env; +use crate::exec_env::create_env_with_network_proxy; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; +use crate::network_proxy; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -35,7 +37,11 @@ impl ShellHandler { command: params.command, cwd: turn_context.resolve_path(params.workdir.clone()), timeout_ms: params.timeout_ms, - env: create_env(&turn_context.shell_environment_policy), + env: create_env_with_network_proxy( + &turn_context.shell_environment_policy, + &turn_context.sandbox_policy, + &turn_context.network_proxy, + ), with_escalated_permissions: params.with_escalated_permissions, justification: params.justification, arg0: None, @@ -57,7 +63,11 @@ impl ShellCommandHandler { command, cwd: turn_context.resolve_path(params.workdir.clone()), timeout_ms: params.timeout_ms, - env: create_env(&turn_context.shell_environment_policy), + env: create_env_with_network_proxy( + &turn_context.shell_environment_policy, + &turn_context.sandbox_policy, + &turn_context.network_proxy, + ), with_escalated_permissions: params.with_escalated_permissions, justification: params.justification, arg0: None, @@ -285,6 +295,26 @@ impl ShellHandler { } // Regular shell execution path. + let network_preflight_blocked = match network_proxy::preflight_blocked_host_if_enabled( + &turn.network_proxy, + &turn.sandbox_policy, + &exec_params.command, + ) { + Ok(Some(_)) => true, + Ok(None) => false, + Err(err) => { + tracing::debug!(error = %err, "network proxy preflight failed"); + false + } + }; + let network_preflight_only = network_preflight_blocked + && !requires_initial_appoval( + turn.approval_policy, + &turn.sandbox_policy, + &exec_params.command, + exec_params.with_escalated_permissions.unwrap_or(false), + ); + let emitter = ToolEmitter::shell( exec_params.command.clone(), exec_params.cwd.clone(), @@ -300,6 +330,8 @@ impl ShellHandler { env: exec_params.env.clone(), with_escalated_permissions: exec_params.with_escalated_permissions, justification: exec_params.justification.clone(), + network_preflight_blocked, + network_preflight_only, }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = ShellRuntime::new(); diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 0cdddd5087..d915120d92 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -127,6 +127,7 @@ impl Approvable for ApplyPatchRuntime { cwd, Some(reason), risk, + 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 bf7ae7fa3b..fa1dbd7b7d 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -33,6 +33,8 @@ pub struct ShellRequest { pub env: std::collections::HashMap, pub with_escalated_permissions: Option, pub justification: Option, + pub network_preflight_blocked: bool, + pub network_preflight_only: bool, } impl ProvidesSandboxRetryData for ShellRequest { @@ -107,7 +109,15 @@ impl Approvable for ShellRuntime { Box::pin(async move { with_cached_approval(&session.services, key, move || async move { session - .request_command_approval(turn, call_id, command, cwd, reason, risk) + .request_command_approval( + turn, + call_id, + command, + cwd, + reason, + risk, + req.network_preflight_only, + ) .await }) .await @@ -120,6 +130,9 @@ impl Approvable for ShellRuntime { policy: AskForApproval, sandbox_policy: &SandboxPolicy, ) -> bool { + if req.network_preflight_blocked { + return true; + } requires_initial_appoval( policy, sandbox_policy, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index cddac1924e..17f99f7fba 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -36,6 +36,8 @@ pub struct UnifiedExecRequest { pub env: HashMap, pub with_escalated_permissions: Option, pub justification: Option, + pub network_preflight_blocked: bool, + pub network_preflight_only: bool, } impl ProvidesSandboxRetryData for UnifiedExecRequest { @@ -65,6 +67,8 @@ impl UnifiedExecRequest { env: HashMap, with_escalated_permissions: Option, justification: Option, + network_preflight_blocked: bool, + network_preflight_only: bool, ) -> Self { Self { command, @@ -72,6 +76,8 @@ impl UnifiedExecRequest { env, with_escalated_permissions, justification, + network_preflight_blocked, + network_preflight_only, } } } @@ -122,7 +128,15 @@ impl Approvable for UnifiedExecRuntime<'_> { Box::pin(async move { with_cached_approval(&session.services, key, || async move { session - .request_command_approval(turn, call_id, command, cwd, reason, risk) + .request_command_approval( + turn, + call_id, + command, + cwd, + reason, + risk, + req.network_preflight_only, + ) .await }) .await @@ -135,6 +149,9 @@ impl Approvable for UnifiedExecRuntime<'_> { policy: AskForApproval, sandbox_policy: &SandboxPolicy, ) -> bool { + if req.network_preflight_blocked { + return true; + } requires_initial_appoval( policy, sandbox_policy, diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index c9763fbc49..a846220393 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -8,7 +8,8 @@ use tokio::time::Instant; use crate::exec::ExecToolCallOutput; use crate::exec::StreamOutput; -use crate::exec_env::create_env; +use crate::exec_env::create_env_with_network_proxy; +use crate::network_proxy; use crate::sandboxing::ExecEnv; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; @@ -329,12 +330,38 @@ impl UnifiedExecSessionManager { ) -> Result { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); + let network_preflight_blocked = match network_proxy::preflight_blocked_host_if_enabled( + &context.turn.network_proxy, + &context.turn.sandbox_policy, + &command, + ) { + Ok(Some(_)) => true, + Ok(None) => false, + Err(err) => { + tracing::debug!(error = %err, "network proxy preflight failed"); + false + } + }; + let network_preflight_only = network_preflight_blocked + && !crate::command_safety::is_dangerous_command::requires_initial_appoval( + context.turn.approval_policy, + &context.turn.sandbox_policy, + &command, + with_escalated_permissions.unwrap_or(false), + ); + let req = UnifiedExecToolRequest::new( command, cwd, - create_env(&context.turn.shell_environment_policy), + create_env_with_network_proxy( + &context.turn.shell_environment_policy, + &context.turn.sandbox_policy, + &context.turn.network_proxy, + ), with_escalated_permissions, justification, + network_preflight_blocked, + network_preflight_only, ); let tool_ctx = ToolCtx { session: context.session.as_ref(), diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 96e875153d..6e0048e429 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -180,6 +180,7 @@ async fn run_codex_tool_session_inner( reason: _, risk, parsed_cmd, + .. }) => { handle_exec_approval_request( command, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 3227ddd1a3..6c37964b40 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -8,6 +8,10 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum SandboxRiskLevel { @@ -47,6 +51,8 @@ pub struct ExecApprovalRequestEvent { #[serde(skip_serializing_if = "Option::is_none")] pub risk: Option, pub parsed_cmd: Vec, + #[serde(default, skip_serializing_if = "is_false")] + pub network_preflight_only: bool, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 817445c59a..d96786b273 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -23,7 +23,10 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::types::NetworkProxyConfig; +use codex_core::default_client::create_client; use codex_core::model_family::find_family_for_model; +use codex_core::network_proxy; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; @@ -35,6 +38,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::style::Stylize; use ratatui::text::Line; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -165,6 +169,8 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, + + network_proxy_pending: HashSet, } impl App { @@ -261,8 +267,16 @@ impl App { feedback: feedback.clone(), pending_update_action: None, skip_world_writable_scan_once: false, + network_proxy_pending: HashSet::new(), }; + if app.config.network_proxy.enabled && app.config.network_proxy.prompt_on_block { + Self::spawn_network_proxy_poller( + app.config.network_proxy.clone(), + app.app_event_tx.clone(), + ); + } + // On startup, if Auto mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] { @@ -678,7 +692,135 @@ impl App { "E X E C".to_string(), )); } + ApprovalRequest::Network { request } => { + let mut lines = Vec::new(); + if !request.host.trim().is_empty() { + lines.push(Line::from(vec![ + "Host: ".into(), + request.host.clone().bold(), + ])); + } + if !request.reason.trim().is_empty() { + lines.push(Line::from(vec![ + "Reason: ".into(), + request.reason.clone().into(), + ])); + } + if let Some(method) = request.method.as_ref().filter(|value| !value.is_empty()) + { + lines.push(Line::from(vec![ + "Method: ".into(), + method.to_string().into(), + ])); + } + if !request.protocol.trim().is_empty() { + lines.push(Line::from(vec![ + "Protocol: ".into(), + request.protocol.clone().into(), + ])); + } + if let Some(mode) = request.mode { + let label = match mode { + codex_core::config::types::NetworkProxyMode::Limited => "limited", + codex_core::config::types::NetworkProxyMode::Full => "full", + }; + lines.push(Line::from(vec!["Mode: ".into(), label.into()])); + } + if let Some(client) = request.client.as_ref().filter(|value| !value.is_empty()) + { + lines.push(Line::from(vec![ + "Client: ".into(), + client.to_string().dim(), + ])); + } + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_static_with_lines(lines, "N E T".to_string())); + } }, + AppEvent::NetworkProxyApprovalRequest(request) => { + if !self.config.network_proxy.prompt_on_block { + return Ok(true); + } + let host = request.host.trim().to_string(); + if host.is_empty() || self.network_proxy_pending.contains(&host) { + return Ok(true); + } + self.network_proxy_pending.insert(host); + self.chat_widget.on_network_approval_request(request); + } + AppEvent::NetworkProxyDecision { host, decision } => { + let host = host.trim().to_string(); + if host.is_empty() { + return Ok(true); + } + self.network_proxy_pending.remove(&host); + let client = create_client(); + let admin_url = self.config.network_proxy.admin_url.clone(); + let should_resume_exec = matches!( + decision, + crate::app_event::NetworkProxyDecision::AllowOnce + | crate::app_event::NetworkProxyDecision::AllowAlways + ); + match decision { + crate::app_event::NetworkProxyDecision::AllowOnce => { + if let Err(err) = + network_proxy::allow_once(&client, &admin_url, &host).await + { + self.chat_widget + .add_error_message(format!("Failed to allow {host} once: {err}")); + } + } + crate::app_event::NetworkProxyDecision::AllowAlways => { + match network_proxy::add_allowed_domain( + &self.config.network_proxy.config_path, + &host, + ) { + Ok(changed) => { + if changed + && let Err(err) = + network_proxy::reload(&client, &admin_url).await + { + self.chat_widget.add_error_message(format!( + "Failed to reload network proxy after allowlist update: {err}" + )); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to add {host} to allowlist: {err}" + )); + } + } + } + crate::app_event::NetworkProxyDecision::Deny => { + match network_proxy::add_denied_domain( + &self.config.network_proxy.config_path, + &host, + ) { + Ok(changed) => { + if changed + && let Err(err) = + network_proxy::reload(&client, &admin_url).await + { + self.chat_widget.add_error_message(format!( + "Failed to reload network proxy after denylist update: {err}" + )); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to add {host} to denylist: {err}" + )); + } + } + } + } + if should_resume_exec { + self.chat_widget.resume_pending_exec_approval(); + } else { + self.chat_widget.reject_pending_exec_approval(); + } + } } Ok(true) } @@ -752,6 +894,35 @@ impl App { }; } + fn spawn_network_proxy_poller(network_proxy: NetworkProxyConfig, tx: AppEventSender) { + if !network_proxy.enabled || !network_proxy.prompt_on_block { + return; + } + let poll_interval_ms = if network_proxy.poll_interval_ms > 0 { + network_proxy.poll_interval_ms + } else { + 1000 + }; + let poll_interval = Duration::from_secs_f64(poll_interval_ms as f64 / 1000.0); + let admin_url = network_proxy.admin_url; + tokio::spawn(async move { + let client = create_client(); + loop { + match network_proxy::fetch_blocked(&client, &admin_url).await { + Ok(blocked) => { + for request in blocked { + tx.send(AppEvent::NetworkProxyApprovalRequest(request)); + } + } + Err(err) => { + tracing::debug!(error = %err, "network proxy poll failed"); + } + } + tokio::time::sleep(poll_interval).await; + } + }); + } + #[cfg(target_os = "windows")] fn spawn_world_writable_scan( cwd: PathBuf, @@ -855,6 +1026,7 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, skip_world_writable_scan_once: false, + network_proxy_pending: HashSet::new(), } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 39485faa93..1535b74451 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; use codex_common::model_presets::ModelPreset; +use codex_core::network_proxy::NetworkProxyBlockedRequest; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_file_search::FileMatch; @@ -144,6 +145,15 @@ pub(crate) enum AppEvent { /// Open the approval popup. FullScreenApprovalRequest(ApprovalRequest), + /// Prompt for a blocked network request from the proxy. + NetworkProxyApprovalRequest(NetworkProxyBlockedRequest), + + /// User decision for a blocked network request. + NetworkProxyDecision { + host: String, + decision: NetworkProxyDecision, + }, + /// Open the feedback note entry overlay after the user selects a category. OpenFeedbackNote { category: FeedbackCategory, @@ -156,6 +166,13 @@ pub(crate) enum AppEvent { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NetworkProxyDecision { + AllowOnce, + AllowAlways, + Deny, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FeedbackCategory { BadResult, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index ef709f0051..01236d76a6 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::app_event::AppEvent; +use crate::app_event::NetworkProxyDecision; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; @@ -16,6 +17,8 @@ use crate::key_hint::KeyBinding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; +use codex_core::config::types::NetworkProxyMode; +use codex_core::network_proxy::NetworkProxyBlockedRequest; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; @@ -48,6 +51,9 @@ pub(crate) enum ApprovalRequest { cwd: PathBuf, changes: HashMap, }, + Network { + request: NetworkProxyBlockedRequest, + }, } /// Modal overlay asking the user to approve or deny one or more requests. @@ -105,6 +111,10 @@ impl ApprovalOverlay { patch_options(), "Would you like to make the following edits?".to_string(), ), + ApprovalVariant::Network { .. } => ( + network_options(), + "Allow network access to this domain?".to_string(), + ), }; let header = Box::new(ColumnRenderable::with([ @@ -149,13 +159,17 @@ impl ApprovalOverlay { return; }; if let Some(variant) = self.current_variant.as_ref() { - match (&variant, option.decision) { - (ApprovalVariant::Exec { id, command }, decision) => { - self.handle_exec_decision(id, command, decision); + match (&variant, &option.decision) { + (ApprovalVariant::Exec { id, command }, ApprovalDecision::Review(decision)) => { + self.handle_exec_decision(id, command, *decision); } - (ApprovalVariant::ApplyPatch { id, .. }, decision) => { - self.handle_patch_decision(id, decision); + (ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { + self.handle_patch_decision(id, *decision); } + (ApprovalVariant::Network { host }, ApprovalDecision::Network(decision)) => { + self.handle_network_decision(host, *decision); + } + _ => {} } } @@ -179,6 +193,15 @@ impl ApprovalOverlay { })); } + fn handle_network_decision(&self, host: &str, decision: NetworkProxyDecision) { + let cell = history_cell::new_network_approval_decision_cell(host.to_string(), decision); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + self.app_event_tx.send(AppEvent::NetworkProxyDecision { + host: host.to_string(), + decision, + }); + } + fn advance_queue(&mut self) { if let Some(next) = self.queue.pop() { self.set_current(next); @@ -244,6 +267,9 @@ impl BottomPaneView for ApprovalOverlay { ApprovalVariant::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); } + ApprovalVariant::Network { host } => { + self.handle_network_decision(host, NetworkProxyDecision::Deny); + } } } self.queue.clear(); @@ -336,6 +362,52 @@ impl From for ApprovalRequestState { header: Box::new(ColumnRenderable::with(header)), } } + ApprovalRequest::Network { request } => { + let mut header: Vec> = Vec::new(); + let host = request.host.trim().to_string(); + if !host.is_empty() { + header.push(Line::from(vec!["Host: ".into(), host.bold()])); + } + let reason = request.reason.trim().to_string(); + if !reason.is_empty() { + let reason_label = network_reason_label(&reason); + header.push(Line::from(vec!["Reason: ".into(), reason_label.into()])); + if let Some(hint) = network_reason_hint(&reason) { + header.push(Line::from(vec!["Hint: ".into(), hint.dim()])); + } + } + if let Some(method) = request + .method + .as_ref() + .filter(|value| !value.is_empty()) + .cloned() + { + header.push(Line::from(vec!["Method: ".into(), method.into()])); + } + let protocol = request.protocol.trim().to_string(); + if !protocol.is_empty() { + header.push(Line::from(vec!["Protocol: ".into(), protocol.into()])); + } + if let Some(mode) = request.mode { + let label = match mode { + NetworkProxyMode::Limited => "limited", + NetworkProxyMode::Full => "full", + }; + header.push(Line::from(vec!["Mode: ".into(), label.into()])); + } + if let Some(client) = request + .client + .as_ref() + .filter(|value| !value.is_empty()) + .cloned() + { + header.push(Line::from(vec!["Client: ".into(), client.dim()])); + } + Self { + variant: ApprovalVariant::Network { host: request.host }, + header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), + } + } } } } @@ -362,16 +434,43 @@ fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec> { lines } +fn network_reason_label(reason: &str) -> String { + match reason { + "not_allowed" => "Domain not in allowlist".to_string(), + "not_allowed_local" => "Loopback blocked by policy".to_string(), + "denied" => "Domain denied by denylist".to_string(), + "method_not_allowed" => "Method blocked by network mode".to_string(), + "mitm_required" => "MITM required for limited HTTPS".to_string(), + _ => reason.to_string(), + } +} + +fn network_reason_hint(reason: &str) -> Option<&'static str> { + match reason { + "not_allowed_local" => Some("Allow loopback or add the host to the allowlist."), + "method_not_allowed" => Some("Switch to full mode or enable MITM to allow this method."), + "mitm_required" => Some("Enable MITM or switch to full mode for HTTPS tunneling."), + _ => None, + } +} + #[derive(Clone)] enum ApprovalVariant { Exec { id: String, command: Vec }, ApplyPatch { id: String }, + Network { host: String }, +} + +#[derive(Clone)] +enum ApprovalDecision { + Review(ReviewDecision), + Network(NetworkProxyDecision), } #[derive(Clone)] struct ApprovalOption { label: String, - decision: ReviewDecision, + decision: ApprovalDecision, display_shortcut: Option, additional_shortcuts: Vec, } @@ -388,19 +487,19 @@ fn exec_options() -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), - decision: ReviewDecision::Approved, + decision: ApprovalDecision::Review(ReviewDecision::Approved), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], }, ApprovalOption { label: "Yes, and don't ask again for this command".to_string(), - decision: ReviewDecision::ApprovedForSession, + decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }, ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), - decision: ReviewDecision::Abort, + decision: ApprovalDecision::Review(ReviewDecision::Abort), display_shortcut: Some(key_hint::plain(KeyCode::Esc)), additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], }, @@ -411,13 +510,36 @@ fn patch_options() -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), - decision: ReviewDecision::Approved, + decision: ApprovalDecision::Review(ReviewDecision::Approved), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], }, ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), - decision: ReviewDecision::Abort, + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ] +} + +fn network_options() -> Vec { + vec![ + ApprovalOption { + label: "Allow once".to_string(), + decision: ApprovalDecision::Network(NetworkProxyDecision::AllowOnce), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }, + ApprovalOption { + label: "Allow always (add to allowlist)".to_string(), + decision: ApprovalDecision::Network(NetworkProxyDecision::AllowAlways), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + }, + ApprovalOption { + label: "Deny (add to denylist)".to_string(), + decision: ApprovalDecision::Network(NetworkProxyDecision::Deny), display_shortcut: Some(key_hint::plain(KeyCode::Esc)), additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4b51a7bdbd..51639ca810 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7,6 +7,8 @@ use codex_core::config::Config; use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; +use codex_core::network_proxy; +use codex_core::network_proxy::NetworkProxyBlockedRequest; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -31,6 +33,7 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; +use codex_core::protocol::ReviewDecision; use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; @@ -282,6 +285,7 @@ pub(crate) struct ChatWidget { is_review_mode: bool, // Whether to add a final message separator after the last message needs_final_message_separator: bool, + pending_exec_approval: Option, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback @@ -290,6 +294,12 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, } +struct PendingExecApproval { + id: String, + event: ExecApprovalRequestEvent, + host: String, +} + struct UserMessage { text: String, image_paths: Vec, @@ -630,6 +640,14 @@ impl ChatWidget { ); } + pub(crate) fn on_network_approval_request(&mut self, request: NetworkProxyBlockedRequest) { + let request2 = request.clone(); + self.defer_or_handle( + |q| q.push_network_approval(request), + |s| s.handle_network_approval_request(request2), + ); + } + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); let ev2 = ev.clone(); @@ -884,6 +902,24 @@ impl ChatWidget { } pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { + if self.pending_exec_approval.is_none() + && let Some(request) = self.preflight_network_request(&ev.command) + { + self.pending_exec_approval = Some(PendingExecApproval { + id, + event: ev, + host: request.host.clone(), + }); + self.bottom_pane + .push_approval_request(ApprovalRequest::Network { request }); + self.request_redraw(); + return; + } + + self.show_exec_approval(id, ev); + } + + fn show_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) { self.flush_answer_stream_with_separator(); let command = shlex::try_join(ev.command.iter().map(String::as_str)) .unwrap_or_else(|_| ev.command.join(" ")); @@ -899,6 +935,46 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn resume_pending_exec_approval(&mut self) { + if let Some(pending) = self.pending_exec_approval.take() { + if pending.event.network_preflight_only { + self.submit_op(Op::ExecApproval { + id: pending.id, + decision: ReviewDecision::Approved, + }); + return; + } + self.show_exec_approval(pending.id, pending.event); + } + } + + pub(crate) fn reject_pending_exec_approval(&mut self) { + if let Some(pending) = self.pending_exec_approval.take() { + self.add_to_history(history_cell::new_error_event(format!( + "Exec canceled because network access to {} was denied.", + pending.host + ))); + self.submit_op(Op::ExecApproval { + id: pending.id, + decision: ReviewDecision::Denied, + }); + } + } + + fn preflight_network_request(&self, command: &[String]) -> Option { + match network_proxy::preflight_blocked_request_if_enabled( + &self.config.network_proxy, + &self.config.sandbox_policy, + command, + ) { + Ok(result) => result, + Err(err) => { + tracing::debug!(error = %err, "network proxy preflight failed"); + None + } + } + } + pub(crate) fn handle_apply_patch_approval_now( &mut self, id: String, @@ -920,6 +996,15 @@ impl ChatWidget { }); } + pub(crate) fn handle_network_approval_request(&mut self, request: NetworkProxyBlockedRequest) { + self.flush_answer_stream_with_separator(); + let host = request.host.clone(); + self.notify(Notification::NetworkApprovalRequested { host }); + self.bottom_pane + .push_approval_request(ApprovalRequest::Network { request }); + self.request_redraw(); + } + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.running_commands.insert( @@ -1053,6 +1138,7 @@ impl ChatWidget { pending_notification: None, is_review_mode: false, needs_final_message_separator: false, + pending_exec_approval: None, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, @@ -1120,6 +1206,7 @@ impl ChatWidget { pending_notification: None, is_review_mode: false, needs_final_message_separator: false, + pending_exec_approval: None, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, @@ -2768,6 +2855,7 @@ enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, EditApprovalRequested { cwd: PathBuf, changes: Vec }, + NetworkApprovalRequested { host: String }, } impl Notification { @@ -2791,6 +2879,9 @@ impl Notification { } ) } + Notification::NetworkApprovalRequested { host } => { + format!("Network approval requested: {}", truncate_text(host, 40)) + } } } @@ -2798,7 +2889,8 @@ impl Notification { match self { Notification::AgentTurnComplete { .. } => "agent-turn-complete", Notification::ExecApprovalRequested { .. } - | Notification::EditApprovalRequested { .. } => "approval-requested", + | Notification::EditApprovalRequested { .. } + | Notification::NetworkApprovalRequested { .. } => "approval-requested", } } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 531de3e646..135d181aae 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -1,5 +1,6 @@ use std::collections::VecDeque; +use codex_core::network_proxy::NetworkProxyBlockedRequest; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; @@ -14,6 +15,7 @@ use super::ChatWidget; pub(crate) enum QueuedInterrupt { ExecApproval(String, ExecApprovalRequestEvent), ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), + NetworkApproval(NetworkProxyBlockedRequest), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), McpBegin(McpToolCallBeginEvent), @@ -51,6 +53,11 @@ impl InterruptManager { .push_back(QueuedInterrupt::ApplyPatchApproval(id, ev)); } + pub(crate) fn push_network_approval(&mut self, request: NetworkProxyBlockedRequest) { + self.queue + .push_back(QueuedInterrupt::NetworkApproval(request)); + } + pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); } @@ -78,6 +85,9 @@ impl InterruptManager { QueuedInterrupt::ApplyPatchApproval(id, ev) => { chat.handle_apply_patch_approval_now(id, ev) } + QueuedInterrupt::NetworkApproval(request) => { + chat.handle_network_approval_request(request) + } QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 217e0fb307..5a8006b80c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -316,6 +316,7 @@ fn make_chatwidget_manual() -> ( pending_notification: None, is_review_mode: false, needs_final_message_separator: false, + pending_exec_approval: None, last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, @@ -516,6 +517,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { ), risk: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-short".into(), @@ -559,6 +561,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { ), risk: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-multi".into(), @@ -608,6 +611,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: None, risk: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-long".into(), @@ -1832,6 +1836,7 @@ fn approval_modal_exec_snapshot() { ), risk: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-approve".into(), @@ -1877,6 +1882,7 @@ fn approval_modal_exec_without_reason_snapshot() { reason: None, risk: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-approve-noreason".into(), @@ -2088,6 +2094,7 @@ fn status_widget_and_approval_modal_snapshot() { ), risk: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-approve-exec".into(), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index bb451f5a51..fff75c7f1f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,3 +1,4 @@ +use crate::app_event::NetworkProxyDecision; use crate::diff_render::create_diff_summary; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -454,6 +455,50 @@ pub fn new_approval_decision_cell( )) } +pub fn new_network_approval_decision_cell( + host: String, + decision: NetworkProxyDecision, +) -> Box { + let host_span = Span::from(host).dim(); + let (symbol, summary): (Span<'static>, Vec>) = match decision { + NetworkProxyDecision::AllowOnce => ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " network access to ".into(), + host_span, + " this time".bold(), + ], + ), + NetworkProxyDecision::AllowAlways => ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " network access to ".into(), + host_span, + " permanently".bold(), + ], + ), + NetworkProxyDecision::Deny => ( + "✗ ".red(), + vec![ + "You ".into(), + "denied".bold(), + " network access to ".into(), + host_span, + ], + ), + }; + + Box::new(PrefixedWrappedHistoryCell::new( + Line::from(summary), + symbol, + " ", + )) +} + /// Cyan history cell line showing the current review status. pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { PlainHistoryCell { diff --git a/docs/config.md b/docs/config.md index 90671ce98d..2df3c354d7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -341,6 +341,29 @@ 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, the `network_proxy` sandbox proxy) and surface approval prompts when requests are blocked by policy. + +```toml +[network_proxy] +enabled = true +proxy_url = "http://127.0.0.1:3128" +admin_url = "http://127.0.0.1:8080" +config_path = "~/.codex/network_proxy/config.toml" +mode = "limited" # limited | full (default) +no_proxy = ["localhost", "127.0.0.1"] +prompt_on_block = true +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. +- When `prompt_on_block = true`, Codex polls the proxy admin API (`/blocked`) and surfaces a prompt to allow once, allow always (add to allowlist), or deny (add to denylist). Codex writes changes to `config_path` and calls `/reload`. + ### tools.\* Use the optional `[tools]` table to toggle built-in tools that the agent may call. `web_search` stays off unless you opt in, while `view_image` is now enabled by default: @@ -917,6 +940,14 @@ Valid values: | `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`/`HTTPS_PROXY`/`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.config_path` | string (path) | Proxy config path to edit on approvals (default: `$CODEX_HOME/network_proxy/config.toml`). | +| `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.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). | | `notify` | array | External program for notifications. | | `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. | | `features.` | boolean | See [feature flags](#feature-flags) for details | diff --git a/docs/example-config.md b/docs/example-config.md index 573e3ed9b0..a183b68686 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -100,6 +100,28 @@ 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 +enabled = false +# HTTP/HTTPS/ALL proxy URL. Default: "http://127.0.0.1:3128" +proxy_url = "http://127.0.0.1:3128" +# Admin API for the proxy (for /blocked, /allow_once, /reload). Default: "http://127.0.0.1:8080" +admin_url = "http://127.0.0.1:8080" +# Proxy config file to edit when approvals are granted/denied. +config_path = "~/.codex/network_proxy/config.toml" +# limited | full (default: full) +mode = "full" +# Hosts/IPs that bypass the proxy. Default includes localhost + loopback. +no_proxy = ["localhost", "127.0.0.1", "::1"] +# Poll proxy /blocked and prompt the user. Default: true +prompt_on_block = true +# Poll interval in milliseconds. Default: 1000 +poll_interval_ms = 1000 + ################################################################################ # Shell Environment Policy for spawned processes ################################################################################ diff --git a/docs/sandbox.md b/docs/sandbox.md index 674ecc4838..cd433e14cc 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -63,6 +63,31 @@ approval_policy = "never" sandbox_mode = "read-only" ``` +### Network proxy approvals + +Codex can optionally route outbound network traffic through a proxy (for example the `network_proxy` sandbox proxy) and prompt you when new domains are blocked by policy. + +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 `network_proxy.prompt_on_block = true`, Codex polls the proxy admin API (`/blocked`) and immediately notifies you about blocked domains. +- For exec commands that include HTTP/HTTPS URLs, Codex preflights the host against the proxy config and prompts before running the command. +- You can choose to: + - **Deny** the request (adds the domain to the proxy denylist). + - **Allow once** (temporary exception via the proxy admin API). + - **Allow permanently** (adds the domain to the proxy allowlist and reloads policy). + +Network access is controlled through a proxy server running outside the sandbox: + +- **Domain restrictions:** Only approved domains can be accessed (denylist takes precedence). +- **User confirmation:** New domain requests trigger permission prompts. +- **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. + +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. It also sets common tool‑specific proxy variables (Yarn/npm/Electron) and, when possible, appends Gradle proxy flags via `GRADLE_OPTS`. + ### Sandbox mechanics by platform {#platform-sandboxing-details} The mechanism Codex uses to enforce the sandbox policy depends on your OS: