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,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<String, NetworkDomainPermissionToml>,
}
impl NetworkDomainPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn allowed_domains(&self) -> Option<Vec<String>> {
let allowed_domains: Vec<String> = 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<Vec<String>> {
let denied_domains: Vec<String> = 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<String, NetworkUnixSocketPermissionToml>,
}
impl NetworkUnixSocketPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn allow_unix_sockets(&self) -> Vec<String> {
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<bool>,
pub http_port: Option<u16>,
@@ -140,17 +227,121 @@ pub struct NetworkRequirementsToml {
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub domains: Option<NetworkDomainPermissionsToml>,
/// 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<bool>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
}
#[derive(Deserialize)]
struct RawNetworkRequirementsToml {
enabled: Option<bool>,
http_port: Option<u16>,
socks_port: Option<u16>,
allow_upstream_proxy: Option<bool>,
dangerously_allow_non_loopback_proxy: Option<bool>,
dangerously_allow_all_unix_sockets: Option<bool>,
domains: Option<NetworkDomainPermissionsToml>,
#[serde(default)]
allowed_domains: Option<Vec<String>>,
/// When true, only managed `allowed_domains` are respected while managed
/// network enforcement is active. User allowlist entries are ignored.
managed_allowed_domains_only: Option<bool>,
#[serde(default)]
denied_domains: Option<Vec<String>>,
unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
#[serde(default)]
allow_unix_sockets: Option<Vec<String>>,
allow_local_binding: Option<bool>,
}
impl<'de> Deserialize<'de> for NetworkRequirementsToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<Vec<String>>,
denied_domains: Option<Vec<String>>,
) -> Option<NetworkDomainPermissionsToml> {
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<Vec<String>>,
) -> Option<NetworkUnixSocketPermissionsToml> {
let entries = allow_unix_sockets
.unwrap_or_default()
.into_iter()
.map(|path| (path, NetworkUnixSocketPermissionToml::Allow))
.collect::<BTreeMap<_, _>>();
(!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<bool>,
pub http_port: Option<u16>,
@@ -158,15 +349,24 @@ pub struct NetworkConstraints {
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub domains: Option<NetworkDomainPermissionsToml>,
/// 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<bool>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
}
impl<'de> Deserialize<'de> for NetworkConstraints {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let requirements = NetworkRequirementsToml::deserialize(deserializer)?;
Ok(requirements.into())
}
}
impl From<NetworkRequirementsToml> for NetworkConstraints {
fn from(value: NetworkRequirementsToml) -> Self {
let NetworkRequirementsToml {
@@ -176,10 +376,9 @@ impl From<NetworkRequirementsToml> 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<NetworkRequirementsToml> 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::<ConfigRequirementsToml>(
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::<ConfigRequirementsToml>(
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#"

View File

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