use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::policy::DomainPattern; use crate::policy::compile_globset; use crate::runtime::ConfigReloader; use crate::runtime::ConfigState; use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use codex_app_server_protocol::ConfigLayerSource; use codex_core::config::CONFIG_TOML_FILE; use codex_core::config::ConstraintError; use codex_core::config::find_codex_home; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::load_config_layers_state; use serde::Deserialize; use std::collections::HashSet; use tokio::sync::RwLock; pub use crate::runtime::BlockedRequest; pub use crate::runtime::BlockedRequestArgs; pub use crate::runtime::NetworkProxyState; #[cfg(test)] pub(crate) use crate::runtime::network_proxy_state_for_policy; pub(crate) async fn build_default_config_state_and_reloader() -> Result<(ConfigState, MtimeConfigReloader)> { let (state, layer_mtimes) = build_config_state_with_mtimes().await?; Ok((state, MtimeConfigReloader::new(layer_mtimes))) } async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec)> { // Load config through `codex-core` so we inherit the same layer ordering and semantics as the // rest of Codex (system/managed layers, user layers, session flags, etc.). let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let cli_overrides = Vec::new(); let overrides = LoaderOverrides::default(); let config_layer_stack = load_config_layers_state( &codex_home, None, &cli_overrides, overrides, CloudRequirementsLoader::default(), ) .await .context("failed to load Codex config")?; let cfg_path = codex_home.join(CONFIG_TOML_FILE); // Deserialize from the merged effective config, rather than parsing config.toml ourselves. // This avoids a second parser/merger implementation (and the drift that comes with it). let merged_toml = config_layer_stack.effective_config(); let config: NetworkProxyConfig = merged_toml .try_into() .context("failed to deserialize network proxy config")?; // Security boundary: user-controlled layers must not be able to widen restrictions set by // trusted/managed layers (e.g., MDM). Enforce this before building runtime state. let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?; let layer_mtimes = collect_layer_mtimes(&config_layer_stack); let deny_set = compile_globset(&config.network.denied_domains)?; let allow_set = compile_globset(&config.network.allowed_domains)?; Ok(( ConfigState { config, allow_set, deny_set, constraints, cfg_path, blocked: std::collections::VecDeque::new(), }, layer_mtimes, )) } fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec { stack .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) .iter() .filter_map(|layer| { let path = match &layer.name { ConfigLayerSource::System { file } => Some(file.as_path().to_path_buf()), ConfigLayerSource::User { file } => Some(file.as_path().to_path_buf()), ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder .join(CONFIG_TOML_FILE) .ok() .map(|p| p.as_path().to_path_buf()), ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => { Some(file.as_path().to_path_buf()) } _ => None, }; path.map(LayerMtime::new) }) .collect() } #[derive(Clone)] struct LayerMtime { path: std::path::PathBuf, mtime: Option, } impl LayerMtime { fn new(path: std::path::PathBuf) -> Self { let mtime = path.metadata().and_then(|m| m.modified()).ok(); Self { path, mtime } } } pub(crate) struct MtimeConfigReloader { layer_mtimes: RwLock>, } impl MtimeConfigReloader { fn new(layer_mtimes: Vec) -> Self { Self { layer_mtimes: RwLock::new(layer_mtimes), } } async fn needs_reload(&self) -> bool { let guard = self.layer_mtimes.read().await; guard.iter().any(|layer| { let metadata = std::fs::metadata(&layer.path).ok(); match (metadata.and_then(|m| m.modified().ok()), layer.mtime) { (Some(new_mtime), Some(old_mtime)) => new_mtime > old_mtime, (Some(_), None) => true, (None, Some(_)) => true, (None, None) => false, } }) } } #[async_trait] impl ConfigReloader for MtimeConfigReloader { async fn maybe_reload(&self) -> Result> { if !self.needs_reload().await { return Ok(None); } let (state, layer_mtimes) = build_config_state_with_mtimes().await?; let mut guard = self.layer_mtimes.write().await; *guard = layer_mtimes; Ok(Some(state)) } async fn reload_now(&self) -> Result { let (state, layer_mtimes) = build_config_state_with_mtimes().await?; let mut guard = self.layer_mtimes.write().await; *guard = layer_mtimes; Ok(state) } } #[derive(Debug, Default, Deserialize)] struct PartialConfig { #[serde(default)] network: PartialNetworkConfig, } #[derive(Debug, Default, Deserialize)] struct PartialNetworkConfig { enabled: Option, mode: Option, allow_upstream_proxy: Option, dangerously_allow_non_loopback_proxy: Option, dangerously_allow_non_loopback_admin: Option, #[serde(default)] allowed_domains: Option>, #[serde(default)] denied_domains: Option>, #[serde(default)] allow_unix_sockets: Option>, #[serde(default)] allow_local_binding: Option, } #[derive(Debug, Default, Clone, PartialEq, Eq)] pub(crate) struct NetworkProxyConstraints { pub(crate) enabled: Option, pub(crate) mode: Option, pub(crate) allow_upstream_proxy: Option, pub(crate) dangerously_allow_non_loopback_proxy: Option, pub(crate) dangerously_allow_non_loopback_admin: Option, pub(crate) allowed_domains: Option>, pub(crate) denied_domains: Option>, pub(crate) allow_unix_sockets: Option>, pub(crate) allow_local_binding: Option, } fn enforce_trusted_constraints( layers: &codex_core::config_loader::ConfigLayerStack, config: &NetworkProxyConfig, ) -> Result { let constraints = network_constraints_from_trusted_layers(layers)?; validate_policy_against_constraints(config, &constraints) .context("network proxy constraints")?; Ok(constraints) } fn network_constraints_from_trusted_layers( layers: &codex_core::config_loader::ConfigLayerStack, ) -> Result { let mut constraints = NetworkProxyConstraints::default(); for layer in layers.get_layers( codex_core::config_loader::ConfigLayerStackOrdering::LowestPrecedenceFirst, false, ) { // Only trusted layers contribute constraints. User-controlled layers can narrow policy but // must never widen beyond what managed config allows. if is_user_controlled_layer(&layer.name) { continue; } let partial: PartialConfig = layer .config .clone() .try_into() .context("failed to deserialize trusted config layer")?; if let Some(enabled) = partial.network.enabled { constraints.enabled = Some(enabled); } if let Some(mode) = partial.network.mode { constraints.mode = Some(mode); } if let Some(allow_upstream_proxy) = partial.network.allow_upstream_proxy { constraints.allow_upstream_proxy = Some(allow_upstream_proxy); } if let Some(dangerously_allow_non_loopback_proxy) = partial.network.dangerously_allow_non_loopback_proxy { constraints.dangerously_allow_non_loopback_proxy = Some(dangerously_allow_non_loopback_proxy); } if let Some(dangerously_allow_non_loopback_admin) = partial.network.dangerously_allow_non_loopback_admin { constraints.dangerously_allow_non_loopback_admin = Some(dangerously_allow_non_loopback_admin); } if let Some(allowed_domains) = partial.network.allowed_domains { constraints.allowed_domains = Some(allowed_domains); } if let Some(denied_domains) = partial.network.denied_domains { constraints.denied_domains = Some(denied_domains); } if let Some(allow_unix_sockets) = partial.network.allow_unix_sockets { constraints.allow_unix_sockets = Some(allow_unix_sockets); } if let Some(allow_local_binding) = partial.network.allow_local_binding { constraints.allow_local_binding = Some(allow_local_binding); } } Ok(constraints) } fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { matches!( layer, ConfigLayerSource::User { .. } | ConfigLayerSource::Project { .. } | ConfigLayerSource::SessionFlags ) } pub(crate) fn validate_policy_against_constraints( config: &NetworkProxyConfig, constraints: &NetworkProxyConstraints, ) -> std::result::Result<(), ConstraintError> { fn invalid_value( field_name: &'static str, candidate: impl Into, allowed: impl Into, ) -> ConstraintError { ConstraintError::InvalidValue { field_name, candidate: candidate.into(), allowed: allowed.into(), requirement_source: RequirementSource::Unknown, } } fn validate( candidate: T, validator: impl FnOnce(&T) -> std::result::Result<(), ConstraintError>, ) -> std::result::Result<(), ConstraintError> { validator(&candidate) } let enabled = config.network.enabled; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { if *candidate && !max_enabled { Err(invalid_value( "network.enabled", "true", "false (disabled by managed config)", )) } else { Ok(()) } })?; } if let Some(max_mode) = constraints.mode { validate(config.network.mode, move |candidate| { if network_mode_rank(*candidate) > network_mode_rank(max_mode) { Err(invalid_value( "network.mode", format!("{candidate:?}"), format!("{max_mode:?} or more restrictive"), )) } else { Ok(()) } })?; } let allow_upstream_proxy = constraints.allow_upstream_proxy; validate( config.network.allow_upstream_proxy, move |candidate| match allow_upstream_proxy { Some(true) | None => Ok(()), Some(false) => { if *candidate { Err(invalid_value( "network.allow_upstream_proxy", "true", "false (disabled by managed config)", )) } else { Ok(()) } } }, )?; let allow_non_loopback_admin = constraints.dangerously_allow_non_loopback_admin; validate( config.network.dangerously_allow_non_loopback_admin, move |candidate| match allow_non_loopback_admin { Some(true) | None => Ok(()), Some(false) => { if *candidate { Err(invalid_value( "network.dangerously_allow_non_loopback_admin", "true", "false (disabled by managed config)", )) } else { Ok(()) } } }, )?; let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback_proxy; validate( config.network.dangerously_allow_non_loopback_proxy, move |candidate| match allow_non_loopback_proxy { Some(true) | None => Ok(()), Some(false) => { if *candidate { Err(invalid_value( "network.dangerously_allow_non_loopback_proxy", "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 { Err(invalid_value( "network.allow_local_binding", "true", "false (disabled by managed config)", )) } else { Ok(()) } })?; } if let Some(allowed_domains) = &constraints.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 .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 { let required_set: HashSet = denied_domains .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:?}"), )) } })?; } if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets { let allowed_set: HashSet = allow_unix_sockets .iter() .map(|s| s.to_ascii_lowercase()) .collect(); validate( config.network.allow_unix_sockets.clone(), move |candidate| { let mut invalid = Vec::new(); for entry in candidate { if !allowed_set.contains(&entry.to_ascii_lowercase()) { invalid.push(entry.clone()); } } if invalid.is_empty() { Ok(()) } else { Err(invalid_value( "network.allow_unix_sockets", format!("{invalid:?}"), "subset of managed allow_unix_sockets", )) } }, )?; } Ok(()) } fn network_mode_rank(mode: NetworkMode) -> u8 { match mode { NetworkMode::Limited => 0, NetworkMode::Full => 1, } }