From b4a1e10ce55584e53e8acce9fed01cc0e1a7d3f4 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Fri, 1 May 2026 11:24:52 -0700 Subject: [PATCH] Use named MITM permissions config --- codex-rs/config/src/permissions_toml.rs | 203 ++++++++++++++++++ codex-rs/core/config.schema.json | 115 ++++++++++ codex-rs/core/src/config/config_tests.rs | 124 +++++++++++ codex-rs/core/src/config/permissions.rs | 1 + codex-rs/core/src/network_proxy_loader.rs | 61 +++++- .../core/src/network_proxy_loader_tests.rs | 70 ++++++ codex-rs/network-proxy/README.md | 14 +- codex-rs/network-proxy/src/mitm_tests.rs | 2 +- 8 files changed, 585 insertions(+), 5 deletions(-) diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index fff8c67706..4a9070cbae 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -1,5 +1,10 @@ use std::collections::BTreeMap; +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; @@ -173,6 +178,36 @@ 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 { + pub hooks: 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)] @@ -182,6 +217,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: &BTreeMap, + ) -> 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<&BTreeMap>, + ) -> 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 { @@ -234,6 +377,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 { @@ -243,6 +391,61 @@ impl NetworkToml { } } +impl NetworkMitmHookToml { + fn to_runtime( + &self, + actions_by_name: Option<&BTreeMap>, + ) -> 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<&BTreeMap>, + ) -> 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 0387b2e401..1d47cc88be 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1535,6 +1535,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", @@ -1638,6 +1750,9 @@ "enabled": { "type": "boolean" }, + "mitm": { + "$ref": "#/definitions/NetworkMitmToml" + }, "mode": { "$ref": "#/definitions/NetworkModeSchema" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 475571605c..9f1db002d0 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; @@ -755,6 +758,15 @@ mode = "full" [permissions.workspace.network.domains] "openai.com" = "allow" + +[permissions.workspace.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST", "PUT"] +path_prefixes = ["/repos/openai/"] +action = ["strip_auth"] + +[permissions.workspace.network.mitm.actions.strip_auth] +strip_request_headers = ["authorization"] "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); @@ -806,6 +818,27 @@ mode = "full" }), unix_sockets: None, allow_local_binding: None, + mitm: Some(NetworkMitmToml { + hooks: Some(BTreeMap::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(BTreeMap::from([( + "strip_auth".to_string(), + NetworkMitmActionToml { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: Vec::new(), + }, + )])), + }), }), }, )]), @@ -813,6 +846,97 @@ 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(BTreeMap::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(BTreeMap::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()] + ); +} + #[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 9f8fcd9ee3..e8ef85657a 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -185,6 +185,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 1c49be2daf..429becfef6 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -13,12 +13,16 @@ use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::loader::load_config_layers_state; +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; @@ -28,6 +32,7 @@ use codex_network_proxy::normalize_host; use codex_network_proxy::validate_policy_against_constraints; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use std::collections::BTreeMap; use std::sync::Arc; use tokio::sync::RwLock; @@ -198,6 +203,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); @@ -205,18 +211,69 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo Ok(()) } +#[derive(Default)] +struct NetworkConfigAccumulator { + config: NetworkProxyConfig, + mitm_hooks: BTreeMap, + mitm_actions: BTreeMap, +} + +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 mitm = NetworkMitmToml { + hooks: Some(self.mitm_hooks), + actions: Some(self.mitm_actions), + }; + let actions = mitm + .actions + .as_ref() + .expect("effective MITM actions should be present"); + 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 accumulator = NetworkConfigAccumulator::default(); for layer in layers.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, ) { let parsed = network_tables_from_toml(&layer.config)?; - apply_network_tables(&mut config, parsed)?; + 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 b5adb935ec..b54f387370 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -104,6 +104,76 @@ default_permissions = "workspace" 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 d9eecf4f6a..8c2117b4dc 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_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index 7adc97aa4e..3db22fbd72 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); }