diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 16f5c1455..5eab764fc 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -625,6 +625,70 @@ ], "type": "object" }, + "NetworkModeSchema": { + "enum": [ + "limited", + "full" + ], + "type": "string" + }, + "NetworkToml": { + "additionalProperties": false, + "properties": { + "admin_url": { + "type": "string" + }, + "allow_local_binding": { + "type": "boolean" + }, + "allow_unix_sockets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allow_upstream_proxy": { + "type": "boolean" + }, + "allowed_domains": { + "items": { + "type": "string" + }, + "type": "array" + }, + "dangerously_allow_non_loopback_admin": { + "type": "boolean" + }, + "dangerously_allow_non_loopback_proxy": { + "type": "boolean" + }, + "denied_domains": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enable_socks5": { + "type": "boolean" + }, + "enable_socks5_udp": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "mode": { + "$ref": "#/definitions/NetworkModeSchema" + }, + "proxy_url": { + "type": "string" + }, + "socks_url": { + "type": "string" + } + }, + "type": "object" + }, "Notice": { "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", "properties": { @@ -866,6 +930,20 @@ }, "type": "object" }, + "PermissionsToml": { + "additionalProperties": false, + "properties": { + "network": { + "allOf": [ + { + "$ref": "#/definitions/NetworkToml" + } + ], + "description": "Network proxy settings from `[permissions.network]`. User config can enable the proxy; managed requirements may still constrain values." + } + }, + "type": "object" + }, "Personality": { "enum": [ "none", @@ -1718,6 +1796,15 @@ ], "description": "OTEL configuration." }, + "permissions": { + "allOf": [ + { + "$ref": "#/definitions/PermissionsToml" + } + ], + "default": null, + "description": "Nested permissions settings." + }, "personality": { "allOf": [ { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3d3a714fa..a27508d1a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -85,12 +85,14 @@ use tempfile::tempdir; #[cfg(not(target_os = "macos"))] type MacOsSeatbeltProfileExtensions = (); +use crate::config::permissions::network_proxy_config_from_permissions; use crate::config::profile::ConfigProfile; use toml::Value as TomlValue; use toml_edit::DocumentMut; pub mod edit; mod network_proxy_spec; +mod permissions; pub mod profile; pub mod schema; pub mod service; @@ -101,6 +103,8 @@ pub use codex_config::ConstraintResult; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub use permissions::NetworkToml; +pub use permissions::PermissionsToml; pub use service::ConfigService; pub use service::ConfigServiceError; @@ -954,6 +958,10 @@ pub struct ConfigToml { /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. pub sandbox_workspace_write: Option, + /// Nested permissions settings. + #[serde(default)] + pub permissions: Option, + /// Optional external command to spawn for end-user notifications. #[serde(default)] pub notify: Option>, @@ -1590,6 +1598,8 @@ impl Config { .clone(), None => ConfigProfile::default(), }; + let configured_network_proxy_config = + network_proxy_config_from_permissions(cfg.permissions.as_ref()); let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, @@ -1902,18 +1912,29 @@ impl Config { let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; - let network = match network_requirements { - Some(Sourced { value, source }) => { - let network = NetworkProxySpec::from_constraints(&config_layer_stack, value) - .map_err(|err| { - std::io::Error::new( - err.kind(), - format!("failed to build managed network proxy from {source}: {err}"), - ) - })?; - Some(network) + let (network_requirements, network_requirements_source) = match network_requirements { + Some(Sourced { value, source }) => (Some(value), Some(source)), + None => (None, None), + }; + let has_network_requirements = network_requirements.is_some(); + let network = NetworkProxySpec::from_config_and_constraints( + configured_network_proxy_config, + network_requirements, + ) + .map_err(|err| { + if let Some(source) = network_requirements_source.as_ref() { + std::io::Error::new( + err.kind(), + format!("failed to build managed network proxy from {source}: {err}"), + ) + } else { + err } - None => None, + })?; + let network = if has_network_requirements { + Some(network) + } else { + network.enabled().then_some(network) }; let config = Self { @@ -2353,6 +2374,95 @@ phase_2_model = "gpt-5" ); } + #[test] + fn config_toml_deserializes_permissions_network() { + let toml = r#" +[permissions.network] +enabled = true +proxy_url = "http://127.0.0.1:43128" +enable_socks5 = false +allow_upstream_proxy = false +allowed_domains = ["openai.com"] +"#; + let cfg: ConfigToml = toml::from_str(toml) + .expect("TOML deserialization should succeed for permissions.network"); + + assert_eq!( + cfg.permissions + .and_then(|permissions| permissions.network) + .expect("permissions.network should deserialize"), + NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + admin_url: None, + enable_socks5: Some(false), + socks_url: None, + enable_socks5_udp: None, + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_non_loopback_admin: None, + mode: None, + allowed_domains: Some(vec!["openai.com".to_string()]), + denied_domains: None, + allow_unix_sockets: None, + allow_local_binding: None, + } + ); + } + + #[test] + fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + permissions: Some(PermissionsToml { + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + let network = config + .permissions + .network + .as_ref() + .expect("enabled permissions.network should produce a NetworkProxySpec"); + + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + assert!(!network.socks_enabled()); + Ok(()) + } + + #[test] + fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + permissions: Some(PermissionsToml { + network: Some(NetworkToml { + allowed_domains: Some(vec!["openai.com".to_string()]), + ..Default::default() + }), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + assert!(config.permissions.network.is_none()); + Ok(()) + } + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 183eafcb1..1957455d6 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -1,4 +1,3 @@ -use crate::config; use crate::config_loader::NetworkConstraints; use async_trait::async_trait; use codex_network_proxy::BlockedRequestObserver; @@ -68,6 +67,10 @@ impl ConfigReloader for StaticNetworkProxyReloader { } impl NetworkProxySpec { + pub(crate) fn enabled(&self) -> bool { + self.config.network.enabled + } + pub fn proxy_host_and_port(&self) -> String { host_and_port_from_network_addr(&self.config.network.proxy_url, 3128) } @@ -76,14 +79,15 @@ impl NetworkProxySpec { self.config.network.enable_socks5 } - pub(crate) fn from_constraints( - _config_layer_stack: &config::ConfigLayerStack, - requirements: NetworkConstraints, + pub(crate) fn from_config_and_constraints( + config: NetworkProxyConfig, + requirements: Option, ) -> std::io::Result { - // TODO(mbolin): Use ConfigLayerStack once we are ready to start - // honoring network configuration in config.toml. - let config = NetworkProxyConfig::default(); - let (config, constraints) = Self::apply_requirements(config, &requirements); + let (config, constraints) = if let Some(requirements) = requirements { + Self::apply_requirements(config, &requirements) + } else { + (config, NetworkProxyConstraints::default()) + }; validate_policy_against_constraints(&config, &constraints).map_err(|err| { std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs new file mode 100644 index 000000000..a03b5273d --- /dev/null +++ b/codex-rs/core/src/config/permissions.rs @@ -0,0 +1,110 @@ +use codex_network_proxy::NetworkMode; +use codex_network_proxy::NetworkProxyConfig; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionsToml { + /// Network proxy settings from `[permissions.network]`. + /// User config can enable the proxy; managed requirements may still constrain values. + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkToml { + pub enabled: Option, + pub proxy_url: Option, + pub admin_url: Option, + pub enable_socks5: Option, + pub socks_url: Option, + pub enable_socks5_udp: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_non_loopback_admin: Option, + #[schemars(with = "Option")] + pub mode: Option, + pub allowed_domains: Option>, + pub denied_domains: Option>, + pub allow_unix_sockets: Option>, + pub allow_local_binding: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum NetworkModeSchema { + Limited, + Full, +} + +impl NetworkToml { + pub(crate) fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { + if let Some(enabled) = self.enabled { + config.network.enabled = enabled; + } + if let Some(proxy_url) = self.proxy_url.as_ref() { + config.network.proxy_url = proxy_url.clone(); + } + if let Some(admin_url) = self.admin_url.as_ref() { + config.network.admin_url = admin_url.clone(); + } + if let Some(enable_socks5) = self.enable_socks5 { + config.network.enable_socks5 = enable_socks5; + } + if let Some(socks_url) = self.socks_url.as_ref() { + config.network.socks_url = socks_url.clone(); + } + if let Some(enable_socks5_udp) = self.enable_socks5_udp { + config.network.enable_socks5_udp = enable_socks5_udp; + } + if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { + config.network.allow_upstream_proxy = allow_upstream_proxy; + } + if let Some(dangerously_allow_non_loopback_proxy) = + self.dangerously_allow_non_loopback_proxy + { + config.network.dangerously_allow_non_loopback_proxy = + dangerously_allow_non_loopback_proxy; + } + if let Some(dangerously_allow_non_loopback_admin) = + self.dangerously_allow_non_loopback_admin + { + config.network.dangerously_allow_non_loopback_admin = + dangerously_allow_non_loopback_admin; + } + if let Some(mode) = self.mode { + config.network.mode = mode; + } + if let Some(allowed_domains) = self.allowed_domains.as_ref() { + config.network.allowed_domains = allowed_domains.clone(); + } + if let Some(denied_domains) = self.denied_domains.as_ref() { + config.network.denied_domains = denied_domains.clone(); + } + if let Some(allow_unix_sockets) = self.allow_unix_sockets.as_ref() { + config.network.allow_unix_sockets = allow_unix_sockets.clone(); + } + if let Some(allow_local_binding) = self.allow_local_binding { + config.network.allow_local_binding = allow_local_binding; + } + } + + pub(crate) fn to_network_proxy_config(&self) -> NetworkProxyConfig { + let mut config = NetworkProxyConfig::default(); + self.apply_to_network_proxy_config(&mut config); + config + } +} + +pub(crate) fn network_proxy_config_from_permissions( + permissions: Option<&PermissionsToml>, +) -> NetworkProxyConfig { + permissions + .and_then(|permissions| permissions.network.as_ref()) + .map_or_else( + NetworkProxyConfig::default, + NetworkToml::to_network_proxy_config, + ) +} diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 3e0645274..6fcf91f08 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,4 +1,6 @@ use crate::config::CONFIG_TOML_FILE; +use crate::config::NetworkToml; +use crate::config::PermissionsToml; use crate::config::find_codex_home; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; @@ -15,9 +17,9 @@ 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 serde::Deserialize; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; @@ -47,10 +49,7 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec, + permissions: Option, +} + +fn network_tables_from_toml(value: &toml::Value) -> Result { + value + .clone() + .try_into() + .context("failed to deserialize network tables from config") +} + +fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) { + if let Some(network) = parsed.network { + network.apply_to_network_proxy_config(config); + } + if let Some(network) = parsed + .permissions + .and_then(|permissions| permissions.network) + { + network.apply_to_network_proxy_config(config); + } +} + +fn config_from_layers(layers: &ConfigLayerStack) -> Result { + let mut config = NetworkProxyConfig::default(); + for layer in layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) { + let parsed = network_tables_from_toml(&layer.config)?; + apply_network_tables(&mut config, parsed); + } + Ok(config) +} + fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { matches!( layer, @@ -215,3 +252,40 @@ impl ConfigReloader for MtimeConfigReloader { Ok(state) } } + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn higher_precedence_network_table_beats_lower_permissions_network_table() { + let lower_permissions: toml::Value = toml::from_str( + r#" +[permissions.network] +allowed_domains = ["lower.example.com"] +"#, + ) + .expect("lower layer should parse"); + let higher_network: toml::Value = toml::from_str( + r#" +[network] +allowed_domains = ["higher.example.com"] +"#, + ) + .expect("higher layer should parse"); + + let mut config = NetworkProxyConfig::default(); + apply_network_tables( + &mut config, + network_tables_from_toml(&lower_permissions).expect("lower layer should deserialize"), + ); + apply_network_tables( + &mut config, + network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), + ); + + assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); + } +}