From dd30c8eedd171d2dda71c43fac27dc42f457da5f Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 26 Mar 2026 23:17:59 -0700 Subject: [PATCH] chore: refactor network permissions to use explicit domain and unix socket rule maps (#15120) ## Summary This PR replaces the legacy network allow/deny list model with explicit rule maps for domains and unix sockets across managed requirements, permissions profiles, the network proxy config, and the app server protocol. Concretely, it: - introduces typed domain (`allow` / `deny`) and unix socket permission (`allow` / `none`) entries instead of separate `allowed_domains`, `denied_domains`, and `allow_unix_sockets` lists - updates config loading, managed requirements merging, and exec-policy overlays to read and upsert rule entries consistently - exposes the new shape through protocol/schema outputs, debug surfaces, and app-server config APIs - rejects the legacy list-based keys and updates docs/tests to reflect the new config format ## Why The previous representation split related network policy across multiple parallel lists, which made merging and overriding rules harder to reason about. Moving to explicit keyed permission maps gives us a single source of truth per host/socket entry, makes allow/deny precedence clearer, and gives protocol consumers access to the full rule state instead of derived projections only. ## Backward Compatibility ### Backward compatible - Managed requirements still accept the legacy `experimental_network.allowed_domains`, `experimental_network.denied_domains`, and `experimental_network.allow_unix_sockets` fields. They are normalized into the new canonical `domains` and `unix_sockets` maps internally. - App-server v2 still deserializes legacy `allowedDomains`, `deniedDomains`, and `allowUnixSockets` payloads, so older clients can continue reading managed network requirements. - App-server v2 responses still populate `allowedDomains`, `deniedDomains`, and `allowUnixSockets` as legacy compatibility views derived from the canonical maps. - `managed_allowed_domains_only` keeps the same behavior after normalization. Legacy managed allowlists still participate in the same enforcement path as canonical `domains` entries. ### Not backward compatible - Permissions profiles under `[permissions..network]` no longer accept the legacy list-based keys. Those configs must use the canonical `[domains]` and `[unix_sockets]` tables instead of `allowed_domains`, `denied_domains`, or `allow_unix_sockets`. - Managed `experimental_network` config cannot mix canonical and legacy forms in the same block. For example, `domains` cannot be combined with `allowed_domains` or `denied_domains`, and `unix_sockets` cannot be combined with `allow_unix_sockets`. - The canonical format can express explicit `"none"` entries for unix sockets, but those entries do not round-trip through the legacy compatibility fields because the legacy fields only represent allow/deny lists. ## Testing `/target/debug/codex sandbox macos --log-denials /bin/zsh -c 'curl https://www.example.com' ` gives 200 with config ``` [permissions.workspace.network.domains] "www.example.com" = "allow" ``` and fails when set to deny: `curl: (56) CONNECT tunnel failed, response 403`. Also tested backward compatibility path by verifying that adding the following to `/etc/codex/requirements.toml` works: ``` [experimental_network] allowed_domains = ["www.example.com"] ``` --- .../codex_app_server_protocol.schemas.json | 44 ++ .../codex_app_server_protocol.v2.schemas.json | 44 ++ .../v2/ConfigRequirementsReadResponse.json | 44 ++ .../typescript/v2/NetworkDomainPermission.ts | 5 + .../typescript/v2/NetworkRequirements.ts | 29 +- .../v2/NetworkUnixSocketPermission.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../app-server-protocol/src/protocol/v2.rs | 114 +++++ codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/config_api.rs | 157 ++++++- codex-rs/config/src/config_requirements.rs | 428 +++++++++++++++++- codex-rs/config/src/lib.rs | 4 + codex-rs/core/config.schema.json | 42 +- codex-rs/core/src/codex.rs | 12 +- codex-rs/core/src/codex_tests.rs | 31 +- codex-rs/core/src/config/config_tests.rs | 21 +- codex-rs/core/src/config/mod.rs | 5 + .../core/src/config/network_proxy_spec.rs | 94 ++-- .../src/config/network_proxy_spec_tests.rs | 287 ++++++++++-- codex-rs/core/src/config/permissions.rs | 137 +++++- codex-rs/core/src/config/permissions_tests.rs | 129 ++++++ codex-rs/core/src/config_loader/mod.rs | 4 + codex-rs/core/src/environment_context.rs | 12 +- codex-rs/core/src/guardian/tests.rs | 17 +- codex-rs/core/src/network_proxy_loader.rs | 40 +- .../core/src/network_proxy_loader_tests.rs | 168 ++++++- codex-rs/network-proxy/README.md | 33 +- codex-rs/network-proxy/src/config.rs | 301 +++++++++++- codex-rs/network-proxy/src/http_proxy.rs | 39 +- codex-rs/network-proxy/src/lib.rs | 5 + codex-rs/network-proxy/src/mitm_tests.rs | 23 +- codex-rs/network-proxy/src/network_policy.rs | 18 +- codex-rs/network-proxy/src/proxy.rs | 2 +- codex-rs/network-proxy/src/runtime.rs | 352 +++++++------- codex-rs/network-proxy/src/state.rs | 90 ++-- codex-rs/tui/src/debug_config.rs | 64 ++- codex-rs/tui_app_server/src/debug_config.rs | 101 ++++- 37 files changed, 2413 insertions(+), 492 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f52faf5b1..8531542d5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9203,6 +9203,13 @@ ], "type": "string" }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -9212,6 +9219,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -9227,6 +9235,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -9248,6 +9257,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -9256,6 +9266,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -9270,6 +9290,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -9277,10 +9304,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5d053604f..9d28f531a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6017,6 +6017,13 @@ ], "type": "string" }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -6026,6 +6033,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -6041,6 +6049,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -6062,6 +6071,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -6070,6 +6080,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -6084,6 +6104,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -6091,10 +6118,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index e0c8304c1..de5eca1d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -102,6 +102,13 @@ }, "type": "object" }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -111,6 +118,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -126,6 +134,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -147,6 +156,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -155,6 +165,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -169,6 +189,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -176,10 +203,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "ResidencyRequirement": { "enum": [ "us" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts new file mode 100644 index 000000000..2ea44392d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkDomainPermission = "allow" | "deny"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index 1f1653c27..5fc942bef 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -1,5 +1,32 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NetworkDomainPermission } from "./NetworkDomainPermission"; +import type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; -export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, +/** + * Canonical network permission map for `experimental_network`. + */ +domains: { [key in string]?: NetworkDomainPermission } | null, +/** + * When true, only managed allowlist entries are respected while managed + * network enforcement is active. + */ +managedAllowedDomainsOnly: boolean | null, +/** + * Legacy compatibility view derived from `domains`. + */ +allowedDomains: Array | null, +/** + * Legacy compatibility view derived from `domains`. + */ +deniedDomains: Array | null, +/** + * Canonical unix socket permission map for `experimental_network`. + */ +unixSockets: { [key in string]?: NetworkUnixSocketPermission } | null, +/** + * Legacy compatibility view derived from `unix_sockets`. + */ +allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts new file mode 100644 index 000000000..466c6e5f8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkUnixSocketPermission = "allow" | "none"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 73fe05eae..d0687e5f1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -197,9 +197,11 @@ export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; +export type { NetworkDomainPermission } from "./NetworkDomainPermission"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2211e7aa1..930112eed 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -866,12 +866,38 @@ pub struct NetworkRequirements { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, + /// Canonical network permission map for `experimental_network`. + pub domains: Option>, + /// When true, only managed allowlist entries are respected while managed + /// network enforcement is active. + pub managed_allowed_domains_only: Option, + /// Legacy compatibility view derived from `domains`. pub allowed_domains: Option>, + /// Legacy compatibility view derived from `domains`. pub denied_domains: Option>, + /// Canonical unix socket permission map for `experimental_network`. + pub unix_sockets: Option>, + /// Legacy compatibility view derived from `unix_sockets`. pub allow_unix_sockets: Option>, pub allow_local_binding: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkDomainPermission { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkUnixSocketPermission { + Allow, + None, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -7487,6 +7513,94 @@ mod tests { ); } + #[test] + fn network_requirements_deserializes_legacy_fields() { + let requirements: NetworkRequirements = serde_json::from_value(json!({ + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "allowUnixSockets": ["/tmp/proxy.sock"] + })) + .expect("legacy network requirements should deserialize"); + + assert_eq!( + requirements, + NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: None, + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: None, + } + ); + } + + #[test] + fn network_requirements_serializes_canonical_and_legacy_fields() { + let requirements = NetworkRequirements { + enabled: Some(true), + http_port: Some(8080), + socks_port: Some(1080), + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(true), + domains: Some(BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow), + ( + "blocked.example.com".to_string(), + NetworkDomainPermission::Deny, + ), + ])), + managed_allowed_domains_only: Some(true), + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: Some(BTreeMap::from([ + ( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + ), + ])), + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: Some(true), + }; + + assert_eq!( + serde_json::to_value(requirements).expect("network requirements should serialize"), + json!({ + "enabled": true, + "httpPort": 8080, + "socksPort": 1080, + "allowUpstreamProxy": false, + "dangerouslyAllowNonLoopbackProxy": false, + "dangerouslyAllowAllUnixSockets": true, + "domains": { + "api.openai.com": "allow", + "blocked.example.com": "deny" + }, + "managedAllowedDomainsOnly": true, + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "unixSockets": { + "/tmp/ignored.sock": "none", + "/tmp/proxy.sock": "allow" + }, + "allowUnixSockets": ["/tmp/proxy.sock"], + "allowLocalBinding": true + }) + ); + } + #[test] fn core_turn_item_into_thread_item_converts_supported_variants() { let user_item = TurnItem::UserMessage(UserMessageItem { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cabea022a..38cdacac7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -194,7 +194,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 7f6acc54d..d138d2f5e 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -12,7 +12,9 @@ use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NetworkDomainPermission; use codex_app_server_protocol::NetworkRequirements; +use codex_app_server_protocol::NetworkUnixSocketPermission; use codex_app_server_protocol::SandboxMode; use codex_core::AnalyticsEventsClient; use codex_core::ThreadManager; @@ -410,6 +412,20 @@ fn map_residency_requirement_to_api( fn map_network_requirements_to_api( network: codex_core::config_loader::NetworkRequirementsToml, ) -> NetworkRequirements { + let allowed_domains = network + .domains + .as_ref() + .and_then(codex_core::config_loader::NetworkDomainPermissionsToml::allowed_domains); + let denied_domains = network + .domains + .as_ref() + .and_then(codex_core::config_loader::NetworkDomainPermissionsToml::denied_domains); + let allow_unix_sockets = network + .unix_sockets + .as_ref() + .map(codex_core::config_loader::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .filter(|entries| !entries.is_empty()); + NetworkRequirements { enabled: network.enabled, http_port: network.http_port, @@ -417,13 +433,58 @@ fn map_network_requirements_to_api( allow_upstream_proxy: network.allow_upstream_proxy, dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, - allowed_domains: network.allowed_domains, - denied_domains: network.denied_domains, - allow_unix_sockets: network.allow_unix_sockets, + domains: network.domains.map(|domains| { + domains + .entries + .into_iter() + .map(|(pattern, permission)| { + (pattern, map_network_domain_permission_to_api(permission)) + }) + .collect() + }), + managed_allowed_domains_only: network.managed_allowed_domains_only, + allowed_domains, + denied_domains, + unix_sockets: network.unix_sockets.map(|unix_sockets| { + unix_sockets + .entries + .into_iter() + .map(|(path, permission)| { + (path, map_network_unix_socket_permission_to_api(permission)) + }) + .collect() + }), + allow_unix_sockets, allow_local_binding: network.allow_local_binding, } } +fn map_network_domain_permission_to_api( + permission: codex_core::config_loader::NetworkDomainPermissionToml, +) -> NetworkDomainPermission { + match permission { + codex_core::config_loader::NetworkDomainPermissionToml::Allow => { + NetworkDomainPermission::Allow + } + codex_core::config_loader::NetworkDomainPermissionToml::Deny => { + NetworkDomainPermission::Deny + } + } +} + +fn map_network_unix_socket_permission_to_api( + permission: codex_core::config_loader::NetworkUnixSocketPermissionToml, +) -> NetworkUnixSocketPermission { + match permission { + codex_core::config_loader::NetworkUnixSocketPermissionToml::Allow => { + NetworkUnixSocketPermission::Allow + } + codex_core::config_loader::NetworkUnixSocketPermissionToml::None => { + NetworkUnixSocketPermission::None + } + } +} + fn map_error(err: ConfigServiceError) -> JSONRPCErrorError { if let Some(code) = err.write_error_code() { return config_write_error(code, err.to_string()); @@ -452,7 +513,11 @@ mod tests { use codex_core::AnalyticsEventsClient; use codex_core::AuthManager; use codex_core::CodexAuth; + use codex_core::config_loader::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml; + use codex_core::config_loader::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml; use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; + use codex_core::config_loader::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml; + use codex_core::config_loader::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml; use codex_features::Feature; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use pretty_assertions::assert_eq; @@ -505,10 +570,25 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), - allowed_domains: Some(vec!["api.openai.com".to_string()]), + domains: Some(CoreNetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([ + ( + "api.openai.com".to_string(), + CoreNetworkDomainPermissionToml::Allow, + ), + ( + "example.com".to_string(), + CoreNetworkDomainPermissionToml::Deny, + ), + ]), + }), managed_allowed_domains_only: Some(false), - denied_domains: Some(vec!["example.com".to_string()]), - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { + entries: std::collections::BTreeMap::from([( + "/tmp/proxy.sock".to_string(), + CoreNetworkUnixSocketPermissionToml::Allow, + )]), + }), allow_local_binding: Some(true), }), }; @@ -550,14 +630,79 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), + domains: Some(std::collections::BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow,), + ("example.com".to_string(), NetworkDomainPermission::Deny), + ])), + managed_allowed_domains_only: Some(false), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), + unix_sockets: Some(std::collections::BTreeMap::from([( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + )])), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), allow_local_binding: Some(true), }), ); } + #[test] + fn map_requirements_toml_to_api_omits_unix_socket_none_entries_from_legacy_network_fields() { + let requirements = ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + guardian_developer_instructions: None, + feature_requirements: None, + mcp_servers: None, + apps: None, + rules: None, + enforce_residency: None, + network: Some(CoreNetworkRequirementsToml { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { + entries: std::collections::BTreeMap::from([( + "/tmp/ignored.sock".to_string(), + CoreNetworkUnixSocketPermissionToml::None, + )]), + }), + allow_local_binding: None, + }), + }; + + let mapped = map_requirements_toml_to_api(requirements); + + assert_eq!( + mapped.network, + Some(NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + allowed_domains: None, + denied_domains: None, + unix_sockets: Some(std::collections::BTreeMap::from([( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + )])), + allow_unix_sockets: None, + allow_local_binding: None, + }), + ); + } + #[test] fn map_requirements_toml_to_api_normalizes_allowed_web_search_modes() { let requirements = ConfigRequirementsToml { diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 57d762c0f..d63fa1e8b 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -5,6 +5,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; +use serde::de::Error as _; use std::collections::BTreeMap; use std::fmt; @@ -132,7 +133,93 @@ pub struct McpServerRequirement { pub identity: McpServerIdentity, } -#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)] pub struct NetworkRequirementsToml { pub enabled: Option, pub http_port: Option, @@ -140,17 +227,121 @@ pub struct NetworkRequirementsToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, - pub allowed_domains: Option>, + pub domains: Option, /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. pub managed_allowed_domains_only: Option, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, + pub unix_sockets: Option, pub allow_local_binding: Option, } +#[derive(Deserialize)] +struct RawNetworkRequirementsToml { + enabled: Option, + http_port: Option, + socks_port: Option, + allow_upstream_proxy: Option, + dangerously_allow_non_loopback_proxy: Option, + dangerously_allow_all_unix_sockets: Option, + domains: Option, + #[serde(default)] + allowed_domains: Option>, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + managed_allowed_domains_only: Option, + #[serde(default)] + denied_domains: Option>, + unix_sockets: Option, + #[serde(default)] + allow_unix_sockets: Option>, + allow_local_binding: Option, +} + +impl<'de> Deserialize<'de> for NetworkRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawNetworkRequirementsToml::deserialize(deserializer)?; + let RawNetworkRequirementsToml { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + allowed_domains, + managed_allowed_domains_only, + denied_domains, + unix_sockets, + allow_unix_sockets, + allow_local_binding, + } = raw; + + if domains.is_some() && (allowed_domains.is_some() || denied_domains.is_some()) { + return Err(D::Error::custom( + "`experimental_network.domains` cannot be combined with legacy `allowed_domains` or `denied_domains`", + )); + } + + if unix_sockets.is_some() && allow_unix_sockets.is_some() { + return Err(D::Error::custom( + "`experimental_network.unix_sockets` cannot be combined with legacy `allow_unix_sockets`", + )); + } + + Ok(Self { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains: domains + .or_else(|| legacy_domain_permissions_from_lists(allowed_domains, denied_domains)), + managed_allowed_domains_only, + unix_sockets: unix_sockets + .or_else(|| legacy_unix_socket_permissions_from_list(allow_unix_sockets)), + allow_local_binding, + }) + } +} + +/// Legacy list normalization is intentionally lossy: explicit empty legacy +/// lists are treated as unset when converted to the canonical network +/// permission shape. +fn legacy_domain_permissions_from_lists( + allowed_domains: Option>, + denied_domains: Option>, +) -> Option { + let mut entries = BTreeMap::new(); + + for pattern in allowed_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Allow); + } + + for pattern in denied_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Deny); + } + + (!entries.is_empty()).then_some(NetworkDomainPermissionsToml { entries }) +} + +fn legacy_unix_socket_permissions_from_list( + allow_unix_sockets: Option>, +) -> Option { + let entries = allow_unix_sockets + .unwrap_or_default() + .into_iter() + .map(|path| (path, NetworkUnixSocketPermissionToml::Allow)) + .collect::>(); + + (!entries.is_empty()).then_some(NetworkUnixSocketPermissionsToml { entries }) +} + /// Normalized network constraints derived from requirements TOML. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct NetworkConstraints { pub enabled: Option, pub http_port: Option, @@ -158,15 +349,24 @@ pub struct NetworkConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, - pub allowed_domains: Option>, + pub domains: Option, /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. pub managed_allowed_domains_only: Option, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, + pub unix_sockets: Option, pub allow_local_binding: Option, } +impl<'de> Deserialize<'de> for NetworkConstraints { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let requirements = NetworkRequirementsToml::deserialize(deserializer)?; + Ok(requirements.into()) + } +} + impl From for NetworkConstraints { fn from(value: NetworkRequirementsToml) -> Self { let NetworkRequirementsToml { @@ -176,10 +376,9 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + unix_sockets, allow_local_binding, } = value; Self { @@ -189,10 +388,9 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + unix_sockets, allow_local_binding, } } @@ -1470,6 +1668,78 @@ guardian_developer_instructions = """ #[test] fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { + let toml_str = r#" + [experimental_network] + enabled = true + allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true + managed_allowed_domains_only = true + allow_local_binding = false + + [experimental_network.domains] + "api.example.com" = "allow" + "*.openai.com" = "allow" + "blocked.example.com" = "deny" + + [experimental_network.unix_sockets] + "/tmp/example.sock" = "allow" + "#; + + let source = RequirementSource::CloudRequirements; + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let sourced_network = requirements + .network + .expect("network requirements should be preserved as constraints"); + + assert_eq!(sourced_network.source, source); + assert_eq!(sourced_network.value.enabled, Some(true)); + assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!( + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) + ); + assert_eq!( + sourced_network.value.managed_allowed_domains_only, + Some(true) + ); + assert_eq!( + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) + ); + assert_eq!(sourced_network.value.allow_local_binding, Some(false)); + + Ok(()) + } + + #[test] + fn legacy_network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { let toml_str = r#" [experimental_network] enabled = true @@ -1499,29 +1769,137 @@ guardian_developer_instructions = """ Some(true) ); assert_eq!( - sourced_network.value.allowed_domains.as_ref(), - Some(&vec![ - "api.example.com".to_string(), - "*.openai.com".to_string() - ]) + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) ); assert_eq!( sourced_network.value.managed_allowed_domains_only, Some(true) ); assert_eq!( - sourced_network.value.denied_domains.as_ref(), - Some(&vec!["blocked.example.com".to_string()]) - ); - assert_eq!( - sourced_network.value.allow_unix_sockets.as_ref(), - Some(&vec!["/tmp/example.sock".to_string()]) + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) ); assert_eq!(sourced_network.value.allow_local_binding, Some(false)); Ok(()) } + #[test] + fn mixed_legacy_and_canonical_network_requirements_are_rejected() { + let err = from_str::( + r#" + [experimental_network] + allowed_domains = ["api.example.com"] + + [experimental_network.domains] + "*.openai.com" = "allow" + "#, + ) + .expect_err("mixed network domain shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.domains` cannot be combined"), + "unexpected error: {err:#}" + ); + + let err = from_str::( + r#" + [experimental_network] + allow_unix_sockets = ["/tmp/example.sock"] + + [experimental_network.unix_sockets] + "/tmp/another.sock" = "allow" + "#, + ) + .expect_err("mixed network unix socket shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.unix_sockets` cannot be combined"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn network_permission_containers_project_allowed_and_denied_entries() { + let domains = NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }; + let unix_sockets = NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }; + + assert_eq!( + domains.allowed_domains(), + Some(vec![ + "*.openai.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + domains.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + } + .denied_domains(), + None + ); + assert_eq!( + unix_sockets.allow_unix_sockets(), + vec!["/tmp/example.sock".to_string()] + ); + } + #[test] fn deserialize_mcp_server_requirements() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 7bd572fea..9492d56b4 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -25,7 +25,11 @@ pub use config_requirements::FeatureRequirementsToml; pub use config_requirements::McpServerIdentity; pub use config_requirements::McpServerRequirement; pub use config_requirements::NetworkConstraints; +pub use config_requirements::NetworkDomainPermissionToml; +pub use config_requirements::NetworkDomainPermissionsToml; pub use config_requirements::NetworkRequirementsToml; +pub use config_requirements::NetworkUnixSocketPermissionToml; +pub use config_requirements::NetworkUnixSocketPermissionsToml; pub use config_requirements::RequirementSource; pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3d091006c..78113b126 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -913,6 +913,16 @@ ], "type": "object" }, + "NetworkDomainPermissionToml": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkDomainPermissionsToml": { + "type": "object" + }, "NetworkModeSchema": { "enum": [ "limited", @@ -926,32 +936,17 @@ "allow_local_binding": { "type": "boolean" }, - "allow_unix_sockets": { - "items": { - "type": "string" - }, - "type": "array" - }, "allow_upstream_proxy": { "type": "boolean" }, - "allowed_domains": { - "items": { - "type": "string" - }, - "type": "array" - }, "dangerously_allow_all_unix_sockets": { "type": "boolean" }, "dangerously_allow_non_loopback_proxy": { "type": "boolean" }, - "denied_domains": { - "items": { - "type": "string" - }, - "type": "array" + "domains": { + "$ref": "#/definitions/NetworkDomainPermissionsToml" }, "enable_socks5": { "type": "boolean" @@ -970,10 +965,23 @@ }, "socks_url": { "type": "string" + }, + "unix_sockets": { + "$ref": "#/definitions/NetworkUnixSocketPermissionsToml" } }, "type": "object" }, + "NetworkUnixSocketPermissionToml": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, + "NetworkUnixSocketPermissionsToml": { + "type": "object" + }, "Notice": { "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", "properties": { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ed24d2846..54f5c93f2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1029,8 +1029,16 @@ impl TurnContext { .network .as_ref()?; Some(TurnContextNetworkItem { - allowed_domains: network.allowed_domains.clone().unwrap_or_default(), - denied_domains: network.denied_domains.clone().unwrap_or_default(), + allowed_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + denied_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) + .unwrap_or_default(), }) } } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5a556f207..ff0e08e21 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -5,6 +5,8 @@ use crate::config::test_config; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::NetworkConstraints; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; use crate::exec::ExecCapturePolicy; @@ -471,8 +473,8 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho let current_cfg = started_proxy.proxy().current_cfg().await?; assert_eq!( - current_cfg.network.allowed_domains, - vec!["example.com".to_string()] + current_cfg.network.allowed_domains(), + Some(vec!["example.com".to_string()]) ); Ok(()) } @@ -483,7 +485,12 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { - allowed_domains: Some(vec!["managed.example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([( + "managed.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), managed_allowed_domains_only: Some(true), ..Default::default() }), @@ -510,8 +517,8 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let current_cfg = started_proxy.proxy().current_cfg().await?; assert_eq!( - current_cfg.network.allowed_domains, - vec!["managed.example.com".to_string()] + current_cfg.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) ); Ok(()) } @@ -3687,8 +3694,18 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( let mut requirements = config.config_layer_stack.requirements().clone(); requirements.network = Some(Sourced::new( NetworkConstraints { - allowed_domains: Some(vec!["api.example.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([ + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }), ..Default::default() }, RequirementSource::CloudRequirements, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 4d6527ab8..60f0e06f7 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -306,7 +306,9 @@ enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false -allowed_domains = ["openai.com"] + +[permissions.workspace.network.domains] +"openai.com" = "allow" "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); @@ -343,9 +345,13 @@ allowed_domains = ["openai.com"] dangerously_allow_non_loopback_proxy: None, dangerously_allow_all_unix_sockets: None, mode: None, - allowed_domains: Some(vec!["openai.com".to_string()]), - denied_domains: None, - allow_unix_sockets: None, + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), + unix_sockets: None, allow_local_binding: None, }), }, @@ -421,7 +427,12 @@ fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> st )]), }), network: Some(NetworkToml { - allowed_domains: Some(vec!["openai.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }), }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fa21cbad8..1a0722119 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -123,9 +123,14 @@ pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub use permissions::FilesystemPermissionToml; pub use permissions::FilesystemPermissionsToml; +pub use permissions::NetworkDomainPermissionToml; +pub use permissions::NetworkDomainPermissionsToml; pub use permissions::NetworkToml; +pub use permissions::NetworkUnixSocketPermissionToml; +pub use permissions::NetworkUnixSocketPermissionsToml; pub use permissions::PermissionProfileToml; pub use permissions::PermissionsToml; +pub(crate) use permissions::overlay_network_domain_permissions; pub(crate) use permissions::resolve_permission_profile; pub use service::ConfigService; pub use service::ConfigServiceError; diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 386100d34..93b59cf5f 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -226,33 +226,63 @@ impl NetworkProxySpec { Some(dangerously_allow_all_unix_sockets); } let managed_allowed_domains = if hard_deny_allowlist_misses { - Some(requirements.allowed_domains.clone().unwrap_or_default()) + Some( + requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + ) } else { - requirements.allowed_domains.clone() + requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) }; - if let Some(allowed_domains) = managed_allowed_domains { + if let Some(managed_allowed_domains) = managed_allowed_domains { // Managed requirements seed the baseline allowlist. User additions // can extend that baseline unless managed-only mode pins the // effective allowlist to the managed set. - config.network.allowed_domains = if allowlist_expansion_enabled { - Self::merge_domain_lists(allowed_domains.clone(), &config.network.allowed_domains) + let effective_allowed_domains = if allowlist_expansion_enabled { + Self::merge_domain_lists( + managed_allowed_domains.clone(), + config.network.allowed_domains().as_deref().unwrap_or(&[]), + ) } else { - allowed_domains.clone() + managed_allowed_domains.clone() }; - constraints.allowed_domains = Some(allowed_domains); + config + .network + .set_allowed_domains(effective_allowed_domains); + constraints.allowed_domains = Some(managed_allowed_domains); constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled); } - if let Some(denied_domains) = requirements.denied_domains.clone() { - config.network.denied_domains = if denylist_expansion_enabled { - Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains) + let managed_denied_domains = requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains); + if let Some(managed_denied_domains) = managed_denied_domains { + let effective_denied_domains = if denylist_expansion_enabled { + Self::merge_domain_lists( + managed_denied_domains.clone(), + config.network.denied_domains().as_deref().unwrap_or(&[]), + ) } else { - denied_domains.clone() + managed_denied_domains.clone() }; - constraints.denied_domains = Some(denied_domains); + config.network.set_denied_domains(effective_denied_domains); + constraints.denied_domains = Some(managed_denied_domains); constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled); } - if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() { - config.network.allow_unix_sockets = allow_unix_sockets.clone(); + if requirements.unix_sockets.is_some() { + let allow_unix_sockets = requirements + .unix_sockets + .as_ref() + .map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .unwrap_or_default(); + config + .network + .set_allow_unix_sockets(allow_unix_sockets.clone()); constraints.allow_unix_sockets = Some(allow_unix_sockets); } if let Some(allow_local_binding) = requirements.allow_local_binding { @@ -299,37 +329,25 @@ impl NetworkProxySpec { fn apply_exec_policy_network_rules(config: &mut NetworkProxyConfig, exec_policy: &Policy) { let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains(); - upsert_network_domains( - &mut config.network.allowed_domains, - &mut config.network.denied_domains, - allowed_domains, - ); - upsert_network_domains( - &mut config.network.denied_domains, - &mut config.network.allowed_domains, - denied_domains, - ); + upsert_network_domains(config, allowed_domains, /*allow*/ true); + upsert_network_domains(config, denied_domains, /*allow*/ false); } -fn upsert_network_domains( - target: &mut Vec, - opposite: &mut Vec, - hosts: Vec, -) { +fn upsert_network_domains(config: &mut NetworkProxyConfig, hosts: Vec, allow: bool) { let mut incoming = HashSet::new(); - let mut deduped_hosts = Vec::new(); for host in hosts { if incoming.insert(host.clone()) { - deduped_hosts.push(host); + config.network.upsert_domain_permission( + host, + if allow { + codex_network_proxy::NetworkDomainPermission::Allow + } else { + codex_network_proxy::NetworkDomainPermission::Deny + }, + normalize_host, + ); } } - if incoming.is_empty() { - return; - } - - opposite.retain(|entry| !incoming.contains(&normalize_host(entry))); - target.retain(|entry| !incoming.contains(&normalize_host(entry))); - target.extend(deduped_hosts); } #[cfg(test)] diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 4c6e82358..77007885a 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -1,6 +1,20 @@ use super::*; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; +use codex_network_proxy::NetworkDomainPermission; use pretty_assertions::assert_eq; +fn domain_permissions( + entries: impl IntoIterator, +) -> NetworkDomainPermissionsToml { + NetworkDomainPermissionsToml { + entries: entries + .into_iter() + .map(|(pattern, permission)| (pattern.to_string(), permission)) + .collect(), + } +} + #[test] fn build_state_with_audit_metadata_threads_metadata_to_state() { let spec = NetworkProxySpec { @@ -24,9 +38,14 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() { #[test] fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), ..Default::default() }; @@ -38,8 +57,11 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { .expect("config should stay within the managed allowlist"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string(), "api.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec![ + "*.example.com".to_string(), + "api.example.com".to_string() + ]) ); assert_eq!( spec.constraints.allowed_domains, @@ -48,14 +70,92 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); } +#[test] +fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "api.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed allowlist should not erase a user deny"); + + assert_eq!(spec.config.network.allowed_domains(), None); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["api.example.com".to_string()]) + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["api.example.com".to_string()]) + ); +} + +#[test] +fn requirements_allowlist_expansion_keeps_user_entries_mutable() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still allow user edits"); + + let mut candidate = spec.config.clone(); + candidate.network.upsert_domain_permission( + "api.example.com".to_string(), + NetworkDomainPermission::Deny, + normalize_host, + ); + + assert_eq!( + candidate.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) + ); + assert_eq!( + candidate.network.denied_domains(), + Some(vec!["api.example.com".to_string()]) + ); + validate_policy_against_constraints(&candidate, &spec.constraints) + .expect("user allowlist entries should not become managed constraints"); +} + #[test] fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["evil.com".to_string()]; - config.network.denied_domains = vec!["more-blocked.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["evil.com".to_string()]); + config + .network + .set_denied_domains(vec!["more-blocked.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), + domains: Some(domain_permissions([ + ("*.example.com", NetworkDomainPermissionToml::Allow), + ("blocked.example.com", NetworkDomainPermissionToml::Deny), + ])), ..Default::default() }; @@ -67,12 +167,12 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { .expect("yolo mode should pin the effective policy to the managed baseline"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) ); assert_eq!( - spec.config.network.denied_domains, - vec!["blocked.example.com".to_string()] + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) ); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); @@ -81,9 +181,14 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { #[test] fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), managed_allowed_domains_only: Some(true), ..Default::default() }; @@ -96,8 +201,8 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { .expect("managed baseline should still load"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) ); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); } @@ -105,9 +210,14 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { #[test] fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["managed.example.com".to_string()]), + domains: Some(domain_permissions([( + "managed.example.com", + NetworkDomainPermissionToml::Allow, + )])), managed_allowed_domains_only: Some(true), ..Default::default() }; @@ -120,8 +230,8 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() .expect("managed-only allowlist should still load"); assert_eq!( - spec.config.network.allowed_domains, - vec!["managed.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) ); assert_eq!( spec.constraints.allowed_domains, @@ -134,7 +244,9 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() #[test] fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { managed_allowed_domains_only: Some(true), ..Default::default() @@ -147,7 +259,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain ) .expect("managed-only mode should treat missing managed allowlist as empty"); - assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.config.network.allowed_domains(), None); assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert!(spec.hard_deny_allowlist_misses); @@ -156,7 +268,9 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain #[test] fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { managed_allowed_domains_only: Some(true), ..Default::default() @@ -169,18 +283,89 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m ) .expect("managed-only mode should treat missing managed allowlist as empty"); - assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.config.network.allowed_domains(), None); assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert!(spec.hard_deny_allowlist_misses); } +#[test] +fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("deny-only requirements should not constrain the allowlist"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["api.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowed_domains, None); + assert_eq!(spec.constraints.allowlist_expansion_enabled, None); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["managed-blocked.example.com".to_string()]) + ); +} + +#[test] +fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("allow-only requirements should not constrain the denylist"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) + ); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!(spec.constraints.denied_domains, None); + assert_eq!(spec.constraints.denylist_expansion_enabled, None); +} + #[test] fn requirements_denied_domains_are_a_baseline_for_default_mode() { let mut config = NetworkProxyConfig::default(); - config.network.denied_domains = vec!["blocked.example.com".to_string()]; + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); let requirements = NetworkConstraints { - denied_domains: Some(vec!["managed-blocked.example.com".to_string()]), + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), ..Default::default() }; @@ -192,11 +377,55 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() { .expect("default mode should merge managed and user deny entries"); assert_eq!( - spec.config.network.denied_domains, - vec![ + spec.config.network.denied_domains(), + Some(vec![ "managed-blocked.example.com".to_string(), "blocked.example.com".to_string() - ] + ]) + ); + assert_eq!( + spec.constraints.denied_domains, + Some(vec!["managed-blocked.example.com".to_string()]) ); assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); } + +#[test] +fn requirements_denylist_expansion_keeps_user_entries_mutable() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still allow user edits"); + + let mut candidate = spec.config.clone(); + candidate.network.upsert_domain_permission( + "blocked.example.com".to_string(), + NetworkDomainPermission::Allow, + normalize_host, + ); + + assert_eq!( + candidate.network.allowed_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + candidate.network.denied_domains(), + Some(vec!["managed-blocked.example.com".to_string()]) + ); + validate_policy_against_constraints(&candidate, &spec.constraints) + .expect("user denylist entries should not become managed constraints"); +} diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 759c269b7..73dad1c73 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -5,8 +5,11 @@ use std::path::Component; use std::path::Path; use std::path::PathBuf; +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 codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -56,6 +59,98 @@ pub enum FilesystemPermissionToml { Scoped(BTreeMap), } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + #[cfg(test)] + pub(crate) fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + #[cfg(test)] + pub(crate) fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub(crate) fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct NetworkToml { @@ -69,9 +164,8 @@ pub struct NetworkToml { pub dangerously_allow_all_unix_sockets: Option, #[schemars(with = "Option")] pub mode: Option, - pub allowed_domains: Option>, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, + pub domains: Option, + pub unix_sockets: Option, pub allow_local_binding: Option, } @@ -114,14 +208,22 @@ impl NetworkToml { if let Some(mode) = self.mode { config.network.mode = mode; } - if let Some(allowed_domains) = self.allowed_domains.as_ref() { - config.network.allowed_domains = allowed_domains.clone(); + if let Some(domains) = self.domains.as_ref() { + overlay_network_domain_permissions(config, domains); } - if let Some(denied_domains) = self.denied_domains.as_ref() { - config.network.denied_domains = denied_domains.clone(); - } - if let Some(allow_unix_sockets) = self.allow_unix_sockets.as_ref() { - config.network.allow_unix_sockets = allow_unix_sockets.clone(); + if let Some(unix_sockets) = self.unix_sockets.as_ref() { + let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default(); + for (path, permission) in &unix_sockets.entries { + let permission = match permission { + NetworkUnixSocketPermissionToml::Allow => { + ProxyNetworkUnixSocketPermission::Allow + } + NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None, + }; + proxy_unix_sockets.entries.insert(path.clone(), permission); + } + config.network.unix_sockets = + (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets); } if let Some(allow_local_binding) = self.allow_local_binding { config.network.allow_local_binding = allow_local_binding; @@ -135,6 +237,21 @@ impl NetworkToml { } } +pub(crate) fn overlay_network_domain_permissions( + config: &mut NetworkProxyConfig, + domains: &NetworkDomainPermissionsToml, +) { + for (pattern, permission) in &domains.entries { + let permission = match permission { + NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow, + NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny, + }; + config + .network + .upsert_domain_permission(pattern.clone(), permission, normalize_host); + } +} + pub(crate) fn network_proxy_config_from_profile_network( network: Option<&NetworkToml>, ) -> NetworkProxyConfig { diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index b90b903e8..b1313d1b0 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -76,3 +76,132 @@ fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> Ok(()) } + +#[test] +fn network_toml_ignores_legacy_network_list_keys() { + let parsed = toml::from_str::( + r#" +allowed_domains = ["openai.com"] +"#, + ) + .expect("legacy network list keys should be ignored"); + + assert_eq!(parsed, NetworkToml::default()); +} + +#[test] +fn network_permission_containers_project_allowed_and_denied_entries() { + let domains = NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }; + let unix_sockets = NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }; + + assert_eq!( + domains.allowed_domains(), + Some(vec![ + "*.openai.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + domains.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + } + .denied_domains(), + None + ); + assert_eq!( + unix_sockets.allow_unix_sockets(), + vec!["/tmp/example.sock".to_string()] + ); +} + +#[test] +fn network_toml_overlays_unix_socket_permissions_by_path() { + let mut config = NetworkProxyConfig::default(); + + NetworkToml { + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/base.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/override.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ]), + }), + ..Default::default() + } + .apply_to_network_proxy_config(&mut config); + + NetworkToml { + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/extra.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/override.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }), + ..Default::default() + } + .apply_to_network_proxy_config(&mut config); + + assert_eq!( + config.network.unix_sockets, + Some(codex_network_proxy::NetworkUnixSocketPermissions { + entries: BTreeMap::from([ + ( + "/tmp/base.sock".to_string(), + ProxyNetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/extra.sock".to_string(), + ProxyNetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/override.sock".to_string(), + ProxyNetworkUnixSocketPermission::None, + ), + ]), + }) + ); +} diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index e4c503958..df3366595 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -42,7 +42,11 @@ pub use codex_config::LoaderOverrides; pub use codex_config::McpServerIdentity; pub use codex_config::McpServerRequirement; pub use codex_config::NetworkConstraints; +pub use codex_config::NetworkDomainPermissionToml; +pub use codex_config::NetworkDomainPermissionsToml; pub use codex_config::NetworkRequirementsToml; +pub use codex_config::NetworkUnixSocketPermissionToml; +pub use codex_config::NetworkUnixSocketPermissionsToml; pub use codex_config::RequirementSource; pub use codex_config::ResidencyRequirement; pub use codex_config::SandboxModeRequirement; diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index bbb39da11..df4e49cf4 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -130,8 +130,16 @@ impl EnvironmentContext { .as_ref()?; Some(NetworkContext { - allowed_domains: network.allowed_domains.clone().unwrap_or_default(), - denied_domains: network.denied_domains.clone().unwrap_or_default(), + allowed_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + denied_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) + .unwrap_or_default(), }) } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 3e6d409e7..b71b48bd6 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -11,6 +11,8 @@ use crate::config::test_config; use crate::config_loader::ConfigLayerStack; use crate::config_loader::FeatureRequirementsToml; use crate::config_loader::NetworkConstraints; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; use crate::protocol::SandboxPolicy; @@ -971,7 +973,12 @@ fn guardian_review_session_config_preserves_parent_network_proxy() { NetworkProxyConfig::default(), Some(NetworkConstraints { enabled: Some(true), - allowed_domains: Some(vec!["github.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([( + "github.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }), parent_config.permissions.sandbox_policy.get(), @@ -1027,7 +1034,9 @@ fn guardian_review_session_config_uses_live_network_proxy_state() { let mut parent_config = test_config(); let mut parent_network = NetworkProxyConfig::default(); parent_network.network.enabled = true; - parent_network.network.allowed_domains = vec!["parent.example".to_string()]; + parent_network + .network + .set_allowed_domains(vec!["parent.example".to_string()]); parent_config.permissions.network = Some( NetworkProxySpec::from_config_and_constraints( parent_network, @@ -1039,7 +1048,9 @@ fn guardian_review_session_config_uses_live_network_proxy_state() { let mut live_network = NetworkProxyConfig::default(); live_network.network.enabled = true; - live_network.network.allowed_domains = vec!["github.com".to_string()]; + live_network + .network + .set_allowed_domains(vec!["github.com".to_string()]); let guardian_config = build_guardian_review_session_config_for_test( &parent_config, diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 1db111ee6..a6740b05f 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,6 +1,7 @@ use crate::config::NetworkToml; use crate::config::PermissionsToml; use crate::config::find_codex_home; +use crate::config::overlay_network_domain_permissions; use crate::config::resolve_permission_profile; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; @@ -150,13 +151,20 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets { constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); } - if let Some(allowed_domains) = network.allowed_domains { - constraints.allowed_domains = Some(allowed_domains); + if let Some(domains) = network.domains.as_ref() { + let mut config = NetworkProxyConfig::default(); + if let Some(allowed_domains) = constraints.allowed_domains.take() { + config.network.set_allowed_domains(allowed_domains); + } + if let Some(denied_domains) = constraints.denied_domains.take() { + config.network.set_denied_domains(denied_domains); + } + overlay_network_domain_permissions(&mut config, domains); + constraints.allowed_domains = config.network.allowed_domains(); + constraints.denied_domains = config.network.denied_domains(); } - if let Some(denied_domains) = network.denied_domains { - constraints.denied_domains = Some(denied_domains); - } - if let Some(allow_unix_sockets) = network.allow_unix_sockets { + if let Some(unix_sockets) = network.unix_sockets.as_ref() { + let allow_unix_sockets = unix_sockets.allow_unix_sockets(); constraints.allow_unix_sockets = Some(allow_unix_sockets); } if let Some(allow_local_binding) = network.allow_local_binding { @@ -220,24 +228,28 @@ fn apply_exec_policy_network_rules( let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains(); for host in allowed_domains { upsert_network_domain( - &mut config.network.allowed_domains, - &mut config.network.denied_domains, + config, host, + codex_network_proxy::NetworkDomainPermission::Allow, ); } for host in denied_domains { upsert_network_domain( - &mut config.network.denied_domains, - &mut config.network.allowed_domains, + config, host, + codex_network_proxy::NetworkDomainPermission::Deny, ); } } -fn upsert_network_domain(target: &mut Vec, opposite: &mut Vec, host: String) { - opposite.retain(|entry| normalize_host(entry) != host); - target.retain(|entry| normalize_host(entry) != host); - target.push(host); +fn upsert_network_domain( + config: &mut NetworkProxyConfig, + host: String, + permission: codex_network_proxy::NetworkDomainPermission, +) { + config + .network + .upsert_domain_permission(host, permission, normalize_host); } fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs index 018061463..395b761dd 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -6,13 +6,16 @@ use codex_execpolicy::Policy; use pretty_assertions::assert_eq; #[test] -fn higher_precedence_profile_network_beats_lower_profile_network() { +fn higher_precedence_profile_network_overlays_domain_entries() { let lower_network: toml::Value = toml::from_str( r#" default_permissions = "workspace" [permissions.workspace.network] -allowed_domains = ["lower.example.com"] + +[permissions.workspace.network.domains] +"lower.example.com" = "allow" +"blocked.example.com" = "deny" "#, ) .expect("lower layer should parse"); @@ -21,7 +24,9 @@ allowed_domains = ["lower.example.com"] default_permissions = "workspace" [permissions.workspace.network] -allowed_domains = ["higher.example.com"] + +[permissions.workspace.network.domains] +"higher.example.com" = "allow" "#, ) .expect("higher layer should parse"); @@ -38,14 +43,76 @@ allowed_domains = ["higher.example.com"] ) .expect("higher layer should apply"); - assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); + assert_eq!( + config.network.allowed_domains(), + Some(vec![ + "lower.example.com".to_string(), + "higher.example.com".to_string() + ]) + ); + assert_eq!( + config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); +} + +#[test] +fn higher_precedence_profile_network_overrides_matching_domain_entries() { + let lower_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"shared.example.com" = "deny" +"other.example.com" = "allow" +"#, + ) + .expect("lower layer should parse"); + let higher_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"shared.example.com" = "allow" +"#, + ) + .expect("higher layer should parse"); + + let mut config = NetworkProxyConfig::default(); + apply_network_tables( + &mut config, + network_tables_from_toml(&lower_network).expect("lower layer should deserialize"), + ) + .expect("lower layer should apply"); + apply_network_tables( + &mut config, + network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), + ) + .expect("higher layer should apply"); + + assert_eq!( + config.network.allowed_domains(), + Some(vec![ + "other.example.com".to_string(), + "shared.example.com".to_string() + ]) + ); + assert_eq!(config.network.denied_domains(), None); } #[test] fn execpolicy_network_rules_overlay_network_lists() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["config.example.com".to_string()]; - config.network.denied_domains = vec!["blocked.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["config.example.com".to_string()]); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); let mut exec_policy = Policy::empty(); exec_policy @@ -68,15 +135,15 @@ fn execpolicy_network_rules_overlay_network_lists() { apply_exec_policy_network_rules(&mut config, &exec_policy); assert_eq!( - config.network.allowed_domains, - vec![ + config.network.allowed_domains(), + Some(vec![ "config.example.com".to_string(), "blocked.example.com".to_string() - ] + ]) ); assert_eq!( - config.network.denied_domains, - vec!["api.example.com".to_string()] + config.network.denied_domains(), + Some(vec!["api.example.com".to_string()]) ); } @@ -102,3 +169,82 @@ dangerously_allow_all_unix_sockets = true assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); } + +#[test] +fn apply_network_constraints_skips_empty_domain_sides() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"managed.example.com" = "allow" +"#, + ) + .expect("permissions profile should parse"); + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("permissions profile should deserialize"), + ) + .expect("permissions profile should select a network table") + .expect("network table should be present"); + + let mut constraints = NetworkProxyConstraints::default(); + apply_network_constraints(network, &mut constraints); + + assert_eq!( + constraints.allowed_domains, + Some(vec!["managed.example.com".to_string()]) + ); + assert_eq!(constraints.denied_domains, None); +} + +#[test] +fn apply_network_constraints_overlay_domain_entries() { + let lower_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"blocked.example.com" = "deny" +"#, + ) + .expect("lower layer should parse"); + let higher_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"api.example.com" = "allow" +"#, + ) + .expect("higher layer should parse"); + + let lower_network = selected_network_from_tables( + network_tables_from_toml(&lower_network).expect("lower layer should deserialize"), + ) + .expect("lower layer should select a network table") + .expect("lower network table should be present"); + let higher_network = selected_network_from_tables( + network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), + ) + .expect("higher layer should select a network table") + .expect("higher network table should be present"); + + let mut constraints = NetworkProxyConstraints::default(); + apply_network_constraints(lower_network, &mut constraints); + apply_network_constraints(higher_network, &mut constraints); + + assert_eq!( + constraints.allowed_domains, + Some(vec!["api.example.com".to_string()]) + ); + assert_eq!( + constraints.denied_domains, + Some(vec!["blocked.example.com".to_string()]) + ); +} diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 0347e6ddf..d9eecf4f6 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -37,24 +37,29 @@ mode = "full" # default when unset; use "limited" for read-only mode mitm = false # CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). -# Hosts must match the allowlist (unless denied). -# Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. -# The global `*` wildcard is allowed in `allowed_domains` to delegate public-host filtering to -# `denied_domains`. -# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. -allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"] -denied_domains = ["evil.example"] - # If false, local/private networking is rejected. Explicit allowlisting of local IP literals # (or `localhost`) is required to permit them. # Hostnames that resolve to local/private IPs are still blocked even if allowlisted. allow_local_binding = false -# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. -allow_unix_sockets = ["/tmp/example.sock"] # DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any # absolute socket path from `x-unix-socket`. dangerously_allow_all_unix_sockets = false + +# Hosts must match the allowlist (unless denied). +# Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. +# The global `*` wildcard is rejected. +# If no domain entries are marked `allow`, the proxy blocks requests until an allowlist is configured. +[permissions.workspace.network.domains] +"*.openai.com" = "allow" +"localhost" = "allow" +"127.0.0.1" = "allow" +"::1" = "allow" +"evil.example" = "deny" + +# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. +[permissions.workspace.network.unix_sockets] +"/tmp/example.sock" = "allow" ``` ### 2) Run the proxy @@ -124,7 +129,7 @@ let handle = proxy.run().await?; handle.shutdown().await?; ``` -When unix socket proxying is enabled (`allow_unix_sockets` or +When unix socket proxying is enabled (`unix_sockets` or `dangerously_allow_all_unix_sockets`), proxy bind overrides are still clamped to loopback to avoid turning the proxy into a remote bridge to local daemons. @@ -189,9 +194,9 @@ Audit events intentionally avoid logging full URL/path/query data. This section documents the protections implemented by `codex-network-proxy`, and the boundaries of what it can reasonably guarantee. -- Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. -- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported. A global `*` wildcard is allowed in `allowed_domains` to permit all public hosts by default, while `denied_domains` remains field-specific and still rejects global `*`. -- Deny wins: entries in `denied_domains` always override the allowlist. +- Allowlist-first policy: if `domains` has no `allow` entries, requests are blocked until an allowlist is configured. +- Domain patterns: exact hosts are supported, `*.example.com` matches subdomains only, and `**.example.com` matches the apex plus subdomains; the global `*` wildcard is only accepted when explicitly enabled for allowlist compilation and is otherwise rejected. +- Deny wins: `domains` entries marked `deny` always override the allowlist. - Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) is required to permit them; hostnames that resolve to local/private IPs are still blocked even if diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index f3f0e06c3..b081abb15 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -3,7 +3,10 @@ use anyhow::Result; use anyhow::bail; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::Serializer; +use std::collections::BTreeMap; use std::net::IpAddr; use std::net::SocketAddr; use std::path::Path; @@ -16,6 +19,101 @@ pub struct NetworkProxyConfig { pub network: NetworkProxySettings, } +/// Variant order encodes effective precedence for duplicate patterns: +/// `None < Allow < Deny`, so deny wins over allow when entries conflict. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermission { + None, + Allow, + Deny, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkDomainPermissionEntry { + pub pattern: String, + pub permission: NetworkDomainPermission, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkDomainPermissions { + pub entries: Vec, +} + +impl Serialize for NetworkDomainPermissions { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + self.effective_entries() + .into_iter() + .map(|entry| (entry.pattern, entry.permission)) + .collect::>() + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for NetworkDomainPermissions { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let entries = BTreeMap::::deserialize(deserializer)? + .into_iter() + .map(|(pattern, permission)| NetworkDomainPermissionEntry { + pattern, + permission, + }) + .collect(); + Ok(Self { entries }) + } +} + +impl NetworkDomainPermissions { + fn effective_entries(&self) -> Vec { + let mut order = Vec::new(); + let mut effective_permissions = BTreeMap::new(); + + for entry in &self.entries { + if !effective_permissions.contains_key(&entry.pattern) { + order.push(entry.pattern.clone()); + } + + let permission = effective_permissions + .entry(entry.pattern.clone()) + .or_insert(entry.permission); + if entry.permission > *permission { + *permission = entry.permission; + } + } + + order + .into_iter() + .filter_map(|pattern| { + effective_permissions.remove(&pattern).map(|permission| { + NetworkDomainPermissionEntry { + pattern, + permission, + } + }) + }) + .collect() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermission { + Allow, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct NetworkUnixSocketPermissions { + #[serde(flatten)] + pub entries: BTreeMap, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct NetworkProxySettings { @@ -35,11 +133,9 @@ pub struct NetworkProxySettings { #[serde(default)] pub mode: NetworkMode, #[serde(default)] - pub allowed_domains: Vec, + pub domains: Option, #[serde(default)] - pub denied_domains: Vec, - #[serde(default)] - pub allow_unix_sockets: Vec, + pub unix_sockets: Option, pub allow_local_binding: bool, #[serde(default)] pub mitm: bool, @@ -57,15 +153,119 @@ impl Default for NetworkProxySettings { dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::default(), - allowed_domains: Vec::new(), - denied_domains: Vec::new(), - allow_unix_sockets: Vec::new(), + domains: None, + unix_sockets: None, allow_local_binding: false, mitm: false, } } } +impl NetworkProxySettings { + pub fn allowed_domains(&self) -> Option> { + self.domain_entries(NetworkDomainPermission::Allow) + } + + pub fn denied_domains(&self) -> Option> { + self.domain_entries(NetworkDomainPermission::Deny) + } + + fn domain_entries(&self, permission: NetworkDomainPermission) -> Option> { + self.domains + .as_ref() + .map(|domains| { + domains + .effective_entries() + .iter() + .filter(|entry| entry.permission == permission) + .map(|entry| entry.pattern.clone()) + .collect() + }) + .filter(|entries: &Vec| !entries.is_empty()) + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.unix_sockets + .as_ref() + .map(|unix_sockets| { + unix_sockets + .entries + .iter() + .filter(|(_, permission)| { + matches!(permission, NetworkUnixSocketPermission::Allow) + }) + .map(|(path, _)| path.clone()) + .collect() + }) + .unwrap_or_default() + } + + pub fn set_allowed_domains(&mut self, allowed_domains: Vec) { + self.set_domain_entries(allowed_domains, NetworkDomainPermission::Allow); + } + + pub fn set_denied_domains(&mut self, denied_domains: Vec) { + self.set_domain_entries(denied_domains, NetworkDomainPermission::Deny); + } + + pub fn upsert_domain_permission( + &mut self, + host: String, + permission: NetworkDomainPermission, + normalize: impl Fn(&str) -> String, + ) { + let mut domains = self.domains.take().unwrap_or_default(); + let normalized_host = normalize(&host); + domains + .entries + .retain(|entry| normalize(&entry.pattern) != normalized_host); + domains.entries.push(NetworkDomainPermissionEntry { + pattern: host, + permission, + }); + self.domains = (!domains.entries.is_empty()).then_some(domains); + } + + pub fn set_allow_unix_sockets(&mut self, allow_unix_sockets: Vec) { + self.set_unix_socket_entries(allow_unix_sockets, NetworkUnixSocketPermission::Allow); + } + + fn set_domain_entries(&mut self, entries: Vec, permission: NetworkDomainPermission) { + let mut domains = self.domains.take().unwrap_or_default(); + domains + .entries + .retain(|entry| entry.permission != permission); + for entry in entries { + if !domains + .entries + .iter() + .any(|existing| existing.pattern == entry && existing.permission == permission) + { + domains.entries.push(NetworkDomainPermissionEntry { + pattern: entry, + permission, + }); + } + } + self.domains = (!domains.entries.is_empty()).then_some(domains); + } + + fn set_unix_socket_entries( + &mut self, + entries: Vec, + permission: NetworkUnixSocketPermission, + ) { + let mut unix_sockets = self.unix_sockets.take().unwrap_or_default(); + unix_sockets + .entries + .retain(|_, existing| *existing != permission); + for entry in entries { + unix_sockets.entries.insert(entry, permission); + } + self.unix_sockets = (!unix_sockets.entries.is_empty()).then_some(unix_sockets); + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum NetworkMode { @@ -136,7 +336,7 @@ pub(crate) fn clamp_bind_addrs( "SOCKS5 proxy", "dangerously_allow_non_loopback_proxy", ); - if cfg.allow_unix_sockets.is_empty() && !cfg.dangerously_allow_all_unix_sockets { + if cfg.allow_unix_sockets().is_empty() && !cfg.dangerously_allow_all_unix_sockets { return (http_addr, socks_addr); } @@ -198,7 +398,7 @@ impl ValidatedUnixSocketPath { } pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> { - for (index, socket_path) in cfg.network.allow_unix_sockets.iter().enumerate() { + for (index, socket_path) in cfg.network.allow_unix_sockets().iter().enumerate() { ValidatedUnixSocketPath::parse(socket_path) .with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?; } @@ -357,6 +557,19 @@ mod tests { use pretty_assertions::assert_eq; + fn settings_with_unix_sockets(unix_sockets: &[&str]) -> NetworkProxySettings { + let mut settings = NetworkProxySettings::default(); + if !unix_sockets.is_empty() { + settings.set_allow_unix_sockets( + unix_sockets + .iter() + .map(|path| (*path).to_string()) + .collect(), + ); + } + settings + } + #[test] fn network_proxy_settings_default_matches_local_use_baseline() { assert_eq!( @@ -371,9 +584,8 @@ mod tests { dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::Full, - allowed_domains: Vec::new(), - denied_domains: Vec::new(), - allow_unix_sockets: Vec::new(), + domains: None, + unix_sockets: None, allow_local_binding: false, mitm: false, } @@ -398,6 +610,53 @@ mod tests { assert_eq!(config.network, expected); } + #[test] + fn set_allowed_domains_preserves_existing_deny_for_same_pattern() { + let mut settings = NetworkProxySettings::default(); + settings.set_denied_domains(vec!["example.com".to_string()]); + + settings.set_allowed_domains(vec!["example.com".to_string()]); + + assert_eq!(settings.allowed_domains(), None); + assert_eq!( + settings.denied_domains(), + Some(vec!["example.com".to_string()]) + ); + } + + #[test] + fn network_domain_permissions_serialize_to_effective_map_shape() { + let mut settings = NetworkProxySettings::default(); + settings.set_denied_domains(vec!["example.com".to_string()]); + settings.set_allowed_domains(vec!["example.com".to_string()]); + let config = NetworkProxyConfig { network: settings }; + + let value = serde_json::to_value(&config).unwrap(); + + assert_eq!( + value, + serde_json::json!({ + "network": { + "enabled": false, + "proxy_url": "http://127.0.0.1:3128", + "enable_socks5": true, + "socks_url": "http://127.0.0.1:8081", + "enable_socks5_udp": true, + "allow_upstream_proxy": true, + "dangerously_allow_non_loopback_proxy": false, + "dangerously_allow_all_unix_sockets": false, + "mode": "full", + "domains": { + "example.com": "deny", + }, + "unix_sockets": null, + "allow_local_binding": false, + "mitm": false, + } + }) + ); + } + #[test] fn parse_host_port_defaults_for_empty_string() { assert!(parse_host_port("", 1234).is_err()); @@ -536,10 +795,10 @@ mod tests { #[test] fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() { - let cfg = NetworkProxySettings { - dangerously_allow_non_loopback_proxy: true, - allow_unix_sockets: vec!["/tmp/docker.sock".to_string()], - ..Default::default() + let cfg = { + let mut settings = settings_with_unix_sockets(&["/tmp/docker.sock"]); + settings.dangerously_allow_non_loopback_proxy = true; + settings }; let http_addr = "0.0.0.0:3128".parse::().unwrap(); let socks_addr = "0.0.0.0:8081".parse::().unwrap(); @@ -569,10 +828,7 @@ mod tests { #[test] fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() { let cfg = NetworkProxyConfig { - network: NetworkProxySettings { - allow_unix_sockets: vec!["relative.sock".to_string()], - ..NetworkProxySettings::default() - }, + network: settings_with_unix_sockets(&["relative.sock"]), }; let err = match resolve_runtime(&cfg) { @@ -591,10 +847,7 @@ mod tests { #[test] fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() { let cfg = NetworkProxyConfig { - network: NetworkProxySettings { - allow_unix_sockets: vec!["/private/tmp/example.sock".to_string()], - ..NetworkProxySettings::default() - }, + network: settings_with_unix_sockets(&["/private/tmp/example.sock"]), }; assert!( diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index a3da7e2dd..1be610feb 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -1002,9 +1002,10 @@ mod tests { #[tokio::test] async fn http_connect_accept_blocks_in_limited_mode() { - let policy = NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..Default::default() + let policy = { + let mut policy = NetworkProxySettings::default(); + policy.set_allowed_domains(vec!["example.com".to_string()]); + policy }; let state = Arc::new(network_proxy_state_for_policy(policy)); state.set_network_mode(NetworkMode::Limited).await.unwrap(); @@ -1027,9 +1028,10 @@ mod tests { #[tokio::test] async fn http_connect_accept_allows_allowlisted_host_in_full_mode() { - let policy = NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..Default::default() + let policy = { + let mut policy = NetworkProxySettings::default(); + policy.set_allowed_domains(vec!["example.com".to_string()]); + policy }; let state = Arc::new(network_proxy_state_for_policy(policy)); @@ -1062,10 +1064,11 @@ mod tests { let _ = timeout(Duration::from_secs(1), stream.read(&mut buf)).await; }); - let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["127.0.0.1".to_string()], - allow_local_binding: true, - ..NetworkProxySettings::default() + let state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["127.0.0.1".to_string()]); + network.allow_local_binding = true; + network })); let listener = StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("proxy listener should bind"); @@ -1161,9 +1164,10 @@ mod tests { #[cfg(target_os = "macos")] #[tokio::test(flavor = "current_thread")] async fn http_plain_proxy_attempts_allowed_unix_socket_proxy() { - let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allow_unix_sockets: vec!["/tmp/test.sock".to_string()], - ..NetworkProxySettings::default() + let state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allow_unix_sockets(vec!["/tmp/test.sock".to_string()]); + network })); let mut req = Request::builder() @@ -1180,10 +1184,11 @@ mod tests { #[tokio::test] async fn http_connect_accept_denies_denylisted_host() { - let policy = NetworkProxySettings { - allowed_domains: vec!["**.openai.com".to_string()], - denied_domains: vec!["api.openai.com".to_string()], - ..Default::default() + let policy = { + let mut policy = NetworkProxySettings::default(); + policy.set_allowed_domains(vec!["**.openai.com".to_string()]); + policy.set_denied_domains(vec!["api.openai.com".to_string()]); + policy }; let state = Arc::new(network_proxy_state_for_policy(policy)); diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 1093a14aa..4fb313805 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -14,8 +14,13 @@ mod socks5; mod state; mod upstream; +pub use config::NetworkDomainPermission; +pub use config::NetworkDomainPermissionEntry; +pub use config::NetworkDomainPermissions; pub use config::NetworkMode; pub use config::NetworkProxyConfig; +pub use config::NetworkUnixSocketPermission; +pub use config::NetworkUnixSocketPermissions; pub use config::host_and_port_from_network_addr; pub use network_policy::NetworkDecision; pub use network_policy::NetworkDecisionSource; diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index d2f7984a7..28eb5c5f7 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -26,9 +26,10 @@ fn policy_ctx( #[tokio::test] async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() { - let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() + let app_state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network })); let ctx = policy_ctx(app_state.clone(), NetworkMode::Limited, "example.com", 443); let req = Request::builder() @@ -59,9 +60,10 @@ async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() { #[tokio::test] async fn mitm_policy_rejects_host_mismatch() { - let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() + let app_state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network })); let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "example.com", 443); let req = Request::builder() @@ -82,10 +84,11 @@ async fn mitm_policy_rejects_host_mismatch() { #[tokio::test] async fn mitm_policy_rechecks_local_private_target_after_connect() { - let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() + let app_state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network.allow_local_binding = false; + network })); let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "10.0.0.1", 443); let req = Request::builder() diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index e759e75c0..f9670c055 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -676,10 +676,11 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - denied_domains: vec!["blocked.com".to_string()], - ..NetworkProxySettings::default() + let state = network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network.set_denied_domains(vec!["blocked.com".to_string()]); + network }); let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Http, @@ -850,10 +851,11 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn evaluate_host_policy_still_denies_not_allowed_local_without_decider_override() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() + let state = network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network.allow_local_binding = false; + network }); let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Http, diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 3eecef3ee..3dc6f6bad 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -220,7 +220,7 @@ impl NetworkProxyBuilder { socks_addr, socks_enabled: current_cfg.network.enable_socks5, allow_local_binding: current_cfg.network.allow_local_binding, - allow_unix_sockets: current_cfg.network.allow_unix_sockets.clone(), + allow_unix_sockets: current_cfg.network.allow_unix_sockets(), dangerously_allow_all_unix_sockets: current_cfg .network .dangerously_allow_all_unix_sockets, diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index ec43a5479..c03c3da4a 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -1,3 +1,4 @@ +use crate::config::NetworkDomainPermission; use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::config::ValidatedUnixSocketPath; @@ -295,8 +296,8 @@ impl NetworkProxyState { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(( - guard.config.network.allowed_domains.clone(), - guard.config.network.denied_domains.clone(), + guard.config.network.allowed_domains().unwrap_or_default(), + guard.config.network.denied_domains().unwrap_or_default(), )) } @@ -340,16 +341,18 @@ impl NetworkProxyState { Ok(host) => host, Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)), }; - let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = { + let (deny_set, allow_set, allow_local_binding, allowed_domains) = { let guard = self.state.read().await; + let allowed_domains = guard.config.network.allowed_domains(); ( guard.deny_set.clone(), guard.allow_set.clone(), guard.config.network.allow_local_binding, - guard.config.network.allowed_domains.is_empty(), - guard.config.network.allowed_domains.clone(), + allowed_domains, ) }; + let allowed_domains_empty = allowed_domains.is_none(); + let allowed_domains = allowed_domains.unwrap_or_default(); let host_str = host.as_str(); @@ -481,7 +484,7 @@ impl NetworkProxyState { Err(_) => return Ok(false), }; let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); - for allowed in &guard.config.network.allow_unix_sockets { + for allowed in &guard.config.network.allow_unix_sockets() { let allowed_path = match ValidatedUnixSocketPath::parse(allowed) { Ok(ValidatedUnixSocketPath::Native(path)) => path, Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue, @@ -585,7 +588,8 @@ impl NetworkProxyState { }; let mut candidate = previous_cfg.clone(); - let (target_entries, opposite_entries) = candidate.split_domain_lists_mut(target); + let target_entries = target.entries(&candidate.network); + let opposite_entries = target.opposite_entries(&candidate.network); let target_contains = target_entries .iter() .any(|entry| normalize_host(entry) == normalized_host); @@ -596,9 +600,11 @@ impl NetworkProxyState { return Ok(()); } - target_entries.retain(|entry| normalize_host(entry) != normalized_host); - target_entries.push(normalized_host.clone()); - opposite_entries.retain(|entry| normalize_host(entry) != normalized_host); + candidate.network.upsert_domain_permission( + normalized_host.clone(), + target.permission(), + normalize_host, + ); validate_policy_against_constraints(&candidate, &constraints) .map_err(NetworkProxyConstraintError::into_anyhow) @@ -669,22 +675,25 @@ impl DomainListKind { Self::Deny => "network.denied_domains", } } -} -impl NetworkProxyConfig { - fn split_domain_lists_mut( - &mut self, - target: DomainListKind, - ) -> (&mut Vec, &mut Vec) { - match target { - DomainListKind::Allow => ( - &mut self.network.allowed_domains, - &mut self.network.denied_domains, - ), - DomainListKind::Deny => ( - &mut self.network.denied_domains, - &mut self.network.allowed_domains, - ), + fn permission(self) -> NetworkDomainPermission { + match self { + Self::Allow => NetworkDomainPermission::Allow, + Self::Deny => NetworkDomainPermission::Deny, + } + } + + fn entries(self, network: &crate::config::NetworkProxySettings) -> Vec { + match self { + Self::Allow => network.allowed_domains().unwrap_or_default(), + Self::Deny => network.denied_domains().unwrap_or_default(), + } + } + + fn opposite_entries(self, network: &crate::config::NetworkProxySettings) -> Vec { + match self { + Self::Allow => network.denied_domains().unwrap_or_default(), + Self::Deny => network.allowed_domains().unwrap_or_default(), } } } @@ -726,16 +735,16 @@ async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool { } fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) { + let previous_allowed_domains = previous.network.allowed_domains().unwrap_or_default(); + let next_allowed_domains = next.network.allowed_domains().unwrap_or_default(); log_domain_list_changes( "allowlist", - &previous.network.allowed_domains, - &next.network.allowed_domains, - ); - log_domain_list_changes( - "denylist", - &previous.network.denied_domains, - &next.network.denied_domains, + &previous_allowed_domains, + &next_allowed_domains, ); + let previous_denied_domains = previous.network.denied_domains().unwrap_or_default(); + let next_denied_domains = next.network.denied_domains().unwrap_or_default(); + log_domain_list_changes("denylist", &previous_denied_domains, &next_denied_domains); } fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) { @@ -836,13 +845,37 @@ mod tests { use crate::state::validate_policy_against_constraints; use pretty_assertions::assert_eq; + fn strings(entries: &[&str]) -> Vec { + entries.iter().map(|entry| (*entry).to_string()).collect() + } + + fn network_settings(allowed_domains: &[&str], denied_domains: &[&str]) -> NetworkProxySettings { + let mut network = NetworkProxySettings::default(); + if !allowed_domains.is_empty() { + network.set_allowed_domains(strings(allowed_domains)); + } + if !denied_domains.is_empty() { + network.set_denied_domains(strings(denied_domains)); + } + network + } + + fn network_settings_with_unix_sockets( + allowed_domains: &[&str], + denied_domains: &[&str], + unix_sockets: &[String], + ) -> NetworkProxySettings { + let mut network = network_settings(allowed_domains, denied_domains); + if !unix_sockets.is_empty() { + network.set_allow_unix_sockets(unix_sockets.to_vec()); + } + network + } + #[tokio::test] async fn host_blocked_denied_wins_over_allowed() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - denied_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = + network_proxy_state_for_policy(network_settings(&["example.com"], &["example.com"])); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), @@ -852,10 +885,7 @@ mod tests { #[tokio::test] async fn host_blocked_requires_allowlist_match() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), @@ -871,10 +901,7 @@ mod tests { #[tokio::test] async fn add_allowed_domain_removes_matching_deny_entry() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - denied_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&[], &["example.com"])); state.add_allowed_domain("ExAmPlE.CoM").await.unwrap(); @@ -889,10 +916,7 @@ mod tests { #[tokio::test] async fn add_denied_domain_removes_matching_allow_entry() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); state.add_denied_domain("EXAMPLE.COM").await.unwrap(); @@ -907,10 +931,7 @@ mod tests { #[tokio::test] async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["*"], &[])); assert_eq!( // Use a public IP literal to avoid relying on ambient DNS behavior. @@ -932,10 +953,10 @@ mod tests { #[tokio::test] async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["managed.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["managed.example.com"], &[]); + network.enabled = true; + network }, }; let constraints = NetworkProxyConstraints { @@ -964,10 +985,10 @@ mod tests { #[tokio::test] async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["managed.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["managed.example.com"], &[]); + network.enabled = true; + network }, }; let constraints = NetworkProxyConstraints { @@ -994,10 +1015,10 @@ mod tests { #[tokio::test] async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - denied_domains: vec!["managed.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&[], &["managed.example.com"]); + network.enabled = true; + network }, }; let constraints = NetworkProxyConstraints { @@ -1109,10 +1130,7 @@ mod tests { #[tokio::test] async fn host_blocked_subdomain_wildcards_exclude_apex() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*.openai.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["*.openai.com"], &[])); assert_eq!( state.host_blocked("api.openai.com", 80).await.unwrap(), @@ -1126,11 +1144,7 @@ mod tests { #[tokio::test] async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], - denied_domains: vec!["evil.example".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["*"], &["evil.example"])); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), @@ -1148,11 +1162,7 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_loopback_when_local_binding_disabled() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); assert_eq!( state.host_blocked("127.0.0.1", 80).await.unwrap(), @@ -1166,11 +1176,7 @@ mod tests { #[tokio::test] async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["localhost".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["localhost"], &[])); assert_eq!( state.host_blocked("localhost", 80).await.unwrap(), @@ -1180,11 +1186,7 @@ mod tests { #[tokio::test] async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["10.0.0.1".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["10.0.0.1"], &[])); assert_eq!( state.host_blocked("10.0.0.1", 80).await.unwrap(), @@ -1194,11 +1196,7 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); assert_eq!( state.host_blocked("fe80::1%lo0", 80).await.unwrap(), @@ -1208,11 +1206,7 @@ mod tests { #[tokio::test] async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["fe80::1%lo0".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["fe80::1%lo0"], &[])); assert_eq!( state.host_blocked("fe80::1%lo0", 80).await.unwrap(), @@ -1222,11 +1216,7 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); assert_eq!( state.host_blocked("10.0.0.1", 80).await.unwrap(), @@ -1236,11 +1226,7 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_loopback_when_allowlist_empty() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec![], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(NetworkProxySettings::default()); assert_eq!( state.host_blocked("127.0.0.1", 80).await.unwrap(), @@ -1250,11 +1236,9 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_allowlisted_hostname_when_dns_lookup_fails() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["does-not-resolve.invalid".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["does-not-resolve.invalid".to_string()]); + let state = network_proxy_state_for_policy(network); assert_eq!( state @@ -1273,10 +1257,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["example.com".to_string(), "evil.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["example.com", "evil.com"], &[]); + network.enabled = true; + network }, }; @@ -1292,10 +1276,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["example.com".to_string(), "api.openai.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["example.com", "api.openai.com"], &[]); + network.enabled = true; + network }, }; @@ -1328,10 +1312,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["api.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["api.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1346,10 +1330,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["**.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["**.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1364,10 +1348,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["api.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["api.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1383,10 +1367,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["api.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["api.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1402,10 +1386,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["api.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["api.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1422,7 +1406,6 @@ mod tests { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, - denied_domains: vec![], ..NetworkProxySettings::default() }, }; @@ -1439,10 +1422,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - denied_domains: vec!["evil.com".to_string(), "more-evil.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&[], &["evil.com", "more-evil.com"]); + network.enabled = true; + network }, }; @@ -1626,10 +1609,10 @@ mod tests { #[test] fn build_config_state_allows_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["*".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["*"], &[]); + network.enabled = true; + network }, }; @@ -1639,10 +1622,10 @@ mod tests { #[test] fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["[*]".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["[*]"], &[]); + network.enabled = true; + network }, }; @@ -1652,11 +1635,10 @@ mod tests { #[test] fn build_config_state_rejects_global_wildcard_denied_domains() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["example.com".to_string()], - denied_domains: vec!["*".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["example.com"], &["*"]); + network.enabled = true; + network }, }; @@ -1666,11 +1648,10 @@ mod tests { #[test] fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["example.com".to_string()], - denied_domains: vec!["[*]".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["example.com"], &["[*]"]); + network.enabled = true; + network }, }; @@ -1681,11 +1662,11 @@ mod tests { #[tokio::test] async fn unix_socket_allowlist_is_respected_on_macos() { let socket_path = "/tmp/example.sock".to_string(); - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_unix_sockets: vec![socket_path.clone()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings_with_unix_sockets( + &["example.com"], + &[], + std::slice::from_ref(&socket_path), + )); assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap()); assert!( @@ -1716,11 +1697,11 @@ mod tests { let real_s = real.to_str().unwrap().to_string(); let link_s = link.to_str().unwrap().to_string(); - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_unix_sockets: vec![real_s], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings_with_unix_sockets( + &["example.com"], + &[], + std::slice::from_ref(&real_s), + )); assert!(state.is_unix_socket_allowed(&link_s).await.unwrap()); } @@ -1728,10 +1709,10 @@ mod tests { #[cfg(target_os = "macos")] #[tokio::test] async fn unix_socket_allow_all_flag_bypasses_allowlist() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - dangerously_allow_all_unix_sockets: true, - ..NetworkProxySettings::default() + let state = network_proxy_state_for_policy({ + let mut network = network_settings(&["example.com"], &[]); + network.dangerously_allow_all_unix_sockets = true; + network }); assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap()); @@ -1742,11 +1723,14 @@ mod tests { #[tokio::test] async fn unix_socket_allowlist_is_rejected_on_non_macos() { let socket_path = "/tmp/example.sock".to_string(); - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_unix_sockets: vec![socket_path.clone()], - dangerously_allow_all_unix_sockets: true, - ..NetworkProxySettings::default() + let state = network_proxy_state_for_policy({ + let mut network = network_settings_with_unix_sockets( + &["example.com"], + &[], + std::slice::from_ref(&socket_path), + ); + network.dangerously_allow_all_unix_sockets = true; + network }); assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap()); diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 435e5beab..01e4966b5 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -1,5 +1,7 @@ +use crate::config::NetworkDomainPermissions; use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; +use crate::config::NetworkUnixSocketPermissions; use crate::mitm::MitmState; use crate::policy::DomainPattern; use crate::policy::compile_allowlist_globset; @@ -46,12 +48,9 @@ pub struct PartialNetworkConfig { pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, #[serde(default)] - pub allowed_domains: Option>, - #[serde(default)] - pub denied_domains: Option>, - #[serde(default)] - pub allow_unix_sockets: Option>, + pub domains: Option, #[serde(default)] + pub unix_sockets: Option, pub allow_local_binding: Option, } @@ -60,10 +59,12 @@ pub fn build_config_state( constraints: NetworkProxyConstraints, ) -> anyhow::Result { crate::config::validate_unix_socket_allowlist_paths(&config)?; - validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains) + let allowed_domains = config.network.allowed_domains().unwrap_or_default(); + let denied_domains = config.network.denied_domains().unwrap_or_default(); + validate_non_global_wildcard_domain_patterns("network.denied_domains", &denied_domains) .map_err(NetworkProxyConstraintError::into_anyhow)?; - let deny_set = compile_denylist_globset(&config.network.denied_domains)?; - let allow_set = compile_allowlist_globset(&config.network.allowed_domains)?; + let deny_set = compile_denylist_globset(&denied_domains)?; + let allow_set = compile_allowlist_globset(&allowed_domains)?; let mitm = if config.network.mitm { Some(Arc::new(MitmState::new( config.network.allow_upstream_proxy, @@ -106,7 +107,14 @@ pub fn validate_policy_against_constraints( } let enabled = config.network.enabled; - validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains)?; + let config_allowed_domains = config.network.allowed_domains().unwrap_or_default(); + let config_denied_domains = config.network.denied_domains().unwrap_or_default(); + let denied_domain_overrides: HashSet = config_denied_domains + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + let config_allow_unix_sockets = config.network.allow_unix_sockets(); + validate_non_global_wildcard_domain_patterns("network.denied_domains", &config_denied_domains)?; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { if *candidate && !max_enabled { @@ -206,20 +214,24 @@ pub fn validate_policy_against_constraints( } if let Some(allowed_domains) = &constraints.allowed_domains { + validate_non_global_wildcard_domain_patterns("network.allowed_domains", allowed_domains)?; match constraints.allowlist_expansion_enabled { Some(true) => { let required_set: HashSet = allowed_domains .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); - validate(config.network.allowed_domains.clone(), move |candidate| { + validate(config_allowed_domains, |candidate| { let candidate_set: HashSet = candidate .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); let missing: Vec = required_set .iter() - .filter(|entry| !candidate_set.contains(*entry)) + .filter(|entry| { + !candidate_set.contains(*entry) + && !denied_domain_overrides.contains(*entry) + }) .cloned() .collect(); if missing.is_empty() { @@ -238,12 +250,16 @@ pub fn validate_policy_against_constraints( .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); - validate(config.network.allowed_domains.clone(), move |candidate| { + validate(config_allowed_domains, |candidate| { let candidate_set: HashSet = candidate .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); - if candidate_set == required_set { + let expected_set: HashSet = required_set + .difference(&denied_domain_overrides) + .cloned() + .collect(); + if candidate_set == expected_set { Ok(()) } else { Err(invalid_value( @@ -259,7 +275,7 @@ pub fn validate_policy_against_constraints( .iter() .map(|entry| DomainPattern::parse_for_constraints(entry)) .collect(); - validate(config.network.allowed_domains.clone(), move |candidate| { + validate(config_allowed_domains, move |candidate| { let mut invalid = Vec::new(); for entry in candidate { let candidate_pattern = DomainPattern::parse_for_constraints(entry); @@ -285,14 +301,14 @@ pub fn validate_policy_against_constraints( } if let Some(denied_domains) = &constraints.denied_domains { - validate_denylist_domain_patterns("network.denied_domains", denied_domains)?; + validate_non_global_wildcard_domain_patterns("network.denied_domains", denied_domains)?; let required_set: HashSet = denied_domains .iter() .map(|s| s.to_ascii_lowercase()) .collect(); match constraints.denylist_expansion_enabled { Some(false) => { - validate(config.network.denied_domains.clone(), move |candidate| { + validate(config_denied_domains, move |candidate| { let candidate_set: HashSet = candidate .iter() .map(|entry| entry.to_ascii_lowercase()) @@ -309,7 +325,7 @@ pub fn validate_policy_against_constraints( })?; } Some(true) | None => { - validate(config.network.denied_domains.clone(), move |candidate| { + validate(config_denied_domains, move |candidate| { let candidate_set: HashSet = candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); let missing: Vec = required_set @@ -336,32 +352,29 @@ pub fn validate_policy_against_constraints( .iter() .map(|s| s.to_ascii_lowercase()) .collect(); - validate( - config.network.allow_unix_sockets.clone(), - move |candidate| { - let mut invalid = Vec::new(); - for entry in candidate { - if !allowed_set.contains(&entry.to_ascii_lowercase()) { - invalid.push(entry.clone()); - } + validate(config_allow_unix_sockets, move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + if !allowed_set.contains(&entry.to_ascii_lowercase()) { + invalid.push(entry.clone()); } - if invalid.is_empty() { - Ok(()) - } else { - Err(invalid_value( - "network.allow_unix_sockets", - format!("{invalid:?}"), - "subset of managed allow_unix_sockets", - )) - } - }, - )?; + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network.allow_unix_sockets", + format!("{invalid:?}"), + "subset of managed allow_unix_sockets", + )) + } + })?; } Ok(()) } -fn validate_denylist_domain_patterns( +fn validate_non_global_wildcard_domain_patterns( field_name: &'static str, patterns: &[String], ) -> Result<(), NetworkProxyConstraintError> { @@ -401,3 +414,6 @@ fn network_mode_rank(mode: NetworkMode) -> u8 { NetworkMode::Full => 1, } } + +#[cfg(test)] +mod tests {} diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 29a5cb7cd..42cfa1958 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -5,6 +5,8 @@ use codex_core::config_loader::ConfigLayerEntry; use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::NetworkConstraints; +use codex_core::config_loader::NetworkDomainPermissionToml; +use codex_core::config_loader::NetworkUnixSocketPermissionToml; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; @@ -333,10 +335,9 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + unix_sockets, allow_local_binding, } = network; @@ -362,21 +363,24 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" )); } - if let Some(allowed_domains) = allowed_domains { - parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); + if let Some(domains) = domains { + parts.push(format!( + "domains={}", + format_network_permission_entries(&domains.entries, format_network_domain_permission) + )); } if let Some(managed_allowed_domains_only) = managed_allowed_domains_only { parts.push(format!( "managed_allowed_domains_only={managed_allowed_domains_only}" )); } - if let Some(denied_domains) = denied_domains { - parts.push(format!("denied_domains=[{}]", denied_domains.join(", "))); - } - if let Some(allow_unix_sockets) = allow_unix_sockets { + if let Some(unix_sockets) = unix_sockets { parts.push(format!( - "allow_unix_sockets=[{}]", - allow_unix_sockets.join(", ") + "unix_sockets={}", + format_network_permission_entries( + &unix_sockets.entries, + format_network_unix_socket_permission, + ) )); } if let Some(allow_local_binding) = allow_local_binding { @@ -386,6 +390,33 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { join_or_empty(parts) } +fn format_network_permission_entries( + entries: &std::collections::BTreeMap, + format_value: impl Fn(T) -> &'static str, +) -> String { + let parts = entries + .iter() + .map(|(key, value)| format!("{key}={}", format_value(*value))) + .collect::>(); + format!("{{{}}}", parts.join(", ")) +} + +fn format_network_domain_permission(permission: NetworkDomainPermissionToml) -> &'static str { + match permission { + NetworkDomainPermissionToml::Allow => "allow", + NetworkDomainPermissionToml::Deny => "deny", + } +} + +fn format_network_unix_socket_permission( + permission: NetworkUnixSocketPermissionToml, +) -> &'static str { + match permission { + NetworkUnixSocketPermissionToml::Allow => "allow", + NetworkUnixSocketPermissionToml::None => "none", + } +} + #[cfg(test)] mod tests { use super::render_debug_config_lines; @@ -400,6 +431,8 @@ mod tests { use codex_core::config_loader::McpServerIdentity; use codex_core::config_loader::McpServerRequirement; use codex_core::config_loader::NetworkConstraints; + use codex_core::config_loader::NetworkDomainPermissionToml; + use codex_core::config_loader::NetworkDomainPermissionsToml; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; @@ -516,7 +549,12 @@ mod tests { network: Some(Sourced::new( NetworkConstraints { enabled: Some(true), - allowed_domains: Some(vec!["example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }, RequirementSource::CloudRequirements, @@ -580,7 +618,7 @@ mod tests { assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); assert!(rendered.contains( - "experimental_network: enabled=true, allowed_domains=[example.com] (source: cloud requirements)" + "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" )); assert!(!rendered.contains(" - rules:")); } diff --git a/codex-rs/tui_app_server/src/debug_config.rs b/codex-rs/tui_app_server/src/debug_config.rs index 29a5cb7cd..76f4a91a5 100644 --- a/codex-rs/tui_app_server/src/debug_config.rs +++ b/codex-rs/tui_app_server/src/debug_config.rs @@ -5,6 +5,8 @@ use codex_core::config_loader::ConfigLayerEntry; use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::NetworkConstraints; +use codex_core::config_loader::NetworkDomainPermissionToml; +use codex_core::config_loader::NetworkUnixSocketPermissionToml; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; @@ -333,10 +335,9 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + unix_sockets, allow_local_binding, } = network; @@ -362,21 +363,24 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" )); } - if let Some(allowed_domains) = allowed_domains { - parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); + if let Some(domains) = domains { + parts.push(format!( + "domains={}", + format_network_permission_entries(&domains.entries, format_network_domain_permission) + )); } if let Some(managed_allowed_domains_only) = managed_allowed_domains_only { parts.push(format!( "managed_allowed_domains_only={managed_allowed_domains_only}" )); } - if let Some(denied_domains) = denied_domains { - parts.push(format!("denied_domains=[{}]", denied_domains.join(", "))); - } - if let Some(allow_unix_sockets) = allow_unix_sockets { + if let Some(unix_sockets) = unix_sockets { parts.push(format!( - "allow_unix_sockets=[{}]", - allow_unix_sockets.join(", ") + "unix_sockets={}", + format_network_permission_entries( + &unix_sockets.entries, + format_network_unix_socket_permission, + ) )); } if let Some(allow_local_binding) = allow_local_binding { @@ -386,6 +390,33 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { join_or_empty(parts) } +fn format_network_permission_entries( + entries: &std::collections::BTreeMap, + format_value: impl Fn(T) -> &'static str, +) -> String { + let parts = entries + .iter() + .map(|(key, value)| format!("{key}={}", format_value(*value))) + .collect::>(); + format!("{{{}}}", parts.join(", ")) +} + +fn format_network_domain_permission(permission: NetworkDomainPermissionToml) -> &'static str { + match permission { + NetworkDomainPermissionToml::Allow => "allow", + NetworkDomainPermissionToml::Deny => "deny", + } +} + +fn format_network_unix_socket_permission( + permission: NetworkUnixSocketPermissionToml, +) -> &'static str { + match permission { + NetworkUnixSocketPermissionToml::Allow => "allow", + NetworkUnixSocketPermissionToml::None => "none", + } +} + #[cfg(test)] mod tests { use super::render_debug_config_lines; @@ -400,6 +431,10 @@ mod tests { use codex_core::config_loader::McpServerIdentity; use codex_core::config_loader::McpServerRequirement; use codex_core::config_loader::NetworkConstraints; + use codex_core::config_loader::NetworkDomainPermissionToml; + use codex_core::config_loader::NetworkDomainPermissionsToml; + use codex_core::config_loader::NetworkUnixSocketPermissionToml; + use codex_core::config_loader::NetworkUnixSocketPermissionsToml; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; @@ -516,7 +551,12 @@ mod tests { network: Some(Sourced::new( NetworkConstraints { enabled: Some(true), - allowed_domains: Some(vec!["example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }, RequirementSource::CloudRequirements, @@ -580,10 +620,45 @@ mod tests { assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); assert!(rendered.contains( - "experimental_network: enabled=true, allowed_domains=[example.com] (source: cloud requirements)" + "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" )); assert!(!rendered.contains(" - rules:")); } + + #[test] + fn debug_config_output_formats_unix_socket_permissions() { + let requirements = ConfigRequirements { + network: Some(Sourced::new( + NetworkConstraints { + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/codex.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/blocked.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }), + ..Default::default() + }, + RequirementSource::CloudRequirements, + )), + ..ConfigRequirements::default() + }; + + let stack = + ConfigLayerStack::new(Vec::new(), requirements, ConfigRequirementsToml::default()) + .expect("config layer stack"); + + let rendered = render_to_text(&render_debug_config_lines(&stack)); + assert!(rendered.contains( + "experimental_network: unix_sockets={/tmp/blocked.sock=none, /tmp/codex.sock=allow} (source: cloud requirements)" + )); + } + #[test] fn debug_config_output_lists_session_flag_key_value_pairs() { let session_flags = toml::from_str::(