Use named MITM permissions config

This commit is contained in:
Eva Wong
2026-05-15 05:20:56 -07:00
committed by Winston Howes
parent 2c9ccbe030
commit b4a1e10ce5
8 changed files with 585 additions and 5 deletions
+203
View File
@@ -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<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
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 {
pub hooks: Option<BTreeMap<String, NetworkMitmHookToml>>,
pub actions: Option<BTreeMap<String, NetworkMitmActionToml>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkMitmTomlUnchecked {
pub hooks: Option<BTreeMap<String, NetworkMitmHookToml>>,
pub actions: Option<BTreeMap<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)]
@@ -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<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: &BTreeMap<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<&BTreeMap<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 {
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<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<&BTreeMap<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(
config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml,
+115
View File
@@ -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"
},
+124
View File
@@ -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::<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(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<()> {
+1
View File
@@ -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);
}
+59 -2
View File
@@ -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<Option<Netw
Ok(profile.network.clone())
}
#[cfg(test)]
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> 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<String, NetworkMitmHookToml>,
mitm_actions: BTreeMap<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 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<NetworkProxyConfig> {
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)
}
@@ -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();
+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.
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"
+1 -1
View File
@@ -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);
}