From e47d02ab27154d0e8afc6aa627e32c6ecacc733c Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 19 Dec 2025 23:57:18 -0800 Subject: [PATCH] Config surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New config key: [network_proxy.policy].allow_unix_sockets (string array), stored/edited via network_proxy.rs. Entries support: SSH_AUTH_SOCK / ${SSH_AUTH_SOCK} preset aliases: ssh-agent, ssh_auth_sock, ssh_auth_socket Entries are resolved at runtime to canonical absolute socket paths before generating Seatbelt rules. macOS Seatbelt integration seatbelt.rs now: allows only loopback proxy ports (localhost:) + explicitly allowed unix socket paths does not emit per-domain (remote tcp ...) rules (those break under sandbox-exec) Unix socket allowlist resolution is done via network_proxy::resolve_unix_socket_allowlist(...). Prompt-on-deny UX (TUI) When an exec approval happens and the command appears to need the SSH agent socket (ssh/scp/sftp/ssh-add, or git with ssh-style remotes), and the socket isn’t already allowed: TUI shows an approval modal for the unix socket. Allow for session: writes the resolved socket path to config (and removes it on exit, like session domain approvals). Allow always: writes SSH_AUTH_SOCK to allow_unix_sockets for portability across restarts. --- codex-rs/core/src/exec_env.rs | 5 +- codex-rs/core/src/network_proxy.rs | 204 ++++++++++++++++++ codex-rs/core/src/seatbelt.rs | 135 ++++-------- codex-rs/core/src/tasks/user_shell.rs | 1 - codex-rs/tui/src/app.rs | 159 ++++++++++++-- codex-rs/tui/src/app_event.rs | 24 +++ .../tui/src/bottom_pane/approval_overlay.rs | 103 +++++++++ codex-rs/tui/src/chatwidget.rs | 103 ++++++++- codex-rs/tui/src/chatwidget/interrupts.rs | 11 + codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/history_cell.rs | 45 ++++ docs/config.md | 7 +- docs/example-config.md | 4 + docs/network-proxy-design.md | 15 +- docs/network-proxy-quickstart.md | 4 + docs/sandbox.md | 9 + 16 files changed, 712 insertions(+), 118 deletions(-) diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index 037c0a9b4a..d7e389c0bf 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -212,10 +212,7 @@ fn resolve_proxy_endpoints(network_proxy: &NetworkProxyConfig) -> ProxyEndpoints }) }; let mut socks = if is_socks { - Some(ProxyEndpoint { - host: host.clone(), - port, - }) + Some(ProxyEndpoint { host, port }) } else { None }; diff --git a/codex-rs/core/src/network_proxy.rs b/codex-rs/core/src/network_proxy.rs index ef61cc7ee2..d5bc05f897 100644 --- a/codex-rs/core/src/network_proxy.rs +++ b/codex-rs/core/src/network_proxy.rs @@ -14,6 +14,7 @@ use shlex::split as shlex_split; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; +use std::path::PathBuf; use toml_edit::Array as TomlArray; use toml_edit::DocumentMut; use toml_edit::InlineTable; @@ -25,6 +26,7 @@ const NETWORK_PROXY_TABLE: &str = "network_proxy"; const NETWORK_PROXY_POLICY_TABLE: &str = "policy"; const ALLOWED_DOMAINS_KEY: &str = "allowed_domains"; const DENIED_DOMAINS_KEY: &str = "denied_domains"; +const ALLOW_UNIX_SOCKETS_KEY: &str = "allow_unix_sockets"; #[derive(Debug, Clone, Deserialize)] pub struct NetworkProxyBlockedRequest { @@ -144,6 +146,14 @@ pub fn add_denied_domain(config_path: &Path, host: &str) -> Result { update_domain_list(config_path, host, DomainListKind::Deny) } +pub fn add_allowed_unix_socket(config_path: &Path, socket: &str) -> Result { + update_unix_socket_list(config_path, socket, UnixSocketListKind::Allow) +} + +pub fn remove_allowed_unix_socket(config_path: &Path, socket: &str) -> Result { + update_unix_socket_list(config_path, socket, UnixSocketListKind::Remove) +} + #[derive(Debug, Clone, Copy)] pub struct DomainState { pub allowed: bool, @@ -193,6 +203,20 @@ pub fn set_domain_state(config_path: &Path, host: &str, state: DomainState) -> R Ok(changed) } +pub fn unix_socket_allowed(config_path: &Path, socket_path: &Path) -> Result { + let policy = load_network_policy(config_path)?; + let allowed = resolve_unix_socket_allowlist(&policy.allow_unix_sockets); + if allowed.is_empty() { + return Ok(false); + } + let canonical_socket = socket_path + .canonicalize() + .unwrap_or_else(|_| socket_path.to_path_buf()); + Ok(allowed + .iter() + .any(|allowed_path| canonical_socket.starts_with(allowed_path))) +} + pub fn should_preflight_network( network_proxy: &NetworkProxyConfig, sandbox_policy: &SandboxPolicy, @@ -239,6 +263,43 @@ pub fn preflight_blocked_request_if_enabled( } } +#[derive(Debug, Clone)] +pub struct UnixSocketPreflightMatch { + /// Socket path that needs to be allowed (canonicalized when possible). + pub socket_path: PathBuf, + /// Suggested config entry to add for a persistent allow (e.g. `$SSH_AUTH_SOCK`). + pub suggested_allow_entry: String, + pub reason: String, +} + +pub fn preflight_blocked_unix_socket_if_enabled( + network_proxy: &NetworkProxyConfig, + sandbox_policy: &SandboxPolicy, + command: &[String], +) -> Result> { + if !cfg!(target_os = "macos") { + return Ok(None); + } + if !should_preflight_network(network_proxy, sandbox_policy) { + return Ok(None); + } + + let Some(socket_path) = ssh_auth_sock_if_needed(command) else { + return Ok(None); + }; + + let config_path = config::default_config_path()?; + if unix_socket_allowed(&config_path, &socket_path)? { + return Ok(None); + } + + Ok(Some(UnixSocketPreflightMatch { + socket_path, + suggested_allow_entry: "$SSH_AUTH_SOCK".to_string(), + reason: "not_allowed_unix_socket".to_string(), + })) +} + pub fn apply_mitm_ca_env_if_enabled( env_map: &mut HashMap, network_proxy: &NetworkProxyConfig, @@ -397,6 +458,34 @@ fn update_domain_list(config_path: &Path, host: &str, list: DomainListKind) -> R Ok(changed) } +#[derive(Copy, Clone)] +enum UnixSocketListKind { + Allow, + Remove, +} + +fn update_unix_socket_list( + config_path: &Path, + socket: &str, + action: UnixSocketListKind, +) -> Result { + let socket = socket.trim(); + if socket.is_empty() { + return Err(anyhow!("socket is empty")); + } + let mut doc = load_document(config_path)?; + let policy = ensure_policy_table(&mut doc); + let list = ensure_array(policy, ALLOW_UNIX_SOCKETS_KEY); + let changed = match action { + UnixSocketListKind::Allow => add_domain(list, socket), + UnixSocketListKind::Remove => remove_domain(list, socket), + }; + if changed { + write_document(config_path, &doc)?; + } + Ok(changed) +} + fn load_document(path: &Path) -> Result { if !path.exists() { return Ok(DocumentMut::new()); @@ -431,6 +520,88 @@ pub(crate) struct NetworkPolicy { pub(crate) allow_local_binding: bool, } +pub(crate) fn resolve_unix_socket_allowlist(entries: &[String]) -> Vec { + let mut resolved = Vec::new(); + let mut seen = HashSet::new(); + + for entry in entries { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + for candidate in resolve_unix_socket_entry(entry) { + if !seen.insert(candidate.clone()) { + continue; + } + resolved.push(candidate); + } + } + + resolved.sort(); + resolved +} + +fn resolve_unix_socket_entry(entry: &str) -> Vec { + // Presets are intentionally simple: they resolve to a path (or set of paths) + // and are ultimately translated into Seatbelt `subpath` rules. + let entry = entry.trim(); + if entry.is_empty() { + return Vec::new(); + } + + let mut candidates: Vec = Vec::new(); + match entry { + "ssh-agent" | "ssh_auth_sock" | "ssh_auth_socket" => { + if let Some(value) = std::env::var_os("SSH_AUTH_SOCK") { + candidates.push(value.to_string_lossy().to_string()); + } + } + _ => { + if let Some(var) = entry.strip_prefix('$') { + candidates.extend(resolve_env_unix_socket(var)); + } else if entry.starts_with("${") && entry.ends_with('}') { + candidates.extend(resolve_env_unix_socket(&entry[2..entry.len() - 1])); + } else { + candidates.push(entry.to_string()); + } + } + } + + candidates + .into_iter() + .filter_map(|candidate| parse_unix_socket_candidate(&candidate)) + .collect() +} + +fn resolve_env_unix_socket(var: &str) -> Vec { + let var = var.trim(); + if var.is_empty() { + return Vec::new(); + } + std::env::var_os(var) + .map(|value| vec![value.to_string_lossy().to_string()]) + .unwrap_or_default() +} + +fn parse_unix_socket_candidate(candidate: &str) -> Option { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return None; + } + let path = if let Some(rest) = trimmed.strip_prefix("unix://") { + rest + } else if let Some(rest) = trimmed.strip_prefix("unix:") { + rest + } else { + trimmed + }; + let path = PathBuf::from(path); + if !path.is_absolute() { + return None; + } + Some(path.canonicalize().unwrap_or(path)) +} + pub(crate) fn load_network_policy(config_path: &Path) -> Result { if !config_path.exists() { return Ok(NetworkPolicy::default()); @@ -478,6 +649,39 @@ fn extract_hosts_from_command(command: &[String]) -> Vec { hosts.into_iter().collect() } +fn ssh_auth_sock_if_needed(command: &[String]) -> Option { + let Some(cmd0) = command.first() else { + return None; + }; + let cmd = std::path::Path::new(cmd0) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + let needs_sock = match cmd { + "ssh" | "scp" | "sftp" | "ssh-add" => true, + "git" => command + .iter() + .skip(1) + .any(|arg| arg.contains("ssh://") || looks_like_scp_host(arg)), + _ => false, + }; + if !needs_sock { + return None; + } + let sock = std::env::var_os("SSH_AUTH_SOCK")?; + let sock = sock.to_string_lossy().to_string(); + parse_unix_socket_candidate(&sock) +} + +fn looks_like_scp_host(value: &str) -> bool { + // e.g. git@github.com:owner/repo.git + let value = value.trim(); + if value.is_empty() || value.starts_with('-') { + return false; + } + value.contains('@') && value.contains(':') && !value.contains("://") +} + fn extract_hosts_from_tokens(tokens: &[String], hosts: &mut HashSet) { let (cmd0, args) = match tokens.split_first() { Some((cmd0, args)) => (cmd0.as_str(), args), diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index aacd84213b..e1c9f2e9c5 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -2,7 +2,6 @@ use std::collections::BTreeSet; use std::collections::HashMap; -use std::collections::HashSet; use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; @@ -55,50 +54,6 @@ pub async fn spawn_command_under_seatbelt( .await } -fn proxy_allowlist_from_env(env: &HashMap) -> Vec { - let mut allowlist = Vec::new(); - let mut seen = HashSet::new(); - - for key in PROXY_ENV_KEYS { - let Some(proxy_url) = env.get(*key) else { - continue; - }; - let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else { - continue; - }; - for entry in proxy_allowlist_entries(&host, port) { - if seen.insert(entry.clone()) { - allowlist.push(entry); - } - } - } - - allowlist -} - -fn proxy_allowlist_entries(host: &str, port: i64) -> Vec { - let mut entries = Vec::new(); - let is_loopback = is_loopback_host(host); - - if is_loopback { - for candidate in ["localhost", "127.0.0.1", "::1"] { - entries.push(format_proxy_host_port(candidate, port)); - } - } else { - entries.push(format_proxy_host_port(host, port)); - } - - entries -} - -fn format_proxy_host_port(host: &str, port: i64) -> String { - if host.contains(':') { - format!("[{host}]:{port}") - } else { - format!("{host}:{port}") - } -} - fn is_loopback_host(host: &str) -> bool { let host_lower = host.to_ascii_lowercase(); host_lower == "localhost" || host == "127.0.0.1" || host == "::1" @@ -108,16 +63,21 @@ fn is_loopback_host(host: &str) -> bool { struct ProxyPorts { http: Vec, socks: Vec, + has_proxy_env: bool, + has_non_loopback_proxy_env: bool, } fn proxy_ports_from_env(env: &HashMap) -> ProxyPorts { let mut http_ports = BTreeSet::new(); let mut socks_ports = BTreeSet::new(); + let mut has_proxy_env = false; + let mut has_non_loopback_proxy_env = false; for key in PROXY_ENV_KEYS { let Some(proxy_url) = env.get(*key) else { continue; }; + has_proxy_env = true; let Some((host, port)) = network_proxy::proxy_host_port(proxy_url) else { continue; }; @@ -125,6 +85,7 @@ fn proxy_ports_from_env(env: &HashMap) -> ProxyPorts { continue; }; if !is_loopback_host(&host) { + has_non_loopback_proxy_env = true; continue; } let scheme = proxy_url_scheme(proxy_url).unwrap_or("http"); @@ -138,6 +99,8 @@ fn proxy_ports_from_env(env: &HashMap) -> ProxyPorts { ProxyPorts { http: http_ports.into_iter().collect(), socks: socks_ports.into_iter().collect(), + has_proxy_env, + has_non_loopback_proxy_env, } } @@ -153,31 +116,16 @@ fn normalize_proxy_port(port: i64) -> Option { } } -fn normalize_unix_socket_path(path: &str) -> Option { - let trimmed = path.trim(); - if trimmed.is_empty() { - return None; - } - let path_buf = PathBuf::from(trimmed); - let normalized = if path_buf.is_absolute() { - path_buf.canonicalize().unwrap_or(path_buf) - } else { - path_buf - }; - Some(normalized.to_string_lossy().to_string()) -} - fn escape_sbpl_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } -fn build_network_policy( - proxy_allowlist: &[String], - policy: &network_proxy::NetworkPolicy, - proxy_ports: &ProxyPorts, -) -> String { +fn build_network_policy(policy: &network_proxy::NetworkPolicy, proxy_ports: &ProxyPorts) -> String { let mut network_rules = String::from("; Network\n"); - if proxy_allowlist.is_empty() { + // On macOS, `sandbox-exec` only accepts `localhost` or `*` in network + // addresses. We use loopback proxy ports + the network proxy itself to + // enforce per-domain policy and prompting. + if !proxy_ports.has_proxy_env { network_rules.push_str("(allow network*)\n"); return format!("{network_rules}{MACOS_SEATBELT_NETWORK_POLICY_BASE}"); } @@ -189,15 +137,9 @@ fn build_network_policy( } if !policy.allow_unix_sockets.is_empty() { - let mut seen = HashSet::new(); - for socket_path in &policy.allow_unix_sockets { - let Some(normalized) = normalize_unix_socket_path(socket_path) else { - continue; - }; - if !seen.insert(normalized.clone()) { - continue; - } - let escaped = escape_sbpl_string(&normalized); + for socket_path in network_proxy::resolve_unix_socket_allowlist(&policy.allow_unix_sockets) + { + let escaped = escape_sbpl_string(&socket_path.to_string_lossy()); network_rules.push_str(&format!("(allow network* (subpath \"{escaped}\"))\n")); } } @@ -226,12 +168,10 @@ fn build_network_policy( )); } - let mut outbound = String::from("(allow network-outbound\n"); - for endpoint in proxy_allowlist { - outbound.push_str(&format!(" (remote tcp \"{endpoint}\")\n")); + if proxy_ports.has_non_loopback_proxy_env { + network_rules + .push_str("; NOTE: Non-loopback proxies are not supported under `sandbox-exec`.\n"); } - outbound.push_str(")\n"); - network_rules.push_str(&outbound); format!("{network_rules}{MACOS_SEATBELT_NETWORK_POLICY_BASE}") } @@ -307,13 +247,12 @@ pub(crate) fn create_seatbelt_command_args( // TODO(mbolin): apply_patch calls must also honor the SandboxPolicy. let network_policy = if sandbox_policy.has_full_network_access() { - let proxy_allowlist = proxy_allowlist_from_env(env); let proxy_ports = proxy_ports_from_env(env); let policy = config::default_config_path() .ok() .and_then(|path| network_proxy::load_network_policy(&path).ok()) .unwrap_or_default(); - build_network_policy(&proxy_allowlist, &policy, &proxy_ports) + build_network_policy(&policy, &proxy_ports) } else { String::new() }; @@ -382,17 +321,24 @@ mod tests { impl CodexHomeGuard { fn new(path: &Path) -> Self { let previous = std::env::var("CODEX_HOME").ok(); - std::env::set_var("CODEX_HOME", path); + // SAFETY: these tests execute serially, and we restore the original value in Drop. + unsafe { + std::env::set_var("CODEX_HOME", path); + } Self { previous } } } impl Drop for CodexHomeGuard { fn drop(&mut self) { - if let Some(previous) = self.previous.take() { - std::env::set_var("CODEX_HOME", previous); - } else { - std::env::remove_var("CODEX_HOME"); + // SAFETY: these tests execute serially, and we restore the original value before other + // tests run. + unsafe { + if let Some(previous) = self.previous.take() { + std::env::set_var("CODEX_HOME", previous); + } else { + std::env::remove_var("CODEX_HOME"); + } } } } @@ -506,6 +452,7 @@ mod tests { .current_dir(&cwd) .output() .expect("execute seatbelt command"); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert_eq!( "sandbox_mode = \"read-only\"\n", String::from_utf8_lossy(&fs::read(&config_toml).expect("read config.toml")), @@ -516,8 +463,14 @@ mod tests { "command to write {} should fail under seatbelt", &config_toml.display() ); + if stderr.starts_with("sandbox-exec: sandbox_apply:") { + // Some environments (including Codex's own test harness) run the process under a + // Seatbelt sandbox already, which prevents nested `sandbox-exec` usage. In that case, + // we can still validate policy generation but cannot validate enforcement. + return; + } assert_eq!( - String::from_utf8_lossy(&output.stderr), + stderr, format!("bash: {}: Operation not permitted\n", config_toml.display()), ); @@ -730,8 +683,12 @@ mod tests { "expected seatbelt policy to allow local proxy outbound" ); assert!( - policy_text.contains("(remote tcp \"127.0.0.1:3128\")"), - "expected seatbelt policy to include the proxy allowlist" + !policy_text.contains("(remote tcp"), + "`sandbox-exec` network addresses only support `localhost` or `*`, so we must not emit host allowlists" + ); + assert!( + !policy_text.contains("127.0.0.1:3128"), + "seatbelt policy must not include numeric loopback hosts (it will fail to parse)" ); assert!( !policy_text.contains("localhost:*"), diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index db8c15d459..37a4bb0a4a 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -21,7 +21,6 @@ use crate::protocol::EventMsg; use crate::protocol::ExecCommandBeginEvent; use crate::protocol::ExecCommandEndEvent; use crate::protocol::ExecCommandSource; -use crate::protocol::SandboxPolicy; use crate::protocol::TaskStartedEvent; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 255e9406c7..8b63441aaf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -318,6 +318,8 @@ pub(crate) struct App { network_proxy_pending: HashSet, network_proxy_session_restore: HashMap, + unix_socket_pending: HashSet, + unix_socket_session_restore: HashSet, } impl App { @@ -450,6 +452,8 @@ impl App { 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.network_proxy.prompt_on_block { @@ -1164,6 +1168,32 @@ impl App { 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, @@ -1191,20 +1221,20 @@ impl App { return Ok(true); } let reason = request.reason.trim(); - if reason.eq_ignore_ascii_case("not_allowed") { - if 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("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" + ); } } } @@ -1221,6 +1251,30 @@ impl App { self.network_proxy_pending.insert(host); self.chat_widget.on_network_approval_request(request); } + AppEvent::UnixSocketApprovalRequest(request) => { + if !self.config.network_proxy.prompt_on_block { + 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() { @@ -1321,6 +1375,71 @@ impl App { 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) } @@ -1348,7 +1467,9 @@ impl App { } async fn restore_network_proxy_approvals(&mut self) -> Result<()> { - if self.network_proxy_session_restore.is_empty() { + if self.network_proxy_session_restore.is_empty() + && self.unix_socket_session_restore.is_empty() + { return Ok(()); } let config_path = codex_config_path()?; @@ -1381,6 +1502,10 @@ impl App { .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(()) } @@ -1566,6 +1691,8 @@ mod tests { 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(), } } @@ -1608,6 +1735,8 @@ mod tests { 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 39e6bf1683..ae023b30aa 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -175,12 +175,22 @@ pub(crate) enum AppEvent { /// 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, @@ -200,6 +210,20 @@ pub(crate) enum NetworkProxyDecision { 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 ae4ef746c9..d02d3a4644 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -3,6 +3,8 @@ 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; @@ -57,6 +59,9 @@ pub(crate) enum ApprovalRequest { Network { request: NetworkProxyBlockedRequest, }, + UnixSocket { + request: UnixSocketApprovalRequest, + }, McpElicitation { server_name: String, request_id: RequestId, @@ -129,6 +134,9 @@ impl ApprovalOverlay { 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."), @@ -187,6 +195,16 @@ impl ApprovalOverlay { (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, @@ -229,6 +247,22 @@ impl ApprovalOverlay { }); } + 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, @@ -311,6 +345,17 @@ impl BottomPaneView for ApprovalOverlay { 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, @@ -463,6 +508,35 @@ impl From for ApprovalRequestState { 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, @@ -520,6 +594,11 @@ enum ApprovalVariant { host: String, preflight_only: bool, }, + UnixSocket { + socket_path: String, + allow_entry: String, + label: String, + }, McpElicitation { server_name: String, request_id: RequestId, @@ -530,6 +609,7 @@ enum ApprovalVariant { enum ApprovalDecision { Review(ReviewDecision), Network(NetworkProxyDecision), + UnixSocket(UnixSocketDecision), McpElicitation(ElicitationAction), } @@ -638,6 +718,29 @@ fn network_options(preflight_only: bool) -> Vec { 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 d9fba2c087..d174196060 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -88,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; @@ -358,6 +359,7 @@ pub(crate) struct ChatWidget { 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 @@ -366,10 +368,15 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, } +enum PendingExecBlock { + NetworkHost(String), + UnixSocket(String), +} + struct PendingExecApproval { id: String, event: ExecApprovalRequestEvent, - host: String, + block: PendingExecBlock, } struct UserMessage { @@ -865,6 +872,14 @@ impl ChatWidget { ); } + 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( @@ -1207,6 +1222,18 @@ 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) { @@ -1225,7 +1252,7 @@ impl ChatWidget { self.pending_exec_approval = Some(PendingExecApproval { id, event: ev, - host: request.host.clone(), + block: PendingExecBlock::NetworkHost(request.host.clone()), }); self.app_event_tx .send(AppEvent::NetworkProxyApprovalRequest(request)); @@ -1261,16 +1288,21 @@ impl ChatWidget { }); return; } - self.show_exec_approval(pending.id, pending.event); + 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() { - self.add_to_history(history_cell::new_error_event(format!( - "Exec canceled because network access to {} was denied.", - pending.host - ))); + 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, @@ -1286,6 +1318,14 @@ impl ChatWidget { 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() } @@ -1303,6 +1343,14 @@ impl ChatWidget { .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, @@ -1322,6 +1370,33 @@ impl ChatWidget { 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, @@ -1353,6 +1428,18 @@ impl ChatWidget { 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(); @@ -1552,6 +1639,7 @@ impl ChatWidget { 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, @@ -1640,6 +1728,7 @@ impl ChatWidget { 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, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 0a1efc8d37..9ca5a6cfb5 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -10,6 +10,8 @@ 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)] @@ -17,6 +19,7 @@ pub(crate) enum QueuedInterrupt { ExecApproval(String, ExecApprovalRequestEvent), ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), NetworkApproval(NetworkProxyBlockedRequest), + UnixSocketApproval(UnixSocketApprovalRequest), Elicitation(ElicitationRequestEvent), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), @@ -60,6 +63,11 @@ impl InterruptManager { .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)); } @@ -94,6 +102,9 @@ impl InterruptManager { 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 99d1aded27..752ccc30d4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -407,6 +407,7 @@ fn make_chatwidget_manual( 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, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b34097d224..621ba6d99c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,4 +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; @@ -579,6 +580,50 @@ pub fn new_network_approval_decision_cell( )) } +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 547260bbee..1a6ee06ca5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -382,6 +382,11 @@ Notes: - `no_proxy` entries bypass the proxy; defaults include localhost + private network ranges. Use sparingly because bypassed traffic is not filtered by the proxy policy. - `[network_proxy.policy]` can optionally allow localhost binding or Unix socket access (macOS only) when proxy-restricted network access is active. - When `prompt_on_block = true`, Codex polls the proxy admin API (`/blocked`) and surfaces a prompt to allow for the session, allow always (add to allowlist), or deny (add to denylist). Allow/deny decisions update `~/.codex/config.toml` under `[network_proxy.policy]`, then Codex calls `/reload`. +- 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`). + 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.\* @@ -984,7 +989,7 @@ Valid values: | `network_proxy.policy.allowed_domains` | array | Allowlist of domain patterns (denylist takes precedence). | | `network_proxy.policy.denied_domains` | array | Denylist of domain patterns (takes precedence over allowlist). | | `network_proxy.policy.allow_local_binding` | boolean | Allow localhost binding when proxy-restricted (macOS only, default: false). | -| `network_proxy.policy.allow_unix_sockets` | array | Allow specific Unix socket paths when proxy-restricted (macOS only, default: []). | +| `network_proxy.policy.allow_unix_sockets` | array | Allow Unix socket paths when proxy-restricted (macOS only, default: []). Supports `$SSH_AUTH_SOCK` and the `ssh-agent` preset. | | `network_proxy.mitm.enabled` | boolean | Enable HTTPS MITM for read-only enforcement in limited mode (default: false). | | `network_proxy.mitm.inspect` | boolean | Enable body inspection in MITM mode (default: false). | | `network_proxy.mitm.max_body_bytes` | number | Max body bytes to buffer when inspection is enabled (default: 4096). | diff --git a/docs/example-config.md b/docs/example-config.md index 7964a03c0d..a1505fb41d 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -133,6 +133,10 @@ poll_interval_ms = 1000 # Allow localhost binds inside the sandbox (macOS only). Default: false allow_local_binding = false # Allow Unix socket paths inside the sandbox (macOS only). Default: [] +# Common values: +# - "$SSH_AUTH_SOCK" (recommended) or "${SSH_AUTH_SOCK}" +# - "ssh-agent" (alias: "ssh_auth_sock", "ssh_auth_socket") +# - an absolute socket path like "/private/tmp/..." (or a directory containing sockets) allow_unix_sockets = [] # Optional domain allow/deny lists (denylist wins) allowed_domains = [] diff --git a/docs/network-proxy-design.md b/docs/network-proxy-design.md index 77bf005347..7cc7489b6a 100644 --- a/docs/network-proxy-design.md +++ b/docs/network-proxy-design.md @@ -45,7 +45,7 @@ flowchart LR The proxy reads `~/.codex/config.toml`: - `[network_proxy]` for endpoints, mode, and toggles. -- `[network_proxy.policy]` for `allowed_domains` / `denied_domains`. +- `[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 is the source of truth. Approval actions update the config and trigger a proxy reload. @@ -56,6 +56,19 @@ Codex is the source of truth. Approval actions update the config and trigger a p - **Limited mode:** only GET/HEAD/OPTIONS are permitted. HTTPS requires MITM to enforce method constraints; otherwise CONNECT is blocked with a clear reason. - **Full mode:** all methods allowed; CONNECT tunneling is permitted without MITM. +## macOS Sandbox Integration (Seatbelt) + +On macOS, Codex uses Seatbelt (`sandbox-exec`) for OS-level enforcement. + +Key points: + +- **Per-domain gating happens in the proxy**, not in Seatbelt: Seatbelt network rules are intentionally limited to loopback proxy ports (e.g. `localhost:3128` / `localhost:8081`) so all outbound traffic is forced through the proxy, which then applies the allow/deny policy and prompts. +- **Local IPC is deny-by-default** when proxy-restricted network access is active. Some tools rely on Unix domain sockets (e.g. the SSH agent). These are blocked unless explicitly allowed via: + - `network_proxy.policy.allow_unix_sockets` (absolute socket paths, `$SSH_AUTH_SOCK`, or the `ssh-agent` preset), and/or + - `network_proxy.policy.allow_local_binding` (if you need to bind/listen on localhost ports). + +When approvals are enabled, Codex can preflight commands that appear to require the SSH agent and prompt to allow the SSH agent socket before running. + ## Logging and Auditability The proxy logs: diff --git a/docs/network-proxy-quickstart.md b/docs/network-proxy-quickstart.md index eb1d312d65..c6d8f31458 100644 --- a/docs/network-proxy-quickstart.md +++ b/docs/network-proxy-quickstart.md @@ -33,6 +33,10 @@ poll_interval_ms = 1000 [network_proxy.policy] allowed_domains = ["example.com", "*.github.com"] denied_domains = ["metadata.google.internal", "169.254.*"] +# macOS only: allow specific local IPC when proxy-restricted. +allow_local_binding = false +# Example: allow SSH agent socket for git/ssh. +allow_unix_sockets = ["$SSH_AUTH_SOCK"] [network_proxy.mitm] enabled = false diff --git a/docs/sandbox.md b/docs/sandbox.md index b68bf10d52..2cc2eb5747 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -80,6 +80,15 @@ Network access is controlled through a proxy server running outside the sandbox: On macOS, `[network_proxy.policy]` can also allow localhost binding or Unix socket paths when proxy-restricted network access is active. These settings influence the Seatbelt profile. +Unix sockets are deny-by-default. If you run tools that rely on local IPC (most commonly the SSH agent via `SSH_AUTH_SOCK`), you can allow them via: + +```toml +[network_proxy.policy] +allow_unix_sockets = ["$SSH_AUTH_SOCK"] +``` + +When 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