use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::config::ValidatedUnixSocketPath; use crate::mitm::MitmState; use crate::policy::Host; use crate::policy::is_loopback_host; use crate::policy::is_non_public_ip; use crate::policy::normalize_host; use crate::reasons::REASON_DENIED; use crate::reasons::REASON_NOT_ALLOWED; use crate::reasons::REASON_NOT_ALLOWED_LOCAL; use crate::state::NetworkProxyConstraintError; use crate::state::NetworkProxyConstraints; use crate::state::build_config_state; use crate::state::validate_policy_against_constraints; use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use codex_utils_absolute_path::AbsolutePathBuf; use globset::GlobSet; use serde::Serialize; use std::collections::HashSet; use std::collections::VecDeque; use std::future::Future; use std::net::IpAddr; use std::path::Path; use std::sync::Arc; use std::time::Duration; use time::OffsetDateTime; use tokio::net::lookup_host; use tokio::sync::RwLock; use tokio::time::timeout; use tracing::debug; use tracing::info; use tracing::warn; const MAX_BLOCKED_EVENTS: usize = 200; const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2); const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION"; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct NetworkProxyAuditMetadata { pub conversation_id: Option, pub app_version: Option, pub user_account_id: Option, pub auth_mode: Option, pub originator: Option, pub user_email: Option, pub terminal_type: Option, pub model: Option, pub slug: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HostBlockReason { Denied, NotAllowed, NotAllowedLocal, } impl HostBlockReason { pub const fn as_str(self) -> &'static str { match self { Self::Denied => REASON_DENIED, Self::NotAllowed => REASON_NOT_ALLOWED, Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL, } } } impl std::fmt::Display for HostBlockReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HostBlockDecision { Allowed, Blocked(HostBlockReason), } #[derive(Clone, Debug, Serialize)] pub struct BlockedRequest { pub host: String, pub reason: String, pub client: Option, pub method: Option, pub mode: Option, pub protocol: String, #[serde(skip_serializing_if = "Option::is_none")] pub decision: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub port: Option, pub timestamp: i64, } pub struct BlockedRequestArgs { pub host: String, pub reason: String, pub client: Option, pub method: Option, pub mode: Option, pub protocol: String, pub decision: Option, pub source: Option, pub port: Option, } impl BlockedRequest { pub fn new(args: BlockedRequestArgs) -> Self { let BlockedRequestArgs { host, reason, client, method, mode, protocol, decision, source, port, } = args; Self { host, reason, client, method, mode, protocol, decision, source, port, timestamp: unix_timestamp(), } } } fn blocked_request_violation_log_line(entry: &BlockedRequest) -> String { match serde_json::to_string(entry) { Ok(json) => format!("{NETWORK_POLICY_VIOLATION_PREFIX} {json}"), Err(err) => { debug!("failed to serialize blocked request for violation log: {err}"); format!( "{NETWORK_POLICY_VIOLATION_PREFIX} host={} reason={}", entry.host, entry.reason ) } } } #[derive(Clone)] pub struct ConfigState { pub config: NetworkProxyConfig, pub allow_set: GlobSet, pub deny_set: GlobSet, pub mitm: Option>, pub constraints: NetworkProxyConstraints, pub blocked: VecDeque, pub blocked_total: u64, } #[async_trait] pub trait ConfigReloader: Send + Sync { /// Human-readable description of where config is loaded from, for logs. fn source_label(&self) -> String; /// Return a freshly loaded state if a reload is needed; otherwise, return `None`. async fn maybe_reload(&self) -> Result>; /// Force a reload, regardless of whether a change was detected. async fn reload_now(&self) -> Result; } #[async_trait] pub trait BlockedRequestObserver: Send + Sync + 'static { async fn on_blocked_request(&self, request: BlockedRequest); } #[async_trait] impl BlockedRequestObserver for Arc { async fn on_blocked_request(&self, request: BlockedRequest) { (**self).on_blocked_request(request).await } } #[async_trait] impl BlockedRequestObserver for F where F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static, Fut: Future + Send, { async fn on_blocked_request(&self, request: BlockedRequest) { (self)(request).await } } pub struct NetworkProxyState { state: Arc>, reloader: Arc, blocked_request_observer: Arc>>>, audit_metadata: NetworkProxyAuditMetadata, } impl std::fmt::Debug for NetworkProxyState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy // and may contain sensitive paths. f.debug_struct("NetworkProxyState").finish_non_exhaustive() } } impl Clone for NetworkProxyState { fn clone(&self) -> Self { Self { state: self.state.clone(), reloader: self.reloader.clone(), blocked_request_observer: self.blocked_request_observer.clone(), audit_metadata: self.audit_metadata.clone(), } } } impl NetworkProxyState { pub fn with_reloader(state: ConfigState, reloader: Arc) -> Self { Self::with_reloader_and_audit_metadata( state, reloader, NetworkProxyAuditMetadata::default(), ) } pub fn with_reloader_and_blocked_observer( state: ConfigState, reloader: Arc, blocked_request_observer: Option>, ) -> Self { Self::with_reloader_and_audit_metadata_and_blocked_observer( state, reloader, NetworkProxyAuditMetadata::default(), blocked_request_observer, ) } pub fn with_reloader_and_audit_metadata( state: ConfigState, reloader: Arc, audit_metadata: NetworkProxyAuditMetadata, ) -> Self { Self::with_reloader_and_audit_metadata_and_blocked_observer( state, reloader, audit_metadata, /*blocked_request_observer*/ None, ) } pub fn with_reloader_and_audit_metadata_and_blocked_observer( state: ConfigState, reloader: Arc, audit_metadata: NetworkProxyAuditMetadata, blocked_request_observer: Option>, ) -> Self { Self { state: Arc::new(RwLock::new(state)), reloader, blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)), audit_metadata, } } pub async fn set_blocked_request_observer( &self, blocked_request_observer: Option>, ) { let mut observer = self.blocked_request_observer.write().await; *observer = blocked_request_observer; } pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata { &self.audit_metadata } pub async fn current_cfg(&self) -> Result { // Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to // `config.toml` (including Codex-managed writes) take effect without a restart. self.reload_if_needed().await?; let guard = self.state.read().await; Ok(guard.config.clone()) } pub async fn current_patterns(&self) -> Result<(Vec, Vec)> { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(( guard.config.network.allowed_domains.clone(), guard.config.network.denied_domains.clone(), )) } pub async fn enabled(&self) -> Result { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(guard.config.network.enabled) } pub async fn force_reload(&self) -> Result<()> { let previous_cfg = { let guard = self.state.read().await; guard.config.clone() }; match self.reloader.reload_now().await { Ok(mut new_state) => { // Policy changes are operationally sensitive; logging diffs makes changes traceable // without needing to dump full config blobs (which can include unrelated settings). log_policy_changes(&previous_cfg, &new_state.config); { let mut guard = self.state.write().await; new_state.blocked = guard.blocked.clone(); *guard = new_state; } let source = self.reloader.source_label(); info!("reloaded config from {source}"); Ok(()) } Err(err) => { let source = self.reloader.source_label(); warn!("failed to reload config from {source}: {err}; keeping previous config"); Err(err) } } } pub async fn host_blocked(&self, host: &str, port: u16) -> Result { self.reload_if_needed().await?; let host = match Host::parse(host) { Ok(host) => host, Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)), }; let (deny_set, allow_set, allow_local_binding, 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.allowed_domains.is_empty(), guard.config.network.allowed_domains.clone(), ) }; let host_str = host.as_str(); // Decision order matters: // 1) explicit deny always wins // 2) local/private networking is opt-in (defense-in-depth) // 3) allowlist is enforced when configured if deny_set.is_match(host_str) { return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied)); } let is_allowlisted = allow_set.is_match(host_str); if !allow_local_binding { // If the intent is "prevent access to local/internal networks", we must not rely solely // on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or // public suffix services that map hostnames onto private IPs. // // We therefore do a best-effort DNS + IP classification check before allowing the // request. Explicit local/loopback literals are allowed only when explicitly // allowlisted; hostnames that resolve to local/private IPs are blocked even if // allowlisted. let local_literal = { let host_no_scope = host_str .split_once('%') .map(|(ip, _)| ip) .unwrap_or(host_str); if is_loopback_host(&host) { true } else if let Ok(ip) = host_no_scope.parse::() { is_non_public_ip(ip) } else { false } }; if local_literal { if !is_explicit_local_allowlisted(&allowed_domains, &host) { return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); } } else if host_resolves_to_non_public_ip(host_str, port).await { return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); } } if allowed_domains_empty || !is_allowlisted { Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)) } else { Ok(HostBlockDecision::Allowed) } } pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { self.reload_if_needed().await?; let blocked_for_observer = entry.clone(); let blocked_request_observer = self.blocked_request_observer.read().await.clone(); let violation_line = blocked_request_violation_log_line(&entry); let mut guard = self.state.write().await; let host = entry.host.clone(); let reason = entry.reason.clone(); let decision = entry.decision.clone(); let source = entry.source.clone(); let protocol = entry.protocol.clone(); let port = entry.port; guard.blocked.push_back(entry); guard.blocked_total = guard.blocked_total.saturating_add(1); let total = guard.blocked_total; while guard.blocked.len() > MAX_BLOCKED_EVENTS { guard.blocked.pop_front(); } debug!( "recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, buffered={})", total, host, reason, decision, source, protocol, port, guard.blocked.len() ); debug!("{violation_line}"); drop(guard); if let Some(observer) = blocked_request_observer { observer.on_blocked_request(blocked_for_observer).await; } Ok(()) } /// Returns a snapshot of buffered blocked-request entries without consuming /// them. pub async fn blocked_snapshot(&self) -> Result> { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(guard.blocked.iter().cloned().collect()) } /// Drain and return the buffered blocked-request entries in FIFO order. pub async fn drain_blocked(&self) -> Result> { self.reload_if_needed().await?; let blocked = { let mut guard = self.state.write().await; std::mem::take(&mut guard.blocked) }; Ok(blocked.into_iter().collect()) } pub async fn is_unix_socket_allowed(&self, path: &str) -> Result { self.reload_if_needed().await?; if !unix_socket_permissions_supported() { return Ok(false); } // We only support absolute unix socket paths (a relative path would be ambiguous with // respect to the proxy process's CWD and can lead to confusing allowlist behavior). let requested_path = Path::new(path); if !requested_path.is_absolute() { return Ok(false); } 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, Err(_) => return Ok(false), }; let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); for allowed in &guard.config.network.allow_unix_sockets { 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); } // Best-effort canonicalization to reduce surprises with symlinks. // If canonicalization fails (e.g., socket not created yet), fall back to raw comparison. let Some(requested_canonical) = &requested_canonical else { continue; }; if let Ok(allowed_canonical) = std::fs::canonicalize(allowed_path.as_path()) && &allowed_canonical == requested_canonical { return Ok(true); } } Ok(false) } pub async fn method_allowed(&self, method: &str) -> Result { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(guard.config.network.mode.allows_method(method)) } pub async fn allow_upstream_proxy(&self) -> Result { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(guard.config.network.allow_upstream_proxy) } pub async fn network_mode(&self) -> Result { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(guard.config.network.mode) } pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> { loop { self.reload_if_needed().await?; let (candidate, constraints) = { let guard = self.state.read().await; let mut candidate = guard.config.clone(); candidate.network.mode = mode; (candidate, guard.constraints.clone()) }; validate_policy_against_constraints(&candidate, &constraints) .map_err(NetworkProxyConstraintError::into_anyhow) .context("network.mode constrained by managed config")?; let mut guard = self.state.write().await; if guard.constraints != constraints { drop(guard); continue; } guard.config.network.mode = mode; info!("updated network mode to {mode:?}"); return Ok(()); } } pub async fn mitm_state(&self) -> Result>> { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(guard.mitm.clone()) } pub async fn add_allowed_domain(&self, host: &str) -> Result<()> { self.update_domain_list(host, DomainListKind::Allow).await } pub async fn add_denied_domain(&self, host: &str) -> Result<()> { self.update_domain_list(host, DomainListKind::Deny).await } async fn update_domain_list(&self, host: &str, target: DomainListKind) -> Result<()> { let host = Host::parse(host).context("invalid network host")?; let normalized_host = host.as_str().to_string(); let list_name = target.list_name(); let constraint_field = target.constraint_field(); loop { self.reload_if_needed().await?; let (previous_cfg, constraints, blocked, blocked_total) = { let guard = self.state.read().await; ( guard.config.clone(), guard.constraints.clone(), guard.blocked.clone(), guard.blocked_total, ) }; let mut candidate = previous_cfg.clone(); let (target_entries, opposite_entries) = candidate.split_domain_lists_mut(target); let target_contains = target_entries .iter() .any(|entry| normalize_host(entry) == normalized_host); let opposite_contains = opposite_entries .iter() .any(|entry| normalize_host(entry) == normalized_host); if target_contains && !opposite_contains { return Ok(()); } target_entries.retain(|entry| normalize_host(entry) != normalized_host); target_entries.push(normalized_host.clone()); opposite_entries.retain(|entry| normalize_host(entry) != normalized_host); validate_policy_against_constraints(&candidate, &constraints) .map_err(NetworkProxyConstraintError::into_anyhow) .with_context(|| format!("{constraint_field} constrained by managed config"))?; let mut new_state = build_config_state(candidate.clone(), constraints.clone()) .with_context(|| format!("failed to compile updated network {list_name}"))?; new_state.blocked = blocked; new_state.blocked_total = blocked_total; let mut guard = self.state.write().await; if guard.constraints != constraints || guard.config != previous_cfg { drop(guard); continue; } log_policy_changes(&guard.config, &candidate); *guard = new_state; info!("updated network {list_name} with {normalized_host}"); return Ok(()); } } async fn reload_if_needed(&self) -> Result<()> { match self.reloader.maybe_reload().await? { None => Ok(()), Some(mut new_state) => { let (previous_cfg, blocked, blocked_total) = { let guard = self.state.read().await; ( guard.config.clone(), guard.blocked.clone(), guard.blocked_total, ) }; log_policy_changes(&previous_cfg, &new_state.config); new_state.blocked = blocked; new_state.blocked_total = blocked_total; { let mut guard = self.state.write().await; *guard = new_state; } let source = self.reloader.source_label(); info!("reloaded config from {source}"); Ok(()) } } } } #[derive(Clone, Copy)] enum DomainListKind { Allow, Deny, } impl DomainListKind { fn list_name(self) -> &'static str { match self { Self::Allow => "allowlist", Self::Deny => "denylist", } } fn constraint_field(self) -> &'static str { match self { Self::Allow => "network.allowed_domains", Self::Deny => "network.denied_domains", } } } impl NetworkProxyConfig { fn split_domain_lists_mut( &mut self, target: DomainListKind, ) -> (&mut Vec, &mut Vec) { match target { DomainListKind::Allow => ( &mut self.network.allowed_domains, &mut self.network.denied_domains, ), DomainListKind::Deny => ( &mut self.network.denied_domains, &mut self.network.allowed_domains, ), } } } pub(crate) fn unix_socket_permissions_supported() -> bool { cfg!(target_os = "macos") } async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool { if let Ok(ip) = host.parse::() { return is_non_public_ip(ip); } // If DNS lookup fails, default to "not local/private" rather than blocking. In practice, the // subsequent connect attempt will fail anyway, and blocking on transient resolver issues would // make the proxy fragile. The allowlist/denylist remains the primary control plane. let addrs = match timeout(DNS_LOOKUP_TIMEOUT, lookup_host((host, port))).await { Ok(Ok(addrs)) => addrs, Ok(Err(_)) | Err(_) => return false, }; for addr in addrs { if is_non_public_ip(addr.ip()) { return true; } } false } fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) { log_domain_list_changes( "allowlist", &previous.network.allowed_domains, &next.network.allowed_domains, ); log_domain_list_changes( "denylist", &previous.network.denied_domains, &next.network.denied_domains, ); } fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) { let previous_set: HashSet = previous .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); let next_set: HashSet = next .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); let added = next_set .difference(&previous_set) .cloned() .collect::>(); let removed = previous_set .difference(&next_set) .cloned() .collect::>(); let mut seen_next = HashSet::new(); for entry in next { let key = entry.to_ascii_lowercase(); if seen_next.insert(key.clone()) && added.contains(&key) { info!("config entry added to {list_name}: {entry}"); } } let mut seen_previous = HashSet::new(); for entry in previous { let key = entry.to_ascii_lowercase(); if seen_previous.insert(key.clone()) && removed.contains(&key) { info!("config entry removed from {list_name}: {entry}"); } } } fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool { let normalized_host = host.as_str(); allowed_domains.iter().any(|pattern| { let pattern = pattern.trim(); if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") { return false; } if pattern.contains('*') || pattern.contains('?') { return false; } normalize_host(pattern) == normalized_host }) } fn unix_timestamp() -> i64 { OffsetDateTime::now_utc().unix_timestamp() } #[cfg(test)] pub(crate) fn network_proxy_state_for_policy( mut network: crate::config::NetworkProxySettings, ) -> NetworkProxyState { network.enabled = true; network.mode = NetworkMode::Full; let config = NetworkProxyConfig { network }; let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap(); NetworkProxyState::with_reloader(state, Arc::new(NoopReloader)) } #[cfg(test)] struct NoopReloader; #[cfg(test)] #[async_trait] impl ConfigReloader for NoopReloader { fn source_label(&self) -> String { "test config state".to_string() } async fn maybe_reload(&self) -> Result> { Ok(None) } async fn reload_now(&self) -> Result { Err(anyhow::anyhow!("force reload is not supported in tests")) } } #[cfg(test)] mod tests { use super::*; use crate::config::NetworkProxyConfig; use crate::config::NetworkProxySettings; use crate::policy::compile_globset; use crate::state::NetworkProxyConstraints; use crate::state::build_config_state; use crate::state::validate_policy_against_constraints; use pretty_assertions::assert_eq; #[tokio::test] async fn host_blocked_denied_wins_over_allowed() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], denied_domains: vec!["example.com".to_string()], ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::Denied) ); } #[tokio::test] async fn host_blocked_requires_allowlist_match() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), HostBlockDecision::Allowed ); assert_eq!( // Use a public IP literal to avoid relying on ambient DNS behavior (some networks // resolve unknown hostnames to private IPs, which would trigger `not_allowed_local`). state.host_blocked("8.8.8.8", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::NotAllowed) ); } #[tokio::test] async fn add_allowed_domain_removes_matching_deny_entry() { let state = network_proxy_state_for_policy(NetworkProxySettings { denied_domains: vec!["example.com".to_string()], ..NetworkProxySettings::default() }); state.add_allowed_domain("ExAmPlE.CoM").await.unwrap(); let (allowed, denied) = state.current_patterns().await.unwrap(); assert_eq!(allowed, vec!["example.com".to_string()]); assert!(denied.is_empty()); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), HostBlockDecision::Allowed ); } #[tokio::test] async fn add_denied_domain_removes_matching_allow_entry() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], ..NetworkProxySettings::default() }); state.add_denied_domain("EXAMPLE.COM").await.unwrap(); let (allowed, denied) = state.current_patterns().await.unwrap(); assert!(allowed.is_empty()); assert_eq!(denied, vec!["example.com".to_string()]); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::Denied) ); } #[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()); state .record_blocked(BlockedRequest::new(BlockedRequestArgs { host: "google.com".to_string(), reason: "not_allowed".to_string(), client: None, method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), })) .await .expect("entry should be recorded"); let snapshot = state .blocked_snapshot() .await .expect("snapshot should succeed"); assert_eq!(snapshot.len(), 1); assert_eq!(snapshot[0].host, "google.com"); assert_eq!(snapshot[0].decision.as_deref(), Some("ask")); let drained = state .drain_blocked() .await .expect("drain should include snapshot entry"); assert_eq!(drained.len(), 1); assert_eq!(drained[0].host, snapshot[0].host); assert_eq!(drained[0].reason, snapshot[0].reason); assert_eq!(drained[0].decision, snapshot[0].decision); assert_eq!(drained[0].source, snapshot[0].source); assert_eq!(drained[0].port, snapshot[0].port); } #[tokio::test] async fn drain_blocked_returns_buffered_window() { let state = network_proxy_state_for_policy(NetworkProxySettings::default()); for idx in 0..(MAX_BLOCKED_EVENTS + 5) { state .record_blocked(BlockedRequest::new(BlockedRequestArgs { host: format!("example{idx}.com"), reason: "not_allowed".to_string(), client: None, method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), })) .await .expect("entry should be recorded"); } let blocked = state.drain_blocked().await.expect("drain should succeed"); assert_eq!(blocked.len(), MAX_BLOCKED_EVENTS); assert_eq!(blocked[0].host, "example5.com"); } #[test] fn blocked_request_violation_log_line_serializes_payload() { let entry = BlockedRequest { host: "google.com".to_string(), reason: "not_allowed".to_string(), client: Some("127.0.0.1".to_string()), method: Some("GET".to_string()), mode: Some(NetworkMode::Full), protocol: "http".to_string(), decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), timestamp: 1_735_689_600, }; assert_eq!( blocked_request_violation_log_line(&entry), r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"# ); } #[tokio::test] async fn host_blocked_subdomain_wildcards_exclude_apex() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["*.openai.com".to_string()], ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("api.openai.com", 80).await.unwrap(), HostBlockDecision::Allowed ); assert_eq!( state.host_blocked("openai.com", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::NotAllowed) ); } #[tokio::test] async fn host_blocked_rejects_loopback_when_local_binding_disabled() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], allow_local_binding: false, ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("127.0.0.1", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) ); assert_eq!( state.host_blocked("localhost", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) ); } #[tokio::test] async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["localhost".to_string()], allow_local_binding: false, ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("localhost", 80).await.unwrap(), HostBlockDecision::Allowed ); } #[tokio::test] async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["10.0.0.1".to_string()], allow_local_binding: false, ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("10.0.0.1", 80).await.unwrap(), HostBlockDecision::Allowed ); } #[tokio::test] async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], allow_local_binding: false, ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("fe80::1%lo0", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) ); } #[tokio::test] async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["fe80::1%lo0".to_string()], allow_local_binding: false, ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("fe80::1%lo0", 80).await.unwrap(), HostBlockDecision::Allowed ); } #[tokio::test] async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], allow_local_binding: false, ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("10.0.0.1", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) ); } #[tokio::test] async fn host_blocked_rejects_loopback_when_allowlist_empty() { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec![], allow_local_binding: false, ..NetworkProxySettings::default() }); assert_eq!( state.host_blocked("127.0.0.1", 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) ); } #[test] fn validate_policy_against_constraints_disallows_widening_allowed_domains() { let constraints = NetworkProxyConstraints { allowed_domains: Some(vec!["example.com".to_string()]), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["example.com".to_string(), "evil.com".to_string()], ..NetworkProxySettings::default() }, }; 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 { mode: Some(NetworkMode::Limited), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, mode: NetworkMode::Full, ..NetworkProxySettings::default() }, }; assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } #[test] fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() { let constraints = NetworkProxyConstraints { allowed_domains: Some(vec!["*.example.com".to_string()]), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["api.example.com".to_string()], ..NetworkProxySettings::default() }, }; assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); } #[test] fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() { let constraints = NetworkProxyConstraints { allowed_domains: Some(vec!["*.example.com".to_string()]), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["**.example.com".to_string()], ..NetworkProxySettings::default() }, }; assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } #[test] fn validate_policy_against_constraints_rejects_global_wildcard_in_managed_allowlist() { let constraints = NetworkProxyConstraints { allowed_domains: Some(vec!["*".to_string()]), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["api.example.com".to_string()], ..NetworkProxySettings::default() }, }; assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } #[test] fn validate_policy_against_constraints_rejects_bracketed_global_wildcard_in_managed_allowlist() { let constraints = NetworkProxyConstraints { allowed_domains: Some(vec!["[*]".to_string()]), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["api.example.com".to_string()], ..NetworkProxySettings::default() }, }; assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } #[test] fn validate_policy_against_constraints_rejects_double_wildcard_bracketed_global_wildcard_in_managed_allowlist() { let constraints = NetworkProxyConstraints { allowed_domains: Some(vec!["**.[*]".to_string()]), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["api.example.com".to_string()], ..NetworkProxySettings::default() }, }; assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } #[test] fn validate_policy_against_constraints_requires_managed_denied_domains_entries() { let constraints = NetworkProxyConstraints { denied_domains: Some(vec!["evil.com".to_string()]), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, denied_domains: vec![], ..NetworkProxySettings::default() }, }; 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 { enabled: Some(false), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, ..NetworkProxySettings::default() }, }; assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } #[test] fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() { let constraints = NetworkProxyConstraints { allow_local_binding: Some(false), ..NetworkProxyConstraints::default() }; let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allow_local_binding: true, ..NetworkProxySettings::default() }, }; assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } #[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()]; let set = compile_globset(&patterns).unwrap(); assert!(set.is_match("example.com")); assert!(set.is_match("EXAMPLE.COM")); } #[test] fn compile_globset_excludes_apex_for_subdomain_patterns() { let patterns = vec!["*.openai.com".to_string()]; let set = compile_globset(&patterns).unwrap(); assert!(set.is_match("api.openai.com")); assert!(!set.is_match("openai.com")); assert!(!set.is_match("evilopenai.com")); } #[test] fn compile_globset_includes_apex_for_double_wildcard_patterns() { let patterns = vec!["**.openai.com".to_string()]; let set = compile_globset(&patterns).unwrap(); assert!(set.is_match("openai.com")); assert!(set.is_match("api.openai.com")); assert!(!set.is_match("evilopenai.com")); } #[test] fn compile_globset_rejects_global_wildcard() { let patterns = vec!["*".to_string()]; assert!(compile_globset(&patterns).is_err()); } #[test] fn compile_globset_rejects_bracketed_global_wildcard() { let patterns = vec!["[*]".to_string()]; assert!(compile_globset(&patterns).is_err()); } #[test] fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() { let patterns = vec!["**.[*]".to_string()]; assert!(compile_globset(&patterns).is_err()); } #[test] fn compile_globset_dedupes_patterns_without_changing_behavior() { let patterns = vec!["example.com".to_string(), "example.com".to_string()]; let set = compile_globset(&patterns).unwrap(); assert!(set.is_match("example.com")); assert!(set.is_match("EXAMPLE.COM")); assert!(!set.is_match("not-example.com")); } #[test] fn compile_globset_rejects_invalid_patterns() { let patterns = vec!["[".to_string()]; assert!(compile_globset(&patterns).is_err()); } #[test] fn build_config_state_rejects_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["*".to_string()], ..NetworkProxySettings::default() }, }; assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); } #[test] fn build_config_state_rejects_bracketed_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["[*]".to_string()], ..NetworkProxySettings::default() }, }; assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); } #[test] fn build_config_state_rejects_global_wildcard_denied_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["example.com".to_string()], denied_domains: vec!["*".to_string()], ..NetworkProxySettings::default() }, }; assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); } #[test] fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, allowed_domains: vec!["example.com".to_string()], denied_domains: vec!["[*]".to_string()], ..NetworkProxySettings::default() }, }; assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); } #[cfg(target_os = "macos")] #[tokio::test] async fn unix_socket_allowlist_is_respected_on_macos() { let socket_path = "/tmp/example.sock".to_string(); let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], allow_unix_sockets: vec![socket_path.clone()], ..NetworkProxySettings::default() }); assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap()); assert!( !state .is_unix_socket_allowed("/tmp/not-allowed.sock") .await .unwrap() ); } #[cfg(target_os = "macos")] #[tokio::test] async fn unix_socket_allowlist_resolves_symlinks() { use std::os::unix::fs::symlink; use tempfile::tempdir; let temp_dir = tempdir().unwrap(); let dir = temp_dir.path(); let real = dir.join("real.sock"); let link = dir.join("link.sock"); // The allowlist mechanism is path-based; for test purposes we don't need an actual unix // domain socket. Any filesystem entry works for canonicalization. std::fs::write(&real, b"not a socket").unwrap(); symlink(&real, &link).unwrap(); let real_s = real.to_str().unwrap().to_string(); let link_s = link.to_str().unwrap().to_string(); let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], allow_unix_sockets: vec![real_s], ..NetworkProxySettings::default() }); 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() { let socket_path = "/tmp/example.sock".to_string(); 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() }); assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap()); } }