Compare commits

...

2 Commits

Author SHA1 Message Date
celia-oai
1e3f8c9da0 changes 2026-03-18 17:54:30 -07:00
celia-oai
8bbe08d23d changes 2026-03-18 17:37:27 -07:00
8 changed files with 329 additions and 46 deletions

View File

@@ -898,6 +898,14 @@
],
"type": "object"
},
"NetworkDomainPermissionToml": {
"enum": [
"allow",
"deny",
"none"
],
"type": "string"
},
"NetworkModeSchema": {
"enum": [
"limited",
@@ -911,32 +919,20 @@
"allow_local_binding": {
"type": "boolean"
},
"allow_unix_sockets": {
"items": {
"type": "string"
},
"type": "array"
},
"allow_upstream_proxy": {
"type": "boolean"
},
"allowed_domains": {
"items": {
"type": "string"
},
"type": "array"
},
"dangerously_allow_all_unix_sockets": {
"type": "boolean"
},
"dangerously_allow_non_loopback_proxy": {
"type": "boolean"
},
"denied_domains": {
"items": {
"type": "string"
"domains": {
"additionalProperties": {
"$ref": "#/definitions/NetworkDomainPermissionToml"
},
"type": "array"
"type": "object"
},
"enable_socks5": {
"type": "boolean"
@@ -955,10 +951,23 @@
},
"socks_url": {
"type": "string"
},
"unix_sockets": {
"additionalProperties": {
"$ref": "#/definitions/NetworkUnixSocketPermissionToml"
},
"type": "object"
}
},
"type": "object"
},
"NetworkUnixSocketPermissionToml": {
"enum": [
"allow",
"none"
],
"type": "string"
},
"Notice": {
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
"properties": {

View File

@@ -280,7 +280,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");
@@ -317,9 +319,11 @@ 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(BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)])),
unix_sockets: None,
allow_local_binding: None,
}),
},
@@ -395,7 +399,10 @@ 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(BTreeMap::from([(
"openai.com".to_string(),
NetworkDomainPermissionToml::Allow,
)])),
..Default::default()
}),
},

View File

@@ -123,7 +123,9 @@ 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::NetworkToml;
pub use permissions::NetworkUnixSocketPermissionToml;
pub use permissions::PermissionProfileToml;
pub use permissions::PermissionsToml;
pub(crate) use permissions::resolve_permission_profile;

View File

