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

@@ -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<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[serde(default)]
pub allowed_domains: Option<Vec<String>>,
#[serde(default)]
pub denied_domains: Option<Vec<String>>,
#[serde(default)]
pub allow_unix_sockets: Option<Vec<String>>,
pub domains: Option<NetworkDomainPermissions>,
#[serde(default)]
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
pub allow_local_binding: Option<bool>,
}
@@ -60,10 +59,12 @@ pub fn build_config_state(
constraints: NetworkProxyConstraints,
) -> anyhow::Result<ConfigState> {
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<String> = 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<String> = 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<String> = candidate
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
let missing: Vec<String> = 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<String> = candidate
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
if candidate_set == required_set {
let expected_set: HashSet<String> = 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<String> = 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<String> = 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<String> =
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
let missing: Vec<String> = 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 {}