Use named MITM permissions config (#18240)

## Stack
1. Parent PR: #18868 adds MITM hook config and model only.
2. Parent PR: #20659 wires hook enforcement into the proxy request path.
3. This PR changes the user facing PermissionProfile TOML shape.

## Why
1. The broader goal is to make MITM clamping usable from the same
permission profile that already controls network behavior.
2. This PR is the config UX layer for the stack. It moves MITM policy
into `[permissions.<profile>.network.mitm]` instead of exposing the flat
runtime shape to users.
3. The named hook and action tables belong here because users need
reusable policy blocks that are easy to review, while the proxy runtime
only needs a flat hook list.
4. This PR validates action refs during config parsing so mistakes in
the user facing policy fail before a proxy session starts.
5. Keeping the lowering here lets the proxy keep its simpler runtime
model and lets PermissionProfile remain the single source of network
permission policy.

## Summary
1. Keep MITM policy inside `[permissions.<profile>.network.mitm]` so the
selected PermissionProfile owns network proxy policy.
2. Use named MITM hooks under
`[permissions.<profile>.network.mitm.hooks.<name>]`.
3. Put host, methods, path prefixes, query, headers, body, and action
refs on the hook table.
4. Define reusable action blocks under
`[permissions.<profile>.network.mitm.actions.<name>]`.
5. Represent action blocks with `NetworkMitmActionToml`, then lower them
into the proxy runtime action config.
6. Reject unknown refs, empty refs, and empty action blocks during
config parsing.
7. Keep the runtime hook model unchanged by lowering config into the
existing proxy hook list.
8. Preserve the #20659 activation fix for nested MITM policy.

## Example
```toml
[permissions.workspace.network.mitm]
enabled = true

[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"]
```

## Validation
1. Regenerated the config schema.
2. Ran the core MITM config parsing and validation tests.
3. Ran the core PermissionProfile MITM proxy activation tests.
4. Ran the core config schema fixture test.
5. Ran the network proxy MITM policy tests.
6. Ran the scoped Clippy fixer for the network proxy crate.
7. Ran the scoped Clippy fixer for the core crate.

---------

Co-authored-by: Winston Howes <winston@openai.com>
This commit is contained in:
evawong-oaiandWinston Howes authored and GitHub committed 2026-05-20 17:10:37 -07:00
1 parent 0a4179bb19
commit 3cae84009a
12 files changed
+672 -14

No files matched your search

+1
View File
@@ -2434,6 +2434,7 @@ dependencies = [
"dunce", "dunce",
"futures", "futures",
"gethostname", "gethostname",
"indexmap 2.13.0",
"libc", "libc",
"multimap", "multimap",
"pretty_assertions", "pretty_assertions",
@@ -696,10 +696,25 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
"Codex guidance" "Codex guidance"
); );
assert_eq!( let config: TomlValue =
fs::read_to_string(codex_home.join("config.toml")).expect("read config"), toml::from_str(&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" .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!( assert_eq!(
fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md")) fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
.expect("read copied skill"), .expect("read copied skill"),
@@ -732,10 +747,24 @@ async fn import_home_config_uses_local_settings_over_project_settings() {
.await .await
.expect("import"); .expect("import");
assert_eq!( let config: TomlValue =
fs::read_to_string(codex_home.join("config.toml")).expect("read config"), toml::from_str(&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" .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] #[tokio::test]
+2 -1
View File
@@ -28,6 +28,7 @@ codex-utils-path = { workspace = true }
dunce = { workspace = true } dunce = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] } futures = { workspace = true, features = ["alloc", "std"] }
gethostname = { workspace = true } gethostname = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
multimap = { workspace = true } multimap = { workspace = true }
prost = "0.14.3" prost = "0.14.3"
schemars = { workspace = true } schemars = { workspace = true }
@@ -38,7 +39,7 @@ serde_path_to_error = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] } tokio = { workspace = true, features = ["fs"] }
toml = { workspace = true } toml = { workspace = true, features = ["preserve_order"] }
toml_edit = { workspace = true } toml_edit = { workspace = true }
tonic = { workspace = true } tonic = { workspace = true }
tonic-prost = { workspace = true } tonic-prost = { workspace = true }
+206
View File
@@ -1,12 +1,18 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crate::merge::merge_toml_values; 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::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_network_proxy::normalize_host; use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemAccessMode;
use indexmap::IndexMap;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@@ -350,6 +356,38 @@ pub struct NetworkToml {
pub domains: Option<NetworkDomainPermissionsToml>, pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>, pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>, pub allow_local_binding: Option<bool>,
pub mitm: Option<NetworkMitmToml>,
}
#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmToml {
#[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
#[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkMitmTomlUnchecked {
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmHookToml {
pub host: String,
pub methods: Vec<String>,
pub path_prefixes: Vec<String>,
#[serde(default)]
pub query: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub headers: BTreeMap<String, Vec<String>>,
#[schemars(with = "Option<MitmHookBodyConfigSchema>")]
pub body: Option<MitmHookBodyConfig>,
pub action: Vec<String>,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -359,6 +397,114 @@ enum NetworkModeSchema {
Full, Full,
} }
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmActionToml {
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmInjectedHeaderToml {
pub name: String,
pub secret_env_var: Option<String>,
pub secret_file: Option<String>,
pub prefix: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
struct MitmHookBodyConfigSchema(pub serde_json::Value);
impl<'de> Deserialize<'de> for NetworkMitmToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<String, NetworkMitmActionToml>,
) -> 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<String, NetworkMitmActionToml>>,
) -> Vec<MitmHookConfig> {
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 { impl NetworkToml {
pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
if let Some(enabled) = self.enabled { if let Some(enabled) = self.enabled {
@@ -411,6 +557,11 @@ impl NetworkToml {
if let Some(allow_local_binding) = self.allow_local_binding { if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = 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 { 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<String, NetworkMitmActionToml>>,
) -> 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<String, NetworkMitmActionToml>>,
) -> 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( pub fn overlay_network_domain_permissions(
config: &mut NetworkProxyConfig, config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml, domains: &NetworkDomainPermissionsToml,
+115
View File
@@ -1584,6 +1584,118 @@
"NetworkDomainPermissionsToml": { "NetworkDomainPermissionsToml": {
"type": "object" "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": { "NetworkModeSchema": {
"enum": [ "enum": [
"limited", "limited",
@@ -1687,6 +1799,9 @@
"enabled": { "enabled": {
"type": "boolean" "type": "boolean"
}, },
"mitm": {
"$ref": "#/definitions/NetworkMitmToml"
},
"mode": { "mode": {
"$ref": "#/definitions/NetworkModeSchema" "$ref": "#/definitions/NetworkModeSchema"
}, },
+168
View File
@@ -26,6 +26,9 @@ use codex_config::permissions_toml::FilesystemPermissionToml;
use codex_config::permissions_toml::FilesystemPermissionsToml; use codex_config::permissions_toml::FilesystemPermissionsToml;
use codex_config::permissions_toml::NetworkDomainPermissionToml; use codex_config::permissions_toml::NetworkDomainPermissionToml;
use codex_config::permissions_toml::NetworkDomainPermissionsToml; 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::NetworkToml;
use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::PermissionsToml;
@@ -96,6 +99,7 @@ use core_test_support::PathBufExt;
use core_test_support::PathExt; use core_test_support::PathExt;
use core_test_support::TempDirExt; use core_test_support::TempDirExt;
use core_test_support::test_absolute_path; use core_test_support::test_absolute_path;
use indexmap::IndexMap;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use rmcp::model::ElicitationCapability; use rmcp::model::ElicitationCapability;
use rmcp::model::FormElicitationCapability; use rmcp::model::FormElicitationCapability;
@@ -760,6 +764,15 @@ mode = "full"
[permissions.dev.network.domains] [permissions.dev.network.domains]
"openai.com" = "allow" "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 = let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
@@ -817,6 +830,27 @@ mode = "full"
}), }),
unix_sockets: None, unix_sockets: None,
allow_local_binding: 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::<ConfigToml>(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::<ConfigToml>(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] #[tokio::test]
async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature() async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature()
-> std::io::Result<()> { -> std::io::Result<()> {
+1
View File
@@ -186,6 +186,7 @@ pub(crate) fn apply_network_proxy_feature_config(
} }
}), }),
allow_local_binding: feature_config.allow_local_binding, allow_local_binding: feature_config.allow_local_binding,
mitm: None,
} }
.apply_to_network_proxy_config(config); .apply_to_network_proxy_config(config);
} }
+56 -2
View File
@@ -16,12 +16,16 @@ use codex_config::ConfigLayerStackOrdering;
use codex_config::LoaderOverrides; use codex_config::LoaderOverrides;
use codex_config::loader::load_config_layers_state; use codex_config::loader::load_config_layers_state;
use codex_config::merge_toml_values; 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::NetworkToml;
use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::PermissionsToml;
use codex_config::permissions_toml::overlay_network_domain_permissions; use codex_config::permissions_toml::overlay_network_domain_permissions;
use codex_exec_server::LOCAL_FS; use codex_exec_server::LOCAL_FS;
use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState; use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraintError; use codex_network_proxy::NetworkProxyConstraintError;
use codex_network_proxy::NetworkProxyConstraints; 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::normalize_host;
use codex_network_proxy::validate_policy_against_constraints; use codex_network_proxy::validate_policy_against_constraints;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use indexmap::IndexMap;
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -208,6 +213,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
Ok(profile.profile.network) Ok(profile.profile.network)
} }
#[cfg(test)]
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> { fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> {
if let Some(network) = selected_network_from_tables(parsed)? { if let Some(network) = selected_network_from_tables(parsed)? {
network.apply_to_network_proxy_config(config); network.apply_to_network_proxy_config(config);
@@ -215,11 +221,57 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo
Ok(()) Ok(())
} }
#[derive(Default)]
struct NetworkConfigAccumulator {
config: NetworkProxyConfig,
mitm_hooks: IndexMap<String, NetworkMitmHookToml>,
mitm_actions: IndexMap<String, NetworkMitmActionToml>,
}
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<NetworkProxyConfig> {
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( fn config_from_layers(
layers: &ConfigLayerStack, layers: &ConfigLayerStack,
exec_policy: &codex_execpolicy::Policy, exec_policy: &codex_execpolicy::Policy,
) -> Result<NetworkProxyConfig> { ) -> Result<NetworkProxyConfig> {
let mut config = NetworkProxyConfig::default();
let mut merged = toml::Value::Table(toml::map::Map::new()); let mut merged = toml::Value::Table(toml::map::Map::new());
for layer in layers.get_layers( for layer in layers.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst, ConfigLayerStackOrdering::LowestPrecedenceFirst,
@@ -228,7 +280,9 @@ fn config_from_layers(
merge_toml_values(&mut merged, &layer.config); merge_toml_values(&mut merged, &layer.config);
} }
let parsed = network_tables_from_toml(&merged)?; 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); apply_exec_policy_network_rules(&mut config, exec_policy);
Ok(config) Ok(config)
} }
@@ -112,6 +112,76 @@ default_permissions = "dev"
assert_eq!(config.network.denied_domains(), None); 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] #[test]
fn execpolicy_network_rules_overlay_network_lists() { fn execpolicy_network_rules_overlay_network_lists() {
let mut config = NetworkProxyConfig::default(); let mut config = NetworkProxyConfig::default();
+12 -2
View File
@@ -33,8 +33,7 @@ allow_upstream_proxy = true
# If you want to expose these listeners beyond localhost, you must opt in explicitly. # If you want to expose these listeners beyond localhost, you must opt in explicitly.
dangerously_allow_non_loopback_proxy = false dangerously_allow_non_loopback_proxy = false
mode = "full" # default when unset; use "limited" for read-only mode 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. # HTTPS MITM is enabled automatically when `mode = "limited"` or when MITM hooks are configured.
mitm = false
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). # 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 # 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" "::1" = "allow"
"evil.example" = "deny" "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`. # macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
[permissions.workspace.network.unix_sockets] [permissions.workspace.network.unix_sockets]
"/tmp/example.sock" = "allow" "/tmp/example.sock" = "allow"
+3
View File
@@ -16,6 +16,7 @@ use crate::upstream::UpstreamClient;
use anyhow::Context as _; use anyhow::Context as _;
use anyhow::Result; use anyhow::Result;
use anyhow::anyhow; use anyhow::anyhow;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
use rama_core::Layer; use rama_core::Layer;
use rama_core::Service; use rama_core::Service;
use rama_core::bytes::Bytes; use rama_core::bytes::Bytes;
@@ -97,6 +98,8 @@ impl std::fmt::Debug for MitmState {
impl MitmState { impl MitmState {
pub(crate) fn new(config: MitmUpstreamConfig) -> Result<Self> { pub(crate) fn new(config: MitmUpstreamConfig) -> Result<Self> {
ensure_rustls_crypto_provider();
// MITM exists when HTTPS policy depends on the inner request: limited-mode method clamps // 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 // 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 // generate/load a local CA and issue per-host leaf certs so we can terminate TLS and
+1 -1
View File
@@ -186,7 +186,7 @@ async fn mitm_policy_allows_matching_hooked_write_in_full_mode() {
assert!( assert!(
response.is_none(), 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); assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0);
} }