@@ -7,6 +7,7 @@ use std::path::PathBuf;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -69,12 +70,32 @@ 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<BTreeMap<String, NetworkDomainPermissionToml>>,
pub unix_sockets: Option<BTreeMap<String, NetworkUnixSocketPermissionToml>>,
pub allow_local_binding: Option<bool>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum NetworkDomainPermissionToml {
Allow,
Deny,
None,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum NetworkUnixSocketPermissionToml {
Allow,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedNetworkDomainEntry {
pub domain: String,
pub permission: NetworkDomainPermissionToml,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum NetworkModeSchema {
@@ -114,14 +135,20 @@ 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 self.domains.is_some() {
let resolved_domain_entries = self.resolved_domain_entries();
let mut allowed_domains = Vec::new();
let mut denied_domains = Vec::new();
apply_resolved_domain_entries(
&mut allowed_domains,
&mut denied_domains,
&resolved_domain_entries,
);
config.network.allowed_domains = allowed_domains;
config.network.denied_domains = denied_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(allow_unix_sockets) = self.compile_unix_socket_permissions() {
config.network.allow_unix_sockets = allow_unix_sockets;
}
if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = allow_local_binding;
@@ -133,6 +160,98 @@ impl NetworkToml {
self.apply_to_network_proxy_config(&mut config);
config
}
pub(crate) fn resolved_domain_entries(&self) -> Vec<ResolvedNetworkDomainEntry> {
self.domains
.as_ref()
.map(|domains| {
domains
.iter()
.map(|(domain, permission)| ResolvedNetworkDomainEntry {
domain: domain.clone(),
permission: *permission,
})
.collect()
})
.unwrap_or_default()
}
pub(crate) fn compile_unix_socket_permissions(&self) -> Option<Vec<String>> {
self.unix_sockets.as_ref().map(|unix_sockets| {
unix_sockets
.iter()
.filter_map(|(path, permission)| {
(*permission == NetworkUnixSocketPermissionToml::Allow).then(|| path.clone())
})
.collect()
})
}
pub(crate) fn apply_domain_permissions_to(
&self,
allowed_domains: &mut Vec<String>,
denied_domains: &mut Vec<String>,
) {
if self.domains.is_some() {
let resolved_domain_entries = self.resolved_domain_entries();
apply_resolved_domain_entries(
allowed_domains,
denied_domains,
&resolved_domain_entries,
);
}
}
pub(crate) fn apply_unix_socket_permissions_to(&self, allow_unix_sockets: &mut Vec<String>) {
if let Some(unix_sockets) = self.unix_sockets.as_ref() {
apply_unix_socket_permissions(allow_unix_sockets, unix_sockets);
}
}
}
fn apply_resolved_domain_entries(
allowed_domains: &mut Vec<String>,
denied_domains: &mut Vec<String>,
resolved_domain_entries: &[ResolvedNetworkDomainEntry],
) {
for ResolvedNetworkDomainEntry { domain, permission } in resolved_domain_entries {
match permission {
NetworkDomainPermissionToml::Allow => {
remove_network_domain(denied_domains, domain);
remove_network_domain(allowed_domains, domain);
allowed_domains.push(domain.clone());
}
NetworkDomainPermissionToml::Deny => {
remove_network_domain(allowed_domains, domain);
remove_network_domain(denied_domains, domain);
denied_domains.push(domain.clone());
}
NetworkDomainPermissionToml::None => {
remove_network_domain(allowed_domains, domain);
remove_network_domain(denied_domains, domain);
}
}
}
allowed_domains.sort_unstable();
denied_domains.sort_unstable();
}
fn apply_unix_socket_permissions(
allow_unix_sockets: &mut Vec<String>,
unix_sockets: &BTreeMap<String, NetworkUnixSocketPermissionToml>,
) {
for (socket_path, permission) in unix_sockets {
allow_unix_sockets.retain(|entry| entry != socket_path);
if *permission == NetworkUnixSocketPermissionToml::Allow {
allow_unix_sockets.push(socket_path.clone());
}
}
allow_unix_sockets.sort_unstable();
}
fn remove_network_domain(domains: &mut Vec<String>, domain: &str) {
let normalized_domain = normalize_host(domain);
domains.retain(|entry| normalize_host(entry) != normalized_domain);
}
pub(crate) fn network_proxy_config_from_profile_network(

View File

@@ -7,3 +7,76 @@ fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true);
assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base"));
}
#[test]
fn resolved_domain_entries_preserve_declared_rules() {
let network = NetworkToml {
domains: Some(BTreeMap::from([
(
"example.com".to_string(),
NetworkDomainPermissionToml::Allow,
),
(
"blocked.example.com".to_string(),
NetworkDomainPermissionToml::Deny,
),
(
"clear.example.com".to_string(),
NetworkDomainPermissionToml::None,
),
])),
..Default::default()
};
assert_eq!(
network.resolved_domain_entries(),
vec![
ResolvedNetworkDomainEntry {
domain: "blocked.example.com".to_string(),
permission: NetworkDomainPermissionToml::Deny,
},
ResolvedNetworkDomainEntry {
domain: "clear.example.com".to_string(),
permission: NetworkDomainPermissionToml::None,
},
ResolvedNetworkDomainEntry {
domain: "example.com".to_string(),
permission: NetworkDomainPermissionToml::Allow,
},
]
);
}
#[test]
fn apply_domain_permissions_to_respects_none_entries_case_insensitively() {
let network = NetworkToml {
domains: Some(BTreeMap::from([(
"EXAMPLE.com".to_string(),
NetworkDomainPermissionToml::None,
)])),
..Default::default()
};
let mut allowed_domains = vec!["example.com".to_string()];
let mut denied_domains = vec!["Example.com".to_string()];
network.apply_domain_permissions_to(&mut allowed_domains, &mut denied_domains);
assert_eq!(allowed_domains, Vec::<String>::new());
assert_eq!(denied_domains, Vec::<String>::new());
}
#[test]
fn apply_to_network_proxy_config_clears_domains_for_empty_table() {
let network = NetworkToml {
domains: Some(BTreeMap::new()),
..Default::default()
};
let mut config = NetworkProxyConfig::default();
config.network.allowed_domains = vec!["example.com".to_string()];
config.network.denied_domains = vec!["blocked.example.com".to_string()];
network.apply_to_network_proxy_config(&mut config);
assert_eq!(config.network.allowed_domains, Vec::<String>::new());
assert_eq!(config.network.denied_domains, Vec::<String>::new());
}

View File

@@ -150,14 +150,14 @@ 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 network.domains.is_some() {
let allowed_domains = constraints.allowed_domains.get_or_insert_default();
let denied_domains = constraints.denied_domains.get_or_insert_default();
network.apply_domain_permissions_to(allowed_domains, 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 {
constraints.allow_unix_sockets = Some(allow_unix_sockets);
if network.unix_sockets.is_some() {
let allow_unix_sockets = constraints.allow_unix_sockets.get_or_insert_default();
network.apply_unix_socket_permissions_to(allow_unix_sockets);
}
if let Some(allow_local_binding) = network.allow_local_binding {
constraints.allow_local_binding = Some(allow_local_binding);
@@ -191,7 +191,18 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
}
fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> {
if let Some(network) = selected_network_from_tables(parsed)? {
if let Some(mut network) = selected_network_from_tables(parsed)? {
if network.domains.is_some() {
network.apply_domain_permissions_to(
&mut config.network.allowed_domains,
&mut config.network.denied_domains,
);
}
if network.unix_sockets.is_some() {
network.apply_unix_socket_permissions_to(&mut config.network.allow_unix_sockets);
}
network.domains = None;
network.unix_sockets = None;
network.apply_to_network_proxy_config(config);
}
Ok(())

View File

@@ -12,7 +12,9 @@ fn higher_precedence_profile_network_beats_lower_profile_network() {
default_permissions = "workspace"
[permissions.workspace.network]
allowed_domains = ["lower.example.com"]
[permissions.workspace.network.domains]
"lower.example.com" = "allow"
"#,
)
.expect("lower layer should parse");
@@ -21,7 +23,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,7 +42,60 @@ 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,
vec![
"higher.example.com".to_string(),
"lower.example.com".to_string()
]
);
}
#[test]
fn higher_precedence_network_domain_none_removes_inherited_entry() {
let lower_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network.domains]
"lower.example.com" = "allow"
"blocked.example.com" = "deny"
"keep.example.com" = "allow"
"#,
)
.expect("lower layer should parse");
let higher_network: toml::Value = toml::from_str(
r#"
default_permissions = "workspace"
[permissions.workspace.network.domains]
"lower.example.com" = "none"
"blocked.example.com" = "none"
"extra.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,
vec![
"extra.example.com".to_string(),
"keep.example.com".to_string()
]
);
assert!(config.network.denied_domains.is_empty());
}
#[test]

View File

@@ -40,9 +40,13 @@ mitm = false
# Hosts must match the allowlist (unless denied).
# Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`.
# The global `*` wildcard is rejected.
# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured.
allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"]
denied_domains = ["evil.example"]
# If no domain entries are set to `allow`, the proxy blocks requests until an allowlist is configured.
[permissions.workspace.network.domains]
"*.openai.com" = "allow"
"localhost" = "allow"
"127.0.0.1" = "allow"
"::1" = "allow"
"evil.example" = "deny"
# If false, local/private networking is rejected. Explicit allowlisting of local IP literals
# (or `localhost`) is required to permit them.
@@ -50,7 +54,8 @@ denied_domains = ["evil.example"]
allow_local_binding = false
# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
allow_unix_sockets = ["/tmp/example.sock"]
[permissions.workspace.network.unix_sockets]
"/tmp/example.sock" = "allow"
# DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any
# absolute socket path from `x-unix-socket`.
dangerously_allow_all_unix_sockets = false