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
+10 -2
View File
@@ -1029,8 +1029,16 @@ impl TurnContext {
.network
.as_ref()?;
Some(TurnContextNetworkItem {
allowed_domains: network.allowed_domains.clone().unwrap_or_default(),
denied_domains: network.denied_domains.clone().unwrap_or_default(),
allowed_domains: network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
denied_domains: network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains)
.unwrap_or_default(),
})
}
}
+24 -7
View File
@@ -5,6 +5,8 @@ use crate::config::test_config;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::NetworkConstraints;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use crate::exec::ExecCapturePolicy;
@@ -471,8 +473,8 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho
let current_cfg = started_proxy.proxy().current_cfg().await?;
assert_eq!(
current_cfg.network.allowed_domains,
vec!["example.com".to_string()]
current_cfg.network.allowed_domains(),
Some(vec!["example.com".to_string()])
);
Ok(())
}
@@ -483,7 +485,12 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules()
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
Some(NetworkConstraints {
allowed_domains: Some(vec!["managed.example.com".to_string()]),
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([(
"managed.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
managed_allowed_domains_only: Some(true),
..Default::default()
}),
@@ -510,8 +517,8 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules()
let current_cfg = started_proxy.proxy().current_cfg().await?;
assert_eq!(
current_cfg.network.allowed_domains,
vec!["managed.example.com".to_string()]
current_cfg.network.allowed_domains(),
Some(vec!["managed.example.com".to_string()])
);
Ok(())
}
@@ -3687,8 +3694,18 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
let mut requirements = config.config_layer_stack.requirements().clone();
requirements.network = Some(Sourced::new(
NetworkConstraints {
allowed_domains: Some(vec!["api.example.com".to_string()]),
denied_domains: Some(vec!["blocked.example.com".to_string()]),
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([
(
"api.example.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
(
"blocked.example.com".to_string(),
NetworkDomainPermissionToml::Deny,
),
]),
}),
..Default::default()
},
RequirementSource::CloudRequirements,
+16 -5
View File
@@ -306,7 +306,9 @@ enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
allow_upstream_proxy = false
allowed_domains = ["openai.com"]
[permissions.workspace.network.domains]
"openai.com" = "allow"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles");
@@ -343,9 +345,13 @@ allowed_domains = ["openai.com"]
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
mode: None,
allowed_domains: Some(vec!["openai.com".to_string()]),
denied_domains: None,
allow_unix_sockets: None,
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
unix_sockets: None,
allow_local_binding: None,
}),
},
@@ -421,7 +427,12 @@ fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> st
)]),
}),
network: Some(NetworkToml {
allowed_domains: Some(vec!["openai.com".to_string()]),
domains: Some(NetworkDomainPermissionsToml {
entries: BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
..Default::default()
}),
},
+5
View File
@@ -123,9 +123,14 @@ pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permissions::FilesystemPermissionToml;
pub use permissions::FilesystemPermissionsToml;
pub use permissions::NetworkDomainPermissionToml;
pub use permissions::NetworkDomainPermissionsToml;
pub use permissions::NetworkToml;
pub use permissions::NetworkUnixSocketPermissionToml;
pub use permissions::NetworkUnixSocketPermissionsToml;
pub use permissions::PermissionProfileToml;
pub use permissions::PermissionsToml;
pub(crate) use permissions::overlay_network_domain_permissions;
pub(crate) use permissions::resolve_permission_profile;
pub use service::ConfigService;
pub use service::ConfigServiceError;
+56 -38
View File
@@ -226,33 +226,63 @@ impl NetworkProxySpec {
Some(dangerously_allow_all_unix_sockets);
}
let managed_allowed_domains = if hard_deny_allowlist_misses {
Some(requirements.allowed_domains.clone().unwrap_or_default())
Some(
requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
)
} else {
requirements.allowed_domains.clone()
requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
};
if let Some(allowed_domains) = managed_allowed_domains {
if let Some(managed_allowed_domains) = managed_allowed_domains {
// Managed requirements seed the baseline allowlist. User additions
// can extend that baseline unless managed-only mode pins the
// effective allowlist to the managed set.
config.network.allowed_domains = if allowlist_expansion_enabled {
Self::merge_domain_lists(allowed_domains.clone(), &config.network.allowed_domains)
let effective_allowed_domains = if allowlist_expansion_enabled {
Self::merge_domain_lists(
managed_allowed_domains.clone(),
config.network.allowed_domains().as_deref().unwrap_or(&[]),
)
} else {
allowed_domains.clone()
managed_allowed_domains.clone()
};
constraints.allowed_domains = Some(allowed_domains);
config
.network
.set_allowed_domains(effective_allowed_domains);
constraints.allowed_domains = Some(managed_allowed_domains);
constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled);
}
if let Some(denied_domains) = requirements.denied_domains.clone() {
config.network.denied_domains = if denylist_expansion_enabled {
Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains)
let managed_denied_domains = requirements
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains);
if let Some(managed_denied_domains) = managed_denied_domains {
let effective_denied_domains = if denylist_expansion_enabled {
Self::merge_domain_lists(
managed_denied_domains.clone(),
config.network.denied_domains().as_deref().unwrap_or(&[]),
)
} else {
denied_domains.clone()
managed_denied_domains.clone()
};
constraints.denied_domains = Some(denied_domains);
config.network.set_denied_domains(effective_denied_domains);
constraints.denied_domains = Some(managed_denied_domains);
constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled);
}
if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() {
config.network.allow_unix_sockets = allow_unix_sockets.clone();
if requirements.unix_sockets.is_some() {
let allow_unix_sockets = requirements
.unix_sockets
.as_ref()
.map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
.unwrap_or_default();
config
.network
.set_allow_unix_sockets(allow_unix_sockets.clone());
constraints.allow_unix_sockets = Some(allow_unix_sockets);
}
if let Some(allow_local_binding) = requirements.allow_local_binding {
@@ -299,37 +329,25 @@ impl NetworkProxySpec {
fn apply_exec_policy_network_rules(config: &mut NetworkProxyConfig, exec_policy: &Policy) {
let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains();
upsert_network_domains(
&mut config.network.allowed_domains,
&mut config.network.denied_domains,
allowed_domains,
);
upsert_network_domains(
&mut config.network.denied_domains,
&mut config.network.allowed_domains,
denied_domains,
);
upsert_network_domains(config, allowed_domains, /*allow*/ true);
upsert_network_domains(config, denied_domains, /*allow*/ false);
}
fn upsert_network_domains(
target: &mut Vec<String>,
opposite: &mut Vec<String>,
hosts: Vec<String>,
) {
fn upsert_network_domains(config: &mut NetworkProxyConfig, hosts: Vec<String>, allow: bool) {
let mut incoming = HashSet::new();
let mut deduped_hosts = Vec::new();
for host in hosts {
if incoming.insert(host.clone()) {
deduped_hosts.push(host);
config.network.upsert_domain_permission(
host,
if allow {
codex_network_proxy::NetworkDomainPermission::Allow
} else {
codex_network_proxy::NetworkDomainPermission::Deny
},
normalize_host,
);
}
}
if incoming.is_empty() {
return;
}
opposite.retain(|entry| !incoming.contains(&normalize_host(entry)));
target.retain(|entry| !incoming.contains(&normalize_host(entry)));
target.extend(deduped_hosts);
}
#[cfg(test)]
@@ -1,6 +1,20 @@
use super::*;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use codex_network_proxy::NetworkDomainPermission;
use pretty_assertions::assert_eq;
fn domain_permissions(
entries: impl IntoIterator<Item = (&'static str, NetworkDomainPermissionToml)>,
) -> NetworkDomainPermissionsToml {
NetworkDomainPermissionsToml {
entries: entries
.into_iter()
.map(|(pattern, permission)| (pattern.to_string(), permission))
.collect(),
}
}
#[test]
fn build_state_with_audit_metadata_threads_metadata_to_state() {
let spec = NetworkProxySpec {
@@ -24,9 +38,14 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() {
#[test]
fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
domains: Some(domain_permissions([(
"*.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
@@ -38,8 +57,11 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
.expect("config should stay within the managed allowlist");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string(), "api.example.com".to_string()]
spec.config.network.allowed_domains(),
Some(vec![
"*.example.com".to_string(),
"api.example.com".to_string()
])
);
assert_eq!(
spec.constraints.allowed_domains,
@@ -48,14 +70,92 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true));
}
#[test]
fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_denied_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"api.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed allowlist should not erase a user deny");
assert_eq!(spec.config.network.allowed_domains(), None);
assert_eq!(
spec.config.network.denied_domains(),
Some(vec!["api.example.com".to_string()])
);
assert_eq!(
spec.constraints.allowed_domains,
Some(vec!["api.example.com".to_string()])
);
}
#[test]
fn requirements_allowlist_expansion_keeps_user_entries_mutable() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"*.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed baseline should still allow user edits");
let mut candidate = spec.config.clone();
candidate.network.upsert_domain_permission(
"api.example.com".to_string(),
NetworkDomainPermission::Deny,
normalize_host,
);
assert_eq!(
candidate.network.allowed_domains(),
Some(vec!["*.example.com".to_string()])
);
assert_eq!(
candidate.network.denied_domains(),
Some(vec!["api.example.com".to_string()])
);
validate_policy_against_constraints(&candidate, &spec.constraints)
.expect("user allowlist entries should not become managed constraints");
}
#[test]
fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["evil.com".to_string()];
config.network.denied_domains = vec!["more-blocked.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["evil.com".to_string()]);
config
.network
.set_denied_domains(vec!["more-blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
denied_domains: Some(vec!["blocked.example.com".to_string()]),
domains: Some(domain_permissions([
("*.example.com", NetworkDomainPermissionToml::Allow),
("blocked.example.com", NetworkDomainPermissionToml::Deny),
])),
..Default::default()
};
@@ -67,12 +167,12 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
.expect("yolo mode should pin the effective policy to the managed baseline");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string()]
spec.config.network.allowed_domains(),
Some(vec!["*.example.com".to_string()])
);
assert_eq!(
spec.config.network.denied_domains,
vec!["blocked.example.com".to_string()]
spec.config.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false));
@@ -81,9 +181,14 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() {
#[test]
fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["*.example.com".to_string()]),
domains: Some(domain_permissions([(
"*.example.com",
NetworkDomainPermissionToml::Allow,
)])),
managed_allowed_domains_only: Some(true),
..Default::default()
};
@@ -96,8 +201,8 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
.expect("managed baseline should still load");
assert_eq!(
spec.config.network.allowed_domains,
vec!["*.example.com".to_string()]
spec.config.network.allowed_domains(),
Some(vec!["*.example.com".to_string()])
);
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
}
@@ -105,9 +210,14 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() {
#[test]
fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
allowed_domains: Some(vec!["managed.example.com".to_string()]),
domains: Some(domain_permissions([(
"managed.example.com",
NetworkDomainPermissionToml::Allow,
)])),
managed_allowed_domains_only: Some(true),
..Default::default()
};
@@ -120,8 +230,8 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses()
.expect("managed-only allowlist should still load");
assert_eq!(
spec.config.network.allowed_domains,
vec!["managed.example.com".to_string()]
spec.config.network.allowed_domains(),
Some(vec!["managed.example.com".to_string()])
);
assert_eq!(
spec.constraints.allowed_domains,
@@ -134,7 +244,9 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses()
#[test]
fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
managed_allowed_domains_only: Some(true),
..Default::default()
@@ -147,7 +259,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain
)
.expect("managed-only mode should treat missing managed allowlist as empty");
assert!(spec.config.network.allowed_domains.is_empty());
assert_eq!(spec.config.network.allowed_domains(), None);
assert_eq!(spec.constraints.allowed_domains, Some(Vec::new()));
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert!(spec.hard_deny_allowlist_misses);
@@ -156,7 +268,9 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain
#[test]
fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["api.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
managed_allowed_domains_only: Some(true),
..Default::default()
@@ -169,18 +283,89 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m
)
.expect("managed-only mode should treat missing managed allowlist as empty");
assert!(spec.config.network.allowed_domains.is_empty());
assert_eq!(spec.config.network.allowed_domains(), None);
assert_eq!(spec.constraints.allowed_domains, Some(Vec::new()));
assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false));
assert!(spec.hard_deny_allowlist_misses);
}
#[test]
fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_allowed_domains(vec!["api.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"managed-blocked.example.com",
NetworkDomainPermissionToml::Deny,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
)
.expect("deny-only requirements should not constrain the allowlist");
assert_eq!(
spec.config.network.allowed_domains(),
Some(vec!["api.example.com".to_string()])
);
assert_eq!(spec.constraints.allowed_domains, None);
assert_eq!(spec.constraints.allowlist_expansion_enabled, None);
assert_eq!(
spec.config.network.denied_domains(),
Some(vec!["managed-blocked.example.com".to_string()])
);
}
#[test]
fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"managed.example.com",
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::DangerFullAccess,
)
.expect("allow-only requirements should not constrain the denylist");
assert_eq!(
spec.config.network.allowed_domains(),
Some(vec!["managed.example.com".to_string()])
);
assert_eq!(
spec.config.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
assert_eq!(spec.constraints.denied_domains, None);
assert_eq!(spec.constraints.denylist_expansion_enabled, None);
}
#[test]
fn requirements_denied_domains_are_a_baseline_for_default_mode() {
let mut config = NetworkProxyConfig::default();
config.network.denied_domains = vec!["blocked.example.com".to_string()];
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
denied_domains: Some(vec!["managed-blocked.example.com".to_string()]),
domains: Some(domain_permissions([(
"managed-blocked.example.com",
NetworkDomainPermissionToml::Deny,
)])),
..Default::default()
};
@@ -192,11 +377,55 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() {
.expect("default mode should merge managed and user deny entries");
assert_eq!(
spec.config.network.denied_domains,
vec![
spec.config.network.denied_domains(),
Some(vec![
"managed-blocked.example.com".to_string(),
"blocked.example.com".to_string()
]
])
);
assert_eq!(
spec.constraints.denied_domains,
Some(vec!["managed-blocked.example.com".to_string()])
);
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true));
}
#[test]
fn requirements_denylist_expansion_keeps_user_entries_mutable() {
let mut config = NetworkProxyConfig::default();
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let requirements = NetworkConstraints {
domains: Some(domain_permissions([(
"managed-blocked.example.com",
NetworkDomainPermissionToml::Deny,
)])),
..Default::default()
};
let spec = NetworkProxySpec::from_config_and_constraints(
config,
Some(requirements),
&SandboxPolicy::new_workspace_write_policy(),
)
.expect("managed baseline should still allow user edits");
let mut candidate = spec.config.clone();
candidate.network.upsert_domain_permission(
"blocked.example.com".to_string(),
NetworkDomainPermission::Allow,
normalize_host,
);
assert_eq!(
candidate.network.allowed_domains(),
Some(vec!["blocked.example.com".to_string()])
);
assert_eq!(
candidate.network.denied_domains(),
Some(vec!["managed-blocked.example.com".to_string()])
);
validate_policy_against_constraints(&candidate, &spec.constraints)
.expect("user denylist entries should not become managed constraints");
}
+127 -10
View File
@@ -5,8 +5,11 @@ use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -56,6 +59,98 @@ pub enum FilesystemPermissionToml {
Scoped(BTreeMap<String, FileSystemAccessMode>),
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
pub struct NetworkDomainPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, NetworkDomainPermissionToml>,
}
impl NetworkDomainPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[cfg(test)]
pub(crate) 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)
}
#[cfg(test)]
pub(crate) 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, JsonSchema,
)]
#[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, JsonSchema)]
pub struct NetworkUnixSocketPermissionsToml {
#[serde(flatten)]
pub entries: BTreeMap<String, NetworkUnixSocketPermissionToml>,
}
impl NetworkUnixSocketPermissionsToml {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(crate) 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, JsonSchema,
)]
#[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, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkToml {
@@ -69,9 +164,8 @@ pub struct NetworkToml {
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[schemars(with = "Option<NetworkModeSchema>")]
pub mode: Option<NetworkMode>,
pub allowed_domains: Option<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
}
@@ -114,14 +208,22 @@ impl NetworkToml {
if let Some(mode) = self.mode {
config.network.mode = mode;
}
if let Some(allowed_domains) = self.allowed_domains.as_ref() {
config.network.allowed_domains = allowed_domains.clone();
if let Some(domains) = self.domains.as_ref() {
overlay_network_domain_permissions(config, domains);
}
if let Some(denied_domains) = self.denied_domains.as_ref() {
config.network.denied_domains = denied_domains.clone();
}
if let Some(allow_unix_sockets) = self.allow_unix_sockets.as_ref() {
config.network.allow_unix_sockets = allow_unix_sockets.clone();
if let Some(unix_sockets) = self.unix_sockets.as_ref() {
let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default();
for (path, permission) in &unix_sockets.entries {
let permission = match permission {
NetworkUnixSocketPermissionToml::Allow => {
ProxyNetworkUnixSocketPermission::Allow
}
NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None,
};
proxy_unix_sockets.entries.insert(path.clone(), permission);
}
config.network.unix_sockets =
(!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets);
}
if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = allow_local_binding;
@@ -135,6 +237,21 @@ impl NetworkToml {
}
}
pub(crate) fn overlay_network_domain_permissions(
config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml,
) {
for (pattern, permission) in &domains.entries {
let permission = match permission {
NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow,
NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny,
};
config
.network
.upsert_domain_permission(pattern.clone(), permission, normalize_host);
}
}
pub(crate) fn network_proxy_config_from_profile_network(
network: Option<&NetworkToml>,
) -> NetworkProxyConfig {
@@ -76,3 +76,132 @@ fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()>
Ok(())
}
#[test]
fn network_toml_ignores_legacy_network_list_keys() {
let parsed = toml::from_str::<NetworkToml>(
r#"
allowed_domains = ["openai.com"]
"#,
)
.expect("legacy network list keys should be ignored");
assert_eq!(parsed, NetworkToml::default());
}
#[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 network_toml_overlays_unix_socket_permissions_by_path() {
let mut config = NetworkProxyConfig::default();
NetworkToml {
unix_sockets: Some(NetworkUnixSocketPermissionsToml {
entries: BTreeMap::from([
(
"/tmp/base.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
),
(
"/tmp/override.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
),
]),
}),
..Default::default()
}
.apply_to_network_proxy_config(&mut config);
NetworkToml {
unix_sockets: Some(NetworkUnixSocketPermissionsToml {
entries: BTreeMap::from([
(
"/tmp/extra.sock".to_string(),
NetworkUnixSocketPermissionToml::Allow,
),
(
"/tmp/override.sock".to_string(),
NetworkUnixSocketPermissionToml::None,
),
]),
}),
..Default::default()
}
.apply_to_network_proxy_config(&mut config);
assert_eq!(
config.network.unix_sockets,
Some(codex_network_proxy::NetworkUnixSocketPermissions {
entries: BTreeMap::from([
(
"/tmp/base.sock".to_string(),
ProxyNetworkUnixSocketPermission::Allow,
),
(
"/tmp/extra.sock".to_string(),
ProxyNetworkUnixSocketPermission::Allow,
),
(
"/tmp/override.sock".to_string(),
ProxyNetworkUnixSocketPermission::None,
),
]),
})
);
}
+4
View File
@@ -42,7 +42,11 @@ pub use codex_config::LoaderOverrides;
pub use codex_config::McpServerIdentity;
pub use codex_config::McpServerRequirement;
pub use codex_config::NetworkConstraints;
pub use codex_config::NetworkDomainPermissionToml;
pub use codex_config::NetworkDomainPermissionsToml;
pub use codex_config::NetworkRequirementsToml;
pub use codex_config::NetworkUnixSocketPermissionToml;
pub use codex_config::NetworkUnixSocketPermissionsToml;
pub use codex_config::RequirementSource;
pub use codex_config::ResidencyRequirement;
pub use codex_config::SandboxModeRequirement;
+10 -2
View File
@@ -130,8 +130,16 @@ impl EnvironmentContext {
.as_ref()?;
Some(NetworkContext {
allowed_domains: network.allowed_domains.clone().unwrap_or_default(),
denied_domains: network.denied_domains.clone().unwrap_or_default(),
allowed_domains: network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains)
.unwrap_or_default(),
denied_domains: network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains)
.unwrap_or_default(),
})
}
+14 -3
View File
@@ -11,6 +11,8 @@ use crate::config::test_config;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::FeatureRequirementsToml;
use crate::config_loader::NetworkConstraints;
use crate::config_loader::NetworkDomainPermissionToml;
use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use crate::protocol::SandboxPolicy;
@@ -971,7 +973,12 @@ fn guardian_review_session_config_preserves_parent_network_proxy() {
NetworkProxyConfig::default(),
Some(NetworkConstraints {
enabled: Some(true),
allowed_domains: Some(vec!["github.com".to_string()]),
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([(
"github.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
..Default::default()
}),
parent_config.permissions.sandbox_policy.get(),
@@ -1027,7 +1034,9 @@ fn guardian_review_session_config_uses_live_network_proxy_state() {
let mut parent_config = test_config();
let mut parent_network = NetworkProxyConfig::default();
parent_network.network.enabled = true;
parent_network.network.allowed_domains = vec!["parent.example".to_string()];
parent_network
.network
.set_allowed_domains(vec!["parent.example".to_string()]);
parent_config.permissions.network = Some(
NetworkProxySpec::from_config_and_constraints(
parent_network,
@@ -1039,7 +1048,9 @@ fn guardian_review_session_config_uses_live_network_proxy_state() {
let mut live_network = NetworkProxyConfig::default();
live_network.network.enabled = true;
live_network.network.allowed_domains = vec!["github.com".to_string()];
live_network
.network
.set_allowed_domains(vec!["github.com".to_string()]);
let guardian_config = build_guardian_review_session_config_for_test(
&parent_config,
+26 -14
View File
@@ -1,6 +1,7 @@
use crate::config::NetworkToml;
use crate::config::PermissionsToml;
use crate::config::find_codex_home;
use crate::config::overlay_network_domain_permissions;
use crate::config::resolve_permission_profile;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
@@ -150,13 +151,20 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx
if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets {
constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets);
}
if let Some(allowed_domains) = network.allowed_domains {
constraints.allowed_domains = Some(allowed_domains);
if let Some(domains) = network.domains.as_ref() {
let mut config = NetworkProxyConfig::default();
if let Some(allowed_domains) = constraints.allowed_domains.take() {
config.network.set_allowed_domains(allowed_domains);
}
if let Some(denied_domains) = constraints.denied_domains.take() {
config.network.set_denied_domains(denied_domains);
}
overlay_network_domain_permissions(&mut config, domains);
constraints.allowed_domains = config.network.allowed_domains();
constraints.denied_domains = config.network.denied_domains();
}
if let Some(denied_domains) = network.denied_domains {
constraints.denied_domains = Some(denied_domains);
}
if let Some(allow_unix_sockets) = network.allow_unix_sockets {
if let Some(unix_sockets) = network.unix_sockets.as_ref() {
let allow_unix_sockets = unix_sockets.allow_unix_sockets();
constraints.allow_unix_sockets = Some(allow_unix_sockets);
}
if let Some(allow_local_binding) = network.allow_local_binding {
@@ -220,24 +228,28 @@ fn apply_exec_policy_network_rules(
let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains();
for host in allowed_domains {
upsert_network_domain(
&mut config.network.allowed_domains,
&mut config.network.denied_domains,
config,
host,
codex_network_proxy::NetworkDomainPermission::Allow,
);
}
for host in denied_domains {
upsert_network_domain(
&mut config.network.denied_domains,
&mut config.network.allowed_domains,
config,
host,
codex_network_proxy::NetworkDomainPermission::Deny,
);
}
}
fn upsert_network_domain(target: &mut Vec<String>, opposite: &mut Vec<String>, host: String) {
opposite.retain(|entry| normalize_host(entry) != host);
target.retain(|entry| normalize_host(entry) != host);
target.push(host);
fn upsert_network_domain(
config: &mut NetworkProxyConfig,
host: String,
permission: codex_network_proxy::NetworkDomainPermission,
) {
config
.network
.upsert_domain_permission(host, permission, normalize_host);
}
fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool {
+157 -11
View File
@@ -6,13 +6,16 @@ use codex_execpolicy::Policy;
use pretty_assertions::assert_eq;
#[test]
fn higher_precedence_profile_network_beats_lower_profile_network() {
fn higher_precedence_profile_network_overlays_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["lower.example.com"]
[permissions.workspace.network.domains]
"lower.example.com" = "allow"
"blocked.example.com" = "deny"
"#,
)
.expect("lower layer should parse");
@@ -21,7 +24,9 @@ allowed_domains = ["lower.example.com"]
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["higher.example.com"]
[permissions.workspace.network.domains]
"higher.example.com" = "allow"
"#,
)
.expect("higher layer should parse");
@@ -38,14 +43,76 @@ allowed_domains = ["higher.example.com"]
)
.expect("higher layer should apply");
assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]);
assert_eq!(
config.network.allowed_domains(),
Some(vec![
"lower.example.com".to_string(),
"higher.example.com".to_string()
])
);
assert_eq!(
config.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
}
#[test]
fn higher_precedence_profile_network_overrides_matching_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"shared.example.com" = "deny"
"other.example.com" = "allow"
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"shared.example.com" = "allow"
"#,
)
.expect("higher layer should parse");
let mut config = NetworkProxyConfig::default();
apply_network_tables(
&mut config,
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should apply");
apply_network_tables(
&mut config,
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
)
.expect("higher layer should apply");
assert_eq!(
config.network.allowed_domains(),
Some(vec![
"other.example.com".to_string(),
"shared.example.com".to_string()
])
);
assert_eq!(config.network.denied_domains(), None);
}
#[test]
fn execpolicy_network_rules_overlay_network_lists() {
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["config.example.com".to_string()];
config.network.denied_domains = vec!["blocked.example.com".to_string()];
config
.network
.set_allowed_domains(vec!["config.example.com".to_string()]);
config
.network
.set_denied_domains(vec!["blocked.example.com".to_string()]);
let mut exec_policy = Policy::empty();
exec_policy
@@ -68,15 +135,15 @@ fn execpolicy_network_rules_overlay_network_lists() {
apply_exec_policy_network_rules(&mut config, &exec_policy);
assert_eq!(
config.network.allowed_domains,
vec![
config.network.allowed_domains(),
Some(vec![
"config.example.com".to_string(),
"blocked.example.com".to_string()
]
])
);
assert_eq!(
config.network.denied_domains,
vec!["api.example.com".to_string()]
config.network.denied_domains(),
Some(vec!["api.example.com".to_string()])
);
}
@@ -102,3 +169,82 @@ dangerously_allow_all_unix_sockets = true
assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true));
}
#[test]
fn apply_network_constraints_skips_empty_domain_sides() {
let config: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"managed.example.com" = "allow"
"#,
)
.expect("permissions profile should parse");
let network = selected_network_from_tables(
network_tables_from_toml(&config).expect("permissions profile should deserialize"),
)
.expect("permissions profile should select a network table")
.expect("network table should be present");
let mut constraints = NetworkProxyConstraints::default();
apply_network_constraints(network, &mut constraints);
assert_eq!(
constraints.allowed_domains,
Some(vec!["managed.example.com".to_string()])
);
assert_eq!(constraints.denied_domains, None);
}
#[test]
fn apply_network_constraints_overlay_domain_entries() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"blocked.example.com" = "deny"
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network]
[permissions.workspace.network.domains]
"api.example.com" = "allow"
"#,
)
.expect("higher layer should parse");
let lower_network = selected_network_from_tables(
network_tables_from_toml(&lower_network).expect("lower layer should deserialize"),
)
.expect("lower layer should select a network table")
.expect("lower network table should be present");
let higher_network = selected_network_from_tables(
network_tables_from_toml(&higher_network).expect("higher layer should deserialize"),
)
.expect("higher layer should select a network table")
.expect("higher network table should be present");
let mut constraints = NetworkProxyConstraints::default();
apply_network_constraints(lower_network, &mut constraints);
apply_network_constraints(higher_network, &mut constraints);
assert_eq!(
constraints.allowed_domains,
Some(vec!["api.example.com".to_string()])
);
assert_eq!(
constraints.denied_domains,
Some(vec!["blocked.example.com".to_string()])
);
}