diff --git a/codex-rs/core/src/config/network_config_tests.rs b/codex-rs/core/src/config/network_config_tests.rs index bef4bb5872..b9517ec6fc 100644 --- a/codex-rs/core/src/config/network_config_tests.rs +++ b/codex-rs/core/src/config/network_config_tests.rs @@ -189,6 +189,7 @@ action = ['redact'] policy, EnvironmentNetworkPolicy::from_config( &NetworkProxyConfig { + enabled: true, unix_sockets: Some(Default::default()), ..Default::default() }, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 5c316b5b68..53a61ddd10 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -245,7 +245,7 @@ impl NetworkProxySpec { ) } - /// Returns the effective traffic policy without exposing controller-owned proxy settings. + /// Returns proxy activation and traffic policy without controller-owned runtime settings. pub fn environment_policy(&self) -> EnvironmentNetworkPolicy { EnvironmentNetworkPolicy::from_config(&self.config, self.hard_deny_allowlist_misses) } diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index da9d6dea30..afdbd894a2 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -308,6 +308,7 @@ fn environment_policy_replaces_soft_controller_allowlist_and_preserves_denials() owner.allow_upstream_proxy = false; owner.dangerously_allow_all_unix_sockets = Some(false); owner.allow_local_binding = Some(true); + owner.enabled = true; assert_eq!( restricted.environment_policy(), EnvironmentNetworkPolicy::from_config(&owner, /*managed_allowed_domains_only*/ false) @@ -320,6 +321,7 @@ fn environment_policy_replaces_soft_controller_allowlist_and_preserves_denials() assert_eq!( external_rootless.environment_policy(), EnvironmentNetworkPolicy { + requires_proxy: true, managed_allowed_domains_only: true, ..owner_policy.clone() } @@ -864,6 +866,7 @@ async fn environment_local_binding_preserves_explicit_denials_and_inherits_omitt assert_eq!( composed.environment_policy(), EnvironmentNetworkPolicy { + requires_proxy: true, allow_local_binding: Some(expected), ..owner.clone() }, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 87092d755f..3473cd50d6 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -232,8 +232,9 @@ impl ToolOrchestrator { SandboxOverride::NoOverride }; let network_approval_spec = tool.network_approval_spec(req, tool_ctx); - // Offline owner attachments stay offline unless approved command permissions grant - // networking. Existing enabled controller proxies remain independently authoritative. + // An explicit owner proxy requirement permits filtered egress, like an enabled + // controller proxy. Traffic-only owner policies stay offline unless command + // permissions grant networking. // Preserve this baseline even when escalation skips the execution proxy, so retained // terminals still record that their launch bypassed network restrictions. let managed_network_active = if owner_network_policy { @@ -243,6 +244,10 @@ impl ToolOrchestrator { .network .as_ref() .is_some_and(NetworkProxySpec::enabled) + || sandbox_config + .network_policy + .as_ref() + .is_some_and(|policy| policy.requires_proxy) || (network_approval_spec.is_some() || tool .sandbox_permissions(req) diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index c98a511f46..4546185ed4 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -2520,6 +2520,10 @@ async fn owner_network_policy_follows_the_selected_remote_command() -> Result<() ("USER_DENIED", true, Some(false)), ] { let mut builder = test_codex().with_config(move |config| { + config + .features + .disable(Feature::NetworkProxy) + .expect("test config should allow feature update"); for feature in [Feature::UnifiedExec, Feature::ExecPermissionApprovals] { config .features @@ -2551,6 +2555,7 @@ async fn owner_network_policy_follows_the_selected_remote_command() -> Result<() }); let test = builder.build_with_remote_and_local_env(&server).await?; assert!(!test.config.managed_network_requirements_enabled()); + assert!(!test.config.features.enabled(Feature::NetworkProxy)); assert_eq!( test.session_configured.network_proxy.is_some(), configured_controller @@ -2593,6 +2598,8 @@ async fn owner_network_policy_follows_the_selected_remote_command() -> Result<() ("DENIED", "owner-only.invalid", "HTTP/1.1 403"), ("REVIEWED", "owner-only.invalid", "HTTP/1.1 502"), ("OFFLINE", "owner-only.invalid", "ROOTLESS_OWNER_OFFLINE"), + ("REQUIRED_ALLOWED", NETWORK_TEST_HOST, "HTTP/1.1 502"), + ("REQUIRED_DENIED", "owner-only.invalid", "HTTP/1.1 403"), ("GRANTED_DENIED", "owner-only.invalid", "HTTP/1.1 403"), ( "LOCAL_GRANTED", @@ -2623,12 +2630,14 @@ async fn owner_network_policy_follows_the_selected_remote_command() -> Result<() suffix, "ESCALATED" | "ESCALATION_DENIED" | "ESCALATED_DENY_READ" ); - let restricted = matches!(suffix, "OFFLINE" | "GRANTED_DENIED"); + let requires_proxy = matches!(suffix, "REQUIRED_ALLOWED" | "REQUIRED_DENIED"); + let restricted = requires_proxy || matches!(suffix, "OFFLINE" | "GRANTED_DENIED"); if scenario != "ROOTLESS" && restricted { continue; } let marker = format!("{scenario}_OWNER_{suffix}"); let mut proxy_config = NetworkProxyConfig { + enabled: requires_proxy, allow_local_binding: match suffix { "LOCAL_DENIED" => Some(false), "LOCAL_OMITTED" => None, @@ -2689,11 +2698,28 @@ async fn owner_network_policy_follows_the_selected_remote_command() -> Result<() }); remote.config = EnvironmentConfigState::Ready(owner_config); - // Restricted owners run offline until an approved grant enables their filtered proxy. + // Traffic policy alone leaves restricted owners offline. Required proxies and + // approved network grants activate the filtered route without a controller proxy. let command = if suffix == "OFFLINE" { format!( "python3 -c \"import socket; sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); sock.connect(('198.51.100.1', 9))\" 2>/dev/null || printf {marker}" ) + } else if requires_proxy { + let direct_probe = r#"python3 - <<'PYTHON' +import errno, socket +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +try: + sock.connect(('198.51.100.1', 9)) +except OSError as error: + assert error.errno in (errno.ENETUNREACH, errno.EACCES, errno.EPERM), error + print('OWNER_DIRECT_NETWORK_BLOCKED') +else: + raise AssertionError('direct network unexpectedly allowed') +PYTHON"#; + format!( + "{direct_probe}\n{}", + remote_network_proxy_request_command(&marker) + ) } else if suffix == "ESCALATED_DENY_READ" { let read_probe = r#"python3 - <<'PYTHON' try: @@ -2721,6 +2747,7 @@ PYTHON"#; remote_network_proxy_request_command(&marker) }; let mut args = network_exec_args(&command); + args.as_object_mut().unwrap().remove("shell"); args["environment_id"] = json!(REMOTE_ENVIRONMENT_ID); if escalated { args["sandbox_permissions"] = json!("require_escalated"); @@ -2818,6 +2845,9 @@ PYTHON"#; output.contains(expected), "unexpected network output for {marker}: {output}" ); + if requires_proxy { + assert!(output.contains("OWNER_DIRECT_NETWORK_BLOCKED"), "{output}"); + } if suffix == "ESCALATION_DENIED" { assert!(!output.contains(":unproxied")); } else if suffix == "ESCALATED_DENY_READ" { diff --git a/codex-rs/network-proxy/src/environment_policy.rs b/codex-rs/network-proxy/src/environment_policy.rs index c1bfd97b27..d877f79d02 100644 --- a/codex-rs/network-proxy/src/environment_policy.rs +++ b/codex-rs/network-proxy/src/environment_policy.rs @@ -5,13 +5,17 @@ use crate::NetworkUnixSocketPermissions; use serde::Deserialize; use serde::Serialize; -/// Traffic restrictions supplied by the owner of one execution environment. +/// Managed proxy requirements supplied by the owner of one execution environment. /// -/// Proxy enablement, listeners, network mode, MITM, and credentials remain outside -/// attachment-owned traffic policy. +/// The owner can require proxy routing without granting direct network access. +/// Listeners, network mode, MITM, and credentials remain controller-owned. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct EnvironmentNetworkPolicy { + /// Requires managed proxy routing even when direct network access is restricted. + /// False or omission leaves activation to the controller and command permissions. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub requires_proxy: bool, pub domains: Option, pub unix_sockets: Option, pub allow_upstream_proxy: bool, @@ -22,9 +26,10 @@ pub struct EnvironmentNetworkPolicy { } impl EnvironmentNetworkPolicy { - /// Captures portable traffic restrictions without exposing controller runtime settings. + /// Captures proxy activation and traffic restrictions without controller runtime settings. pub fn from_config(config: &NetworkProxyConfig, managed_allowed_domains_only: bool) -> Self { Self { + requires_proxy: config.enabled, domains: config.domains.clone(), unix_sockets: config.unix_sockets.clone(), allow_upstream_proxy: config.allow_upstream_proxy,