mirror of
https://github.com/openai/codex.git
synced 2026-04-29 17:06:51 +00:00
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:
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user