diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a93a7072f..90d711792 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1569,6 +1569,7 @@ dependencies = [ "codex-file-search", "codex-git", "codex-keyring-store", + "codex-network-proxy", "codex-otel", "codex-protocol", "codex-rmcp-client", @@ -1905,8 +1906,6 @@ dependencies = [ "anyhow", "async-trait", "clap", - "codex-app-server-protocol", - "codex-core", "codex-utils-absolute-path", "globset", "pretty_assertions", @@ -1921,13 +1920,25 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thiserror 2.0.18", "time", "tokio", "tracing", - "tracing-subscriber", "url", ] +[[package]] +name = "codex-network-proxy-cli" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-core", + "codex-network-proxy", + "tokio", + "tracing-subscriber", +] + [[package]] name = "codex-ollama" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index c8453cb3b..82af59b64 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -29,6 +29,7 @@ members = [ "login", "mcp-server", "network-proxy", + "network-proxy-cli", "ollama", "process-hardening", "protocol", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d686343ae..fb7ff7cfd 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -41,6 +41,7 @@ codex-rmcp-client = { workspace = true } codex-state = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } +codex-network-proxy = { path = "../network-proxy" } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index f1534cf0f..5f1920171 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -42,6 +42,7 @@ pub mod landlock; pub mod mcp; mod mcp_connection_manager; pub mod models_manager; +pub mod network_proxy_loader; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs new file mode 100644 index 000000000..cafe05a0c --- /dev/null +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -0,0 +1,214 @@ +use crate::config::CONFIG_TOML_FILE; +use crate::config::find_codex_home; +use crate::config_loader::CloudRequirementsLoader; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; +use crate::config_loader::LoaderOverrides; +use crate::config_loader::load_config_layers_state; +use anyhow::Context; +use anyhow::Result; +use async_trait::async_trait; +use codex_app_server_protocol::ConfigLayerSource; +use codex_network_proxy::ConfigReloader; +use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkProxyConstraintError; +use codex_network_proxy::NetworkProxyConstraints; +use codex_network_proxy::NetworkProxyState; +use codex_network_proxy::PartialNetworkProxyConfig; +use codex_network_proxy::build_config_state; +use codex_network_proxy::validate_policy_against_constraints; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub async fn build_network_proxy_state() -> Result { + let (state, reloader) = build_network_proxy_state_and_reloader().await?; + Ok(NetworkProxyState::with_reloader(state, Arc::new(reloader))) +} + +pub async fn build_network_proxy_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)> { + 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); + let merged_toml = config_layer_stack.effective_config(); + let config: NetworkProxyConfig = merged_toml + .try_into() + .context("failed to deserialize network proxy config")?; + + let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?; + let layer_mtimes = collect_layer_mtimes(&config_layer_stack); + let state = build_config_state(config, constraints, cfg_path)?; + Ok((state, 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() +} + +fn enforce_trusted_constraints( + layers: &ConfigLayerStack, + config: &NetworkProxyConfig, +) -> Result { + let constraints = network_constraints_from_trusted_layers(layers)?; + validate_policy_against_constraints(config, &constraints) + .map_err(NetworkProxyConstraintError::into_anyhow) + .context("network proxy constraints")?; + Ok(constraints) +} + +fn network_constraints_from_trusted_layers( + layers: &ConfigLayerStack, +) -> Result { + let mut constraints = NetworkProxyConstraints::default(); + for layer in layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) { + if is_user_controlled_layer(&layer.name) { + continue; + } + + let partial: PartialNetworkProxyConfig = 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 + ) +} + +#[derive(Clone)] +struct LayerMtime { + path: PathBuf, + mtime: Option, +} + +impl LayerMtime { + fn new(path: PathBuf) -> Self { + let mtime = path.metadata().and_then(|m| m.modified()).ok(); + Self { path, mtime } + } +} + +pub 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) + } +} diff --git a/codex-rs/network-proxy-cli/Cargo.toml b/codex-rs/network-proxy-cli/Cargo.toml new file mode 100644 index 000000000..9cc05372d --- /dev/null +++ b/codex-rs/network-proxy-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "codex-network-proxy-cli" +edition = "2024" +version = { workspace = true } +license.workspace = true + +[[bin]] +name = "codex-network-proxy" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-core = { workspace = true } +codex-network-proxy = { path = "../network-proxy" } +tokio = { workspace = true, features = ["full"] } +tracing-subscriber = { workspace = true, features = ["fmt"] } diff --git a/codex-rs/network-proxy-cli/src/main.rs b/codex-rs/network-proxy-cli/src/main.rs new file mode 100644 index 000000000..c74e7a226 --- /dev/null +++ b/codex-rs/network-proxy-cli/src/main.rs @@ -0,0 +1,19 @@ +use anyhow::Result; +use clap::Parser; +use codex_core::network_proxy_loader; +use codex_network_proxy::Args; +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::NetworkProxyState; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let (state, reloader) = network_proxy_loader::build_network_proxy_state_and_reloader().await?; + let state = Arc::new(NetworkProxyState::with_reloader(state, Arc::new(reloader))); + let _ = args; + let proxy = NetworkProxy::builder().state(state).build().await?; + proxy.run().await?.wait().await +} diff --git a/codex-rs/network-proxy/BUILD.bazel b/codex-rs/network-proxy/BUILD.bazel new file mode 100644 index 000000000..79b3dc5ef --- /dev/null +++ b/codex-rs/network-proxy/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "network-proxy", + crate_name = "codex_network_proxy", +) diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index c334ce473..cf6df5503 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -4,10 +4,6 @@ edition = "2024" version = { workspace = true } license.workspace = true -[[bin]] -name = "codex-network-proxy" -path = "src/main.rs" - [lib] name = "codex_network_proxy" path = "src/lib.rs" @@ -19,16 +15,14 @@ workspace = true anyhow = { workspace = true } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } -codex-app-server-protocol = { workspace = true } -codex-core = { workspace = true } codex-utils-absolute-path = { workspace = true } globset = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +thiserror = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } -tracing-subscriber = { workspace = true, features = ["fmt"] } url = { workspace = true } rama-core = { version = "=0.3.0-alpha.4" } rama-http = { version = "=0.3.0-alpha.4" } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index e63627312..a974ce13b 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -13,7 +13,8 @@ mod socks5; mod state; mod upstream; -use anyhow::Result; +pub use config::NetworkMode; +pub use config::NetworkProxyConfig; pub use network_policy::NetworkDecision; pub use network_policy::NetworkPolicyDecider; pub use network_policy::NetworkPolicyRequest; @@ -23,9 +24,12 @@ pub use proxy::Args; pub use proxy::NetworkProxy; pub use proxy::NetworkProxyBuilder; pub use proxy::NetworkProxyHandle; - -pub async fn run_main(args: Args) -> Result<()> { - let _ = args; - let proxy = NetworkProxy::builder().build().await?; - proxy.run().await?.wait().await -} +pub use runtime::ConfigReloader; +pub use runtime::ConfigState; +pub use runtime::NetworkProxyState; +pub use state::NetworkProxyConstraintError; +pub use state::NetworkProxyConstraints; +pub use state::PartialNetworkConfig; +pub use state::PartialNetworkProxyConfig; +pub use state::build_config_state; +pub use state::validate_policy_against_constraints; diff --git a/codex-rs/network-proxy/src/main.rs b/codex-rs/network-proxy/src/main.rs deleted file mode 100644 index 7cb28aad5..000000000 --- a/codex-rs/network-proxy/src/main.rs +++ /dev/null @@ -1,14 +0,0 @@ -use anyhow::Result; -use clap::Parser; -use codex_network_proxy::Args; -use codex_network_proxy::NetworkProxy; - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt::init(); - - let args = Args::parse(); - let _ = args; - let proxy = NetworkProxy::builder().build().await?; - proxy.run().await?.wait().await -} diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ab347c2c0..cca026717 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -55,10 +55,11 @@ impl NetworkProxyBuilder { } pub async fn build(self) -> Result { - let state = match self.state { - Some(state) => state, - None => Arc::new(NetworkProxyState::new().await?), - }; + let state = self.state.ok_or_else(|| { + anyhow::anyhow!( + "NetworkProxyBuilder requires a state; supply one via builder.state(...)" + ) + })?; let current_cfg = state.current_cfg().await?; let runtime = config::resolve_runtime(¤t_cfg)?; // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only. diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b426de7eb..a1ab34757 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -7,8 +7,10 @@ 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_default_config_state_and_reloader; +#[cfg(test)] +use crate::state::build_config_state; use crate::state::validate_policy_against_constraints; use anyhow::Context; use anyhow::Result; @@ -105,17 +107,17 @@ impl BlockedRequest { } #[derive(Clone)] -pub(crate) struct ConfigState { - pub(crate) config: NetworkProxyConfig, - pub(crate) allow_set: GlobSet, - pub(crate) deny_set: GlobSet, - pub(crate) constraints: NetworkProxyConstraints, - pub(crate) cfg_path: PathBuf, - pub(crate) blocked: VecDeque, +pub struct ConfigState { + pub config: NetworkProxyConfig, + pub allow_set: GlobSet, + pub deny_set: GlobSet, + pub constraints: NetworkProxyConstraints, + pub cfg_path: PathBuf, + pub blocked: VecDeque, } #[async_trait] -pub(crate) trait ConfigReloader: Send + Sync { +pub trait ConfigReloader: Send + Sync { /// Return a freshly loaded state if a reload is needed; otherwise, return `None`. async fn maybe_reload(&self) -> Result>; @@ -146,12 +148,7 @@ impl Clone for NetworkProxyState { } impl NetworkProxyState { - pub async fn new() -> Result { - let (cfg_state, reloader) = build_default_config_state_and_reloader().await?; - Ok(Self::with_reloader(cfg_state, Arc::new(reloader))) - } - - pub(crate) fn with_reloader(state: ConfigState, reloader: Arc) -> Self { + pub fn with_reloader(state: ConfigState, reloader: Arc) -> Self { Self { state: Arc::new(RwLock::new(state)), reloader, @@ -362,6 +359,7 @@ impl NetworkProxyState { }; 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; @@ -495,18 +493,12 @@ pub(crate) fn network_proxy_state_for_policy( network.enabled = true; network.mode = NetworkMode::Full; let config = NetworkProxyConfig { network }; - - let allow_set = crate::policy::compile_globset(&config.network.allowed_domains).unwrap(); - let deny_set = crate::policy::compile_globset(&config.network.denied_domains).unwrap(); - - let state = ConfigState { + let state = build_config_state( config, - allow_set, - deny_set, - constraints: NetworkProxyConstraints::default(), - cfg_path: PathBuf::from("/nonexistent/config.toml"), - blocked: VecDeque::new(), - }; + NetworkProxyConstraints::default(), + PathBuf::from("/nonexistent/config.toml"), + ) + .unwrap(); NetworkProxyState::with_reloader(state, Arc::new(NoopReloader)) } diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 6b6517d13..99009e76a 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -2,24 +2,10 @@ 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; +use std::path::PathBuf; pub use crate::runtime::BlockedRequest; pub use crate::runtime::BlockedRequestArgs; @@ -27,272 +13,79 @@ 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))) +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct NetworkProxyConstraints { + pub enabled: Option, + pub mode: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_non_loopback_admin: Option, + pub allowed_domains: Option>, + pub denied_domains: Option>, + pub allow_unix_sockets: Option>, + pub allow_local_binding: Option, } -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")?; +#[derive(Debug, Clone, Deserialize)] +pub struct PartialNetworkProxyConfig { + #[serde(default)] + pub network: PartialNetworkConfig, +} - let cfg_path = codex_home.join(CONFIG_TOML_FILE); +#[derive(Debug, Default, Clone, Deserialize)] +pub struct PartialNetworkConfig { + pub enabled: Option, + pub mode: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_non_loopback_admin: Option, + #[serde(default)] + pub allowed_domains: Option>, + #[serde(default)] + pub denied_domains: Option>, + #[serde(default)] + pub allow_unix_sockets: Option>, + #[serde(default)] + pub allow_local_binding: Option, +} - // 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); +pub fn build_config_state( + config: NetworkProxyConfig, + constraints: NetworkProxyConstraints, + cfg_path: PathBuf, +) -> anyhow::Result { 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, - )) + Ok(ConfigState { + config, + allow_set, + deny_set, + constraints, + cfg_path, + blocked: std::collections::VecDeque::new(), + }) } -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( +pub fn validate_policy_against_constraints( config: &NetworkProxyConfig, constraints: &NetworkProxyConstraints, -) -> std::result::Result<(), ConstraintError> { +) -> Result<(), NetworkProxyConstraintError> { fn invalid_value( field_name: &'static str, candidate: impl Into, allowed: impl Into, - ) -> ConstraintError { - ConstraintError::InvalidValue { + ) -> NetworkProxyConstraintError { + NetworkProxyConstraintError::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: impl FnOnce(&T) -> Result<(), NetworkProxyConstraintError>, + ) -> Result<(), NetworkProxyConstraintError> { validator(&candidate) } @@ -479,6 +272,22 @@ pub(crate) fn validate_policy_against_constraints( Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum NetworkProxyConstraintError { + #[error("invalid value for {field_name}: {candidate} (allowed {allowed})")] + InvalidValue { + field_name: &'static str, + candidate: String, + allowed: String, + }, +} + +impl NetworkProxyConstraintError { + pub fn into_anyhow(self) -> anyhow::Error { + anyhow::anyhow!(self) + } +} + fn network_mode_rank(mode: NetworkMode) -> u8 { match mode { NetworkMode::Limited => 0,