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
Unverified
parent 21a03f1671
commit dd30c8eedd
37 changed files with 2413 additions and 492 deletions
@@ -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",
@@ -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",
@@ -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"
@@ -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";
@@ -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<string> | null, deniedDomains: Array<string> | null, allowUnixSockets: Array<string> | 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<string> | null,
/**
* Legacy compatibility view derived from `domains`.
*/
deniedDomains: Array<string> | null,
/**
* Canonical unix socket permission map for `experimental_network`.
*/
unixSockets: { [key in string]?: NetworkUnixSocketPermission } | null,
/**
* Legacy compatibility view derived from `unix_sockets`.
*/
allowUnixSockets: Array<string> | null, allowLocalBinding: boolean | null, };
@@ -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";
@@ -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";
@@ -866,12 +866,38 @@ pub struct NetworkRequirements {
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
/// Canonical network permission map for `experimental_network`.
pub domains: Option<BTreeMap<String, NetworkDomainPermission>>,
/// When true, only managed allowlist entries are respected while managed
/// network enforcement is active.
pub managed_allowed_domains_only: Option<bool>,
/// Legacy compatibility view derived from `domains`.
pub allowed_domains: Option<Vec<String>>,
/// Legacy compatibility view derived from `domains`.
pub denied_domains: Option<Vec<String>>,
/// Canonical unix socket permission map for `experimental_network`.
pub unix_sockets: Option<BTreeMap<String, NetworkUnixSocketPermission>>,
/// Legacy compatibility view derived from `unix_sockets`.
pub allow_unix_sockets: Option<Vec<String>>,
pub allow_local_binding: Option<bool>,
}
#[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 {