diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 2f2f5a51f..cf4b7da62 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -231,6 +231,7 @@ mod tests { dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: 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()]), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), allow_local_binding: Some(true), diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 9f4719ff1..7e6a97657 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -138,6 +138,9 @@ pub struct NetworkRequirementsToml { pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + pub managed_allowed_domains_only: Option, pub denied_domains: Option>, pub allow_unix_sockets: Option>, pub allow_local_binding: Option, @@ -153,6 +156,9 @@ pub struct NetworkConstraints { pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + pub managed_allowed_domains_only: Option, pub denied_domains: Option>, pub allow_unix_sockets: Option>, pub allow_local_binding: Option, @@ -168,6 +174,7 @@ impl From for NetworkConstraints { dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, allowed_domains, + managed_allowed_domains_only, denied_domains, allow_unix_sockets, allow_local_binding, @@ -180,6 +187,7 @@ impl From for NetworkConstraints { dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, allowed_domains, + managed_allowed_domains_only, denied_domains, allow_unix_sockets, allow_local_binding, @@ -1118,6 +1126,7 @@ mod tests { allow_upstream_proxy = false dangerously_allow_all_unix_sockets = true allowed_domains = ["api.example.com", "*.openai.com"] + managed_allowed_domains_only = true denied_domains = ["blocked.example.com"] allow_unix_sockets = ["/tmp/example.sock"] allow_local_binding = false @@ -1146,6 +1155,10 @@ mod tests { "*.openai.com".to_string() ]) ); + assert_eq!( + sourced_network.value.managed_allowed_domains_only, + Some(true) + ); assert_eq!( sourced_network.value.denied_domains.as_ref(), Some(&vec!["blocked.example.com".to_string()]) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9f636d268..f7ef78b26 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2308,6 +2308,7 @@ impl Config { let network = NetworkProxySpec::from_config_and_constraints( configured_network_proxy_config, network_requirements, + constrained_sandbox_policy.get(), ) .map_err(|err| { if let Some(source) = network_requirements_source.as_ref() { diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 671593dca..9c70cf084 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -21,6 +21,7 @@ use std::sync::Arc; pub struct NetworkProxySpec { config: NetworkProxyConfig, constraints: NetworkProxyConstraints, + hard_deny_allowlist_misses: bool, } pub struct StartedNetworkProxy { @@ -83,9 +84,18 @@ impl NetworkProxySpec { pub(crate) fn from_config_and_constraints( config: NetworkProxyConfig, requirements: Option, + sandbox_policy: &SandboxPolicy, ) -> std::io::Result { + let hard_deny_allowlist_misses = requirements + .as_ref() + .is_some_and(Self::managed_allowed_domains_only); let (config, constraints) = if let Some(requirements) = requirements { - Self::apply_requirements(config, &requirements) + Self::apply_requirements( + config, + &requirements, + sandbox_policy, + hard_deny_allowlist_misses, + ) } else { (config, NetworkProxyConstraints::default()) }; @@ -98,6 +108,7 @@ impl NetworkProxySpec { Ok(Self { config, constraints, + hard_deny_allowlist_misses, }) } @@ -112,6 +123,7 @@ impl NetworkProxySpec { let state = self.build_state_with_audit_metadata(audit_metadata)?; let mut builder = NetworkProxy::builder().state(Arc::new(state)); if enable_network_approval_flow + && !self.hard_deny_allowlist_misses && matches!( sandbox_policy, SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } @@ -158,8 +170,13 @@ impl NetworkProxySpec { fn apply_requirements( mut config: NetworkProxyConfig, requirements: &NetworkConstraints, + sandbox_policy: &SandboxPolicy, + hard_deny_allowlist_misses: bool, ) -> (NetworkProxyConfig, NetworkProxyConstraints) { let mut constraints = NetworkProxyConstraints::default(); + let allowlist_expansion_enabled = + Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses); + let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy); if let Some(enabled) = requirements.enabled { config.network.enabled = enabled; @@ -190,13 +207,31 @@ impl NetworkProxySpec { 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(); + let managed_allowed_domains = if hard_deny_allowlist_misses { + Some(requirements.allowed_domains.clone().unwrap_or_default()) + } else { + requirements.allowed_domains.clone() + }; + if let Some(allowed_domains) = managed_allowed_domains { + // Managed requirements seed the baseline allowlist. User additions + // can extend that baseline unless managed-only mode pins the + // effective allowlist to the managed set. + config.network.allowed_domains = if allowlist_expansion_enabled { + Self::merge_domain_lists(allowed_domains.clone(), &config.network.allowed_domains) + } else { + allowed_domains.clone() + }; constraints.allowed_domains = Some(allowed_domains); + constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled); } if let Some(denied_domains) = requirements.denied_domains.clone() { - config.network.denied_domains = denied_domains.clone(); + config.network.denied_domains = if denylist_expansion_enabled { + Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains) + } else { + denied_domains.clone() + }; constraints.denied_domains = Some(denied_domains); + constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled); } if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() { config.network.allow_unix_sockets = allow_unix_sockets.clone(); @@ -209,6 +244,39 @@ impl NetworkProxySpec { (config, constraints) } + + fn allowlist_expansion_enabled( + sandbox_policy: &SandboxPolicy, + hard_deny_allowlist_misses: bool, + ) -> bool { + matches!( + sandbox_policy, + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } + ) && !hard_deny_allowlist_misses + } + + fn managed_allowed_domains_only(requirements: &NetworkConstraints) -> bool { + requirements.managed_allowed_domains_only.unwrap_or(false) + } + + fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool { + matches!( + sandbox_policy, + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } + ) + } + + fn merge_domain_lists(mut managed: Vec, user_entries: &[String]) -> Vec { + for entry in user_entries { + if !managed + .iter() + .any(|managed_entry| managed_entry.eq_ignore_ascii_case(entry)) + { + managed.push(entry.clone()); + } + } + managed + } } #[cfg(test)] @@ -221,6 +289,7 @@ mod tests { let spec = NetworkProxySpec { config: NetworkProxyConfig::default(), constraints: NetworkProxyConstraints::default(), + hard_deny_allowlist_misses: false, }; let metadata = NetworkProxyAuditMetadata { conversation_id: Some("conversation-1".to_string()), @@ -234,4 +303,184 @@ mod tests { .expect("state should build"); assert_eq!(state.audit_metadata(), &metadata); } + + #[test] + fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_read_only_policy(), + ) + .expect("config should stay within the managed allowlist"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["*.example.com".to_string(), "api.example.com".to_string()] + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["*.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); + } + + #[test] + fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["evil.com".to_string()]; + config.network.denied_domains = vec!["more-blocked.example.com".to_string()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("yolo mode should pin the effective policy to the managed baseline"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["*.example.com".to_string()] + ); + assert_eq!( + spec.config.network.denied_domains, + vec!["blocked.example.com".to_string()] + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); + } + + #[test] + fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still load"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["*.example.com".to_string()] + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + } + + #[test] + fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.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::new_workspace_write_policy(), + ) + .expect("managed-only allowlist should still load"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["managed.example.com".to_string()] + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["managed.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert!(spec.hard_deny_allowlist_misses); + } + + #[test] + fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed-only mode should treat missing managed allowlist as empty"); + + assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert!(spec.hard_deny_allowlist_misses); + } + + #[test] + fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() { + let mut config = NetworkProxyConfig::default(); + config.network.allowed_domains = vec!["api.example.com".to_string()]; + let requirements = NetworkConstraints { + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("managed-only mode should treat missing managed allowlist as empty"); + + assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert!(spec.hard_deny_allowlist_misses); + } + + #[test] + fn requirements_denied_domains_are_a_baseline_for_default_mode() { + let mut config = NetworkProxyConfig::default(); + config.network.denied_domains = vec!["blocked.example.com".to_string()]; + let requirements = NetworkConstraints { + denied_domains: Some(vec!["managed-blocked.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("default mode should merge managed and user deny entries"); + + assert_eq!( + spec.config.network.denied_domains, + vec![ + "managed-blocked.example.com".to_string(), + "blocked.example.com".to_string() + ] + ); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); + } } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 6212b9426..6fb689490 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -47,7 +47,7 @@ denied_domains = ["evil.example"] # If false, local/private networking is rejected. 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 allowlisted. -allow_local_binding = true +allow_local_binding = false # macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. allow_unix_sockets = ["/tmp/example.sock"] diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 817dda26d..e00ec1944 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -60,7 +60,7 @@ impl Default for NetworkProxySettings { allowed_domains: Vec::new(), denied_domains: Vec::new(), allow_unix_sockets: Vec::new(), - allow_local_binding: true, + allow_local_binding: false, mitm: false, } } @@ -374,7 +374,7 @@ mod tests { allowed_domains: Vec::new(), denied_domains: Vec::new(), allow_unix_sockets: Vec::new(), - allow_local_binding: true, + allow_local_binding: false, mitm: false, } ); diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b82549580..d043106fa 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -894,6 +894,98 @@ mod tests { ); } + #[tokio::test] + async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["managed.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["managed.example.com".to_string()]), + allowlist_expansion_enabled: Some(true), + ..NetworkProxyConstraints::default() + }; + let state = NetworkProxyState::with_reloader( + build_config_state(config, constraints).unwrap(), + Arc::new(NoopReloader), + ); + + state.add_allowed_domain("user.example.com").await.unwrap(); + + let (allowed, denied) = state.current_patterns().await.unwrap(); + assert_eq!( + allowed, + vec![ + "managed.example.com".to_string(), + "user.example.com".to_string() + ] + ); + assert!(denied.is_empty()); + } + + #[tokio::test] + async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["managed.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["managed.example.com".to_string()]), + allowlist_expansion_enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + let state = NetworkProxyState::with_reloader( + build_config_state(config, constraints).unwrap(), + Arc::new(NoopReloader), + ); + + let err = state + .add_allowed_domain("user.example.com") + .await + .expect_err("managed baseline should reject allowlist expansion"); + + assert!( + format!("{err:#}").contains("network.allowed_domains constrained by managed config"), + "unexpected error: {err:#}" + ); + } + + #[tokio::test] + async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() { + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + denied_domains: vec!["managed.example.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + let constraints = NetworkProxyConstraints { + denied_domains: Some(vec!["managed.example.com".to_string()]), + denylist_expansion_enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + let state = NetworkProxyState::with_reloader( + build_config_state(config, constraints).unwrap(), + Arc::new(NoopReloader), + ); + + let err = state + .add_denied_domain("user.example.com") + .await + .expect_err("managed baseline should reject denylist expansion"); + + assert!( + format!("{err:#}").contains("network.denied_domains constrained by managed config"), + "unexpected error: {err:#}" + ); + } + #[tokio::test] async fn blocked_snapshot_does_not_consume_entries() { let state = network_proxy_state_for_policy(NetworkProxySettings::default()); @@ -1117,6 +1209,25 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } + #[test] + fn validate_policy_against_constraints_allows_expanding_allowed_domains_when_enabled() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["example.com".to_string()]), + allowlist_expansion_enabled: Some(true), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["example.com".to_string(), "api.openai.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + #[test] fn validate_policy_against_constraints_disallows_widening_mode() { let constraints = NetworkProxyConstraints { @@ -1245,6 +1356,25 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } + #[test] + fn validate_policy_against_constraints_disallows_expanding_denied_domains_when_fixed() { + let constraints = NetworkProxyConstraints { + denied_domains: Some(vec!["evil.com".to_string()]), + denylist_expansion_enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + denied_domains: vec!["evil.com".to_string(), "more-evil.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + #[test] fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() { let constraints = NetworkProxyConstraints { diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index fb8898f61..612e6c5b5 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -24,7 +24,9 @@ pub struct NetworkProxyConstraints { pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, + pub allowlist_expansion_enabled: Option, pub denied_domains: Option>, + pub denylist_expansion_enabled: Option, pub allow_unix_sockets: Option>, pub allow_local_binding: Option, } @@ -207,31 +209,82 @@ pub fn validate_policy_against_constraints( if let Some(allowed_domains) = &constraints.allowed_domains { validate_domain_patterns("network.allowed_domains", allowed_domains)?; - let managed_patterns: Vec = allowed_domains - .iter() - .map(|entry| DomainPattern::parse_for_constraints(entry)) - .collect(); - validate(config.network.allowed_domains.clone(), move |candidate| { - let mut invalid = Vec::new(); - for entry in candidate { - let candidate_pattern = DomainPattern::parse_for_constraints(entry); - if !managed_patterns + match constraints.allowlist_expansion_enabled { + Some(true) => { + let required_set: HashSet = allowed_domains .iter() - .any(|managed| managed.allows(&candidate_pattern)) - { - invalid.push(entry.clone()); - } + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + validate(config.network.allowed_domains.clone(), move |candidate| { + let candidate_set: HashSet = candidate + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + let missing: Vec = required_set + .iter() + .filter(|entry| !candidate_set.contains(*entry)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network.allowed_domains", + "missing managed allowed_domains entries", + format!("{missing:?}"), + )) + } + })?; } - if invalid.is_empty() { - Ok(()) - } else { - Err(invalid_value( - "network.allowed_domains", - format!("{invalid:?}"), - "subset of managed allowed_domains", - )) + Some(false) => { + let required_set: HashSet = allowed_domains + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + validate(config.network.allowed_domains.clone(), move |candidate| { + let candidate_set: HashSet = candidate + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + if candidate_set == required_set { + Ok(()) + } else { + Err(invalid_value( + "network.allowed_domains", + format!("{candidate:?}"), + "must match managed allowed_domains", + )) + } + })?; } - })?; + None => { + let managed_patterns: Vec = allowed_domains + .iter() + .map(|entry| DomainPattern::parse_for_constraints(entry)) + .collect(); + validate(config.network.allowed_domains.clone(), move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + let candidate_pattern = DomainPattern::parse_for_constraints(entry); + if !managed_patterns + .iter() + .any(|managed| managed.allows(&candidate_pattern)) + { + invalid.push(entry.clone()); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network.allowed_domains", + format!("{invalid:?}"), + "subset of managed allowed_domains", + )) + } + })?; + } + } } if let Some(denied_domains) = &constraints.denied_domains { @@ -240,24 +293,45 @@ pub fn validate_policy_against_constraints( .iter() .map(|s| s.to_ascii_lowercase()) .collect(); - validate(config.network.denied_domains.clone(), move |candidate| { - let candidate_set: HashSet = - candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); - let missing: Vec = required_set - .iter() - .filter(|entry| !candidate_set.contains(*entry)) - .cloned() - .collect(); - if missing.is_empty() { - Ok(()) - } else { - Err(invalid_value( - "network.denied_domains", - "missing managed denied_domains entries", - format!("{missing:?}"), - )) + match constraints.denylist_expansion_enabled { + Some(false) => { + validate(config.network.denied_domains.clone(), move |candidate| { + let candidate_set: HashSet = candidate + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + if candidate_set == required_set { + Ok(()) + } else { + Err(invalid_value( + "network.denied_domains", + format!("{candidate:?}"), + "must match managed denied_domains", + )) + } + })?; } - })?; + Some(true) | None => { + validate(config.network.denied_domains.clone(), move |candidate| { + let candidate_set: HashSet = + candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); + let missing: Vec = required_set + .iter() + .filter(|entry| !candidate_set.contains(*entry)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network.denied_domains", + "missing managed denied_domains entries", + format!("{missing:?}"), + )) + } + })?; + } + } } if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets { diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 7cab73437..d4b19a97b 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -331,6 +331,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, allowed_domains, + managed_allowed_domains_only, denied_domains, allow_unix_sockets, allow_local_binding, @@ -361,6 +362,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { if let Some(allowed_domains) = allowed_domains { parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); } + if let Some(managed_allowed_domains_only) = managed_allowed_domains_only { + parts.push(format!( + "managed_allowed_domains_only={managed_allowed_domains_only}" + )); + } if let Some(denied_domains) = denied_domains { parts.push(format!("denied_domains=[{}]", denied_domains.join(", "))); }