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.<profile>.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"]
```
This commit is contained in:
Celia Chen
2026-03-26 23:17:59 -07:00
committed by GitHub
parent 21a03f1671
commit dd30c8eedd
37 changed files with 2413 additions and 492 deletions

View File

@@ -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<T: Copy>(
entries: &std::collections::BTreeMap<String, T>,
format_value: impl Fn(T) -> &'static str,
) -> String {
let parts = entries
.iter()
.map(|(key, value)| format!("{key}={}", format_value(*value)))
.collect::<Vec<_>>();
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:"));
}