From 274dd16057c59a8a9993122377c00e6dcc8ce832 Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Tue, 24 Mar 2026 10:01:44 -0400 Subject: [PATCH] Add yolo blocklist-only network mode - Add yolo_only_enforce_blocklist to user config, requirements, API schemas, and debug output. - Make full-access proxy runtime enforce denylist-only mode while preserving deny precedence. - Prevent managed allowlist-only requirements from being bypassed by user blocklist-only yolo settings. - Keep managed-config ignore helper test-only so release builds do not ship an env-var bypass. --- .../codex_app_server_protocol.schemas.json | 6 ++ .../codex_app_server_protocol.v2.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 | 8 ++ codex-rs/core/config.schema.json | 4 + codex-rs/core/src/config/config_tests.rs | 1 + .../core/src/config/network_proxy_spec.rs | 17 +++- .../src/config/network_proxy_spec_tests.rs | 83 +++++++++++++++++++ codex-rs/core/src/config/permissions.rs | 6 ++ codex-rs/core/src/config_loader/layer_io.rs | 12 +++ codex-rs/core/src/config_loader/mod.rs | 35 ++++++-- codex-rs/core/src/config_loader/tests.rs | 38 +++++++++ codex-rs/network-proxy/README.md | 6 ++ codex-rs/network-proxy/src/config.rs | 6 ++ codex-rs/network-proxy/src/runtime.rs | 40 ++++++++- codex-rs/network-proxy/src/state.rs | 19 +++++ codex-rs/tui/src/debug_config.rs | 6 ++ codex-rs/tui_app_server/src/debug_config.rs | 6 ++ 21 files changed, 299 insertions(+), 12 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 6b731a9b50..cf28853a1e 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 @@ -9133,6 +9133,12 @@ "integer", "null" ] + }, + "yoloOnlyEnforceBlocklist": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0d69834f15..0f003e77ef 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5881,6 +5881,12 @@ "integer", "null" ] + }, + "yoloOnlyEnforceBlocklist": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" 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 e0c8304c1f..6abcf38bbc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -176,6 +176,12 @@ "integer", "null" ] + }, + "yoloOnlyEnforceBlocklist": { + "type": [ + "boolean", + "null" + ] } }, "type": "object" 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 1f1653c277..f589a58ea0 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, dangerouslyAllowAllUnixSockets: 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, dangerouslyAllowAllUnixSockets: boolean | null, yoloOnlyEnforceBlocklist: 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 e270d8ad61..0ebb9dfcbd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -871,6 +871,7 @@ pub struct NetworkRequirements { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, + pub yolo_only_enforce_blocklist: 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 bc82e9152e..7028898fa2 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -231,6 +231,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_all_unix_sockets: network.dangerously_allow_all_unix_sockets, + yolo_only_enforce_blocklist: network.yolo_only_enforce_blocklist, allowed_domains: network.allowed_domains, denied_domains: network.denied_domains, allow_unix_sockets: network.allow_unix_sockets, @@ -316,6 +317,7 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), + yolo_only_enforce_blocklist: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), managed_allowed_domains_only: Some(false), denied_domains: Some(vec!["example.com".to_string()]), @@ -361,6 +363,7 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), + yolo_only_enforce_blocklist: 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 57d762c0f1..bc3e1579d5 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -140,6 +140,9 @@ pub struct NetworkRequirementsToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, + /// When true, yolo/full-access sandbox mode only enforces `denied_domains` + /// and allows all other requests through. + pub yolo_only_enforce_blocklist: Option, pub allowed_domains: Option>, /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. @@ -158,6 +161,9 @@ pub struct NetworkConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, + /// When true, yolo/full-access sandbox mode only enforces `denied_domains` + /// and allows all other requests through. + pub yolo_only_enforce_blocklist: Option, pub allowed_domains: Option>, /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. @@ -176,6 +182,7 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, + yolo_only_enforce_blocklist, allowed_domains, managed_allowed_domains_only, denied_domains, @@ -189,6 +196,7 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, + yolo_only_enforce_blocklist, allowed_domains, managed_allowed_domains_only, denied_domains, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0b7cf3ced9..6e37960cc3 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -955,6 +955,10 @@ }, "socks_url": { "type": "string" + }, + "yolo_only_enforce_blocklist": { + "description": "Only applies in yolo/full-access sandbox mode. When enabled, the network proxy enforces `denied_domains` but allows all other requests through.", + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7786b30873..12272d67d3 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -319,6 +319,7 @@ allowed_domains = ["openai.com"] dangerously_allow_non_loopback_proxy: None, dangerously_allow_all_unix_sockets: None, mode: None, + yolo_only_enforce_blocklist: None, allowed_domains: Some(vec!["openai.com".to_string()]), denied_domains: None, allow_unix_sockets: None, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 386100d34b..0f48ca67a0 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -92,7 +92,7 @@ impl NetworkProxySpec { let hard_deny_allowlist_misses = requirements .as_ref() .is_some_and(Self::managed_allowed_domains_only); - let (config, constraints) = if let Some(requirements) = requirements { + let (mut config, constraints) = if let Some(requirements) = requirements { Self::apply_requirements( config, &requirements, @@ -102,6 +102,12 @@ impl NetworkProxySpec { } else { (config, NetworkProxyConstraints::default()) }; + // The blocklist-only escape hatch is intentionally scoped to yolo mode, + // regardless of whether it came from user config or managed requirements. + if !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + config.network.yolo_only_enforce_blocklist = false; + } + validate_policy_against_constraints(&config, &constraints).map_err(|err| { std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -225,6 +231,15 @@ impl NetworkProxySpec { constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); } + if let Some(yolo_only_enforce_blocklist) = requirements.yolo_only_enforce_blocklist { + config.network.yolo_only_enforce_blocklist = yolo_only_enforce_blocklist; + constraints.yolo_only_enforce_blocklist = Some(yolo_only_enforce_blocklist); + } else if hard_deny_allowlist_misses { + // Managed allowlist-only mode must not be bypassable by a user + // opting into blocklist-only yolo behavior. + config.network.yolo_only_enforce_blocklist = false; + constraints.yolo_only_enforce_blocklist = Some(false); + } let managed_allowed_domains = if hard_deny_allowlist_misses { Some(requirements.allowed_domains.clone().unwrap_or_default()) } else { diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 4c6e82358e..142c35186e 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -175,6 +175,89 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m assert!(spec.hard_deny_allowlist_misses); } +#[test] +fn yolo_only_enforce_blocklist_is_ignored_outside_full_access() { + let mut config = NetworkProxyConfig::default(); + config.network.yolo_only_enforce_blocklist = true; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + None, + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("non-yolo modes should load with blocklist-only disabled"); + + assert!(!spec.config.network.yolo_only_enforce_blocklist); +} + +#[test] +fn requirements_can_enable_yolo_only_enforce_blocklist_in_full_access() { + let requirements = NetworkConstraints { + yolo_only_enforce_blocklist: Some(true), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + NetworkProxyConfig::default(), + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("requirements blocklist-only mode should load in yolo"); + + assert!(spec.config.network.yolo_only_enforce_blocklist); + assert_eq!(spec.constraints.yolo_only_enforce_blocklist, Some(true)); + assert_eq!( + spec.config.network.denied_domains, + vec!["blocked.example.com".to_string()] + ); +} + +#[test] +fn requirements_yolo_only_enforce_blocklist_is_ignored_outside_full_access() { + let requirements = NetworkConstraints { + yolo_only_enforce_blocklist: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + NetworkProxyConfig::default(), + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("requirements blocklist-only should be disabled outside yolo"); + + assert!(!spec.config.network.yolo_only_enforce_blocklist); +} + +#[test] +fn managed_allowed_domains_only_disables_user_blocklist_only_yolo_without_explicit_requirement() { + let mut config = NetworkProxyConfig::default(); + config.network.yolo_only_enforce_blocklist = true; + config.network.allowed_domains = vec!["user.example.com".to_string()]; + + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["managed.example.com".to_string()]), + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("managed allowlist-only should disable user blocklist-only yolo"); + + assert!(!spec.config.network.yolo_only_enforce_blocklist); + assert_eq!(spec.constraints.yolo_only_enforce_blocklist, Some(false)); + assert_eq!( + spec.config.network.allowed_domains, + vec!["managed.example.com".to_string()] + ); + assert!(spec.hard_deny_allowlist_misses); +} + #[test] fn requirements_denied_domains_are_a_baseline_for_default_mode() { let mut config = NetworkProxyConfig::default(); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 759c269b76..2ca3f2bd26 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -69,6 +69,9 @@ pub struct NetworkToml { pub dangerously_allow_all_unix_sockets: Option, #[schemars(with = "Option")] pub mode: Option, + /// Only applies in yolo/full-access sandbox mode. When enabled, the network + /// proxy enforces `denied_domains` but allows all other requests through. + pub yolo_only_enforce_blocklist: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -114,6 +117,9 @@ impl NetworkToml { if let Some(mode) = self.mode { config.network.mode = mode; } + if let Some(yolo_only_enforce_blocklist) = self.yolo_only_enforce_blocklist { + config.network.yolo_only_enforce_blocklist = yolo_only_enforce_blocklist; + } if let Some(allowed_domains) = self.allowed_domains.as_ref() { config.network.allowed_domains = allowed_domains.clone(); } diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs index af77bdafa5..d912159195 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/core/src/config_loader/layer_io.rs @@ -39,6 +39,18 @@ pub(super) async fn load_config_layers_internal( codex_home: &Path, overrides: LoaderOverrides, ) -> io::Result { + if super::should_ignore_managed_config() { + #[cfg(test)] + tracing::info!( + "Ignoring managed config layers because {} is set", + super::CODEX_IGNORE_MANAGED_CONFIG_ENV_VAR + ); + return Ok(LoadedConfigLayers { + managed_config: None, + managed_config_from_mdm: None, + }); + } + #[cfg(target_os = "macos")] let LoaderOverrides { managed_config_path, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 0c426b155f..0f6ccf0b89 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -68,6 +68,20 @@ pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; +#[cfg(test)] +/// Local test escape hatch for ignoring managed admin configuration on +/// machines with MDM or system managed config installed. +pub(super) const CODEX_IGNORE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_IGNORE_MANAGED_CONFIG"; + +#[cfg(test)] +pub(super) fn should_ignore_managed_config() -> bool { + std::env::var_os(CODEX_IGNORE_MANAGED_CONFIG_ENV_VAR).is_some() +} + +#[cfg(not(test))] +pub(super) fn should_ignore_managed_config() -> bool { + false +} pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { codex_config::first_layer_config_error::(layers, CONFIG_TOML_FILE).await @@ -126,13 +140,20 @@ pub async fn load_config_layers_state( } #[cfg(target_os = "macos")] - macos::load_managed_admin_requirements_toml( - &mut config_requirements_toml, - overrides - .macos_managed_config_requirements_base64 - .as_deref(), - ) - .await?; + if should_ignore_managed_config() { + #[cfg(test)] + tracing::info!( + "Ignoring managed requirements from MDM because {CODEX_IGNORE_MANAGED_CONFIG_ENV_VAR} is set" + ); + } else { + macos::load_managed_admin_requirements_toml( + &mut config_requirements_toml, + overrides + .macos_managed_config_requirements_base64 + .as_deref(), + ) + .await?; + } // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file()?; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 03be02ebfe..03575998e1 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -12,6 +12,7 @@ use crate::config_loader::ConfigLoadError; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; use crate::config_loader::ConfigRequirementsWithSources; +use crate::config_loader::NetworkRequirementsToml; use crate::config_loader::RequirementSource; use crate::config_loader::load_requirements_toml; use crate::config_loader::version_for_toml; @@ -577,6 +578,43 @@ personality = true Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn load_requirements_toml_parses_yolo_only_enforce_blocklist() -> anyhow::Result<()> { + let tmp = tempdir()?; + let requirements_file = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_file, + r#" +[experimental_network] +yolo_only_enforce_blocklist = true +denied_domains = ["blocked.example.com"] +"#, + ) + .await?; + + let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; + + assert_eq!( + config_requirements_toml.network.as_deref().cloned(), + Some(NetworkRequirementsToml { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + yolo_only_enforce_blocklist: Some(true), + allowed_domains: None, + managed_allowed_domains_only: None, + denied_domains: Some(vec!["blocked.example.com".to_string()]), + allow_unix_sockets: None, + allow_local_binding: None, + }) + ); + Ok(()) +} + #[cfg(target_os = "macos")] #[tokio::test] async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::Result<()> { diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 6fb689490a..c81d6523ba 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -33,6 +33,9 @@ allow_upstream_proxy = true # If you want to expose these listeners beyond localhost, you must opt in explicitly. dangerously_allow_non_loopback_proxy = false mode = "full" # default when unset; use "limited" for read-only mode +# In yolo/full-access sandbox mode only: ignore the allowlist and local/private-network guard, +# enforcing only `denied_domains`. +yolo_only_enforce_blocklist = false # When true, HTTPS CONNECT can be terminated so limited-mode method policy still applies. mitm = false # CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). @@ -191,6 +194,9 @@ what it can reasonably guarantee. - Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. - Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported; the global `*` wildcard is rejected. - Deny wins: entries in `denied_domains` always override the allowlist. +- YOLO blocklist-only mode: with `yolo_only_enforce_blocklist = true` in full-access sandbox mode, + the proxy allows requests that are not explicitly denied, including hosts outside the allowlist + and local/private destinations. Use only when that broader egress is acceptable. - Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) is required to permit them; hostnames that resolve to local/private IPs are still blocked even if diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index f3f0e06c34..27f619584f 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -34,6 +34,10 @@ pub struct NetworkProxySettings { pub dangerously_allow_all_unix_sockets: bool, #[serde(default)] pub mode: NetworkMode, + /// In yolo/full-access sandbox mode, ignore the allowlist and local-network + /// guard so only `denied_domains` remains enforced. + #[serde(default)] + pub yolo_only_enforce_blocklist: bool, #[serde(default)] pub allowed_domains: Vec, #[serde(default)] @@ -57,6 +61,7 @@ impl Default for NetworkProxySettings { dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::default(), + yolo_only_enforce_blocklist: false, allowed_domains: Vec::new(), denied_domains: Vec::new(), allow_unix_sockets: Vec::new(), @@ -371,6 +376,7 @@ mod tests { dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::Full, + yolo_only_enforce_blocklist: false, allowed_domains: Vec::new(), denied_domains: Vec::new(), allow_unix_sockets: Vec::new(), diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b634a8630e..89f740f078 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -340,12 +340,20 @@ impl NetworkProxyState { Ok(host) => host, Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)), }; - let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = { + let ( + deny_set, + allow_set, + allow_local_binding, + yolo_only_enforce_blocklist, + allowed_domains_empty, + allowed_domains, + ) = { let guard = self.state.read().await; ( guard.deny_set.clone(), guard.allow_set.clone(), guard.config.network.allow_local_binding, + guard.config.network.yolo_only_enforce_blocklist, guard.config.network.allowed_domains.is_empty(), guard.config.network.allowed_domains.clone(), ) @@ -355,11 +363,15 @@ impl NetworkProxyState { // Decision order matters: // 1) explicit deny always wins - // 2) local/private networking is opt-in (defense-in-depth) - // 3) allowlist is enforced when configured + // 2) yolo blocklist-only mode bypasses allowlist and local/private guards + // 3) local/private networking is opt-in (defense-in-depth) + // 4) allowlist is enforced when configured if deny_set.is_match(host_str) { return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied)); } + if yolo_only_enforce_blocklist { + return Ok(HostBlockDecision::Allowed); + } let is_allowlisted = allow_set.is_match(host_str); if !allow_local_binding { @@ -858,6 +870,28 @@ mod tests { ); } + #[tokio::test] + async fn host_blocked_yolo_only_enforce_blocklist_allows_non_denied_hosts_without_allowlist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + yolo_only_enforce_blocklist: true, + denied_domains: vec!["blocked.example.com".to_string()], + ..NetworkProxySettings::default() + }); + + assert_eq!( + state.host_blocked("blocked.example.com", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + assert_eq!( + state.host_blocked("8.8.8.8", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + #[tokio::test] async fn add_allowed_domain_removes_matching_deny_entry() { let state = network_proxy_state_for_policy(NetworkProxySettings { diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 612e6c5b5c..b53b582bcb 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -23,6 +23,7 @@ pub struct NetworkProxyConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, + pub yolo_only_enforce_blocklist: Option, pub allowed_domains: Option>, pub allowlist_expansion_enabled: Option, pub denied_domains: Option>, @@ -44,6 +45,7 @@ pub struct PartialNetworkConfig { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, + pub yolo_only_enforce_blocklist: Option, #[serde(default)] pub allowed_domains: Option>, #[serde(default)] @@ -193,6 +195,23 @@ pub fn validate_policy_against_constraints( }, )?; + if let Some(yolo_only_enforce_blocklist) = constraints.yolo_only_enforce_blocklist { + validate( + config.network.yolo_only_enforce_blocklist, + move |candidate| { + if *candidate && !yolo_only_enforce_blocklist { + Err(invalid_value( + "network.yolo_only_enforce_blocklist", + "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 29a5cb7cdf..1d2bc6d69c 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -333,6 +333,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, + yolo_only_enforce_blocklist, allowed_domains, managed_allowed_domains_only, denied_domains, @@ -362,6 +363,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" )); } + if let Some(yolo_only_enforce_blocklist) = yolo_only_enforce_blocklist { + parts.push(format!( + "yolo_only_enforce_blocklist={yolo_only_enforce_blocklist}" + )); + } if let Some(allowed_domains) = allowed_domains { parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); } diff --git a/codex-rs/tui_app_server/src/debug_config.rs b/codex-rs/tui_app_server/src/debug_config.rs index 29a5cb7cdf..1d2bc6d69c 100644 --- a/codex-rs/tui_app_server/src/debug_config.rs +++ b/codex-rs/tui_app_server/src/debug_config.rs @@ -333,6 +333,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, + yolo_only_enforce_blocklist, allowed_domains, managed_allowed_domains_only, denied_domains, @@ -362,6 +363,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" )); } + if let Some(yolo_only_enforce_blocklist) = yolo_only_enforce_blocklist { + parts.push(format!( + "yolo_only_enforce_blocklist={yolo_only_enforce_blocklist}" + )); + } if let Some(allowed_domains) = allowed_domains { parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); }