mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
## 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"] ```
420 lines
15 KiB
Rust
420 lines
15 KiB
Rust
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;
|
|
use crate::policy::compile_denylist_globset;
|
|
use crate::policy::is_global_wildcard_domain_pattern;
|
|
use crate::runtime::ConfigState;
|
|
use serde::Deserialize;
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
|
|
pub use crate::runtime::BlockedRequest;
|
|
pub use crate::runtime::BlockedRequestArgs;
|
|
pub use crate::runtime::NetworkProxyAuditMetadata;
|
|
pub use crate::runtime::NetworkProxyState;
|
|
#[cfg(test)]
|
|
pub(crate) use crate::runtime::network_proxy_state_for_policy;
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
pub struct NetworkProxyConstraints {
|
|
pub enabled: Option<bool>,
|
|
pub mode: Option<NetworkMode>,
|
|
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 allowlist_expansion_enabled: Option<bool>,
|
|
pub denied_domains: Option<Vec<String>>,
|
|
pub denylist_expansion_enabled: Option<bool>,
|
|
pub allow_unix_sockets: Option<Vec<String>>,
|
|
pub allow_local_binding: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct PartialNetworkProxyConfig {
|
|
#[serde(default)]
|
|
pub network: PartialNetworkConfig,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Deserialize)]
|
|
pub struct PartialNetworkConfig {
|
|
pub enabled: Option<bool>,
|
|
pub mode: Option<NetworkMode>,
|
|
pub allow_upstream_proxy: Option<bool>,
|
|
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
|
pub dangerously_allow_all_unix_sockets: Option<bool>,
|
|
#[serde(default)]
|
|
pub domains: Option<NetworkDomainPermissions>,
|
|
#[serde(default)]
|
|
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
|
|
pub allow_local_binding: Option<bool>,
|
|
}
|
|
|
|
pub fn build_config_state(
|
|
config: NetworkProxyConfig,
|
|
constraints: NetworkProxyConstraints,
|
|
) -> anyhow::Result<ConfigState> {
|
|
crate::config::validate_unix_socket_allowlist_paths(&config)?;
|
|
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(&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,
|
|
)?))
|
|
} else {
|
|
None
|
|
};
|
|
Ok(ConfigState {
|
|
config,
|
|
allow_set,
|
|
deny_set,
|
|
mitm,
|
|
constraints,
|
|
blocked: std::collections::VecDeque::new(),
|
|
blocked_total: 0,
|
|
})
|
|
}
|
|
|
|
pub fn validate_policy_against_constraints(
|
|
config: &NetworkProxyConfig,
|
|
constraints: &NetworkProxyConstraints,
|
|
) -> Result<(), NetworkProxyConstraintError> {
|
|
fn invalid_value(
|
|
field_name: &'static str,
|
|
candidate: impl Into<String>,
|
|
allowed: impl Into<String>,
|
|
) -> NetworkProxyConstraintError {
|
|
NetworkProxyConstraintError::InvalidValue {
|
|
field_name,
|
|
candidate: candidate.into(),
|
|
allowed: allowed.into(),
|
|
}
|
|
}
|
|
|
|
fn validate<T>(
|
|
candidate: T,
|
|
validator: impl FnOnce(&T) -> Result<(), NetworkProxyConstraintError>,
|
|
) -> Result<(), NetworkProxyConstraintError> {
|
|
validator(&candidate)
|
|
}
|
|
|
|
let enabled = config.network.enabled;
|
|
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 {
|
|
Err(invalid_value(
|
|
"network.enabled",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
})?;
|
|
}
|
|
|
|
if let Some(max_mode) = constraints.mode {
|
|
validate(config.network.mode, move |candidate| {
|
|
if network_mode_rank(*candidate) > network_mode_rank(max_mode) {
|
|
Err(invalid_value(
|
|
"network.mode",
|
|
format!("{candidate:?}"),
|
|
format!("{max_mode:?} or more restrictive"),
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
})?;
|
|
}
|
|
|
|
let allow_upstream_proxy = constraints.allow_upstream_proxy;
|
|
validate(
|
|
config.network.allow_upstream_proxy,
|
|
move |candidate| match allow_upstream_proxy {
|
|
Some(true) | None => Ok(()),
|
|
Some(false) => {
|
|
if *candidate {
|
|
Err(invalid_value(
|
|
"network.allow_upstream_proxy",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
},
|
|
)?;
|
|
|
|
let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback_proxy;
|
|
validate(
|
|
config.network.dangerously_allow_non_loopback_proxy,
|
|
move |candidate| match allow_non_loopback_proxy {
|
|
Some(true) | None => Ok(()),
|
|
Some(false) => {
|
|
if *candidate {
|
|
Err(invalid_value(
|
|
"network.dangerously_allow_non_loopback_proxy",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
},
|
|
)?;
|
|
|
|
let allow_all_unix_sockets = constraints
|
|
.dangerously_allow_all_unix_sockets
|
|
.unwrap_or(constraints.allow_unix_sockets.is_none());
|
|
validate(
|
|
config.network.dangerously_allow_all_unix_sockets,
|
|
move |candidate| {
|
|
if *candidate && !allow_all_unix_sockets {
|
|
Err(invalid_value(
|
|
"network.dangerously_allow_all_unix_sockets",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
},
|
|
)?;
|
|
|
|
if let Some(allow_local_binding) = constraints.allow_local_binding {
|
|
validate(config.network.allow_local_binding, move |candidate| {
|
|
if *candidate && !allow_local_binding {
|
|
Err(invalid_value(
|
|
"network.allow_local_binding",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
})?;
|
|
}
|
|
|
|
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_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)
|
|
&& !denied_domain_overrides.contains(*entry)
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
if missing.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(invalid_value(
|
|
"network.allowed_domains",
|
|
"missing managed allowed_domains entries",
|
|
format!("{missing:?}"),
|
|
))
|
|
}
|
|
})?;
|
|
}
|
|
Some(false) => {
|
|
let required_set: HashSet<String> = allowed_domains
|
|
.iter()
|
|
.map(|entry| entry.to_ascii_lowercase())
|
|
.collect();
|
|
validate(config_allowed_domains, |candidate| {
|
|
let candidate_set: HashSet<String> = candidate
|
|
.iter()
|
|
.map(|entry| entry.to_ascii_lowercase())
|
|
.collect();
|
|
let expected_set: HashSet<String> = required_set
|
|
.difference(&denied_domain_overrides)
|
|
.cloned()
|
|
.collect();
|
|
if candidate_set == expected_set {
|
|
Ok(())
|
|
} else {
|
|
Err(invalid_value(
|
|
"network.allowed_domains",
|
|
format!("{candidate:?}"),
|
|
"must match managed allowed_domains",
|
|
))
|
|
}
|
|
})?;
|
|
}
|
|
None => {
|
|
let managed_patterns: Vec<DomainPattern> = allowed_domains
|
|
.iter()
|
|
.map(|entry| DomainPattern::parse_for_constraints(entry))
|
|
.collect();
|
|
validate(config_allowed_domains, move |candidate| {
|
|
let mut invalid = Vec::new();
|
|
for entry in candidate {
|
|
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
|
|
if !managed_patterns
|
|
.iter()
|
|
.any(|managed| managed.allows(&candidate_pattern))
|
|
{
|
|
invalid.push(entry.clone());
|
|
}
|
|
}
|
|
if invalid.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(invalid_value(
|
|
"network.allowed_domains",
|
|
format!("{invalid:?}"),
|
|
"subset of managed allowed_domains",
|
|
))
|
|
}
|
|
})?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(denied_domains) = &constraints.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_denied_domains, move |candidate| {
|
|
let candidate_set: HashSet<String> = candidate
|
|
.iter()
|
|
.map(|entry| entry.to_ascii_lowercase())
|
|
.collect();
|
|
if candidate_set == required_set {
|
|
Ok(())
|
|
} else {
|
|
Err(invalid_value(
|
|
"network.denied_domains",
|
|
format!("{candidate:?}"),
|
|
"must match managed denied_domains",
|
|
))
|
|
}
|
|
})?;
|
|
}
|
|
Some(true) | None => {
|
|
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
|
|
.iter()
|
|
.filter(|entry| !candidate_set.contains(*entry))
|
|
.cloned()
|
|
.collect();
|
|
if missing.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(invalid_value(
|
|
"network.denied_domains",
|
|
"missing managed denied_domains entries",
|
|
format!("{missing:?}"),
|
|
))
|
|
}
|
|
})?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets {
|
|
let allowed_set: HashSet<String> = allow_unix_sockets
|
|
.iter()
|
|
.map(|s| s.to_ascii_lowercase())
|
|
.collect();
|
|
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",
|
|
))
|
|
}
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_non_global_wildcard_domain_patterns(
|
|
field_name: &'static str,
|
|
patterns: &[String],
|
|
) -> Result<(), NetworkProxyConstraintError> {
|
|
if let Some(pattern) = patterns
|
|
.iter()
|
|
.find(|pattern| is_global_wildcard_domain_pattern(pattern))
|
|
{
|
|
return Err(NetworkProxyConstraintError::InvalidValue {
|
|
field_name,
|
|
candidate: pattern.trim().to_string(),
|
|
allowed: "exact hosts or scoped wildcards like *.example.com or **.example.com"
|
|
.to_string(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum NetworkProxyConstraintError {
|
|
#[error("invalid value for {field_name}: {candidate} (allowed {allowed})")]
|
|
InvalidValue {
|
|
field_name: &'static str,
|
|
candidate: String,
|
|
allowed: String,
|
|
},
|
|
}
|
|
|
|
impl NetworkProxyConstraintError {
|
|
pub fn into_anyhow(self) -> anyhow::Error {
|
|
anyhow::anyhow!(self)
|
|
}
|
|
}
|
|
|
|
fn network_mode_rank(mode: NetworkMode) -> u8 {
|
|
match mode {
|
|
NetworkMode::Limited => 0,
|
|
NetworkMode::Full => 1,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {}
|