Skip to content

Commit 81fa047

Browse files
viyatb-oaicodex
andauthored
feat(windows-sandbox): add network proxy support (#12220)
## Summary This PR makes Windows sandbox proxying enforceable by routing proxy-only runs through the existing `offline` sandbox user and reserving direct network access for the existing `online` sandbox user. In brief: - if a Windows sandbox run should be proxy-enforced, we run it as the `offline` user - the `offline` user gets firewall rules that block direct outbound traffic and only permit the configured localhost proxy path - if a Windows sandbox run should have true direct network access, we run it as the `online` user - no new sandbox identity is introduced This brings Windows in line with the intended model: proxy use is not just env-based, it is backed by OS-level egress controls. Windows already has two sandbox identities: - `offline`: intended to have no direct network egress - `online`: intended to have full network access This PR makes proxy-enforced runs use that model directly. ### Proxy-enforced runs When proxy enforcement is active: - the run is assigned to the `offline` identity - setup extracts the loopback proxy ports from the sandbox env - Windows setup programs firewall rules for the `offline` user that: - block all non-loopback outbound traffic - block loopback UDP - block loopback TCP except for the configured proxy ports - optionally allow broader localhost access when `allow_local_binding=1` So the sandboxed process can only talk to the local proxy. It cannot open direct outbound sockets or do local UDP-based DNS on its own.The proxy then performs the real outbound network access outside that restricted sandbox identity. ### Direct-network runs When proxy enforcement is not active and full network access is allowed: - the run is assigned to the `online` identity - no proxy-only firewall restrictions are applied - the process gets normal direct network access ### Unelevated vs elevated The restricted-token / unelevated path cannot enforce per-identity firewall policy by itself. So for Windows proxy-enforced runs, we transparently use the logon-user sandbox path under the hood, even if the caller started from the unelevated mode. That keeps enforcement real instead of best-effort. --------- Co-authored-by: Codex <noreply@openai.com>
1 parent e6e2999 commit 81fa047

12 files changed

Lines changed: 1032 additions & 216 deletions

File tree

codex-rs/cli/src/debug_sandbox.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,17 @@ async fn run_command_under_sandbox(
164164
let res = tokio::task::spawn_blocking(move || {
165165
if use_elevated {
166166
run_windows_sandbox_capture_elevated(
167-
policy_str.as_str(),
168-
&sandbox_cwd,
169-
base_dir.as_path(),
170-
command_vec,
171-
&cwd_clone,
172-
env_map,
173-
/*timeout_ms*/ None,
174-
config.permissions.windows_sandbox_private_desktop,
167+
codex_windows_sandbox::ElevatedSandboxCaptureRequest {
168+
policy_json_or_preset: policy_str.as_str(),
169+
sandbox_policy_cwd: &sandbox_cwd,
170+
codex_home: base_dir.as_path(),
171+
command: command_vec,
172+
cwd: &cwd_clone,
173+
env_map,
174+
timeout_ms: None,
175+
use_private_desktop: config.permissions.windows_sandbox_private_desktop,
176+
proxy_enforced: false,
177+
},
175178
)
176179
} else {
177180
run_windows_sandbox_capture(

codex-rs/core/src/exec.rs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,11 @@ async fn exec_windows_sandbox(
478478
})?;
479479
let command_path = command.first().cloned();
480480
let sandbox_level = windows_sandbox_level;
481-
let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated);
481+
let proxy_enforced = network.is_some();
482+
// Windows firewall enforcement is tied to the logon-user sandbox identities, so
483+
// proxy-enforced sessions must use that backend even when the configured mode is
484+
// the default restricted-token sandbox.
485+
let use_elevated = proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated);
482486
let additional_deny_write_paths = windows_restricted_token_filesystem_overlay
483487
.map(|overlay| {
484488
overlay
@@ -491,14 +495,17 @@ async fn exec_windows_sandbox(
491495
let spawn_res = tokio::task::spawn_blocking(move || {
492496
if use_elevated {
493497
run_windows_sandbox_capture_elevated(
494-
policy_str.as_str(),
495-
&sandbox_cwd,
496-
codex_home.as_ref(),
497-
command,
498-
&cwd,
499-
env,
500-
timeout_ms,
501-
windows_sandbox_private_desktop,
498+
codex_windows_sandbox::ElevatedSandboxCaptureRequest {
499+
policy_json_or_preset: policy_str.as_str(),
500+
sandbox_policy_cwd: &sandbox_cwd,
501+
codex_home: codex_home.as_ref(),
502+
command,
503+
cwd: &cwd,
504+
env_map: env,
505+
timeout_ms,
506+
use_private_desktop: windows_sandbox_private_desktop,
507+
proxy_enforced,
508+
},
502509
)
503510
} else {
504511
run_windows_sandbox_capture_with_extra_deny_write_paths(

codex-rs/core/src/windows_sandbox.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,15 @@ pub fn run_elevated_setup(
180180
codex_home: &Path,
181181
) -> anyhow::Result<()> {
182182
codex_windows_sandbox::run_elevated_setup(
183-
policy,
184-
policy_cwd,
185-
command_cwd,
186-
env_map,
187-
codex_home,
188-
/*read_roots_override*/ None,
189-
/*write_roots_override*/ None,
183+
codex_windows_sandbox::SandboxSetupRequest {
184+
policy,
185+
policy_cwd,
186+
command_cwd,
187+
env_map,
188+
codex_home,
189+
proxy_enforced: false,
190+
},
191+
codex_windows_sandbox::SetupRootOverrides::default(),
190192
)
191193
}
192194

@@ -234,6 +236,7 @@ pub fn run_setup_refresh_with_extra_read_roots(
234236
env_map,
235237
codex_home,
236238
extra_read_roots,
239+
/*proxy_enforced*/ false,
237240
)
238241
}
239242

codex-rs/network-proxy/src/proxy.rs

Lines changed: 185 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,43 @@ impl ReservedListeners {
5151
}
5252
}
5353

54+
struct ReservedListenerSet {
55+
http_listener: StdTcpListener,
56+
socks_listener: Option<StdTcpListener>,
57+
}
58+
59+
impl ReservedListenerSet {
60+
fn new(http_listener: StdTcpListener, socks_listener: Option<StdTcpListener>) -> Self {
61+
Self {
62+
http_listener,
63+
socks_listener,
64+
}
65+
}
66+
67+
fn http_addr(&self) -> Result<SocketAddr> {
68+
self.http_listener
69+
.local_addr()
70+
.context("failed to read reserved HTTP proxy address")
71+
}
72+
73+
fn socks_addr(&self, default_addr: SocketAddr) -> Result<SocketAddr> {
74+
self.socks_listener
75+
.as_ref()
76+
.map_or(Ok(default_addr), |listener| {
77+
listener
78+
.local_addr()
79+
.context("failed to read reserved SOCKS5 proxy address")
80+
})
81+
}
82+
83+
fn into_reserved_listeners(self) -> Arc<ReservedListeners> {
84+
Arc::new(ReservedListeners::new(
85+
self.http_listener,
86+
self.socks_listener,
87+
))
88+
}
89+
}
90+
5491
#[derive(Clone)]
5592
pub struct NetworkProxyBuilder {
5693
state: Option<Arc<NetworkProxyState>>,
@@ -134,38 +171,41 @@ impl NetworkProxyBuilder {
134171
.set_blocked_request_observer(self.blocked_request_observer.clone())
135172
.await;
136173
let current_cfg = state.current_cfg().await?;
137-
let (requested_http_addr, requested_socks_addr, reserved_listeners) =
138-
if self.managed_by_codex {
139-
let runtime = config::resolve_runtime(&current_cfg)?;
140-
let (http_listener, socks_listener) =
141-
reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5)
142-
.context("reserve managed loopback proxy listeners")?;
143-
let http_addr = http_listener
144-
.local_addr()
145-
.context("failed to read reserved HTTP proxy address")?;
146-
let socks_addr = if let Some(socks_listener) = socks_listener.as_ref() {
147-
socks_listener
148-
.local_addr()
149-
.context("failed to read reserved SOCKS5 proxy address")?
150-
} else {
151-
runtime.socks_addr
152-
};
153-
(
154-
http_addr,
155-
socks_addr,
156-
Some(Arc::new(ReservedListeners::new(
157-
http_listener,
158-
socks_listener,
159-
))),
160-
)
161-
} else {
162-
let runtime = config::resolve_runtime(&current_cfg)?;
163-
(
164-
self.http_addr.unwrap_or(runtime.http_addr),
165-
self.socks_addr.unwrap_or(runtime.socks_addr),
166-
None,
167-
)
168-
};
174+
let (requested_http_addr, requested_socks_addr, reserved_listeners) = if self
175+
.managed_by_codex
176+
{
177+
let runtime = config::resolve_runtime(&current_cfg)?;
178+
#[cfg(target_os = "windows")]
179+
let (managed_http_addr, managed_socks_addr) = config::clamp_bind_addrs(
180+
runtime.http_addr,
181+
runtime.socks_addr,
182+
&current_cfg.network,
183+
);
184+
#[cfg(target_os = "windows")]
185+
let reserved = reserve_windows_managed_listeners(
186+
managed_http_addr,
187+
managed_socks_addr,
188+
current_cfg.network.enable_socks5,
189+
)
190+
.context("reserve managed loopback proxy listeners")?;
191+
#[cfg(not(target_os = "windows"))]
192+
let reserved = reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5)
193+
.context("reserve managed loopback proxy listeners")?;
194+
let http_addr = reserved.http_addr()?;
195+
let socks_addr = reserved.socks_addr(runtime.socks_addr)?;
196+
(
197+
http_addr,
198+
socks_addr,
199+
Some(reserved.into_reserved_listeners()),
200+
)
201+
} else {
202+
let runtime = config::resolve_runtime(&current_cfg)?;
203+
(
204+
self.http_addr.unwrap_or(runtime.http_addr),
205+
self.socks_addr.unwrap_or(runtime.socks_addr),
206+
None,
207+
)
208+
};
169209

170210
// Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only.
171211
let (http_addr, socks_addr) = config::clamp_bind_addrs(
@@ -192,15 +232,61 @@ impl NetworkProxyBuilder {
192232

193233
fn reserve_loopback_ephemeral_listeners(
194234
reserve_socks_listener: bool,
195-
) -> Result<(StdTcpListener, Option<StdTcpListener>)> {
235+
) -> Result<ReservedListenerSet> {
196236
let http_listener =
197237
reserve_loopback_ephemeral_listener().context("reserve HTTP proxy listener")?;
198238
let socks_listener = if reserve_socks_listener {
199239
Some(reserve_loopback_ephemeral_listener().context("reserve SOCKS5 proxy listener")?)
200240
} else {
201241
None
202242
};
203-
Ok((http_listener, socks_listener))
243+
Ok(ReservedListenerSet::new(http_listener, socks_listener))
244+
}
245+
246+
#[cfg(target_os = "windows")]
247+
fn reserve_windows_managed_listeners(
248+
http_addr: SocketAddr,
249+
socks_addr: SocketAddr,
250+
reserve_socks_listener: bool,
251+
) -> Result<ReservedListenerSet> {
252+
let http_addr = windows_managed_loopback_addr(http_addr);
253+
let socks_addr = windows_managed_loopback_addr(socks_addr);
254+
255+
match try_reserve_windows_managed_listeners(http_addr, socks_addr, reserve_socks_listener) {
256+
Ok(listeners) => Ok(listeners),
257+
Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
258+
warn!("managed Windows proxy ports are busy; falling back to ephemeral loopback ports");
259+
reserve_loopback_ephemeral_listeners(reserve_socks_listener)
260+
.context("reserve fallback loopback proxy listeners")
261+
}
262+
Err(err) => Err(err).context("reserve Windows managed proxy listeners"),
263+
}
264+
}
265+
266+
#[cfg(target_os = "windows")]
267+
fn try_reserve_windows_managed_listeners(
268+
http_addr: SocketAddr,
269+
socks_addr: SocketAddr,
270+
reserve_socks_listener: bool,
271+
) -> std::io::Result<ReservedListenerSet> {
272+
let http_listener = StdTcpListener::bind(http_addr)?;
273+
let socks_listener = if reserve_socks_listener {
274+
Some(StdTcpListener::bind(socks_addr)?)
275+
} else {
276+
None
277+
};
278+
Ok(ReservedListenerSet::new(http_listener, socks_listener))
279+
}
280+
281+
#[cfg(target_os = "windows")]
282+
fn windows_managed_loopback_addr(addr: SocketAddr) -> SocketAddr {
283+
if !addr.ip().is_loopback() {
284+
warn!(
285+
"managed Windows proxies must bind to loopback; clamping {addr} to 127.0.0.1:{}",
286+
addr.port()
287+
);
288+
}
289+
SocketAddr::from(([127, 0, 0, 1], addr.port()))
204290
}
205291

206292
fn reserve_loopback_ephemeral_listener() -> Result<StdTcpListener> {
@@ -570,10 +656,12 @@ mod tests {
570656
use std::net::Ipv4Addr;
571657

572658
#[tokio::test]
573-
async fn managed_proxy_builder_uses_loopback_ephemeral_ports() {
574-
let state = Arc::new(network_proxy_state_for_policy(
575-
NetworkProxySettings::default(),
576-
));
659+
async fn managed_proxy_builder_uses_loopback_ports() {
660+
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
661+
proxy_url: "http://127.0.0.1:43128".to_string(),
662+
socks_url: "http://127.0.0.1:48081".to_string(),
663+
..NetworkProxySettings::default()
664+
}));
577665
let proxy = match NetworkProxy::builder().state(state).build().await {
578666
Ok(proxy) => proxy,
579667
Err(err) => {
@@ -589,8 +677,22 @@ mod tests {
589677

590678
assert!(proxy.http_addr.ip().is_loopback());
591679
assert!(proxy.socks_addr.ip().is_loopback());
592-
assert_ne!(proxy.http_addr.port(), 0);
593-
assert_ne!(proxy.socks_addr.port(), 0);
680+
#[cfg(target_os = "windows")]
681+
{
682+
assert_eq!(
683+
proxy.http_addr,
684+
"127.0.0.1:43128".parse::<SocketAddr>().unwrap()
685+
);
686+
assert_eq!(
687+
proxy.socks_addr,
688+
"127.0.0.1:48081".parse::<SocketAddr>().unwrap()
689+
);
690+
}
691+
#[cfg(not(target_os = "windows"))]
692+
{
693+
assert_ne!(proxy.http_addr.port(), 0);
694+
assert_ne!(proxy.socks_addr.port(), 0);
695+
}
594696
}
595697

596698
#[tokio::test]
@@ -622,6 +724,7 @@ mod tests {
622724
async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
623725
let settings = NetworkProxySettings {
624726
enable_socks5: false,
727+
proxy_url: "http://127.0.0.1:43128".to_string(),
625728
socks_url: "http://127.0.0.1:43129".to_string(),
626729
..NetworkProxySettings::default()
627730
};
@@ -640,6 +743,7 @@ mod tests {
640743
};
641744

642745
assert!(proxy.http_addr.ip().is_loopback());
746+
assert_ne!(proxy.http_addr.port(), 0);
643747
assert_eq!(
644748
proxy.socks_addr,
645749
"127.0.0.1:43129".parse::<SocketAddr>().unwrap()
@@ -654,6 +758,47 @@ mod tests {
654758
);
655759
}
656760

761+
#[cfg(target_os = "windows")]
762+
#[test]
763+
fn windows_managed_loopback_addr_clamps_non_loopback_inputs() {
764+
assert_eq!(
765+
windows_managed_loopback_addr("0.0.0.0:3128".parse::<SocketAddr>().unwrap()),
766+
"127.0.0.1:3128".parse::<SocketAddr>().unwrap()
767+
);
768+
assert_eq!(
769+
windows_managed_loopback_addr("[::]:8081".parse::<SocketAddr>().unwrap()),
770+
"127.0.0.1:8081".parse::<SocketAddr>().unwrap()
771+
);
772+
}
773+
774+
#[cfg(target_os = "windows")]
775+
#[test]
776+
fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() {
777+
let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
778+
let busy_port = occupied.local_addr().unwrap().port();
779+
780+
let reserved = reserve_windows_managed_listeners(
781+
SocketAddr::from(([127, 0, 0, 1], busy_port)),
782+
SocketAddr::from(([127, 0, 0, 1], 48081)),
783+
false,
784+
)
785+
.unwrap();
786+
787+
assert!(reserved.socks_listener.is_none());
788+
assert!(
789+
reserved
790+
.http_listener
791+
.local_addr()
792+
.unwrap()
793+
.ip()
794+
.is_loopback()
795+
);
796+
assert_ne!(
797+
reserved.http_listener.local_addr().unwrap().port(),
798+
busy_port
799+
);
800+
}
801+
657802
#[test]
658803
fn proxy_url_env_value_resolves_lowercase_aliases() {
659804
let mut env = HashMap::new();

0 commit comments

Comments
 (0)