From fa48061df37ab70fae7fec66e538dbd64ef6cc0f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 8 Feb 2026 09:18:31 -0800 Subject: [PATCH] feat: include NetworkConfig through ExecParams --- codex-rs/Cargo.lock | 5 +- codex-rs/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 1 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 2 + codex-rs/core/src/config/mod.rs | 9 + codex-rs/core/src/exec.rs | 23 +- codex-rs/core/src/models_manager/manager.rs | 28 ++ codex-rs/core/src/tools/handlers/shell.rs | 4 + codex-rs/core/src/tools/runtimes/shell.rs | 8 +- .../core/src/tools/runtimes/unified_exec.rs | 10 +- .../core/src/unified_exec/process_manager.rs | 1 + codex-rs/core/tests/suite/exec.rs | 1 + .../exec-server/src/posix/escalate_server.rs | 1 + .../linux-sandbox/tests/suite/landlock.rs | 2 + codex-rs/network-proxy/Cargo.toml | 4 +- codex-rs/network-proxy/src/proxy.rs | 32 +++ codex-rs/network-proxy/src/state.rs | 254 +++++++++--------- 18 files changed, 257 insertions(+), 130 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e2b13f91e7..c476c4b3ce 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1565,6 +1565,7 @@ dependencies = [ "codex-file-search", "codex-git", "codex-keyring-store", + "codex-network-proxy", "codex-otel", "codex-protocol", "codex-rmcp-client", @@ -1901,9 +1902,8 @@ dependencies = [ "anyhow", "async-trait", "clap", - "codex-app-server-protocol", - "codex-core", "codex-utils-absolute-path", + "codex-utils-home-dir", "globset", "pretty_assertions", "rama-core", @@ -1919,6 +1919,7 @@ dependencies = [ "tempfile", "time", "tokio", + "toml 0.9.11+spec-1.1.0", "tracing", "tracing-subscriber", "url", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2cfcf59ef8..10e3b122aa 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -93,6 +93,7 @@ codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } codex-mcp-server = { path = "mcp-server" } +codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3f65cf2879..10d534a7db 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1628,6 +1628,7 @@ impl CodexMessageProcessor { cwd, expiration: timeout_ms.into(), env, + network: self.config.network.clone(), sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, justification: None, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d686343ae7..b388463151 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -35,6 +35,7 @@ codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } codex-keyring-store = { workspace = true } +codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8280b5a9e0..37d48848bf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6658,6 +6658,7 @@ mod tests { cwd: turn_context.cwd.clone(), expiration: timeout_ms.into(), env: HashMap::new(), + network: None, sandbox_permissions, windows_sandbox_level: turn_context.windows_sandbox_level, justification: Some("test".to_string()), @@ -6670,6 +6671,7 @@ mod tests { cwd: params.cwd.clone(), expiration: timeout_ms.into(), env: HashMap::new(), + network: None, windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dee21376cb..34a1772266 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -47,6 +47,7 @@ use crate::protocol::SandboxPolicy; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; +use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModeKind; @@ -152,6 +153,9 @@ pub struct Config { /// using backend-specific headers or URLs to enforce this. pub enforce_residency: Constrained>, + /// Effective network configuration applied to all spawned processes. + pub network: Option, + /// True if the user passed in an override or set a value in config.toml /// for either of approval_policy or sandbox_mode. pub did_user_set_custom_approval_policy_or_sandbox_mode: bool, @@ -1690,6 +1694,7 @@ impl Config { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, enforce_residency: enforce_residency.value, + network: None, did_user_set_custom_approval_policy_or_sandbox_mode, forced_auto_mode_downgraded_on_windows, shell_environment_policy, @@ -4003,6 +4008,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), enforce_residency: Constrained::allow_any(None), + network: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -4091,6 +4097,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), enforce_residency: Constrained::allow_any(None), + network: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -4194,6 +4201,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), enforce_residency: Constrained::allow_any(None), + network: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -4283,6 +4291,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), enforce_residency: Constrained::allow_any(None), + network: None, did_user_set_custom_approval_policy_or_sandbox_mode: true, forced_auto_mode_downgraded_on_windows: false, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index d4d508ced6..2cd06f78a4 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -32,6 +32,7 @@ use crate::sandboxing::SandboxPermissions; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; +use codex_network_proxy::NetworkProxy; use codex_utils_pty::process_group::kill_child_process_group; pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000; @@ -63,6 +64,7 @@ pub struct ExecParams { pub cwd: PathBuf, pub expiration: ExecExpiration, pub env: HashMap, + pub network: Option, pub sandbox_permissions: SandboxPermissions, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub justification: Option, @@ -170,12 +172,16 @@ pub async fn process_exec_tool_call( command, cwd, expiration, - env, + mut env, + network, sandbox_permissions, windows_sandbox_level, justification, arg0: _, } = params; + if let Some(network) = network.as_ref() { + network.apply_to_env(&mut env); + } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -233,6 +239,7 @@ pub(crate) async fn execute_exec_env( cwd, expiration, env, + network: None, sandbox_permissions, windows_sandbox_level, justification, @@ -324,11 +331,15 @@ async fn exec_windows_sandbox( let ExecParams { command, cwd, - env, + mut env, + network, expiration, windows_sandbox_level, .. } = params; + if let Some(network) = network.as_ref() { + network.apply_to_env(&mut env); + } // TODO(iceweasel-oai): run_windows_sandbox_capture should support all // variants of ExecExpiration, not just timeout. let timeout_ms = expiration.timeout_ms(); @@ -677,12 +688,16 @@ async fn exec( let ExecParams { command, cwd, - env, + mut env, + network, arg0, expiration, windows_sandbox_level: _, .. } = params; + if let Some(network) = network.as_ref() { + network.apply_to_env(&mut env); + } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -1061,6 +1076,7 @@ mod tests { cwd: std::env::current_dir()?, expiration: 500.into(), env, + network: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, @@ -1107,6 +1123,7 @@ mod tests { cwd: cwd.clone(), expiration: ExecExpiration::Cancellation(cancel_token), env, + network: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 2d85185076..0ff3afa3ca 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -430,8 +430,20 @@ mod tests { } } + fn can_bind_loopback_port() -> bool { + match std::net::TcpListener::bind(("127.0.0.1", 0)) { + Ok(_listener) => true, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => false, + Err(err) => panic!("unexpected error probing loopback listen permissions: {err}"), + } + } + #[tokio::test] async fn refresh_available_models_sorts_by_priority() { + if !can_bind_loopback_port() { + return; + } + let server = MockServer::start().await; let remote_models = vec![ remote_model("priority-low", "Low", 1), @@ -489,6 +501,10 @@ mod tests { #[tokio::test] async fn refresh_available_models_uses_cache_when_fresh() { + if !can_bind_loopback_port() { + return; + } + let server = MockServer::start().await; let remote_models = vec![remote_model("cached", "Cached", 5)]; let models_mock = mount_models_once( @@ -536,6 +552,10 @@ mod tests { #[tokio::test] async fn refresh_available_models_refetches_when_cache_stale() { + if !can_bind_loopback_port() { + return; + } + let server = MockServer::start().await; let initial_models = vec![remote_model("stale", "Stale", 1)]; let initial_mock = mount_models_once( @@ -605,6 +625,10 @@ mod tests { #[tokio::test] async fn refresh_available_models_refetches_when_version_mismatch() { + if !can_bind_loopback_port() { + return; + } + let server = MockServer::start().await; let initial_models = vec![remote_model("old", "Old", 1)]; let initial_mock = mount_models_once( @@ -674,6 +698,10 @@ mod tests { #[tokio::test] async fn refresh_available_models_drops_removed_remote_models() { + if !can_bind_loopback_port() { + return; + } + let server = MockServer::start().await; let initial_models = vec![remote_model("remote-old", "Remote Old", 1)]; let initial_mock = mount_models_once( diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 1d4f01356f..fd678c3a3c 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -53,6 +53,7 @@ impl ShellHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), + network: turn_context.config.network.clone(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -81,6 +82,7 @@ impl ShellCommandHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), + network: turn_context.config.network.clone(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -311,6 +313,7 @@ impl ShellHandler { cwd: exec_params.cwd.clone(), timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), + network: exec_params.network.clone(), sandbox_permissions: exec_params.sandbox_permissions, justification: exec_params.justification.clone(), exec_approval_requirement, @@ -441,6 +444,7 @@ mod tests { assert_eq!(exec_params.command, expected_command); assert_eq!(exec_params.cwd, expected_cwd); assert_eq!(exec_params.env, expected_env); + assert_eq!(exec_params.network, turn_context.config.network); assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); assert_eq!(exec_params.justification, justification); diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index fc862a9aea..0598fafa7a 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -23,6 +23,7 @@ use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::with_cached_approval; +use codex_network_proxy::NetworkProxy; use codex_protocol::protocol::ReviewDecision; use futures::future::BoxFuture; use std::path::PathBuf; @@ -33,6 +34,7 @@ pub struct ShellRequest { pub cwd: PathBuf, pub timeout_ms: Option, pub env: std::collections::HashMap, + pub network: Option, pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, @@ -155,10 +157,14 @@ impl ToolRuntime for ShellRuntime { command }; + let mut env = req.env.clone(); + if let Some(network) = req.network.as_ref() { + network.apply_to_env(&mut env); + } let spec = build_command_spec( &command, &req.cwd, - &req.env, + &env, req.timeout_ms.into(), req.sandbox_permissions, req.justification.clone(), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 2505b10ed2..83c6878136 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -27,6 +27,7 @@ use crate::tools::sandboxing::with_cached_approval; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; +use codex_network_proxy::NetworkProxy; use codex_protocol::protocol::ReviewDecision; use futures::future::BoxFuture; use std::collections::HashMap; @@ -37,6 +38,7 @@ pub struct UnifiedExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, + pub network: Option, pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub justification: Option, @@ -60,6 +62,7 @@ impl UnifiedExecRequest { command: Vec, cwd: PathBuf, env: HashMap, + network: Option, tty: bool, sandbox_permissions: SandboxPermissions, justification: Option, @@ -69,6 +72,7 @@ impl UnifiedExecRequest { command, cwd, env, + network, tty, sandbox_permissions, justification, @@ -181,10 +185,14 @@ impl<'a> ToolRuntime for UnifiedExecRunt command }; + let mut env = req.env.clone(); + if let Some(network) = req.network.as_ref() { + network.apply_to_env(&mut env); + } let spec = build_command_spec( &command, &req.cwd, - &req.env, + &env, ExecExpiration::DefaultTimeout, req.sandbox_permissions, req.justification.clone(), diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 1659ba6951..2aa2d33e21 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -505,6 +505,7 @@ impl UnifiedExecProcessManager { request.command.clone(), cwd, env, + context.turn.config.network.clone(), request.tty, request.sandbox_permissions, request.justification.clone(), diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index b70062d7ed..c3cb15875b 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -36,6 +36,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result>, } +impl std::fmt::Debug for NetworkProxy { + 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("NetworkProxy") + .field("http_addr", &self.http_addr) + .field("socks_addr", &self.socks_addr) + .field("admin_addr", &self.admin_addr) + .finish_non_exhaustive() + } +} + +impl PartialEq for NetworkProxy { + fn eq(&self, other: &Self) -> bool { + self.http_addr == other.http_addr + && self.socks_addr == other.socks_addr + && self.admin_addr == other.admin_addr + } +} + +impl Eq for NetworkProxy {} + impl NetworkProxy { pub fn builder() -> NetworkProxyBuilder { NetworkProxyBuilder::default() } + pub fn apply_to_env(&self, env: &mut HashMap) { + // Enforce proxying for all child processes when configured. We always override to ensure + // the proxy is actually used even if the caller passed conflicting environment variables. + let proxy_url = format!("http://{}", self.http_addr); + for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] { + env.insert(key.to_string(), proxy_url.clone()); + } + } + pub async fn run(&self) -> Result { let current_cfg = self.state.current_cfg().await?; if !current_cfg.network.enabled { diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 179d33dec4..1941591312 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -6,18 +6,17 @@ use crate::runtime::ConfigState; use crate::runtime::LayerMtime; use anyhow::Context; use anyhow::Result; -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 codex_utils_home_dir::find_codex_home; use serde::Deserialize; use std::collections::HashSet; +use std::path::Path; +use tokio::fs; +use toml::Value as TomlValue; + +const CONFIG_TOML_FILE: &str = "config.toml"; + +#[cfg(unix)] +const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; pub use crate::runtime::BlockedRequest; pub use crate::runtime::BlockedRequestArgs; @@ -26,35 +25,37 @@ pub use crate::runtime::NetworkProxyState; pub(crate) use crate::runtime::network_proxy_state_for_policy; pub(crate) async fn build_config_state() -> Result { - // 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 system_cfg_path = system_config_path(); + let system_config = read_toml_file_best_effort(&system_cfg_path) + .await + .context("failed to read system config")?; + let user_config = read_toml_file_best_effort(&cfg_path) + .await + .context("failed to read user config")?; + + let mut merged_toml = TomlValue::Table(toml::map::Map::new()); + if let Some(system_config) = system_config.clone() { + merge_toml_values(&mut merged_toml, system_config); + } + if let Some(user_config) = user_config { + merge_toml_values(&mut merged_toml, user_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 constraints = enforce_trusted_constraints(system_config.as_ref(), &config)?; - let layer_mtimes = collect_layer_mtimes(&config_layer_stack); + let layer_mtimes = vec![ + LayerMtime::new(system_cfg_path), + LayerMtime::new(cfg_path.clone()), + ]; let deny_set = compile_globset(&config.network.denied_domains)?; let allow_set = compile_globset(&config.network.allowed_domains)?; Ok(ConfigState { @@ -68,26 +69,57 @@ pub(crate) async fn build_config_state() -> Result { }) } -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()) +fn system_config_path() -> std::path::PathBuf { + #[cfg(unix)] + { + Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX).to_path_buf() + } + + #[cfg(not(unix))] + { + // Use a dummy path on non-Unix platforms. This keeps the reload logic stable without + // needing per-platform config stack logic yet. + std::path::PathBuf::from("__no_system_config.toml") + } +} + +async fn read_toml_file_best_effort(path: &Path) -> Result> { + let contents = match fs::read_to_string(path).await { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + tracing::warn!( + path = %path.display(), + error = %err, + "permission denied reading config; ignoring" + ); + return Ok(None); + } + Err(err) => return Err(err).context(format!("read {}", path.display())), + }; + + let parsed: TomlValue = contents + .parse() + .with_context(|| format!("parse {}", path.display()))?; + Ok(Some(parsed)) +} + +fn merge_toml_values(base: &mut TomlValue, overlay: TomlValue) { + match (base, overlay) { + (TomlValue::Table(base_table), TomlValue::Table(overlay_table)) => { + for (key, value) in overlay_table { + match base_table.get_mut(&key) { + Some(existing) => merge_toml_values(existing, value), + None => { + base_table.insert(key, value); + } } - _ => None, - }; - path.map(LayerMtime::new) - }) - .collect() + } + } + (base_slot, overlay_value) => { + *base_slot = overlay_value; + } + } } #[derive(Debug, Default, Deserialize)] @@ -127,103 +159,83 @@ pub(crate) struct NetworkProxyConstraints { } fn enforce_trusted_constraints( - layers: &codex_core::config_loader::ConfigLayerStack, + system_config: Option<&TomlValue>, config: &NetworkProxyConfig, ) -> Result { - let constraints = network_constraints_from_trusted_layers(layers)?; + let constraints = network_constraints_from_system_config(system_config)?; validate_policy_against_constraints(config, &constraints) .context("network proxy constraints")?; Ok(constraints) } -fn network_constraints_from_trusted_layers( - layers: &codex_core::config_loader::ConfigLayerStack, +fn network_constraints_from_system_config( + system_config: Option<&TomlValue>, ) -> 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 Some(system_config) = system_config else { + return Ok(constraints); + }; - let partial: PartialConfig = layer - .config - .clone() - .try_into() - .context("failed to deserialize trusted config layer")?; + let partial: PartialConfig = system_config + .clone() + .try_into() + .context("failed to deserialize system config constraints")?; - 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); - } + 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); } - Ok(constraints) -} -fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { - matches!( - layer, - ConfigLayerSource::User { .. } - | ConfigLayerSource::Project { .. } - | ConfigLayerSource::SessionFlags - ) + 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) } pub(crate) fn validate_policy_against_constraints( config: &NetworkProxyConfig, constraints: &NetworkProxyConstraints, -) -> std::result::Result<(), ConstraintError> { +) -> Result<()> { 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, - } + ) -> anyhow::Error { + anyhow::anyhow!( + "invalid value for {field_name}: candidate={} allowed={}", + candidate.into(), + allowed.into() + ) } - fn validate( - candidate: T, - validator: impl FnOnce(&T) -> std::result::Result<(), ConstraintError>, - ) -> std::result::Result<(), ConstraintError> { + fn validate(candidate: T, validator: impl FnOnce(&T) -> Result<()>) -> Result<()> { validator(&candidate) }