From 5f44478b0071490df8bcc1b9e4469b7d695eb37c Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Sun, 21 Dec 2025 13:03:57 -0800 Subject: [PATCH] TUI: prompt for network proxy approvals --- .../src/protocol/common.rs | 4 +- .../app-server-protocol/src/protocol/v1.rs | 2 + .../app-server/src/bespoke_event_handling.rs | 3 + .../suite/codex_message_processor_flow.rs | 1 + codex-rs/core/src/codex.rs | 22 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/network_proxy.rs | 3 - codex-rs/core/src/tools/handlers/shell.rs | 34 +- .../core/src/tools/runtimes/apply_patch.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 2 + .../core/src/tools/runtimes/unified_exec.rs | 4 + .../core/src/unified_exec/session_manager.rs | 30 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/approvals.rs | 6 + codex-rs/protocol/src/protocol.rs | 8 + codex-rs/tui/src/app.rs | 414 ++++++++++++++++++ codex-rs/tui/src/app_event.rs | 41 ++ .../tui/src/bottom_pane/approval_overlay.rs | 238 ++++++++++ codex-rs/tui/src/chatwidget.rs | 225 ++++++++++ codex-rs/tui/src/chatwidget/interrupts.rs | 21 + codex-rs/tui/src/chatwidget/tests.rs | 9 + codex-rs/tui/src/history_cell.rs | 90 ++++ docs/config.md | 5 +- docs/network-proxy-design.md | 1 + docs/network-proxy-quickstart.md | 10 + docs/sandbox.md | 23 +- 27 files changed, 1185 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bd7fd8e28c..cb1a8ce5fe 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -659,6 +659,7 @@ mod tests { parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello".to_string(), }], + network_preflight_only: false, }; let request = ServerRequest::ExecCommandApproval { request_id: RequestId::Integer(7), @@ -680,7 +681,8 @@ mod tests { "type": "unknown", "cmd": "echo hello" } - ] + ], + "networkPreflightOnly": false } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index df39f8809e..4d5fbadb5f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -227,6 +227,8 @@ pub struct ExecCommandApprovalParams { pub cwd: PathBuf, pub reason: Option, pub parsed_cmd: Vec, + #[serde(default)] + pub network_preflight_only: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index dec9d8c089..7ac0efeafc 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -182,6 +182,8 @@ pub(crate) async fn apply_bespoke_event_handling( reason, proposed_execpolicy_amendment, parsed_cmd, + network_preflight_only, + .. }) => match api_version { ApiVersion::V1 => { let params = ExecCommandApprovalParams { @@ -191,6 +193,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, parsed_cmd, + network_preflight_only, }; let rx = outgoing .send_request(ServerRequestPayload::ExecCommandApproval(params)) diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index be94dd822e..1c347cf07b 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 @@ -274,6 +274,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 cf831ad100..f6b898b307 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -393,6 +393,7 @@ impl TurnContext { } } +#[allow(dead_code)] #[derive(Clone)] pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). @@ -1101,6 +1102,7 @@ impl Session { cwd: PathBuf, reason: Option, proposed_execpolicy_amendment: Option, + network_preflight_only: bool, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. @@ -1129,6 +1131,7 @@ impl Session { reason, proposed_execpolicy_amendment, parsed_cmd, + network_preflight_only, }); self.send_event(turn_context, event).await; rx_approve.await.unwrap_or_default() @@ -1662,6 +1665,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::PatchApproval { id, decision } => { handlers::patch_approval(&sess, id, decision).await; } + Op::NetworkApprovalCache { host, decision } => { + handlers::network_approval_cache(&sess, host, decision).await; + } Op::AddToHistory { text } => { handlers::add_to_history(&sess, &config, text).await; } @@ -1725,6 +1731,7 @@ mod handlers { use crate::features::Feature; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; + use crate::network_proxy; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; @@ -1908,6 +1915,21 @@ mod handlers { } } + pub async fn network_approval_cache( + sess: &Arc, + host: String, + decision: ReviewDecision, + ) { + if !matches!( + decision, + ReviewDecision::Approved | ReviewDecision::ApprovedForSession + ) { + return; + } + let mut store = sess.services.tool_approvals.lock().await; + network_proxy::cache_network_approval(&mut store, &host, decision); + } + pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { let id = sess.conversation_id; let config = Arc::clone(config); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index c7aebbaf92..fa84f619c2 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -282,6 +282,7 @@ async fn handle_exec_approval( event.cwd, event.reason, event.proposed_execpolicy_amendment, + event.network_preflight_only, ); let decision = await_approval_with_cancel( approval_fut, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 5ffe2c47bf..3e84cab6ef 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -33,7 +33,6 @@ 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; @@ -71,6 +70,7 @@ pub use conversation_manager::NewConversation; pub use auth::AuthManager; pub use auth::CodexAuth; pub mod default_client; +pub mod network_proxy; pub mod project_doc; mod rollout; pub(crate) mod safety; diff --git a/codex-rs/core/src/network_proxy.rs b/codex-rs/core/src/network_proxy.rs index 1fd06fbee9..6f0d1c1cd7 100644 --- a/codex-rs/core/src/network_proxy.rs +++ b/codex-rs/core/src/network_proxy.rs @@ -71,14 +71,12 @@ pub async fn fetch_blocked( 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() { @@ -90,7 +88,6 @@ impl NetworkApprovalKey { } } -#[allow(dead_code)] pub(crate) fn cache_network_approval( store: &mut ApprovalStore, host: &str, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index aed4e36d8f..0cc2d7304d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -9,6 +9,7 @@ use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; +use crate::network_proxy; use crate::protocol::ExecCommandSource; use crate::shell::Shell; use crate::tools::context::ToolInvocation; @@ -22,6 +23,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use crate::tools::runtimes::shell::ShellRequest; use crate::tools::runtimes::shell::ShellRuntime; +use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::ToolCtx; pub struct ShellHandler; @@ -34,12 +36,12 @@ impl ShellHandler { command: params.command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), 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, } @@ -64,12 +66,12 @@ impl ShellCommandHandler { command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), 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, } @@ -260,7 +262,7 @@ impl ShellHandler { emitter.begin(event_ctx).await; let features = session.features(); - let exec_approval_requirement = create_exec_approval_requirement_for_command( + let mut exec_approval_requirement = create_exec_approval_requirement_for_command( &turn.exec_policy, &features, &exec_params.command, @@ -269,6 +271,31 @@ impl ShellHandler { exec_params.sandbox_permissions, ) .await; + 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 mut network_preflight_only = false; + if network_preflight_blocked + && matches!( + exec_approval_requirement, + ExecApprovalRequirement::Skip { .. } + ) + { + exec_approval_requirement = ExecApprovalRequirement::NeedsApproval { + reason: Some("Network access requires approval.".to_string()), + proposed_execpolicy_amendment: None, + }; + network_preflight_only = true; + } let req = ShellRequest { command: exec_params.command.clone(), @@ -277,6 +304,7 @@ impl ShellHandler { env: exec_params.env.clone(), sandbox_permissions: exec_params.sandbox_permissions, justification: exec_params.justification.clone(), + network_preflight_only, exec_approval_requirement, }; let mut orchestrator = ToolOrchestrator::new(); diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 26d04f578c..9f9dad13c4 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -119,6 +119,7 @@ impl Approvable for ApplyPatchRuntime { cwd, Some(reason), None, + false, ) .await } else if user_explicitly_approved { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 2c6c6cd29c..4c07db706f 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -32,6 +32,7 @@ pub struct ShellRequest { pub env: std::collections::HashMap, pub sandbox_permissions: SandboxPermissions, pub justification: Option, + pub network_preflight_only: bool, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -106,6 +107,7 @@ impl Approvable for ShellRuntime { req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), + req.network_preflight_only, ) .await }) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index a38ac9bc3a..b14c70aabd 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -36,6 +36,7 @@ pub struct UnifiedExecRequest { pub env: HashMap, pub sandbox_permissions: SandboxPermissions, pub justification: Option, + pub network_preflight_only: bool, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -57,6 +58,7 @@ impl UnifiedExecRequest { env: HashMap, sandbox_permissions: SandboxPermissions, justification: Option, + network_preflight_only: bool, exec_approval_requirement: ExecApprovalRequirement, ) -> Self { Self { @@ -65,6 +67,7 @@ impl UnifiedExecRequest { env, sandbox_permissions, justification, + network_preflight_only, exec_approval_requirement, } } @@ -124,6 +127,7 @@ impl Approvable for UnifiedExecRuntime<'_> { req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), + req.network_preflight_only, ) .await }) diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 083de7e885..a61d229967 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -15,6 +15,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; +use crate::network_proxy; use crate::protocol::BackgroundEventEvent; use crate::protocol::EventMsg; use crate::sandboxing::ExecEnv; @@ -488,7 +489,19 @@ impl UnifiedExecSessionManager { let features = context.session.features(); let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); - let exec_approval_requirement = create_exec_approval_requirement_for_command( + 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 mut exec_approval_requirement = create_exec_approval_requirement_for_command( &context.turn.exec_policy, &features, command, @@ -497,12 +510,27 @@ impl UnifiedExecSessionManager { sandbox_permissions, ) .await; + let mut network_preflight_only = false; + if network_preflight_blocked + && matches!( + exec_approval_requirement, + crate::tools::sandboxing::ExecApprovalRequirement::Skip { .. } + ) + { + exec_approval_requirement = + crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval { + reason: Some("Network access requires approval.".to_string()), + proposed_execpolicy_amendment: None, + }; + network_preflight_only = true; + } let req = UnifiedExecToolRequest::new( command.to_vec(), cwd, env, sandbox_permissions, justification, + network_preflight_only, exec_approval_requirement, ); let tool_ctx = ToolCtx { diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 39ae7486e5..ae98405081 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -181,6 +181,7 @@ async fn run_codex_tool_session_inner( reason: _, proposed_execpolicy_amendment: _, parsed_cmd, + .. }) => { handle_exec_approval_request( command, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 78050dfa86..ab03062b4f 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -9,6 +9,10 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; +fn is_false(value: &bool) -> bool { + !*value +} + /// Proposed execpolicy change to allow commands starting with this prefix. /// /// The `command` tokens form the prefix that would be added as an execpolicy @@ -57,6 +61,8 @@ pub struct ExecApprovalRequestEvent { #[ts(optional)] pub proposed_execpolicy_amendment: 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/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2fdb022b89..cca80505aa 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -167,6 +167,14 @@ pub enum Op { decision: ElicitationAction, }, + /// Cache a network approval decision for this session. + NetworkApprovalCache { + /// The host that was approved. + host: String, + /// The user's decision in response to the request. + decision: ReviewDecision, + }, + /// Append an entry to the persistent cross-session message history. /// /// Note the entry is not guaranteed to be logged if the user has diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 348f24dfc1..33a68290b2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -22,11 +22,15 @@ use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; +use codex_core::config; use codex_core::config::Config; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::types::NetworkProxyConfig; +use codex_core::default_client::create_client; #[cfg(target_os = "windows")] use codex_core::features::Feature; +use codex_core::network_proxy; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::models_manager::ModelsManager; @@ -34,6 +38,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; +use codex_core::protocol::ReviewDecision; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; @@ -52,6 +57,8 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -72,6 +79,10 @@ pub struct AppExitInfo { pub update_action: Option, } +fn codex_config_path() -> Result { + config::default_config_path().wrap_err("failed to resolve Codex config path") +} + fn session_summary( token_usage: TokenUsage, conversation_id: Option, @@ -304,6 +315,11 @@ 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, + network_proxy_session_restore: HashMap, + unix_socket_pending: HashSet, + unix_socket_session_restore: HashSet, } impl App { @@ -434,8 +450,19 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + network_proxy_pending: HashSet::new(), + network_proxy_session_restore: HashMap::new(), + unix_socket_pending: HashSet::new(), + unix_socket_session_restore: HashSet::new(), }; + if app.config.network_proxy.enabled && app.config.sandbox_policy.has_full_network_access() { + Self::spawn_network_proxy_poller( + app.config.network_proxy.clone(), + app.app_event_tx.clone(), + ); + } + // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] { @@ -485,6 +512,9 @@ impl App { app.handle_tui_event(tui, event).await? } } {} + if let Err(err) = app.restore_network_proxy_approvals().await { + tracing::error!(error = %err, "failed to restore network proxy approvals"); + } tui.terminal.clear()?; Ok(AppExitInfo { token_usage: app.token_usage(), @@ -544,6 +574,7 @@ impl App { .await; match event { AppEvent::NewSession => { + let session_allow = self.chat_widget.network_session_allow(); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), @@ -563,6 +594,7 @@ impl App { model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.chat_widget.set_network_session_allow(session_allow); self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -575,6 +607,7 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::OpenResumePicker => { + let session_allow = self.chat_widget.network_session_allow(); match crate::resume_picker::run_resume_picker( tui, &self.config.codex_home, @@ -617,6 +650,7 @@ impl App { resumed.conversation, resumed.session_configured, ); + self.chat_widget.set_network_session_allow(session_allow); self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = @@ -1088,6 +1122,78 @@ 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 cfg!(debug_assertions) { + 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())); + } + ApprovalRequest::UnixSocket { request } => { + let mut lines = Vec::new(); + if !request.label.trim().is_empty() { + lines.push(Line::from(vec![ + "Resource: ".into(), + request.label.clone().bold(), + ])); + } + if !request.socket_path.trim().is_empty() { + lines.push(Line::from(vec![ + "Socket: ".into(), + request.socket_path.clone().dim(), + ])); + } + if !request.allow_entry.trim().is_empty() { + lines.push(Line::from(vec![ + "Allow entry: ".into(), + request.allow_entry.clone().dim(), + ])); + } + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_static_with_lines( + lines, + "S O C K E T".to_string(), + )); + } ApprovalRequest::McpElicitation { server_name, message, @@ -1106,6 +1212,234 @@ impl App { )); } }, + AppEvent::NetworkProxyApprovalRequest(request) => { + if !self.config.network_proxy.enabled { + return Ok(true); + } + let host = request.host.trim().to_string(); + if host.is_empty() || self.network_proxy_pending.contains(&host) { + return Ok(true); + } + let reason = request.reason.trim(); + if reason.eq_ignore_ascii_case("not_allowed") + && let Ok(config_path) = codex_config_path() + { + match network_proxy::preflight_host(&config_path, &host) { + Ok(None) => { + self.chat_widget.resume_pending_exec_approval(); + return Ok(true); + } + Ok(Some(_)) => {} + Err(err) => { + tracing::debug!( + error = %err, + "network proxy preflight host check failed" + ); + } + } + } + if reason.eq_ignore_ascii_case("denied") { + self.chat_widget.add_error_message(format!( + "Network access to {host} is denied by the denylist." + )); + return Ok(true); + } + if self.chat_widget.is_network_session_allowed(&host) { + self.chat_widget.resume_pending_exec_approval(); + return Ok(true); + } + self.network_proxy_pending.insert(host); + self.chat_widget.on_network_approval_request(request); + } + AppEvent::UnixSocketApprovalRequest(request) => { + if !self.config.network_proxy.enabled { + return Ok(true); + } + let socket_path = request.socket_path.trim().to_string(); + if socket_path.is_empty() || self.unix_socket_pending.contains(&socket_path) { + return Ok(true); + } + if let Ok(config_path) = codex_config_path() { + let socket = std::path::Path::new(&socket_path); + match network_proxy::unix_socket_allowed(&config_path, socket) { + Ok(true) => { + self.chat_widget.resume_pending_exec_approval(); + return Ok(true); + } + Ok(false) => {} + Err(err) => { + tracing::debug!(error = %err, "unix socket preflight check failed"); + } + } + } + self.unix_socket_pending.insert(socket_path); + self.chat_widget.on_unix_socket_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 config_path = codex_config_path()?; + let mut reload_needed = false; + let mut should_resume_exec = matches!( + decision, + crate::app_event::NetworkProxyDecision::AllowSession + | crate::app_event::NetworkProxyDecision::AllowAlways + ); + match decision { + crate::app_event::NetworkProxyDecision::AllowSession => { + let original = match network_proxy::domain_state(&config_path, &host) { + Ok(state) => Some(state), + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to read network policy for {host}: {err}" + )); + should_resume_exec = false; + None + } + }; + let allow_state = network_proxy::DomainState { + allowed: true, + denied: false, + }; + if let Some(original) = original { + match network_proxy::set_domain_state(&config_path, &host, allow_state) + { + Ok(changed) => { + reload_needed |= changed; + self.network_proxy_session_restore + .entry(host.clone()) + .or_insert(original); + self.chat_widget.add_network_session_allow(host.clone()); + self.chat_widget.submit_op(Op::NetworkApprovalCache { + host: host.clone(), + decision: ReviewDecision::ApprovedForSession, + }); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to allow {host} for this session: {err}" + )); + should_resume_exec = false; + } + } + } + } + crate::app_event::NetworkProxyDecision::AllowAlways => { + let allow_state = network_proxy::DomainState { + allowed: true, + denied: false, + }; + match network_proxy::set_domain_state(&config_path, &host, allow_state) { + Ok(changed) => { + reload_needed |= changed; + self.chat_widget.add_network_session_allow(host.clone()); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to add {host} to allowlist: {err}" + )); + } + } + } + crate::app_event::NetworkProxyDecision::Deny => { + let deny_state = network_proxy::DomainState { + allowed: false, + denied: true, + }; + match network_proxy::set_domain_state(&config_path, &host, deny_state) { + Ok(changed) => { + reload_needed |= changed; + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to add {host} to denylist: {err}" + )); + } + } + } + } + if reload_needed && let Err(err) = network_proxy::reload(&client, &admin_url).await + { + self.chat_widget.add_error_message(format!( + "Failed to reload network proxy after policy update: {err}" + )); + } + if should_resume_exec { + self.chat_widget.resume_pending_exec_approval(); + } else { + self.chat_widget.reject_pending_exec_approval(); + } + } + AppEvent::UnixSocketDecision { + socket_path, + allow_entry, + decision, + } => { + let socket_path = socket_path.trim().to_string(); + if socket_path.is_empty() { + return Ok(true); + } + self.unix_socket_pending.remove(&socket_path); + let config_path = codex_config_path()?; + let mut should_resume_exec = matches!( + decision, + crate::app_event::UnixSocketDecision::AllowSession + | crate::app_event::UnixSocketDecision::AllowAlways + ); + match decision { + crate::app_event::UnixSocketDecision::AllowSession => { + match network_proxy::add_allowed_unix_socket(&config_path, &socket_path) { + Ok(changed) => { + if changed { + self.unix_socket_session_restore.insert(socket_path.clone()); + } + self.chat_widget + .add_unix_socket_session_allow(socket_path.clone()); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to allow Unix socket {socket_path} for this session: {err}" + )); + should_resume_exec = false; + } + } + } + crate::app_event::UnixSocketDecision::AllowAlways => { + if allow_entry.trim().is_empty() { + self.chat_widget.add_error_message( + "Failed to allow Unix socket permanently: missing allow entry" + .to_string(), + ); + should_resume_exec = false; + } else { + match network_proxy::add_allowed_unix_socket(&config_path, &allow_entry) + { + Ok(_) => { + self.chat_widget + .add_unix_socket_session_allow(socket_path.clone()); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to add Unix socket allow entry {allow_entry}: {err}" + )); + should_resume_exec = false; + } + } + } + } + crate::app_event::UnixSocketDecision::Deny => {} + } + if should_resume_exec { + self.chat_widget.resume_pending_exec_approval(); + } else { + self.chat_widget.reject_pending_exec_approval(); + } + } } Ok(true) } @@ -1132,6 +1466,49 @@ impl App { self.chat_widget.token_usage() } + async fn restore_network_proxy_approvals(&mut self) -> Result<()> { + if self.network_proxy_session_restore.is_empty() + && self.unix_socket_session_restore.is_empty() + { + return Ok(()); + } + let config_path = codex_config_path()?; + let client = create_client(); + let admin_url = self.config.network_proxy.admin_url.clone(); + let mut reload_needed = false; + + let mut restores: HashMap = HashMap::new(); + for (host, state) in self.network_proxy_session_restore.drain() { + restores.insert(host, state); + } + + for (host, state) in restores { + match network_proxy::set_domain_state(&config_path, &host, state) { + Ok(changed) => { + reload_needed |= changed; + } + Err(err) => { + tracing::error!( + error = %err, + host = host, + "failed to restore network policy" + ); + } + } + } + + if reload_needed { + network_proxy::reload(&client, &admin_url) + .await + .map_err(|err| color_eyre::eyre::eyre!(err))?; + } + + for socket_path in self.unix_socket_session_restore.drain() { + let _ = network_proxy::remove_allowed_unix_socket(&config_path, &socket_path); + } + Ok(()) + } + fn on_update_reasoning_effort(&mut self, effort: Option) { self.chat_widget.set_reasoning_effort(effort); self.config.model_reasoning_effort = effort; @@ -1197,6 +1574,35 @@ impl App { }; } + fn spawn_network_proxy_poller(network_proxy: NetworkProxyConfig, tx: AppEventSender) { + if !network_proxy.enabled { + 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, @@ -1283,6 +1689,10 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + network_proxy_pending: HashSet::new(), + network_proxy_session_restore: HashMap::new(), + unix_socket_pending: HashSet::new(), + unix_socket_session_restore: HashSet::new(), } } @@ -1323,6 +1733,10 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + network_proxy_pending: HashSet::new(), + network_proxy_session_restore: HashMap::new(), + unix_socket_pending: HashSet::new(), + unix_socket_session_restore: HashSet::new(), }, rx, op_rx, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0be556de8b..ae023b30aa 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; +use codex_core::network_proxy::NetworkProxyBlockedRequest; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; @@ -171,6 +172,25 @@ pub(crate) enum AppEvent { /// Open the approval popup. FullScreenApprovalRequest(ApprovalRequest), + /// Prompt for a blocked network request from the proxy. + NetworkProxyApprovalRequest(NetworkProxyBlockedRequest), + + /// Prompt to allow a Unix socket path inside the sandbox (macOS only). + UnixSocketApprovalRequest(UnixSocketApprovalRequest), + + /// User decision for a blocked network request. + NetworkProxyDecision { + host: String, + decision: NetworkProxyDecision, + }, + + /// User decision for a Unix socket approval request. + UnixSocketDecision { + socket_path: String, + allow_entry: String, + decision: UnixSocketDecision, + }, + /// Open the feedback note entry overlay after the user selects a category. OpenFeedbackNote { category: FeedbackCategory, @@ -183,6 +203,27 @@ pub(crate) enum AppEvent { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NetworkProxyDecision { + AllowSession, + AllowAlways, + Deny, +} + +#[derive(Debug, Clone)] +pub(crate) struct UnixSocketApprovalRequest { + pub label: String, + pub socket_path: String, + pub allow_entry: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnixSocketDecision { + AllowSession, + 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 d42861eb1d..d02d3a4644 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -2,6 +2,9 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::app_event::AppEvent; +use crate::app_event::NetworkProxyDecision; +use crate::app_event::UnixSocketApprovalRequest; +use crate::app_event::UnixSocketDecision; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::CancellationEvent; @@ -16,8 +19,10 @@ 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::features::Feature; use codex_core::features::Features; +use codex_core::network_proxy::NetworkProxyBlockedRequest; use codex_core::protocol::ElicitationAction; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; @@ -51,6 +56,12 @@ pub(crate) enum ApprovalRequest { cwd: PathBuf, changes: HashMap, }, + Network { + request: NetworkProxyBlockedRequest, + }, + UnixSocket { + request: UnixSocketApprovalRequest, + }, McpElicitation { server_name: String, request_id: RequestId, @@ -119,6 +130,13 @@ impl ApprovalOverlay { patch_options(), "Would you like to make the following edits?".to_string(), ), + ApprovalVariant::Network { preflight_only, .. } => ( + network_options(*preflight_only), + "Allow network access to this domain?".to_string(), + ), + ApprovalVariant::UnixSocket { label, .. } => { + (unix_socket_options(), format!("Allow access to {label}?")) + } ApprovalVariant::McpElicitation { server_name, .. } => ( elicitation_options(), format!("{server_name} needs your approval."), @@ -174,6 +192,19 @@ impl ApprovalOverlay { (ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { self.handle_patch_decision(id, decision.clone()); } + (ApprovalVariant::Network { host, .. }, ApprovalDecision::Network(decision)) => { + self.handle_network_decision(host, *decision); + } + ( + ApprovalVariant::UnixSocket { + socket_path, + allow_entry, + .. + }, + ApprovalDecision::UnixSocket(decision), + ) => { + self.handle_unix_socket_decision(socket_path, allow_entry, *decision); + } ( ApprovalVariant::McpElicitation { server_name, @@ -207,6 +238,31 @@ 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 handle_unix_socket_decision( + &self, + socket_path: &str, + allow_entry: &str, + decision: UnixSocketDecision, + ) { + let cell = + history_cell::new_unix_socket_approval_decision_cell(socket_path.to_string(), decision); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + self.app_event_tx.send(AppEvent::UnixSocketDecision { + socket_path: socket_path.to_string(), + allow_entry: allow_entry.to_string(), + decision, + }); + } + fn handle_elicitation_decision( &self, server_name: &str, @@ -286,6 +342,20 @@ impl BottomPaneView for ApprovalOverlay { ApprovalVariant::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); } + ApprovalVariant::Network { host, .. } => { + self.handle_network_decision(host, NetworkProxyDecision::Deny); + } + ApprovalVariant::UnixSocket { + socket_path, + allow_entry, + .. + } => { + self.handle_unix_socket_decision( + socket_path, + allow_entry, + UnixSocketDecision::Deny, + ); + } ApprovalVariant::McpElicitation { server_name, request_id, @@ -386,6 +456,87 @@ 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.clone().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()])); + } + if cfg!(debug_assertions) { + 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()])); + } + let preflight_only = request.protocol.trim().eq_ignore_ascii_case("preflight"); + Self { + variant: ApprovalVariant::Network { + host, + preflight_only, + }, + header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), + } + } + ApprovalRequest::UnixSocket { request } => { + let mut header: Vec> = Vec::new(); + if !request.label.trim().is_empty() { + header.push(Line::from(vec![ + "Resource: ".into(), + request.label.clone().bold(), + ])); + } + if !request.socket_path.trim().is_empty() { + header.push(Line::from(vec![ + "Socket: ".into(), + request.socket_path.clone().dim(), + ])); + } + if !request.allow_entry.trim().is_empty() { + header.push(Line::from(vec![ + "Allow entry: ".into(), + request.allow_entry.clone().dim(), + ])); + } + Self { + variant: ApprovalVariant::UnixSocket { + socket_path: request.socket_path, + allow_entry: request.allow_entry, + label: request.label, + }, + header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), + } + } ApprovalRequest::McpElicitation { server_name, request_id, @@ -409,6 +560,26 @@ impl From for ApprovalRequestState { } } +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 { @@ -419,6 +590,15 @@ enum ApprovalVariant { ApplyPatch { id: String, }, + Network { + host: String, + preflight_only: bool, + }, + UnixSocket { + socket_path: String, + allow_entry: String, + label: String, + }, McpElicitation { server_name: String, request_id: RequestId, @@ -428,6 +608,8 @@ enum ApprovalVariant { #[derive(Clone)] enum ApprovalDecision { Review(ReviewDecision), + Network(NetworkProxyDecision), + UnixSocket(UnixSocketDecision), McpElicitation(ElicitationAction), } @@ -503,6 +685,62 @@ fn patch_options() -> Vec { ] } +fn network_options(preflight_only: bool) -> Vec { + let mut options = Vec::new(); + let mut allow_session = ApprovalOption { + label: if preflight_only { + "Allow for session".to_string() + } else { + "Allow".to_string() + }, + decision: ApprovalDecision::Network(NetworkProxyDecision::AllowSession), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('s'))], + }; + if preflight_only { + allow_session + .additional_shortcuts + .push(key_hint::plain(KeyCode::Char('y'))); + } + options.push(allow_session); + options.push(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'))], + }); + options.push(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'))], + }); + options +} + +fn unix_socket_options() -> Vec { + vec![ + ApprovalOption { + label: "Allow for session".to_string(), + decision: ApprovalDecision::UnixSocket(UnixSocketDecision::AllowSession), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('s'))], + }, + ApprovalOption { + label: "Allow always (add to allowlist)".to_string(), + decision: ApprovalDecision::UnixSocket(UnixSocketDecision::AllowAlways), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + }, + ApprovalOption { + label: "Deny".to_string(), + decision: ApprovalDecision::UnixSocket(UnixSocketDecision::Deny), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }, + ] +} + fn elicitation_options() -> Vec { vec![ ApprovalOption { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a7bf4e3fa2..d174196060 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -13,6 +13,8 @@ use codex_core::features::FEATURES; use codex_core::features::Feature; 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::openai_models::model_family::ModelFamily; use codex_core::openai_models::models_manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; @@ -45,6 +47,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::ReviewTarget; use codex_core::protocol::SkillsListEntry; @@ -85,6 +88,7 @@ use tokio::task::JoinHandle; use tracing::debug; use crate::app_event::AppEvent; +use crate::app_event::UnixSocketApprovalRequest; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BetaFeatureItem; @@ -353,6 +357,9 @@ pub(crate) struct ChatWidget { pre_review_token_info: Option>, // Whether to add a final message separator after the last message needs_final_message_separator: bool, + pending_exec_approval: Option, + network_proxy_session_allow: HashSet, + unix_socket_session_allow: HashSet, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback @@ -361,6 +368,17 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, } +enum PendingExecBlock { + NetworkHost(String), + UnixSocket(String), +} + +struct PendingExecApproval { + id: String, + event: ExecApprovalRequestEvent, + block: PendingExecBlock, +} + struct UserMessage { text: String, image_paths: Vec, @@ -846,6 +864,22 @@ 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), + ); + } + + pub(crate) fn on_unix_socket_approval_request(&mut self, request: UnixSocketApprovalRequest) { + let request2 = request.clone(); + self.defer_or_handle( + |q| q.push_unix_socket_approval(request), + |s| s.handle_unix_socket_approval_request(request2), + ); + } + fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { let ev2 = ev.clone(); self.defer_or_handle( @@ -1188,6 +1222,47 @@ 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_unix_socket_request(&ev.command) + { + self.pending_exec_approval = Some(PendingExecApproval { + id: id.clone(), + event: ev, + block: PendingExecBlock::UnixSocket(request.socket_path.clone()), + }); + self.app_event_tx + .send(AppEvent::UnixSocketApprovalRequest(request)); + return; + } + if self.pending_exec_approval.is_none() + && let Some(mut request) = self.preflight_network_request(&ev.command) + { + if request.reason.trim().eq_ignore_ascii_case("denied") { + self.add_error_message(format!( + "Exec canceled because network access to {} is denied by the denylist.", + request.host + )); + self.submit_op(Op::ExecApproval { + id, + decision: ReviewDecision::Denied, + }); + return; + } + request.call_id = Some(id.clone()); + self.pending_exec_approval = Some(PendingExecApproval { + id, + event: ev, + block: PendingExecBlock::NetworkHost(request.host.clone()), + }); + self.app_event_tx + .send(AppEvent::NetworkProxyApprovalRequest(request)); + 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(" ")); @@ -1204,6 +1279,124 @@ 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.handle_exec_approval_now(pending.id, pending.event); + } + } + + pub(crate) fn reject_pending_exec_approval(&mut self) { + if let Some(pending) = self.pending_exec_approval.take() { + let reason = match pending.block { + PendingExecBlock::NetworkHost(host) => { + format!("Exec canceled because network access to {host} was denied.") + } + PendingExecBlock::UnixSocket(socket_path) => { + format!("Exec canceled because Unix socket access to {socket_path} was denied.") + } + }; + self.add_to_history(history_cell::new_error_event(reason)); + self.submit_op(Op::ExecApproval { + id: pending.id, + decision: ReviewDecision::Denied, + }); + } + } + + pub(crate) fn add_network_session_allow(&mut self, host: String) { + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() { + return; + } + self.network_proxy_session_allow.insert(host); + } + + pub(crate) fn add_unix_socket_session_allow(&mut self, socket_path: String) { + let socket_path = socket_path.trim().to_string(); + if socket_path.is_empty() { + return; + } + self.unix_socket_session_allow.insert(socket_path); + } + + pub(crate) fn network_session_allow(&self) -> HashSet { + self.network_proxy_session_allow.clone() + } + + pub(crate) fn set_network_session_allow(&mut self, allowed: HashSet) { + self.network_proxy_session_allow = allowed; + } + + pub(crate) fn is_network_session_allowed(&self, host: &str) -> bool { + let normalized = host.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return false; + } + self.network_proxy_session_allow + .contains(normalized.as_str()) + } + + fn is_unix_socket_session_allowed(&self, socket_path: &str) -> bool { + let socket_path = socket_path.trim(); + if socket_path.is_empty() { + return false; + } + self.unix_socket_session_allow.contains(socket_path) + } + + fn preflight_network_request(&self, command: &[String]) -> Option { + let blocked = 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 + } + }; + let request = blocked?; + if self.is_network_session_allowed(&request.host) { + return None; + } + Some(request) + } + + fn preflight_unix_socket_request( + &self, + command: &[String], + ) -> Option { + let blocked = match network_proxy::preflight_blocked_unix_socket_if_enabled( + &self.config.network_proxy, + &self.config.sandbox_policy, + command, + ) { + Ok(result) => result, + Err(err) => { + tracing::debug!(error = %err, "unix socket preflight failed"); + None + } + }; + let blocked = blocked?; + let socket_path = blocked.socket_path.to_string_lossy().to_string(); + if self.is_unix_socket_session_allowed(&socket_path) { + return None; + } + Some(UnixSocketApprovalRequest { + label: "SSH agent socket".to_string(), + socket_path, + allow_entry: blocked.suggested_allow_entry, + }) + } + pub(crate) fn handle_apply_patch_approval_now( &mut self, id: String, @@ -1226,6 +1419,27 @@ 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.config.features); + self.request_redraw(); + } + + pub(crate) fn handle_unix_socket_approval_request( + &mut self, + request: UnixSocketApprovalRequest, + ) { + self.flush_answer_stream_with_separator(); + self.bottom_pane.push_approval_request( + ApprovalRequest::UnixSocket { request }, + &self.config.features, + ); + self.request_redraw(); + } + pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { self.flush_answer_stream_with_separator(); @@ -1423,6 +1637,9 @@ impl ChatWidget { is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + pending_exec_approval: None, + network_proxy_session_allow: HashSet::new(), + unix_socket_session_allow: HashSet::new(), last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, @@ -1509,6 +1726,9 @@ impl ChatWidget { is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + pending_exec_approval: None, + network_proxy_session_allow: HashSet::new(), + unix_socket_session_allow: HashSet::new(), last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, @@ -3469,6 +3689,7 @@ enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, EditApprovalRequested { cwd: PathBuf, changes: Vec }, + NetworkApprovalRequested { host: String }, ElicitationRequested { server_name: String }, } @@ -3493,6 +3714,9 @@ impl Notification { } ) } + Notification::NetworkApprovalRequested { host } => { + format!("Network approval requested: {}", truncate_text(host, 40)) + } Notification::ElicitationRequested { server_name } => { format!("Approval requested by {server_name}") } @@ -3504,6 +3728,7 @@ impl Notification { Notification::AgentTurnComplete { .. } => "agent-turn-complete", Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } + | Notification::NetworkApprovalRequested { .. } | Notification::ElicitationRequested { .. } => "approval-requested", } } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index dc1e683ea5..9ca5a6cfb5 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; @@ -9,12 +10,16 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_protocol::approvals::ElicitationRequestEvent; +use crate::app_event::UnixSocketApprovalRequest; + use super::ChatWidget; #[derive(Debug)] pub(crate) enum QueuedInterrupt { ExecApproval(String, ExecApprovalRequestEvent), ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), + NetworkApproval(NetworkProxyBlockedRequest), + UnixSocketApproval(UnixSocketApprovalRequest), Elicitation(ElicitationRequestEvent), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), @@ -53,6 +58,16 @@ 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_unix_socket_approval(&mut self, request: UnixSocketApprovalRequest) { + self.queue + .push_back(QueuedInterrupt::UnixSocketApproval(request)); + } + pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) { self.queue.push_back(QueuedInterrupt::Elicitation(ev)); } @@ -84,6 +99,12 @@ impl InterruptManager { QueuedInterrupt::ApplyPatchApproval(id, ev) => { chat.handle_apply_patch_approval_now(id, ev) } + QueuedInterrupt::NetworkApproval(request) => { + chat.handle_network_approval_request(request) + } + QueuedInterrupt::UnixSocketApproval(request) => { + chat.handle_unix_socket_approval_request(request) + } QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 07b2be0405..752ccc30d4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -405,6 +405,9 @@ fn make_chatwidget_manual( is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, + pending_exec_approval: None, + network_proxy_session_allow: std::collections::HashSet::new(), + unix_socket_session_allow: std::collections::HashSet::new(), last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, @@ -724,6 +727,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { ), proposed_execpolicy_amendment: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-short".into(), @@ -768,6 +772,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { ), proposed_execpolicy_amendment: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-multi".into(), @@ -818,6 +823,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-long".into(), @@ -2282,6 +2288,7 @@ fn approval_modal_exec_snapshot() -> anyhow::Result<()> { "world".into(), ])), parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-approve".into(), @@ -2335,6 +2342,7 @@ fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { "world".into(), ])), parsed_cmd: vec![], + network_preflight_only: false, }; chat.handle_codex_event(Event { id: "sub-approve-noreason".into(), @@ -2556,6 +2564,7 @@ fn status_widget_and_approval_modal_snapshot() { "hello world".into(), ])), 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 2c0a37ecea..621ba6d99c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,3 +1,5 @@ +use crate::app_event::NetworkProxyDecision; +use crate::app_event::UnixSocketDecision; use crate::diff_render::create_diff_summary; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; @@ -534,6 +536,94 @@ 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::AllowSession => ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " network access to ".into(), + host_span, + " for this session".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, + " ", + )) +} + +pub fn new_unix_socket_approval_decision_cell( + socket_path: String, + decision: UnixSocketDecision, +) -> Box { + let socket_span = Span::from(socket_path).dim(); + let (symbol, summary): (Span<'static>, Vec>) = match decision { + UnixSocketDecision::AllowSession => ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " Unix socket access to ".into(), + socket_span, + " for this session".bold(), + ], + ), + UnixSocketDecision::AllowAlways => ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " Unix socket access to ".into(), + socket_span, + " permanently".bold(), + ], + ), + UnixSocketDecision::Deny => ( + "✗ ".red(), + vec![ + "You ".into(), + "denied".bold(), + " Unix socket access to ".into(), + socket_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 e83ff74fbe..b2fbdc7e00 100644 --- a/docs/config.md +++ b/docs/config.md @@ -351,7 +351,7 @@ Though using this option may also be necessary if you try to use Codex in enviro ### 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. +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 [features] @@ -384,11 +384,12 @@ Notes: - When `proxy_url` points at localhost, Codex also assumes a SOCKS5 proxy on `localhost:8081` for `ALL_PROXY`, `GRPC_PROXY`, `FTP_PROXY`, `RSYNC_PROXY`, and (macOS only) `GIT_SSH_COMMAND`. - `no_proxy` entries bypass the proxy; defaults include localhost + private network ranges. Use sparingly because bypassed traffic is not filtered by the proxy policy. - `[network_proxy.policy]` can optionally allow localhost binding or Unix socket access (macOS only) when proxy-restricted network access is active. +- When enabled, Codex polls the proxy admin API (`/blocked`) and surfaces a prompt to allow for the session, allow always (add to allowlist), or deny (add to denylist). Allow/deny decisions update `~/.codex/config.toml` under `[network_proxy.policy]`, then Codex calls `/reload`. - 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"]`. + When approvals are enabled, Codex may prompt to allow the SSH agent socket before running commands that appear to require it (e.g. `ssh`, `scp`, `sftp`, `ssh-add`, or `git` over SSH). ### tools.\* diff --git a/docs/network-proxy-design.md b/docs/network-proxy-design.md index 7cc7489b6a..5b30f050ea 100644 --- a/docs/network-proxy-design.md +++ b/docs/network-proxy-design.md @@ -47,6 +47,7 @@ The proxy reads `~/.codex/config.toml`: - `[network_proxy]` for endpoints, mode, and toggles. - `[network_proxy.policy]` for `allowed_domains` / `denied_domains` (and, on macOS, optional local IPC allowances). - `[network_proxy.mitm]` for MITM CA paths and inspection settings. +- Codex integration is rollout-gated behind `[features].network_proxy = true`. Codex is the source of truth. Approval actions update the config and trigger a proxy reload. diff --git a/docs/network-proxy-quickstart.md b/docs/network-proxy-quickstart.md index ee9533ad5b..3d144797ca 100644 --- a/docs/network-proxy-quickstart.md +++ b/docs/network-proxy-quickstart.md @@ -22,6 +22,9 @@ cargo build -p codex-network-proxy --features mitm Add this to `~/.codex/config.toml`: ```toml +[features] +network_proxy = true + [network_proxy] enabled = true proxy_url = "http://127.0.0.1:3128" @@ -91,3 +94,10 @@ Switch modes: ```bash curl -fsS -X POST http://127.0.0.1:8080/mode -d '{"mode":"full"}' ``` + +## Codex integration sanity check + +1) Start the proxy. +2) Launch Codex with `[features].network_proxy = true` and `network_proxy.enabled = true` set in config. +3) Run a network command (e.g., `curl https://example.com`). +4) Confirm you see the allow/deny prompt and that the proxy logs reflect the decision. diff --git a/docs/sandbox.md b/docs/sandbox.md index 4fc667c2ea..2b328303ff 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -54,16 +54,27 @@ approval_policy = "never" sandbox_mode = "read-only" ``` -### Network proxy +### Network proxy approvals -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. +Codex can optionally route outbound network traffic through a 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 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. +- When network is enabled and both `[features].network_proxy = true` and `network_proxy.enabled = 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. +- Approvals update `~/.codex/config.toml` under `[network_proxy.policy]` and trigger a proxy reload. +- You can choose to: + - **Deny** the request (adds the domain to the denylist). + - **Allow for session** (temporary allow that is reverted on exit). + - **Allow always** (adds the domain to the allowlist). + +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`. Defaults include localhost and private network ranges; any entries in that list bypass the proxy and are not filtered by proxy policy. @@ -76,6 +87,8 @@ Unix sockets are deny-by-default. If you run tools that rely on local IPC (most allow_unix_sockets = ["$SSH_AUTH_SOCK"] ``` +When approvals are enabled, Codex may prompt to allow the SSH agent socket before running commands that appear to require it (for example `ssh`, `scp`, `sftp`, `ssh-add`, or `git` over SSH). “Allow always” records `$SSH_AUTH_SOCK`; “Allow for session” records the resolved socket path and is removed when Codex exits. + 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