From 28c0089060b06bf73118ba906f198a1ca8630ad6 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 20 Feb 2026 10:56:57 -0800 Subject: [PATCH] fix(network-proxy): add unix socket allow-all and update seatbelt rules (#11368) ## Summary Adds support for a Unix socket escape hatch so we can bypass socket allowlisting when explicitly enabled. ## Description * added a new flag, `network.dangerously_allow_all_unix_sockets` as an explicit escape hatch * In codex-network-proxy, enabling that flag now allows any absolute Unix socket path from x-unix-socket instead of requiring each path to be explicitly allowlisted. Relative paths are still rejected. * updated the macOS seatbelt path in core so it enforces the same Unix socket behavior: * allowlisted sockets generate explicit network* subpath rules * allow-all generates a broad network* (subpath "/") rule --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- .../codex_app_server_protocol.schemas.json | 6 + .../v2/ConfigRequirementsReadResponse.json | 6 + .../typescript/v2/NetworkRequirements.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/app-server/src/config_api.rs | 3 + codex-rs/config/src/config_requirements.rs | 9 + codex-rs/core/config.schema.json | 3 + codex-rs/core/src/config/mod.rs | 1 + .../core/src/config/network_proxy_spec.rs | 7 + codex-rs/core/src/config/permissions.rs | 4 + codex-rs/core/src/network_proxy_loader.rs | 23 ++ codex-rs/core/src/seatbelt.rs | 233 +++++++++++++++++- codex-rs/network-proxy/README.md | 10 +- codex-rs/network-proxy/src/config.rs | 107 +++++++- codex-rs/network-proxy/src/http_proxy.rs | 4 +- codex-rs/network-proxy/src/proxy.rs | 22 +- codex-rs/network-proxy/src/runtime.rs | 103 +++++++- codex-rs/network-proxy/src/state.rs | 21 ++ codex-rs/tui/src/debug_config.rs | 6 + 19 files changed, 553 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e1e0137b7..5a840f0bd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -13466,6 +13466,12 @@ "null" ] }, + "dangerouslyAllowAllUnixSockets": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowNonLoopbackAdmin": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 4d8a96737..651e035e9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -117,6 +117,12 @@ "null" ] }, + "dangerouslyAllowAllUnixSockets": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowNonLoopbackAdmin": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index b7ac9d2f7..6205de1f4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index dfb185b40..2641d7c2a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -577,6 +577,7 @@ pub struct NetworkRequirements { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index e1531dce2..c9317ac9f 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -161,6 +161,7 @@ fn map_network_requirements_to_api( allow_upstream_proxy: network.allow_upstream_proxy, dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin: network.dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, allowed_domains: network.allowed_domains, denied_domains: network.denied_domains, allow_unix_sockets: network.allow_unix_sockets, @@ -221,6 +222,7 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_non_loopback_admin: Some(false), + dangerously_allow_all_unix_sockets: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), @@ -258,6 +260,7 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_non_loopback_admin: Some(false), + dangerously_allow_all_unix_sockets: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index c6b63258f..db81b45e3 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -135,6 +135,7 @@ pub struct NetworkRequirementsToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -150,6 +151,7 @@ pub struct NetworkConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -165,6 +167,7 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -177,6 +180,7 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -1040,6 +1044,7 @@ mod tests { [experimental_network] enabled = true allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true allowed_domains = ["api.example.com", "*.openai.com"] denied_domains = ["blocked.example.com"] allow_unix_sockets = ["/tmp/example.sock"] @@ -1058,6 +1063,10 @@ mod tests { assert_eq!(sourced_network.source, source); assert_eq!(sourced_network.value.enabled, Some(true)); assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); assert_eq!( sourced_network.value.allowed_domains.as_ref(), Some(&vec![ diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5eab764fc..732f9590f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -656,6 +656,9 @@ }, "type": "array" }, + "dangerously_allow_all_unix_sockets": { + "type": "boolean" + }, "dangerously_allow_non_loopback_admin": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a27508d1a..1675067c7 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2401,6 +2401,7 @@ allowed_domains = ["openai.com"] allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: None, dangerously_allow_non_loopback_admin: None, + dangerously_allow_all_unix_sockets: None, mode: None, allowed_domains: Some(vec!["openai.com".to_string()]), denied_domains: None, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 1957455d6..cd65a4dc4 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -178,6 +178,13 @@ impl NetworkProxySpec { constraints.dangerously_allow_non_loopback_admin = Some(dangerously_allow_non_loopback_admin); } + if let Some(dangerously_allow_all_unix_sockets) = + requirements.dangerously_allow_all_unix_sockets + { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + constraints.dangerously_allow_all_unix_sockets = + Some(dangerously_allow_all_unix_sockets); + } if let Some(allowed_domains) = requirements.allowed_domains.clone() { config.network.allowed_domains = allowed_domains.clone(); constraints.allowed_domains = Some(allowed_domains); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index a03b5273d..dd242bd85 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -24,6 +24,7 @@ pub struct NetworkToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, #[schemars(with = "Option")] pub mode: Option, pub allowed_domains: Option>, @@ -74,6 +75,9 @@ impl NetworkToml { config.network.dangerously_allow_non_loopback_admin = dangerously_allow_non_loopback_admin; } + if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + } if let Some(mode) = self.mode { config.network.mode = mode; } diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 6fcf91f08..1af36b2ac 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -133,6 +133,9 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx constraints.dangerously_allow_non_loopback_admin = Some(dangerously_allow_non_loopback_admin); } + if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets { + constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); + } if let Some(allowed_domains) = network.allowed_domains { constraints.allowed_domains = Some(allowed_domains); } @@ -288,4 +291,24 @@ allowed_domains = ["higher.example.com"] assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); } + + #[test] + fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { + let config: toml::Value = toml::from_str( + r#" +[network] +dangerously_allow_all_unix_sockets = true +"#, + ) + .expect("network table should parse"); + let network = network_tables_from_toml(&config) + .expect("network table should deserialize") + .network + .expect("network table should be present"); + + let mut constraints = NetworkProxyConstraints::default(); + apply_network_constraints(network, &mut constraints); + + assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); + } } diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 03cb45d91..e9d8879f8 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,16 +1,18 @@ #![cfg(target_os = "macos")] -use codex_network_proxy::ALLOW_LOCAL_BINDING_ENV_KEY; use codex_network_proxy::NetworkProxy; use codex_network_proxy::PROXY_URL_ENV_KEYS; use codex_network_proxy::has_proxy_url_env_vars; use codex_network_proxy::proxy_url_env_value; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; use tokio::process::Child; +use tracing::warn; use url::Url; use crate::protocol::SandboxPolicy; @@ -110,34 +112,131 @@ fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { ports.into_iter().collect() } -fn local_binding_enabled(env: &HashMap) -> bool { - env.get(ALLOW_LOCAL_BINDING_ENV_KEY).is_some_and(|value| { - let trimmed = value.trim(); - trimmed == "1" || trimmed.eq_ignore_ascii_case("true") - }) -} - #[derive(Debug, Default)] struct ProxyPolicyInputs { ports: Vec, has_proxy_config: bool, allow_local_binding: bool, + unix_domain_socket_policy: UnixDomainSocketPolicy, +} + +#[derive(Debug, Clone)] +// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. +enum UnixDomainSocketPolicy { + AllowAll, + Restricted { allowed: Vec }, +} + +impl Default for UnixDomainSocketPolicy { + fn default() -> Self { + Self::Restricted { allowed: vec![] } + } +} + +#[derive(Debug, Clone)] +struct UnixSocketPathParam { + key: String, + path: AbsolutePathBuf, } fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { if let Some(network) = network { let mut env = HashMap::new(); network.apply_to_env(&mut env); + let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { + UnixDomainSocketPolicy::AllowAll + } else { + let allowed = network + .allow_unix_sockets() + .iter() + .filter_map( + |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { + Some(path) => Some((path.to_string_lossy().to_string(), path)), + None => { + warn!( + "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" + ); + None + } + }, + ) + .collect::>() + .into_values() + .collect(); + UnixDomainSocketPolicy::Restricted { allowed } + }; return ProxyPolicyInputs { ports: proxy_loopback_ports_from_env(&env), has_proxy_config: has_proxy_url_env_vars(&env), - allow_local_binding: local_binding_enabled(&env), + allow_local_binding: network.allow_local_binding(), + unix_domain_socket_policy, }; } ProxyPolicyInputs::default() } +fn normalize_path_for_sandbox(path: &Path) -> Option { + // `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current + // working directory, so keep the explicit check to avoid silently accepting relative entries. + if !path.is_absolute() { + return None; + } + + let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?; + let normalized_path = absolute_path + .as_path() + .canonicalize() + .ok() + .and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok()); + normalized_path.or(Some(absolute_path)) +} + +fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { + let mut deduped_paths: BTreeMap = BTreeMap::new(); + let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { + return vec![]; + }; + for path in allowed { + deduped_paths + .entry(path.to_string_lossy().to_string()) + .or_insert_with(|| path.clone()); + } + + deduped_paths + .into_values() + .enumerate() + .map(|(index, path)| UnixSocketPathParam { + key: format!("UNIX_SOCKET_PATH_{index}"), + path, + }) + .collect() +} + +fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { + unix_socket_path_params(proxy) + .into_iter() + .map(|param| (param.key, param.path.into_path_buf())) + .collect() +} + +/// Returns zero or more complete Seatbelt policy lines for unix socket rules. +/// When non-empty, the returned string is newline-terminated so callers can +/// append it directly to larger policy blocks. +fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String { + if matches!( + proxy.unix_domain_socket_policy, + UnixDomainSocketPolicy::AllowAll + ) { + return "(allow network* (subpath \"/\"))\n".to_string(); + } + + unix_socket_path_params(proxy) + .iter() + .map(|param| format!("(allow network* (subpath (param \"{}\")))\n", param.key)) + .collect() +} + fn dynamic_network_policy( sandbox_policy: &SandboxPolicy, enforce_managed_network: bool, @@ -157,6 +256,11 @@ fn dynamic_network_policy( "(allow network-outbound (remote ip \"localhost:{port}\"))\n" )); } + let unix_socket_policy = unix_socket_policy(proxy); + if !unix_socket_policy.is_empty() { + policy.push_str("; allow unix domain sockets for local IPC\n"); + policy.push_str(&unix_socket_policy); + } return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); } @@ -339,6 +443,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions( file_write_dir_params, unix_socket_params, macos_dir_params(), + unix_socket_dir_params(&proxy), seatbelt_extensions.dir_params, ] .concat(); @@ -404,15 +509,20 @@ fn macos_dir_params() -> Vec<(String, PathBuf)> { mod tests { use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; + use super::UnixDomainSocketPolicy; use super::create_seatbelt_command_args; use super::create_seatbelt_command_args_with_extensions; use super::dynamic_network_policy; use super::macos_dir_params; + use super::normalize_path_for_sandbox; + use super::unix_socket_dir_params; + use super::unix_socket_policy; use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::seatbelt_permissions::MacOsAutomationPermission; use crate::seatbelt_permissions::MacOsPreferencesPermission; use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -430,6 +540,10 @@ mod tests { ); } + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(Path::new(path)).expect("absolute path") + } + #[test] fn base_policy_allows_node_cpu_sysctls() { assert!( @@ -451,6 +565,7 @@ mod tests { ports: vec![43128, 48081], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); @@ -574,6 +689,7 @@ mod tests { ports: vec![43128], has_proxy_config: true, allow_local_binding: true, + ..ProxyPolicyInputs::default() }, ); @@ -610,6 +726,7 @@ mod tests { ports: vec![], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); @@ -638,12 +755,109 @@ mod tests { ports: vec![], has_proxy_config: false, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); assert_eq!(policy, ""); } + #[test] + fn create_seatbelt_args_allowlists_unix_socket_paths() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![absolute_path("/tmp/example.sock")], + }, + }, + ); + + assert!( + policy.contains("(allow network* (subpath (param \"UNIX_SOCKET_PATH_0\")))"), + "policy should allow explicitly configured unix sockets:\n{policy}" + ); + } + + #[test] + fn unix_socket_policy_non_empty_output_is_newline_terminated() { + let allowlist_policy = unix_socket_policy(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![absolute_path("/tmp/example.sock")], + }, + ..ProxyPolicyInputs::default() + }); + assert!( + allowlist_policy.ends_with('\n'), + "allowlist unix socket policy should end with a newline:\n{allowlist_policy}" + ); + + let allow_all_policy = unix_socket_policy(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, + ..ProxyPolicyInputs::default() + }); + assert!( + allow_all_policy.ends_with('\n'), + "allow-all unix socket policy should end with a newline:\n{allow_all_policy}" + ); + } + + #[test] + fn unix_socket_dir_params_use_stable_param_names() { + let params = unix_socket_dir_params(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![ + absolute_path("/tmp/b.sock"), + absolute_path("/tmp/a.sock"), + absolute_path("/tmp/a.sock"), + ], + }, + ..ProxyPolicyInputs::default() + }); + + assert_eq!( + params, + vec![ + ( + "UNIX_SOCKET_PATH_0".to_string(), + PathBuf::from("/tmp/a.sock") + ), + ( + "UNIX_SOCKET_PATH_1".to_string(), + PathBuf::from("/tmp/b.sock") + ), + ] + ); + } + + #[test] + fn normalize_path_for_sandbox_rejects_relative_paths() { + assert_eq!(normalize_path_for_sandbox(Path::new("relative.sock")), None); + } + + #[test] + fn create_seatbelt_args_allows_all_unix_sockets_when_enabled() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, + }, + ); + + assert!( + policy.contains("(allow network* (subpath \"/\"))"), + "policy should allow all unix sockets when flag is enabled:\n{policy}" + ); + } + #[test] fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { let policy = dynamic_network_policy( @@ -659,6 +873,7 @@ mod tests { ports: vec![43128], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 925d7147b..198dbbd61 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -47,6 +47,9 @@ allow_local_binding = true # macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. allow_unix_sockets = ["/tmp/example.sock"] +# DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any +# absolute socket path from `x-unix-socket`. +dangerously_allow_all_unix_sockets = false ``` ### 2) Run the proxy @@ -116,8 +119,9 @@ let handle = proxy.run().await?; handle.shutdown().await?; ``` -When unix socket proxying is enabled, HTTP/admin bind overrides are still clamped to loopback -to avoid turning the proxy into a remote bridge to local daemons. +When unix socket proxying is enabled (`allow_unix_sockets` or +`dangerously_allow_all_unix_sockets`), HTTP/admin bind overrides are still clamped to loopback to +avoid turning the proxy into a remote bridge to local daemons. ### Policy hook (exec-policy mapping) @@ -176,6 +180,8 @@ what it can reasonably guarantee. `dangerously_allow_non_loopback_proxy` - when unix socket proxying is enabled, both listeners are forced to loopback to avoid turning the proxy into a remote bridge into local daemons. +- `dangerously_allow_all_unix_sockets = true` bypasses the unix socket allowlist entirely (still + macOS-only and absolute-path-only). Use only in tightly controlled environments. - `enabled` is enforced at runtime; when false the proxy no-ops and does not bind listeners. Limitations: diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 31197a1c9..328482223 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -1,10 +1,12 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; use std::net::IpAddr; use std::net::SocketAddr; +use std::path::Path; use tracing::warn; use url::Url; @@ -33,6 +35,8 @@ pub struct NetworkProxySettings { #[serde(default)] pub dangerously_allow_non_loopback_admin: bool, #[serde(default)] + pub dangerously_allow_all_unix_sockets: bool, + #[serde(default)] pub mode: NetworkMode, #[serde(default)] pub allowed_domains: Vec, @@ -55,6 +59,7 @@ impl Default for NetworkProxySettings { allow_upstream_proxy: true, dangerously_allow_non_loopback_proxy: false, dangerously_allow_non_loopback_admin: false, + dangerously_allow_all_unix_sockets: false, mode: NetworkMode::default(), allowed_domains: Vec::new(), denied_domains: Vec::new(), @@ -136,7 +141,7 @@ pub(crate) fn clamp_bind_addrs( cfg.dangerously_allow_non_loopback_admin, "admin API", ); - if cfg.allow_unix_sockets.is_empty() { + if cfg.allow_unix_sockets.is_empty() && !cfg.dangerously_allow_all_unix_sockets { return (http_addr, socks_addr, admin_addr); } @@ -172,7 +177,49 @@ pub struct RuntimeConfig { pub admin_addr: SocketAddr, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct UnixStyleAbsolutePath(String); + +impl UnixStyleAbsolutePath { + fn parse(value: &str) -> Option { + value.starts_with('/').then(|| Self(value.to_string())) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ValidatedUnixSocketPath { + Native(AbsolutePathBuf), + UnixStyleAbsolute(UnixStyleAbsolutePath), +} + +impl ValidatedUnixSocketPath { + pub(crate) fn parse(socket_path: &str) -> Result { + let path = Path::new(socket_path); + if path.is_absolute() { + let path = AbsolutePathBuf::from_absolute_path(path) + .with_context(|| format!("failed to normalize unix socket path {socket_path:?}"))?; + return Ok(Self::Native(path)); + } + + if let Some(path) = UnixStyleAbsolutePath::parse(socket_path) { + return Ok(Self::UnixStyleAbsolute(path)); + } + + bail!("expected an absolute path, got {socket_path:?}"); + } +} + +pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> { + for (index, socket_path) in cfg.network.allow_unix_sockets.iter().enumerate() { + ValidatedUnixSocketPath::parse(socket_path) + .with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?; + } + Ok(()) +} + pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result { + validate_unix_socket_allowlist_paths(cfg)?; + let http_addr = resolve_addr(&cfg.network.proxy_url, 3128) .with_context(|| format!("invalid network.proxy_url: {}", cfg.network.proxy_url))?; let socks_addr = resolve_addr(&cfg.network.socks_url, 8081) @@ -340,6 +387,7 @@ mod tests { allow_upstream_proxy: true, dangerously_allow_non_loopback_proxy: false, dangerously_allow_non_loopback_admin: false, + dangerously_allow_all_unix_sockets: false, mode: NetworkMode::Full, allowed_domains: Vec::new(), denied_domains: Vec::new(), @@ -526,4 +574,61 @@ mod tests { assert_eq!(socks_addr, "127.0.0.1:8081".parse::().unwrap()); assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); } + + #[test] + fn clamp_bind_addrs_forces_loopback_when_all_unix_sockets_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + dangerously_allow_all_unix_sockets: true, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let socks_addr = "0.0.0.0:8081".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, socks_addr, admin_addr) = + clamp_bind_addrs(http_addr, socks_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "127.0.0.1:3128".parse::().unwrap()); + assert_eq!(socks_addr, "127.0.0.1:8081".parse::().unwrap()); + assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); + } + + #[test] + fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() { + let cfg = NetworkProxyConfig { + network: NetworkProxySettings { + allow_unix_sockets: vec!["relative.sock".to_string()], + ..NetworkProxySettings::default() + }, + }; + + let err = match resolve_runtime(&cfg) { + Ok(runtime) => panic!( + "relative allow_unix_sockets should fail, but resolve_runtime succeeded: {:?}", + runtime.http_addr + ), + Err(err) => err, + }; + assert!( + err.to_string().contains("network.allow_unix_sockets[0]"), + "error should point at the invalid allow_unix_sockets entry: {err:#}" + ); + } + + #[test] + fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() { + let cfg = NetworkProxyConfig { + network: NetworkProxySettings { + allow_unix_sockets: vec!["/private/tmp/example.sock".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!( + resolve_runtime(&cfg).is_ok(), + "unix-style absolute allow_unix_sockets entry should be accepted" + ); + } } diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index d02e02bcf..7ea88ba34 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -378,8 +378,8 @@ async fn http_plain_proxy( }; // `x-unix-socket` is an escape hatch for talking to local daemons. We keep it tightly scoped: - // macOS-only + explicit allowlist, to avoid turning the proxy into a general local capability - // escalation mechanism. + // macOS-only + explicit allowlist by default, to avoid turning the proxy into a general local + // capability escalation mechanism. if let Some(unix_socket_header) = req.headers().get("x-unix-socket") { let socket_path = match unix_socket_header.to_str() { Ok(value) => value.to_string(), diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ba385dbba..f30006958 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -206,6 +206,10 @@ impl NetworkProxyBuilder { socks_addr, socks_enabled: current_cfg.network.enable_socks5, allow_local_binding: current_cfg.network.allow_local_binding, + allow_unix_sockets: current_cfg.network.allow_unix_sockets.clone(), + dangerously_allow_all_unix_sockets: current_cfg + .network + .dangerously_allow_all_unix_sockets, admin_addr, reserved_listeners, policy_decider: self.policy_decider, @@ -240,6 +244,8 @@ pub struct NetworkProxy { socks_addr: SocketAddr, socks_enabled: bool, allow_local_binding: bool, + allow_unix_sockets: Vec, + dangerously_allow_all_unix_sockets: bool, admin_addr: SocketAddr, reserved_listeners: Option>, policy_decider: Option>, @@ -419,6 +425,18 @@ impl NetworkProxy { self.admin_addr } + pub fn allow_local_binding(&self) -> bool { + self.allow_local_binding + } + + pub fn allow_unix_sockets(&self) -> &[String] { + &self.allow_unix_sockets + } + + pub fn dangerously_allow_all_unix_sockets(&self) -> bool { + self.dangerously_allow_all_unix_sockets + } + pub fn apply_to_env(&self, env: &mut HashMap) { // Enforce proxying for child processes. We intentionally override existing values so // command-level environment cannot bypass the managed proxy endpoint. @@ -441,7 +459,9 @@ impl NetworkProxy { ensure_rustls_crypto_provider(); if !unix_socket_permissions_supported() { - warn!("allowUnixSockets is macOS-only; requests will be rejected on this platform"); + warn!( + "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform" + ); } let reserved_listeners = self.reserved_listeners.as_ref(); diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 238582a6a..583ecd7ed 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -1,5 +1,6 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; +use crate::config::ValidatedUnixSocketPath; use crate::policy::Host; use crate::policy::is_loopback_host; use crate::policy::is_non_public_ip; @@ -418,6 +419,10 @@ impl NetworkProxyState { } let guard = self.state.read().await; + if guard.config.network.dangerously_allow_all_unix_sockets { + return Ok(true); + } + // Normalize the path while keeping the absolute-path requirement explicit. let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) { Ok(path) => path, @@ -425,7 +430,16 @@ impl NetworkProxyState { }; let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); for allowed in &guard.config.network.allow_unix_sockets { - if allowed == path { + let allowed_path = match ValidatedUnixSocketPath::parse(allowed) { + Ok(ValidatedUnixSocketPath::Native(path)) => path, + Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue, + Err(err) => { + warn!("ignoring invalid network.allow_unix_sockets entry at runtime: {err:#}"); + continue; + } + }; + + if allowed_path.as_path() == requested_abs.as_path() { return Ok(true); } @@ -434,7 +448,7 @@ impl NetworkProxyState { let Some(requested_canonical) = &requested_canonical else { continue; }; - if let Ok(allowed_canonical) = std::fs::canonicalize(allowed) + if let Ok(allowed_canonical) = std::fs::canonicalize(allowed_path.as_path()) && &allowed_canonical == requested_canonical { return Ok(true); @@ -1075,6 +1089,77 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); } + #[test] + fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_without_managed_opt_in() + { + let constraints = NetworkProxyConstraints { + dangerously_allow_all_unix_sockets: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_when_allowlist_is_managed() + { + let constraints = NetworkProxyConstraints { + allow_unix_sockets: Some(vec!["/tmp/allowed.sock".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_allow_all_unix_sockets_with_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_all_unix_sockets: Some(true), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn validate_policy_against_constraints_allows_allow_all_unix_sockets_when_unmanaged() { + let constraints = NetworkProxyConstraints::default(); + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + #[test] fn compile_globset_is_case_insensitive() { let patterns = vec!["ExAmPle.CoM".to_string()]; @@ -1172,6 +1257,19 @@ mod tests { assert!(state.is_unix_socket_allowed(&link_s).await.unwrap()); } + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allow_all_flag_bypasses_allowlist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["example.com".to_string()], + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }); + + assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap()); + assert!(!state.is_unix_socket_allowed("relative.sock").await.unwrap()); + } + #[cfg(not(target_os = "macos"))] #[tokio::test] async fn unix_socket_allowlist_is_rejected_on_non_macos() { @@ -1179,6 +1277,7 @@ mod tests { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], allow_unix_sockets: vec![socket_path.clone()], + dangerously_allow_all_unix_sockets: true, ..NetworkProxySettings::default() }); diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 97dfcc4b5..d7dc705ac 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -19,6 +19,7 @@ pub struct NetworkProxyConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -38,6 +39,7 @@ pub struct PartialNetworkConfig { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, #[serde(default)] pub allowed_domains: Option>, #[serde(default)] @@ -52,6 +54,7 @@ pub fn build_config_state( config: NetworkProxyConfig, constraints: NetworkProxyConstraints, ) -> anyhow::Result { + crate::config::validate_unix_socket_allowlist_paths(&config)?; let deny_set = compile_globset(&config.network.denied_domains)?; let allow_set = compile_globset(&config.network.allowed_domains)?; Ok(ConfigState { @@ -173,6 +176,24 @@ pub fn validate_policy_against_constraints( }, )?; + let allow_all_unix_sockets = constraints + .dangerously_allow_all_unix_sockets + .unwrap_or(constraints.allow_unix_sockets.is_none()); + validate( + config.network.dangerously_allow_all_unix_sockets, + move |candidate| { + if *candidate && !allow_all_unix_sockets { + Err(invalid_value( + "network.dangerously_allow_all_unix_sockets", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + }, + )?; + if let Some(allow_local_binding) = constraints.allow_local_binding { validate(config.network.allow_local_binding, move |candidate| { if *candidate && !allow_local_binding { diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index a3f32d298..38faf2d35 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -332,6 +332,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -360,6 +361,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_non_loopback_admin={dangerously_allow_non_loopback_admin}" )); } + if let Some(dangerously_allow_all_unix_sockets) = dangerously_allow_all_unix_sockets { + parts.push(format!( + "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" + )); + } if let Some(allowed_domains) = allowed_domains { parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); }