From d790a2738ddcf005c68333d34f09fa9892ed79da Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 7 Apr 2026 13:22:04 -0700 Subject: [PATCH 1/4] fix: refresh managed network proxy after sandbox changes Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/codex.rs | 50 +++++++++++ codex-rs/core/src/codex_tests.rs | 65 ++++++++++++++ .../core/src/config/network_proxy_spec.rs | 45 ++++++++-- .../src/config/network_proxy_spec_tests.rs | 51 +++++++++++ codex-rs/network-proxy/src/proxy.rs | 84 +++++++++++++++---- codex-rs/network-proxy/src/runtime.rs | 11 +++ 6 files changed, 286 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index dce76bf3d2bb..759efe423080 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1344,6 +1344,46 @@ impl Session { Ok((network_proxy, session_network_proxy)) } + async fn refresh_managed_network_proxy_for_sandbox_policy( + &self, + session_configuration: &SessionConfiguration, + ) { + let Some(started_proxy) = self.services.network_proxy.as_ref() else { + return; + }; + let Some(spec) = session_configuration + .original_config_do_not_use + .permissions + .network + .as_ref() + else { + return; + }; + + let spec = match spec + .recompute_for_sandbox_policy(session_configuration.sandbox_policy.get()) + { + Ok(spec) => spec, + Err(err) => { + warn!("failed to rebuild managed network proxy policy for sandbox change: {err}"); + return; + } + }; + let current_exec_policy = self.services.exec_policy.current(); + let spec = match spec.with_exec_policy_network_rules(current_exec_policy.as_ref()) { + Ok(spec) => spec, + Err(err) => { + warn!( + "failed to apply execpolicy network rules while refreshing managed network proxy: {err}" + ); + spec + } + }; + if let Err(err) = spec.apply_to_started_proxy(started_proxy).await { + warn!("failed to refresh managed network proxy for sandbox change: {err}"); + } + } + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config @@ -2421,10 +2461,14 @@ impl Session { match state.session_configuration.apply(&updates) { Ok(updated) => { let previous_cwd = state.session_configuration.cwd.clone(); + let sandbox_policy_changed = + state.session_configuration.sandbox_policy != updated.sandbox_policy; let next_cwd = updated.cwd.clone(); let codex_home = updated.codex_home.clone(); let session_source = updated.session_source.clone(); state.session_configuration = updated; + let session_configuration = + sandbox_policy_changed.then(|| state.session_configuration.clone()); drop(state); self.maybe_refresh_shell_snapshot_for_cwd( @@ -2433,6 +2477,10 @@ impl Session { &codex_home, &session_source, ); + if let Some(session_configuration) = session_configuration { + self.refresh_managed_network_proxy_for_sandbox_policy(&session_configuration) + .await; + } Ok(()) } @@ -2519,6 +2567,8 @@ impl Session { .set_approval_policy(&session_configuration.approval_policy); if sandbox_policy_changed { + self.refresh_managed_network_proxy_for_sandbox_policy(&session_configuration) + .await; let sandbox_state = SandboxState { sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(), codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 6e11dd825a12..c1a5b02c131a 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -544,6 +544,71 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() Ok(()) } +#[tokio::test] +async fn managed_network_proxy_refreshes_when_sandbox_policy_changes() -> anyhow::Result<()> { + let spec = crate::config::NetworkProxySpec::from_config_and_constraints( + NetworkProxyConfig::default(), + Some(NetworkConstraints { + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + )]), + }), + danger_full_access_denylist_only: Some(true), + allow_local_binding: Some(false), + ..Default::default() + }), + &SandboxPolicy::new_workspace_write_policy(), + )?; + let exec_policy = Policy::empty(); + + let (started_proxy, _) = Session::start_managed_network_proxy( + &spec, + &exec_policy, + &SandboxPolicy::new_workspace_write_policy(), + /*network_policy_decider*/ None, + /*blocked_request_observer*/ None, + /*managed_network_requirements_enabled*/ false, + crate::config::NetworkProxyAuditMetadata::default(), + ) + .await?; + + assert!(!started_proxy.proxy().allow_local_binding()); + let current_cfg = started_proxy.proxy().current_cfg().await?; + assert_eq!(current_cfg.network.allowed_domains(), None); + assert_eq!( + current_cfg.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + + let spec = spec.recompute_for_sandbox_policy(&SandboxPolicy::DangerFullAccess)?; + spec.apply_to_started_proxy(&started_proxy).await?; + + assert!(started_proxy.proxy().allow_local_binding()); + let current_cfg = started_proxy.proxy().current_cfg().await?; + assert_eq!( + current_cfg.network.allowed_domains(), + Some(vec!["*".to_string()]) + ); + assert_eq!( + current_cfg.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + + let spec = spec.recompute_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy())?; + spec.apply_to_started_proxy(&started_proxy).await?; + + assert!(!started_proxy.proxy().allow_local_binding()); + let current_cfg = started_proxy.proxy().current_cfg().await?; + assert_eq!(current_cfg.network.allowed_domains(), None); + assert_eq!( + current_cfg.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + Ok(()) +} + #[tokio::test] async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 0a37f46cdfb8..039070082a62 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -24,6 +24,8 @@ const GLOBAL_ALLOWLIST_PATTERN: &str = "*"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct NetworkProxySpec { + base_config: NetworkProxyConfig, + requirements: Option, config: NetworkProxyConfig, constraints: NetworkProxyConstraints, hard_deny_allowlist_misses: bool, @@ -91,13 +93,14 @@ impl NetworkProxySpec { requirements: Option, sandbox_policy: &SandboxPolicy, ) -> std::io::Result { + let base_config = config.clone(); let hard_deny_allowlist_misses = requirements .as_ref() .is_some_and(Self::managed_allowed_domains_only); - let (config, constraints) = if let Some(requirements) = requirements { + let (config, constraints) = if let Some(requirements) = requirements.as_ref() { Self::apply_requirements( config, - &requirements, + requirements, sandbox_policy, hard_deny_allowlist_misses, ) @@ -111,6 +114,8 @@ impl NetworkProxySpec { ) })?; Ok(Self { + base_config, + requirements, config, constraints, hard_deny_allowlist_misses, @@ -156,6 +161,17 @@ impl NetworkProxySpec { Ok(StartedNetworkProxy::new(proxy, handle)) } + pub(crate) fn recompute_for_sandbox_policy( + &self, + sandbox_policy: &SandboxPolicy, + ) -> std::io::Result { + Self::from_config_and_constraints( + self.base_config.clone(), + self.requirements.clone(), + sandbox_policy, + ) + } + pub(crate) fn with_exec_policy_network_rules( &self, exec_policy: &Policy, @@ -171,14 +187,25 @@ impl NetworkProxySpec { Ok(spec) } + pub(crate) async fn apply_to_started_proxy( + &self, + started_proxy: &StartedNetworkProxy, + ) -> std::io::Result<()> { + let state = self.build_config_state_for_spec()?; + started_proxy + .proxy() + .replace_config_state(state) + .await + .map_err(|err| { + std::io::Error::other(format!("failed to update network proxy state: {err}")) + }) + } + fn build_state_with_audit_metadata( &self, audit_metadata: NetworkProxyAuditMetadata, ) -> std::io::Result { - let state = - build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| { - std::io::Error::other(format!("failed to build network proxy state: {err}")) - })?; + let state = self.build_config_state_for_spec()?; let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone())); Ok(NetworkProxyState::with_reloader_and_audit_metadata( state, @@ -187,6 +214,12 @@ impl NetworkProxySpec { )) } + fn build_config_state_for_spec(&self) -> std::io::Result { + build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| { + std::io::Error::other(format!("failed to build network proxy state: {err}")) + }) + } + fn apply_requirements( mut config: NetworkProxyConfig, requirements: &NetworkConstraints, 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 742512d600b6..ff351d1e7399 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -21,6 +21,8 @@ fn domain_permissions( #[test] fn build_state_with_audit_metadata_threads_metadata_to_state() { let spec = NetworkProxySpec { + base_config: NetworkProxyConfig::default(), + requirements: None, config: NetworkProxyConfig::default(), constraints: NetworkProxyConstraints::default(), hard_deny_allowlist_misses: false, @@ -322,6 +324,55 @@ fn danger_full_access_denylist_only_does_not_change_workspace_write_behavior() { assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); } +#[test] +fn recompute_for_sandbox_policy_rebuilds_denylist_only_full_access_policy() { + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), + danger_full_access_denylist_only: Some(true), + ..Default::default() + }; + let spec = NetworkProxySpec::from_config_and_constraints( + NetworkProxyConfig::default(), + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("workspace-write policy should load"); + + assert_eq!(spec.config.network.allowed_domains(), None); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + + let spec = spec + .recompute_for_sandbox_policy(&SandboxPolicy::DangerFullAccess) + .expect("full-access policy should load"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["*".to_string()]) + ); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert!(spec.config.network.allow_local_binding); + + let spec = spec + .recompute_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()) + .expect("workspace-write policy should reload"); + + assert_eq!(spec.config.network.allowed_domains(), None); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert!(!spec.config.network.allow_local_binding); +} + #[test] fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let mut config = NetworkProxyConfig::default(); diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c334e817d081..0270966d4414 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -2,6 +2,7 @@ use crate::config; use crate::http_proxy; use crate::network_policy::NetworkPolicyDecider; use crate::runtime::BlockedRequestObserver; +use crate::runtime::ConfigState; use crate::runtime::unix_socket_permissions_supported; use crate::socks5; use crate::state::NetworkProxyState; @@ -13,6 +14,7 @@ use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; use std::sync::Arc; use std::sync::Mutex; +use std::sync::RwLock; use tokio::task::JoinHandle; use tracing::warn; @@ -219,11 +221,9 @@ impl NetworkProxyBuilder { http_addr, socks_addr, socks_enabled: current_cfg.network.enable_socks5, - allow_local_binding: current_cfg.network.allow_local_binding, - allow_unix_sockets: current_cfg.network.allow_unix_sockets(), - dangerously_allow_all_unix_sockets: current_cfg - .network - .dangerously_allow_all_unix_sockets, + runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config( + ¤t_cfg, + ))), reserved_listeners, policy_decider: self.policy_decider, }) @@ -294,15 +294,30 @@ fn reserve_loopback_ephemeral_listener() -> Result { .context("bind loopback ephemeral port") } +#[derive(Debug, Clone, PartialEq, Eq)] +struct NetworkProxyRuntimeSettings { + allow_local_binding: bool, + allow_unix_sockets: Arc<[String]>, + dangerously_allow_all_unix_sockets: bool, +} + +impl NetworkProxyRuntimeSettings { + fn from_config(config: &config::NetworkProxyConfig) -> Self { + Self { + allow_local_binding: config.network.allow_local_binding, + allow_unix_sockets: config.network.allow_unix_sockets().into(), + dangerously_allow_all_unix_sockets: config.network.dangerously_allow_all_unix_sockets, + } + } +} + #[derive(Clone)] pub struct NetworkProxy { state: Arc, http_addr: SocketAddr, socks_addr: SocketAddr, socks_enabled: bool, - allow_local_binding: bool, - allow_unix_sockets: Vec, - dangerously_allow_all_unix_sockets: bool, + runtime_settings: Arc>, reserved_listeners: Option>, policy_decider: Option>, } @@ -322,7 +337,7 @@ impl PartialEq for NetworkProxy { fn eq(&self, other: &Self) -> bool { self.http_addr == other.http_addr && self.socks_addr == other.socks_addr - && self.allow_local_binding == other.allow_local_binding + && self.runtime_settings() == other.runtime_settings() } } @@ -488,18 +503,19 @@ impl NetworkProxy { } pub fn allow_local_binding(&self) -> bool { - self.allow_local_binding + self.runtime_settings().allow_local_binding } - pub fn allow_unix_sockets(&self) -> &[String] { - &self.allow_unix_sockets + pub fn allow_unix_sockets(&self) -> Arc<[String]> { + self.runtime_settings().allow_unix_sockets } pub fn dangerously_allow_all_unix_sockets(&self) -> bool { - self.dangerously_allow_all_unix_sockets + self.runtime_settings().dangerously_allow_all_unix_sockets } pub fn apply_to_env(&self, env: &mut HashMap) { + let allow_local_binding = self.allow_local_binding(); // Enforce proxying for child processes. We intentionally override existing values so // command-level environment cannot bypass the managed proxy endpoint. apply_proxy_env_overrides( @@ -507,10 +523,50 @@ impl NetworkProxy { self.http_addr, self.socks_addr, self.socks_enabled, - self.allow_local_binding, + allow_local_binding, ); } + pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> { + let current_cfg = self.state.current_cfg().await?; + anyhow::ensure!( + new_state.config.network.enabled == current_cfg.network.enabled, + "cannot update network.enabled on a running proxy" + ); + anyhow::ensure!( + new_state.config.network.proxy_url == current_cfg.network.proxy_url, + "cannot update network.proxy_url on a running proxy" + ); + anyhow::ensure!( + new_state.config.network.socks_url == current_cfg.network.socks_url, + "cannot update network.socks_url on a running proxy" + ); + anyhow::ensure!( + new_state.config.network.enable_socks5 == current_cfg.network.enable_socks5, + "cannot update network.enable_socks5 on a running proxy" + ); + anyhow::ensure!( + new_state.config.network.enable_socks5_udp == current_cfg.network.enable_socks5_udp, + "cannot update network.enable_socks5_udp on a running proxy" + ); + + let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config); + self.state.replace_config_state(new_state).await?; + let mut guard = self + .runtime_settings + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = settings; + Ok(()) + } + + fn runtime_settings(&self) -> NetworkProxyRuntimeSettings { + self.runtime_settings + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + pub async fn run(&self) -> Result { let current_cfg = self.state.current_cfg().await?; if !current_cfg.network.enabled { diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index e96783256498..da090a69d40d 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -335,6 +335,17 @@ impl NetworkProxyState { } } + pub async fn replace_config_state(&self, mut new_state: ConfigState) -> Result<()> { + self.reload_if_needed().await?; + let mut guard = self.state.write().await; + log_policy_changes(&guard.config, &new_state.config); + new_state.blocked = guard.blocked.clone(); + new_state.blocked_total = guard.blocked_total; + *guard = new_state; + info!("updated network proxy config state"); + Ok(()) + } + pub async fn host_blocked(&self, host: &str, port: u16) -> Result { self.reload_if_needed().await?; let host = match Host::parse(host) { From f18ca21d8a3bcd9c5ae06e39f17f2ac2c63b0235 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 7 Apr 2026 17:36:16 -0700 Subject: [PATCH 2/4] fix: keep managed network approval decider attached Attach the session network policy decider even if the managed proxy starts while the turn is in full access. Gate approval asks on the active turn's sandbox mode so downgrading to a restricted sandbox can ask for approval again. Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/codex_tests.rs | 68 +++++++++++++++++++ .../core/src/config/network_proxy_spec.rs | 21 +++--- codex-rs/core/src/tools/network_approval.rs | 18 +++++ .../core/src/tools/network_approval_tests.rs | 14 ++++ 4 files changed, 108 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index c1a5b02c131a..c387efd3f32f 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -609,6 +609,74 @@ async fn managed_network_proxy_refreshes_when_sandbox_policy_changes() -> anyhow Ok(()) } +#[tokio::test] +async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::Result<()> { + let spec = crate::config::NetworkProxySpec::from_config_and_constraints( + NetworkProxyConfig::default(), + Some(NetworkConstraints { + enabled: Some(true), + danger_full_access_denylist_only: Some(true), + ..Default::default() + }), + &SandboxPolicy::DangerFullAccess, + )?; + let exec_policy = Policy::empty(); + let decider_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let network_policy_decider: Arc = Arc::new({ + let decider_calls = Arc::clone(&decider_calls); + move |_request| { + decider_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { codex_network_proxy::NetworkDecision::ask("not_allowed") } + } + }); + + let (started_proxy, _) = Session::start_managed_network_proxy( + &spec, + &exec_policy, + &SandboxPolicy::DangerFullAccess, + Some(network_policy_decider), + /*blocked_request_observer*/ None, + /*managed_network_requirements_enabled*/ true, + crate::config::NetworkProxyAuditMetadata::default(), + ) + .await?; + + let spec = spec.recompute_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy())?; + spec.apply_to_started_proxy(&started_proxy).await?; + let current_cfg = started_proxy.proxy().current_cfg().await?; + assert_eq!(current_cfg.network.allowed_domains(), None); + + use tokio::io::AsyncReadExt as _; + use tokio::io::AsyncWriteExt as _; + + let mut stream = tokio::net::TcpStream::connect(started_proxy.proxy().http_addr()).await?; + stream + .write_all( + b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n", + ) + .await?; + let mut buffer = [0_u8; 4096]; + let bytes_read = tokio::time::timeout(StdDuration::from_secs(2), stream.read(&mut buffer)) + .await + .expect("timed out waiting for proxy response")?; + let response = String::from_utf8_lossy(&buffer[..bytes_read]); + + assert!( + response.starts_with("HTTP/1.1 403 Forbidden"), + "unexpected proxy response: {response}" + ); + assert!( + response.contains("x-proxy-error: blocked-by-allowlist"), + "unexpected proxy response: {response}" + ); + assert_eq!( + decider_calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "unexpected proxy response: {response}" + ); + Ok(()) +} + #[tokio::test] async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 039070082a62..b67c41a44279 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -132,21 +132,16 @@ impl NetworkProxySpec { ) -> std::io::Result { let state = self.build_state_with_audit_metadata(audit_metadata)?; let mut builder = NetworkProxy::builder().state(Arc::new(state)); - if enable_network_approval_flow - && !self.hard_deny_allowlist_misses - && matches!( + if enable_network_approval_flow && !self.hard_deny_allowlist_misses { + if let Some(policy_decider) = policy_decider { + builder = builder.policy_decider_arc(policy_decider); + } else if matches!( sandbox_policy, SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } - ) - { - builder = match policy_decider { - Some(policy_decider) => builder.policy_decider_arc(policy_decider), - None => builder.policy_decider(|_request| async { - // In restricted sandbox modes, allowlist misses should ask for - // explicit network approval instead of hard-denying. - NetworkDecision::ask("not_allowed") - }), - }; + ) { + builder = builder + .policy_decider(|_request| async { NetworkDecision::ask("not_allowed") }); + } } if let Some(blocked_request_observer) = blocked_request_observer { builder = builder.blocked_request_observer_arc(blocked_request_observer); diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index d3c526e99e93..ecc5fb6daae5 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::WarningEvent; use indexmap::IndexMap; use std::collections::HashMap; @@ -118,6 +119,13 @@ fn allows_network_approval_flow(policy: AskForApproval) -> bool { !matches!(policy, AskForApproval::Never) } +fn sandbox_policy_allows_network_approval_flow(policy: &SandboxPolicy) -> bool { + matches!( + policy, + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } + ) +} + impl PendingApprovalDecision { fn to_network_decision(self) -> NetworkDecision { match self { @@ -334,6 +342,16 @@ impl NetworkApprovalService { .await; return NetworkDecision::deny(REASON_NOT_ALLOWED); }; + if !sandbox_policy_allows_network_approval_flow(turn_context.sandbox_policy.get()) { + pending.set_decision(PendingApprovalDecision::Deny).await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( + policy_denial_message, + )) + .await; + return NetworkDecision::deny(REASON_NOT_ALLOWED); + } if !allows_network_approval_flow(turn_context.approval_policy.value()) { pending.set_decision(PendingApprovalDecision::Deny).await; let mut pending_approvals = self.pending_host_approvals.lock().await; diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index ad01a45bbd38..ca777e7cd462 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -1,6 +1,7 @@ use super::*; use codex_network_proxy::BlockedRequestArgs; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; #[tokio::test] @@ -179,6 +180,19 @@ fn only_never_policy_disables_network_approval_flow() { assert!(allows_network_approval_flow(AskForApproval::UnlessTrusted)); } +#[test] +fn network_approval_flow_is_limited_to_restricted_sandbox_modes() { + assert!(sandbox_policy_allows_network_approval_flow( + &SandboxPolicy::new_read_only_policy() + )); + assert!(sandbox_policy_allows_network_approval_flow( + &SandboxPolicy::new_workspace_write_policy() + )); + assert!(!sandbox_policy_allows_network_approval_flow( + &SandboxPolicy::DangerFullAccess + )); +} + fn denied_blocked_request(host: &str) -> BlockedRequest { BlockedRequest::new(BlockedRequestArgs { host: host.to_string(), From 562c57c5a8caf32281eb7f61144a52bc30c0932a Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 7 Apr 2026 19:02:54 -0700 Subject: [PATCH 3/4] fix: serialize managed network proxy refresh Refresh the running managed proxy from the session's current settings under a session-local refresh lock. This prevents a late sandbox-update task from reapplying a stale proxy allowlist after a newer sandbox mode was already stored. Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/codex.rs | 22 +++++++++++++--------- codex-rs/core/src/codex_tests.rs | 2 ++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 759efe423080..cf0ca1241b2f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -828,6 +828,9 @@ pub(crate) struct Session { agent_status: watch::Sender, out_of_band_elicitation_paused: watch::Sender, state: Mutex, + /// Serializes rebuild/apply cycles for the running proxy; each cycle + /// rebuilds from the current SessionState while holding this lock. + managed_network_proxy_refresh_lock: Mutex<()>, /// The set of enabled features should be invariant for the lifetime of the /// session. features: ManagedFeatures, @@ -1344,13 +1347,15 @@ impl Session { Ok((network_proxy, session_network_proxy)) } - async fn refresh_managed_network_proxy_for_sandbox_policy( - &self, - session_configuration: &SessionConfiguration, - ) { + async fn refresh_managed_network_proxy_for_current_sandbox_policy(&self) { let Some(started_proxy) = self.services.network_proxy.as_ref() else { return; }; + let _refresh_guard = self.managed_network_proxy_refresh_lock.lock().await; + let session_configuration = { + let state = self.state.lock().await; + state.session_configuration.clone() + }; let Some(spec) = session_configuration .original_config_do_not_use .permissions @@ -2042,6 +2047,7 @@ impl Session { agent_status, out_of_band_elicitation_paused, state: Mutex::new(state), + managed_network_proxy_refresh_lock: Mutex::new(()), features: config.features.clone(), pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), @@ -2467,8 +2473,6 @@ impl Session { let codex_home = updated.codex_home.clone(); let session_source = updated.session_source.clone(); state.session_configuration = updated; - let session_configuration = - sandbox_policy_changed.then(|| state.session_configuration.clone()); drop(state); self.maybe_refresh_shell_snapshot_for_cwd( @@ -2477,8 +2481,8 @@ impl Session { &codex_home, &session_source, ); - if let Some(session_configuration) = session_configuration { - self.refresh_managed_network_proxy_for_sandbox_policy(&session_configuration) + if sandbox_policy_changed { + self.refresh_managed_network_proxy_for_current_sandbox_policy() .await; } @@ -2567,7 +2571,7 @@ impl Session { .set_approval_policy(&session_configuration.approval_policy); if sandbox_policy_changed { - self.refresh_managed_network_proxy_for_sandbox_policy(&session_configuration) + self.refresh_managed_network_proxy_for_current_sandbox_policy() .await; let sandbox_state = SandboxState { sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index c387efd3f32f..55b7eb9c0431 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2957,6 +2957,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, state: Mutex::new(state), + managed_network_proxy_refresh_lock: Mutex::new(()), features: config.features.clone(), pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), @@ -3799,6 +3800,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, state: Mutex::new(state), + managed_network_proxy_refresh_lock: Mutex::new(()), features: config.features.clone(), pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), From 0b6820eae15eeccb80f7ee001f065f0aed21a106 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 7 Apr 2026 19:59:43 -0700 Subject: [PATCH 4/4] fix: synchronize network policy amendments with proxy refresh Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/codex.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cf0ca1241b2f..18d689a52a16 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3008,6 +3008,7 @@ impl Session { amendment: &NetworkPolicyAmendment, network_approval_context: &NetworkApprovalContext, ) -> anyhow::Result<()> { + let _refresh_guard = self.managed_network_proxy_refresh_lock.lock().await; let host = Self::validated_network_policy_amendment_host(amendment, network_approval_context)?; let codex_home = self