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

@@ -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));