diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 62146075c..476a38a9d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2434,6 +2434,7 @@ dependencies = [ "dunce", "futures", "gethostname", + "indexmap 2.13.0", "libc", "multimap", "pretty_assertions", diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 07b09c5a1..47f93a5f8 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -696,10 +696,25 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { "Codex guidance" ); - assert_eq!( - fs::read_to_string(codex_home.join("config.toml")).expect("read config"), - "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nCI = \"false\"\nFOO = \"bar\"\nMAX_RETRIES = \"3\"\nMY_TEAM = \"codex\"\n" - ); + let config: TomlValue = + toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) + .expect("parse config"); + let expected: TomlValue = toml::from_str( + r#" +sandbox_mode = "workspace-write" + +[shell_environment_policy] +inherit = "core" + +[shell_environment_policy.set] +CI = "false" +FOO = "bar" +MAX_RETRIES = "3" +MY_TEAM = "codex" +"#, + ) + .expect("parse expected config"); + assert_eq!(config, expected); assert_eq!( fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md")) .expect("read copied skill"), @@ -732,10 +747,24 @@ async fn import_home_config_uses_local_settings_over_project_settings() { .await .expect("import"); - assert_eq!( - fs::read_to_string(codex_home.join("config.toml")).expect("read config"), - "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"local\"\nLOCAL_ONLY = \"true\"\nPROJECT_ONLY = \"yes\"\n" - ); + let config: TomlValue = + toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) + .expect("parse config"); + let expected: TomlValue = toml::from_str( + r#" +sandbox_mode = "workspace-write" + +[shell_environment_policy] +inherit = "core" + +[shell_environment_policy.set] +FOO = "local" +LOCAL_ONLY = "true" +PROJECT_ONLY = "yes" +"#, + ) + .expect("parse expected config"); + assert_eq!(config, expected); } #[tokio::test] diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index c345f31c6..f77a76b76 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -28,6 +28,7 @@ codex-utils-path = { workspace = true } dunce = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } gethostname = { workspace = true } +indexmap = { workspace = true, features = ["serde"] } multimap = { workspace = true } prost = "0.14.3" schemars = { workspace = true } @@ -38,7 +39,7 @@ serde_path_to_error = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs"] } -toml = { workspace = true } +toml = { workspace = true, features = ["preserve_order"] } toml_edit = { workspace = true } tonic = { workspace = true } tonic-prost = { workspace = true } diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index 60ec9e128..a4ef5d266 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -1,12 +1,18 @@ use std::collections::BTreeMap; use crate::merge::merge_toml_values; +use codex_network_proxy::InjectedHeaderConfig; +use codex_network_proxy::MitmHookActionsConfig; +use codex_network_proxy::MitmHookBodyConfig; +use codex_network_proxy::MitmHookConfig; +use codex_network_proxy::MitmHookMatchConfig; use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; use codex_network_proxy::normalize_host; use codex_protocol::permissions::FileSystemAccessMode; +use indexmap::IndexMap; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -350,6 +356,38 @@ pub struct NetworkToml { pub domains: Option, pub unix_sockets: Option, pub allow_local_binding: Option, + pub mitm: Option, +} + +#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkMitmToml { + #[schemars(with = "Option>")] + pub hooks: Option>, + #[schemars(with = "Option>")] + pub actions: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkMitmTomlUnchecked { + pub hooks: Option>, + pub actions: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkMitmHookToml { + pub host: String, + pub methods: Vec, + pub path_prefixes: Vec, + #[serde(default)] + pub query: BTreeMap>, + #[serde(default)] + pub headers: BTreeMap>, + #[schemars(with = "Option")] + pub body: Option, + pub action: Vec, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -359,6 +397,114 @@ enum NetworkModeSchema { Full, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +pub struct NetworkMitmActionToml { + pub strip_request_headers: Vec, + pub inject_request_headers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +pub struct NetworkMitmInjectedHeaderToml { + pub name: String, + pub secret_env_var: Option, + pub secret_file: Option, + pub prefix: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +struct MitmHookBodyConfigSchema(pub serde_json::Value); + +impl<'de> Deserialize<'de> for NetworkMitmToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?; + let mitm = Self { + hooks: unchecked.hooks, + actions: unchecked.actions, + }; + mitm.validate_action_definitions() + .map_err(serde::de::Error::custom)?; + Ok(mitm) + } +} + +impl NetworkMitmToml { + pub fn validate_action_definitions(&self) -> Result<(), String> { + if let Some(actions) = self.actions.as_ref() { + for (action_name, action) in actions { + if action.is_empty() { + return Err(format!( + "network.mitm.actions.{action_name} must define at least one operation" + )); + } + } + } + + let Some(hooks) = self.hooks.as_ref() else { + return Ok(()); + }; + + for (hook_name, hook) in hooks { + if hook.action.is_empty() { + return Err(format!( + "network.mitm.hooks.{hook_name}.action must not be empty" + )); + } + } + + Ok(()) + } + + pub fn validate_action_references( + &self, + actions_by_name: &IndexMap, + ) -> Result<(), String> { + self.validate_action_definitions()?; + + let Some(hooks) = self.hooks.as_ref() else { + return Ok(()); + }; + + for (hook_name, hook) in hooks { + for action_name in &hook.action { + if !actions_by_name.contains_key(action_name) { + return Err(format!( + "network.mitm.hooks.{hook_name}.action references undefined action `{action_name}`" + )); + } + } + } + + Ok(()) + } + + pub fn to_runtime_hooks( + &self, + actions_by_name: Option<&IndexMap>, + ) -> Vec { + self.hooks + .as_ref() + .map(|hooks| { + hooks + .values() + .map(|hook| hook.to_runtime(actions_by_name)) + .collect() + }) + .unwrap_or_default() + } +} + +impl NetworkMitmActionToml { + pub fn is_empty(&self) -> bool { + self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty() + } +} + impl NetworkToml { pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { if let Some(enabled) = self.enabled { @@ -411,6 +557,11 @@ impl NetworkToml { if let Some(allow_local_binding) = self.allow_local_binding { config.network.allow_local_binding = allow_local_binding; } + if let Some(mitm) = self.mitm.as_ref() { + config.network.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref()); + } + config.network.mitm = + config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty(); } pub fn to_network_proxy_config(&self) -> NetworkProxyConfig { @@ -420,6 +571,61 @@ impl NetworkToml { } } +impl NetworkMitmHookToml { + fn to_runtime( + &self, + actions_by_name: Option<&IndexMap>, + ) -> MitmHookConfig { + MitmHookConfig { + host: self.host.clone(), + matcher: MitmHookMatchConfig { + methods: self.methods.clone(), + path_prefixes: self.path_prefixes.clone(), + query: self.query.clone(), + headers: self.headers.clone(), + body: self.body.clone(), + }, + actions: self.selected_actions(actions_by_name), + } + } + + fn selected_actions( + &self, + actions_by_name: Option<&IndexMap>, + ) -> MitmHookActionsConfig { + let Some(actions_by_name) = actions_by_name else { + return MitmHookActionsConfig::default(); + }; + + let mut selected = MitmHookActionsConfig::default(); + for action_name in &self.action { + if let Some(action) = actions_by_name.get(action_name) { + selected + .strip_request_headers + .extend(action.strip_request_headers.clone()); + selected.inject_request_headers.extend( + action + .inject_request_headers + .iter() + .map(NetworkMitmInjectedHeaderToml::to_runtime), + ); + } + } + selected + } +} + +impl NetworkMitmInjectedHeaderToml { + fn to_runtime(&self) -> InjectedHeaderConfig { + InjectedHeaderConfig { + name: self.name.clone(), + secret_env_var: self.secret_env_var.clone(), + secret_file: self.secret_file.clone(), + prefix: self.prefix.clone(), + } + } +} + pub fn overlay_network_domain_permissions( config: &mut NetworkProxyConfig, domains: &NetworkDomainPermissionsToml, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a71f84f27..e810ce5f7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1584,6 +1584,118 @@ "NetworkDomainPermissionsToml": { "type": "object" }, + "NetworkMitmActionToml": { + "properties": { + "inject_request_headers": { + "default": [], + "items": { + "$ref": "#/definitions/NetworkMitmInjectedHeaderToml" + }, + "type": "array" + }, + "strip_request_headers": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "NetworkMitmHookToml": { + "additionalProperties": false, + "properties": { + "action": { + "items": { + "type": "string" + }, + "type": "array" + }, + "body": true, + "headers": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "type": "object" + }, + "host": { + "type": "string" + }, + "methods": { + "items": { + "type": "string" + }, + "type": "array" + }, + "path_prefixes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "query": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "type": "object" + } + }, + "required": [ + "action", + "host", + "methods", + "path_prefixes" + ], + "type": "object" + }, + "NetworkMitmInjectedHeaderToml": { + "properties": { + "name": { + "default": "", + "type": "string" + }, + "prefix": { + "default": null, + "type": "string" + }, + "secret_env_var": { + "default": null, + "type": "string" + }, + "secret_file": { + "default": null, + "type": "string" + } + }, + "type": "object" + }, + "NetworkMitmToml": { + "additionalProperties": false, + "properties": { + "actions": { + "additionalProperties": { + "$ref": "#/definitions/NetworkMitmActionToml" + }, + "type": "object" + }, + "hooks": { + "additionalProperties": { + "$ref": "#/definitions/NetworkMitmHookToml" + }, + "type": "object" + } + }, + "type": "object" + }, "NetworkModeSchema": { "enum": [ "limited", @@ -1687,6 +1799,9 @@ "enabled": { "type": "boolean" }, + "mitm": { + "$ref": "#/definitions/NetworkMitmToml" + }, "mode": { "$ref": "#/definitions/NetworkModeSchema" }, @@ -4883,4 +4998,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 66ca4daa3..5fa284ad3 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -26,6 +26,9 @@ use codex_config::permissions_toml::FilesystemPermissionToml; use codex_config::permissions_toml::FilesystemPermissionsToml; use codex_config::permissions_toml::NetworkDomainPermissionToml; use codex_config::permissions_toml::NetworkDomainPermissionsToml; +use codex_config::permissions_toml::NetworkMitmActionToml; +use codex_config::permissions_toml::NetworkMitmHookToml; +use codex_config::permissions_toml::NetworkMitmToml; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; @@ -96,6 +99,7 @@ use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::TempDirExt; use core_test_support::test_absolute_path; +use indexmap::IndexMap; use pretty_assertions::assert_eq; use rmcp::model::ElicitationCapability; use rmcp::model::FormElicitationCapability; @@ -760,6 +764,15 @@ mode = "full" [permissions.dev.network.domains] "openai.com" = "allow" + +[permissions.dev.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST", "PUT"] +path_prefixes = ["/repos/openai/"] +action = ["strip_auth"] + +[permissions.dev.network.mitm.actions.strip_auth] +strip_request_headers = ["authorization"] "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); @@ -817,6 +830,27 @@ mode = "full" }), unix_sockets: None, allow_local_binding: None, + mitm: Some(NetworkMitmToml { + hooks: Some(IndexMap::from([( + "github_write".to_string(), + NetworkMitmHookToml { + host: "api.github.com".to_string(), + methods: vec!["POST".to_string(), "PUT".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + query: BTreeMap::new(), + headers: BTreeMap::new(), + body: None, + action: vec!["strip_auth".to_string()], + }, + )])), + actions: Some(IndexMap::from([( + "strip_auth".to_string(), + NetworkMitmActionToml { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: Vec::new(), + }, + )])), + }), }), }, )]), @@ -824,6 +858,140 @@ mode = "full" ); } +#[test] +fn config_toml_rejects_empty_mitm_action_reference_list() { + let toml = r#" +default_permissions = "workspace" + +[permissions.workspace.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/openai/"] +action = [] + +[permissions.workspace.network.mitm.actions.strip_auth] +strip_request_headers = ["authorization"] +"#; + + let err = + toml::from_str::(toml).expect_err("empty MITM action refs should fail closed"); + + assert!( + err.to_string() + .contains("network.mitm.hooks.github_write.action must not be empty"), + "{err}" + ); +} + +#[test] +fn config_toml_rejects_empty_mitm_action_definition() { + let toml = r#" +default_permissions = "workspace" + +[permissions.workspace.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/openai/"] +action = ["strip_auth"] + +[permissions.workspace.network.mitm.actions.strip_auth] +"#; + + let err = toml::from_str::(toml) + .expect_err("empty MITM action definitions should fail closed"); + + assert!( + err.to_string() + .contains("network.mitm.actions.strip_auth must define at least one operation"), + "{err}" + ); +} + +#[test] +fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() { + let network = NetworkToml { + mode: Some(NetworkMode::Full), + mitm: Some(NetworkMitmToml { + hooks: Some(IndexMap::from([( + "github_write".to_string(), + NetworkMitmHookToml { + host: "api.github.com".to_string(), + methods: vec!["POST".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + action: vec!["strip_auth".to_string()], + ..NetworkMitmHookToml::default() + }, + )])), + actions: Some(IndexMap::from([( + "strip_auth".to_string(), + NetworkMitmActionToml { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: Vec::new(), + }, + )])), + }), + ..NetworkToml::default() + }; + + let config = network.to_network_proxy_config(); + + assert_eq!(config.network.mode, NetworkMode::Full); + assert!(config.network.mitm); + assert_eq!(config.network.mitm_hooks.len(), 1); + assert_eq!(config.network.mitm_hooks[0].host, "api.github.com"); + assert_eq!( + config.network.mitm_hooks[0].matcher.methods, + vec!["POST".to_string()] + ); + assert_eq!( + config.network.mitm_hooks[0].actions.strip_request_headers, + vec!["authorization".to_string()] + ); +} + +#[test] +fn permissions_profile_network_to_proxy_config_preserves_mitm_hook_declaration_order() { + let toml = r#" +default_permissions = "workspace" + +[permissions.workspace.network.mitm.actions.noop] +strip_request_headers = ["authorization"] + +[permissions.workspace.network.mitm.hooks.z_first] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/openai/"] +action = ["noop"] + +[permissions.workspace.network.mitm.hooks.a_second] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/"] +action = ["noop"] +"#; + let cfg: ConfigToml = toml::from_str(toml).expect("permissions profile should deserialize"); + let permissions = cfg.permissions.expect("permissions should deserialize"); + let network = permissions + .entries + .get("workspace") + .expect("workspace profile should exist") + .network + .as_ref() + .expect("network profile should exist"); + + let config = network.to_network_proxy_config(); + + assert_eq!(config.network.mitm_hooks.len(), 2); + assert_eq!( + config.network.mitm_hooks[0].matcher.path_prefixes, + vec!["/repos/openai/".to_string()] + ); + assert_eq!( + config.network.mitm_hooks[1].matcher.path_prefixes, + vec!["/repos/".to_string()] + ); +} + #[tokio::test] async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 7c757d386..3aaa7d66f 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -186,6 +186,7 @@ pub(crate) fn apply_network_proxy_feature_config( } }), allow_local_binding: feature_config.allow_local_binding, + mitm: None, } .apply_to_network_proxy_config(config); } diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index dc4b5b6b1..7d67ad785 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -16,12 +16,16 @@ use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::loader::load_config_layers_state; use codex_config::merge_toml_values; +use codex_config::permissions_toml::NetworkMitmActionToml; +use codex_config::permissions_toml::NetworkMitmHookToml; +use codex_config::permissions_toml::NetworkMitmToml; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::overlay_network_domain_permissions; use codex_exec_server::LOCAL_FS; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkProxyConstraintError; use codex_network_proxy::NetworkProxyConstraints; @@ -30,6 +34,7 @@ use codex_network_proxy::build_config_state; use codex_network_proxy::normalize_host; use codex_network_proxy::validate_policy_against_constraints; use codex_utils_absolute_path::AbsolutePathBuf; +use indexmap::IndexMap; use serde::Deserialize; use std::sync::Arc; use tokio::sync::RwLock; @@ -208,6 +213,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result Result<()> { if let Some(network) = selected_network_from_tables(parsed)? { network.apply_to_network_proxy_config(config); @@ -215,11 +221,57 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo Ok(()) } +#[derive(Default)] +struct NetworkConfigAccumulator { + config: NetworkProxyConfig, + mitm_hooks: IndexMap, + mitm_actions: IndexMap, +} + +impl NetworkConfigAccumulator { + fn apply_network_tables(&mut self, parsed: NetworkTablesToml) -> Result<()> { + if let Some(network) = selected_network_from_tables(parsed)? { + self.apply_network(network); + } + Ok(()) + } + + fn apply_network(&mut self, mut network: NetworkToml) { + let mitm = network.mitm.take(); + network.apply_to_network_proxy_config(&mut self.config); + + if let Some(mitm) = mitm { + if let Some(actions) = mitm.actions { + self.mitm_actions.extend(actions); + } + if let Some(hooks) = mitm.hooks { + self.mitm_hooks.extend(hooks); + } + } + } + + fn finish(mut self) -> Result { + if !self.mitm_hooks.is_empty() { + let actions = self.mitm_actions; + let mitm = NetworkMitmToml { + hooks: Some(self.mitm_hooks), + actions: Some(actions.clone()), + }; + mitm.validate_action_references(&actions) + .map_err(anyhow::Error::msg)?; + self.config.network.mitm_hooks = mitm.to_runtime_hooks(Some(&actions)); + } + + self.config.network.mitm = self.config.network.mode == NetworkMode::Limited + || !self.config.network.mitm_hooks.is_empty(); + Ok(self.config) + } +} + fn config_from_layers( layers: &ConfigLayerStack, exec_policy: &codex_execpolicy::Policy, ) -> Result { - let mut config = NetworkProxyConfig::default(); let mut merged = toml::Value::Table(toml::map::Map::new()); for layer in layers.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, @@ -228,7 +280,9 @@ fn config_from_layers( merge_toml_values(&mut merged, &layer.config); } let parsed = network_tables_from_toml(&merged)?; - apply_network_tables(&mut config, parsed)?; + let mut accumulator = NetworkConfigAccumulator::default(); + accumulator.apply_network_tables(parsed)?; + let mut config = accumulator.finish()?; apply_exec_policy_network_rules(&mut config, exec_policy); Ok(config) } diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs index e2f3e7b98..33ca54019 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -112,6 +112,76 @@ default_permissions = "dev" assert_eq!(config.network.denied_domains(), None); } +#[test] +fn higher_precedence_profile_network_overrides_named_mitm_actions() { + let lower_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] +mode = "full" + +[permissions.workspace.network.domains] +"lower.example.com" = "allow" + +[permissions.workspace.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/openai/"] +action = ["strip_auth"] + +[permissions.workspace.network.mitm.actions.strip_auth] +strip_request_headers = ["authorization"] +"#, + ) + .expect("lower layer should parse"); + let higher_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] +mode = "full" + +[permissions.workspace.network.domains] +"higher.example.com" = "allow" + +[permissions.workspace.network.mitm.actions.strip_auth] +strip_request_headers = ["x-api-key"] +"#, + ) + .expect("higher layer should parse"); + + let mut accumulator = NetworkConfigAccumulator::default(); + accumulator + .apply_network_tables( + network_tables_from_toml(&lower_network).expect("lower layer should deserialize"), + ) + .expect("lower layer should apply"); + accumulator + .apply_network_tables( + network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), + ) + .expect("higher layer should apply"); + let config = accumulator.finish().expect("merged config should build"); + + assert_eq!(config.network.mode, codex_network_proxy::NetworkMode::Full); + assert!(config.network.mitm); + assert_eq!( + config.network.allowed_domains(), + Some(vec![ + "lower.example.com".to_string(), + "higher.example.com".to_string() + ]) + ); + assert_eq!(config.network.mitm_hooks.len(), 1); + assert_eq!(config.network.mitm_hooks[0].host, "api.github.com"); + assert_eq!(config.network.mitm_hooks[0].matcher.methods, vec!["POST"]); + assert_eq!( + config.network.mitm_hooks[0].actions.strip_request_headers, + vec!["x-api-key"] + ); +} + #[test] fn execpolicy_network_rules_overlay_network_lists() { let mut config = NetworkProxyConfig::default(); diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index d9eecf4f6..8c2117b4d 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -33,8 +33,7 @@ allow_upstream_proxy = true # If you want to expose these listeners beyond localhost, you must opt in explicitly. dangerously_allow_non_loopback_proxy = false mode = "full" # default when unset; use "limited" for read-only mode -# When true, HTTPS CONNECT can be terminated so limited-mode method policy still applies. -mitm = false +# HTTPS MITM is enabled automatically when `mode = "limited"` or when MITM hooks are configured. # CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). # If false, local/private networking is rejected. Explicit allowlisting of local IP literals @@ -57,6 +56,17 @@ dangerously_allow_all_unix_sockets = false "::1" = "allow" "evil.example" = "deny" +# MITM hooks match HTTPS requests after CONNECT is terminated. +[permissions.workspace.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST", "PUT"] +path_prefixes = ["/repos/openai/"] +action = ["strip_auth"] + +# Named actions can be shared across hooks and overridden by higher-precedence config layers. +[permissions.workspace.network.mitm.actions.strip_auth] +strip_request_headers = ["authorization"] + # macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. [permissions.workspace.network.unix_sockets] "/tmp/example.sock" = "allow" diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index fdcb9d0aa..9ee5bfc25 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -16,6 +16,7 @@ use crate::upstream::UpstreamClient; use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use rama_core::Layer; use rama_core::Service; use rama_core::bytes::Bytes; @@ -97,6 +98,8 @@ impl std::fmt::Debug for MitmState { impl MitmState { pub(crate) fn new(config: MitmUpstreamConfig) -> Result { + ensure_rustls_crypto_provider(); + // MITM exists when HTTPS policy depends on the inner request: limited-mode method clamps // and host-specific hooks both need visibility after CONNECT is established. We // generate/load a local CA and issue per-host leaf certs so we can terminate TLS and diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 098eb745b..0823be517 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -186,7 +186,7 @@ async fn mitm_policy_allows_matching_hooked_write_in_full_mode() { assert!( response.is_none(), - "matching hook should be allowed in full mode" + "matching hook should bypass method clamp" ); assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0); }