Compare commits

...

11 Commits

12 changed files with 989 additions and 194 deletions

View File

@@ -158,13 +158,16 @@ async fn run_command_under_sandbox(
let res = tokio::task::spawn_blocking(move || {
if use_elevated {
run_windows_sandbox_capture_elevated(
policy_str.as_str(),
&sandbox_cwd,
base_dir.as_path(),
command_vec,
&cwd_clone,
env_map,
None,
codex_windows_sandbox::ElevatedSandboxCaptureRequest {
policy_json_or_preset: policy_str.as_str(),
sandbox_policy_cwd: &sandbox_cwd,
codex_home: base_dir.as_path(),
command: command_vec,
cwd: &cwd_clone,
env_map,
timeout_ms: None,
proxy_enforced: false,
},
)
} else {
run_windows_sandbox_capture(

View File

@@ -374,17 +374,24 @@ async fn exec_windows_sandbox(
})?;
let command_path = command.first().cloned();
let sandbox_level = windows_sandbox_level;
let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated);
let proxy_enforced = network.is_some();
// Windows firewall enforcement is tied to the logon-user sandbox identities, so
// proxy-enforced sessions must use that backend even when the configured mode is
// the default restricted-token sandbox.
let use_elevated = proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated);
let spawn_res = tokio::task::spawn_blocking(move || {
if use_elevated {
run_windows_sandbox_capture_elevated(
policy_str.as_str(),
&sandbox_cwd,
codex_home.as_ref(),
command,
&cwd,
env,
timeout_ms,
codex_windows_sandbox::ElevatedSandboxCaptureRequest {
policy_json_or_preset: policy_str.as_str(),
sandbox_policy_cwd: &sandbox_cwd,
codex_home: codex_home.as_ref(),
command,
cwd: &cwd,
env_map: env,
timeout_ms,
proxy_enforced,
},
)
} else {
run_windows_sandbox_capture(

View File

@@ -167,13 +167,15 @@ pub fn run_elevated_setup(
codex_home: &Path,
) -> anyhow::Result<()> {
codex_windows_sandbox::run_elevated_setup(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
None,
None,
codex_windows_sandbox::SandboxSetupRequest {
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
proxy_enforced: false,
},
codex_windows_sandbox::SetupRootOverrides::default(),
)
}
@@ -221,6 +223,7 @@ pub fn run_setup_refresh_with_extra_read_roots(
env_map,
codex_home,
extra_read_roots,
false,
)
}

View File

@@ -63,6 +63,56 @@ impl ReservedListeners {
}
}
struct ReservedListenerSet {
http_listener: StdTcpListener,
socks_listener: Option<StdTcpListener>,
admin_listener: StdTcpListener,
}
impl ReservedListenerSet {
fn new(
http_listener: StdTcpListener,
socks_listener: Option<StdTcpListener>,
admin_listener: StdTcpListener,
) -> Self {
Self {
http_listener,
socks_listener,
admin_listener,
}
}
fn http_addr(&self) -> Result<SocketAddr> {
self.http_listener
.local_addr()
.context("failed to read reserved HTTP proxy address")
}
fn socks_addr(&self, default_addr: SocketAddr) -> Result<SocketAddr> {
self.socks_listener
.as_ref()
.map_or(Ok(default_addr), |listener| {
listener
.local_addr()
.context("failed to read reserved SOCKS5 proxy address")
})
}
fn admin_addr(&self) -> Result<SocketAddr> {
self.admin_listener
.local_addr()
.context("failed to read reserved admin API address")
}
fn into_reserved_listeners(self) -> Arc<ReservedListeners> {
Arc::new(ReservedListeners::new(
self.http_listener,
self.socks_listener,
self.admin_listener,
))
}
}
#[derive(Clone)]
pub struct NetworkProxyBuilder {
state: Option<Arc<NetworkProxyState>>,
@@ -156,31 +206,33 @@ impl NetworkProxyBuilder {
let (requested_http_addr, requested_socks_addr, requested_admin_addr, reserved_listeners) =
if self.managed_by_codex {
let runtime = config::resolve_runtime(&current_cfg)?;
let (http_listener, socks_listener, admin_listener) =
#[cfg(target_os = "windows")]
let (managed_http_addr, managed_socks_addr, _managed_admin_addr) =
config::clamp_bind_addrs(
runtime.http_addr,
runtime.socks_addr,
runtime.admin_addr,
&current_cfg.network,
);
#[cfg(target_os = "windows")]
let reserved = reserve_windows_managed_listeners(
managed_http_addr,
managed_socks_addr,
current_cfg.network.enable_socks5,
)
.context("reserve managed loopback proxy listeners")?;
#[cfg(not(target_os = "windows"))]
let reserved =
reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5)
.context("reserve managed loopback proxy listeners")?;
let http_addr = http_listener
.local_addr()
.context("failed to read reserved HTTP proxy address")?;
let socks_addr = if let Some(socks_listener) = socks_listener.as_ref() {
socks_listener
.local_addr()
.context("failed to read reserved SOCKS5 proxy address")?
} else {
runtime.socks_addr
};
let admin_addr = admin_listener
.local_addr()
.context("failed to read reserved admin API address")?;
let http_addr = reserved.http_addr()?;
let socks_addr = reserved.socks_addr(runtime.socks_addr)?;
let admin_addr = reserved.admin_addr()?;
(
http_addr,
socks_addr,
admin_addr,
Some(Arc::new(ReservedListeners::new(
http_listener,
socks_listener,
admin_listener,
))),
Some(reserved.into_reserved_listeners()),
)
} else {
let runtime = config::resolve_runtime(&current_cfg)?;
@@ -219,7 +271,7 @@ impl NetworkProxyBuilder {
fn reserve_loopback_ephemeral_listeners(
reserve_socks_listener: bool,
) -> Result<(StdTcpListener, Option<StdTcpListener>, StdTcpListener)> {
) -> Result<ReservedListenerSet> {
let http_listener =
reserve_loopback_ephemeral_listener().context("reserve HTTP proxy listener")?;
let socks_listener = if reserve_socks_listener {
@@ -229,7 +281,62 @@ fn reserve_loopback_ephemeral_listeners(
};
let admin_listener =
reserve_loopback_ephemeral_listener().context("reserve admin API listener")?;
Ok((http_listener, socks_listener, admin_listener))
Ok(ReservedListenerSet::new(
http_listener,
socks_listener,
admin_listener,
))
}
#[cfg(target_os = "windows")]
fn reserve_windows_managed_listeners(
http_addr: SocketAddr,
socks_addr: SocketAddr,
reserve_socks_listener: bool,
) -> Result<ReservedListenerSet> {
let http_addr = windows_managed_loopback_addr(http_addr);
let socks_addr = windows_managed_loopback_addr(socks_addr);
match try_reserve_windows_managed_listeners(http_addr, socks_addr, reserve_socks_listener) {
Ok(listeners) => Ok(listeners),
Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
warn!("managed Windows proxy ports are busy; falling back to ephemeral loopback ports");
reserve_loopback_ephemeral_listeners(reserve_socks_listener)
.context("reserve fallback loopback proxy listeners")
}
Err(err) => Err(err).context("reserve Windows managed proxy listeners"),
}
}
#[cfg(target_os = "windows")]
fn try_reserve_windows_managed_listeners(
http_addr: SocketAddr,
socks_addr: SocketAddr,
reserve_socks_listener: bool,
) -> std::io::Result<ReservedListenerSet> {
let http_listener = StdTcpListener::bind(http_addr)?;
let socks_listener = if reserve_socks_listener {
Some(StdTcpListener::bind(socks_addr)?)
} else {
None
};
let admin_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?;
Ok(ReservedListenerSet::new(
http_listener,
socks_listener,
admin_listener,
))
}
#[cfg(target_os = "windows")]
fn windows_managed_loopback_addr(addr: SocketAddr) -> SocketAddr {
if !addr.ip().is_loopback() {
warn!(
"managed Windows proxies must bind to loopback; clamping {addr} to 127.0.0.1:{}",
addr.port()
);
}
SocketAddr::from(([127, 0, 0, 1], addr.port()))
}
fn reserve_loopback_ephemeral_listener() -> Result<StdTcpListener> {
@@ -629,10 +736,13 @@ mod tests {
use std::net::Ipv4Addr;
#[tokio::test]
async fn managed_proxy_builder_uses_loopback_ephemeral_ports() {
let state = Arc::new(network_proxy_state_for_policy(
NetworkProxySettings::default(),
));
async fn managed_proxy_builder_uses_loopback_ports() {
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
proxy_url: "http://127.0.0.1:43128".to_string(),
socks_url: "http://127.0.0.1:48081".to_string(),
admin_url: "http://127.0.0.1:48080".to_string(),
..NetworkProxySettings::default()
}));
let proxy = match NetworkProxy::builder().state(state).build().await {
Ok(proxy) => proxy,
Err(err) => {
@@ -649,8 +759,22 @@ mod tests {
assert!(proxy.http_addr.ip().is_loopback());
assert!(proxy.socks_addr.ip().is_loopback());
assert!(proxy.admin_addr.ip().is_loopback());
assert_ne!(proxy.http_addr.port(), 0);
assert_ne!(proxy.socks_addr.port(), 0);
#[cfg(target_os = "windows")]
{
assert_eq!(
proxy.http_addr,
"127.0.0.1:43128".parse::<SocketAddr>().unwrap()
);
assert_eq!(
proxy.socks_addr,
"127.0.0.1:48081".parse::<SocketAddr>().unwrap()
);
}
#[cfg(not(target_os = "windows"))]
{
assert_ne!(proxy.http_addr.port(), 0);
assert_ne!(proxy.socks_addr.port(), 0);
}
assert_ne!(proxy.admin_addr.port(), 0);
}
@@ -688,7 +812,9 @@ mod tests {
async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
let settings = NetworkProxySettings {
enable_socks5: false,
proxy_url: "http://127.0.0.1:43128".to_string(),
socks_url: "http://127.0.0.1:43129".to_string(),
admin_url: "http://127.0.0.1:48080".to_string(),
..NetworkProxySettings::default()
};
let state = Arc::new(network_proxy_state_for_policy(settings));
@@ -707,6 +833,11 @@ mod tests {
assert!(proxy.http_addr.ip().is_loopback());
assert!(proxy.admin_addr.ip().is_loopback());
#[cfg(target_os = "windows")]
assert_eq!(
proxy.http_addr,
"127.0.0.1:43128".parse::<SocketAddr>().unwrap()
);
assert_eq!(
proxy.socks_addr,
"127.0.0.1:43129".parse::<SocketAddr>().unwrap()
@@ -721,6 +852,55 @@ mod tests {
);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_managed_loopback_addr_clamps_non_loopback_inputs() {
assert_eq!(
windows_managed_loopback_addr("0.0.0.0:3128".parse::<SocketAddr>().unwrap()),
"127.0.0.1:3128".parse::<SocketAddr>().unwrap()
);
assert_eq!(
windows_managed_loopback_addr("[::]:8081".parse::<SocketAddr>().unwrap()),
"127.0.0.1:8081".parse::<SocketAddr>().unwrap()
);
}
#[cfg(target_os = "windows")]
#[test]
fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() {
let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
let busy_port = occupied.local_addr().unwrap().port();
let reserved = reserve_windows_managed_listeners(
SocketAddr::from(([127, 0, 0, 1], busy_port)),
SocketAddr::from(([127, 0, 0, 1], 48081)),
false,
)
.unwrap();
assert!(reserved.socks_listener.is_none());
assert!(
reserved
.http_listener
.local_addr()
.unwrap()
.ip()
.is_loopback()
);
assert_ne!(
reserved.http_listener.local_addr().unwrap().port(),
busy_port
);
assert!(
reserved
.admin_listener
.local_addr()
.unwrap()
.ip()
.is_loopback()
);
}
#[test]
fn proxy_url_env_value_resolves_lowercase_aliases() {
let mut env = HashMap::new();

View File

@@ -6,8 +6,13 @@ import os
import sys
import shutil
import subprocess
import contextlib
import http.client
import http.server
import threading
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.parse import urlsplit
def _resolve_codex_cmd() -> List[str]:
"""Resolve the Codex CLI to invoke `codex sandbox windows`.
@@ -135,6 +140,68 @@ def make_symlink(link: Path, target: Path) -> bool:
cp = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return cp.returncode == 0 and link.exists()
class _QuietHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
class _TargetHandler(_QuietHandler):
def do_GET(self):
body = b"proxy-ok"
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
class _ProxyHandler(_QuietHandler):
def do_GET(self):
parsed = urlsplit(self.path)
if not parsed.scheme or not parsed.hostname:
self.send_error(400, "absolute URL required")
return
if parsed.hostname not in ("127.0.0.1", "localhost"):
self.send_error(403, "only loopback hosts are allowed in smoke proxy")
return
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
conn = None
try:
conn = http.client.HTTPConnection(parsed.hostname, parsed.port or 80, timeout=2)
conn.request("GET", path)
upstream = conn.getresponse()
body = upstream.read()
except Exception as err:
self.send_error(502, f"proxy upstream error: {err}")
return
finally:
if conn is not None:
with contextlib.suppress(Exception):
conn.close()
self.send_response(upstream.status, upstream.reason)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
@contextlib.contextmanager
def start_loopback_proxy_fixture():
target = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _TargetHandler)
proxy = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _ProxyHandler)
target_port = target.server_address[1]
proxy_port = proxy.server_address[1]
target_thread = threading.Thread(target=target.serve_forever, daemon=True)
proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True)
target_thread.start()
proxy_thread.start()
try:
yield target_port, proxy_port
finally:
proxy.shutdown()
target.shutdown()
proxy.server_close()
target.server_close()
def summarize(results: List[CaseResult]) -> int:
ok = sum(1 for r in results if r.ok)
total = len(results)
@@ -278,7 +345,65 @@ def main() -> int:
"try { iwr http://neverssl.com -TimeoutSec 2 } catch { exit 1 }"], WS_ROOT)
add("WS: iwr network blocked", rc != 0, f"rc={rc}")
# 17. RO: deny TEMP writes via PowerShell
# 17. WS: direct loopback blocked, proxy loopback allowed via env proxy
if have("curl"):
with start_loopback_proxy_fixture() as (target_port, proxy_port):
proxy_home = WS_ROOT / ".codex_proxy_smoke"
remove_if_exists(proxy_home)
proxy_home.mkdir(parents=True, exist_ok=True)
proxy_url = f"http://127.0.0.1:{proxy_port}"
proxy_env = {
"CODEX_HOME": str(proxy_home),
"HTTP_PROXY": proxy_url,
"http_proxy": proxy_url,
"ALL_PROXY": proxy_url,
"all_proxy": proxy_url,
"NO_PROXY": "",
"no_proxy": "",
}
proxied_cmd = [
"curl",
"--noproxy",
"",
"--connect-timeout",
"2",
"--max-time",
"4",
f"http://127.0.0.1:{target_port}/proxied",
]
rc_proxy, out_proxy, err_proxy = run_sbx(
"workspace-write",
proxied_cmd,
WS_ROOT,
env_extra=proxy_env,
)
add(
"WS: loopback proxy allowed",
rc_proxy == 0 and "proxy-ok" in out_proxy,
f"rc={rc_proxy}, out={out_proxy}, err={err_proxy}",
)
direct_cmd = [
"curl",
"--noproxy",
"*",
"--connect-timeout",
"1",
"--max-time",
"2",
f"http://127.0.0.1:{target_port}/direct",
]
rc_direct, _out_direct, err_direct = run_sbx(
"workspace-write",
direct_cmd,
WS_ROOT,
env_extra={"CODEX_HOME": str(proxy_home)},
)
add("WS: direct loopback blocked", rc_direct != 0, f"rc={rc_direct}, err={err_direct}")
else:
add("WS: direct/proxy loopback tests (curl missing)", True, "curl not installed")
# 18. RO: deny TEMP writes via PowerShell
rc, out, err = run_sbx("read-only",
["powershell", "-NoLogo", "-NoProfile", "-Command",
"Set-Content -LiteralPath $env:TEMP\\ro_tmpfail.txt -Value 'x'"], WS_ROOT)
@@ -287,21 +412,21 @@ def main() -> int:
else:
add("RO: TEMP write denied (PS, skipped)", True)
# 18. WS: curl version check — don't rely on stub, just succeed
# 19. WS: curl version check — don't rely on stub, just succeed
if have("curl"):
rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "curl --version"], WS_ROOT)
add("WS: curl present (version prints)", rc == 0, f"rc={rc}, err={err}")
else:
add("WS: curl present (optional, skipped)", True)
# 19. Optional: ripgrep version
# 20. Optional: ripgrep version
if have("rg"):
rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "rg --version"], WS_ROOT)
add("WS: rg --version (optional)", rc == 0, f"rc={rc}, err={err}")
else:
add("WS: rg --version (optional, skipped)", True)
# 20. Optional: git --version
# 21. Optional: git --version
if have("git"):
rc, out, err = run_sbx("workspace-write", ["git", "--version"], WS_ROOT)
add("WS: git --version (optional)", rc == 0, f"rc={rc}, err={err}")

View File

@@ -1,4 +1,19 @@
use std::collections::HashMap;
use std::path::Path;
pub struct ElevatedSandboxCaptureRequest<'a> {
pub policy_json_or_preset: &'a str,
pub sandbox_policy_cwd: &'a Path,
pub codex_home: &'a Path,
pub command: Vec<String>,
pub cwd: &'a Path,
pub env_map: HashMap<String, String>,
pub timeout_ms: Option<u64>,
pub proxy_enforced: bool,
}
mod windows_impl {
use super::ElevatedSandboxCaptureRequest;
use crate::acl::allow_null_device;
use crate::allow::compute_allow_paths;
use crate::allow::AllowDenyPaths;
@@ -209,14 +224,18 @@ mod windows_impl {
/// Launches the command runner under the sandbox user and captures its output.
pub fn run_windows_sandbox_capture(
policy_json_or_preset: &str,
sandbox_policy_cwd: &Path,
codex_home: &Path,
command: Vec<String>,
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
request: ElevatedSandboxCaptureRequest<'_>,
) -> Result<CaptureResult> {
let ElevatedSandboxCaptureRequest {
policy_json_or_preset,
sandbox_policy_cwd,
codex_home,
command,
cwd,
mut env_map,
timeout_ms,
proxy_enforced,
} = request;
let policy = parse_policy(policy_json_or_preset)?;
normalize_null_device_env(&mut env_map);
ensure_non_interactive_pager(&mut env_map);
@@ -229,8 +248,14 @@ mod windows_impl {
let logs_base_dir: Option<&Path> = Some(sandbox_base.as_path());
log_start(&command, logs_base_dir);
let sandbox_creds =
require_logon_sandbox_creds(&policy, sandbox_policy_cwd, cwd, &env_map, codex_home)?;
let sandbox_creds = require_logon_sandbox_creds(
&policy,
sandbox_policy_cwd,
cwd,
&env_map,
codex_home,
proxy_enforced,
)?;
// Build capability SID for ACL grants.
if matches!(
&policy,
@@ -503,11 +528,9 @@ pub use windows_impl::run_windows_sandbox_capture;
#[cfg(not(target_os = "windows"))]
mod stub {
use super::ElevatedSandboxCaptureRequest;
use anyhow::bail;
use anyhow::Result;
use codex_protocol::protocol::SandboxPolicy;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Default)]
pub struct CaptureResult {
@@ -519,13 +542,7 @@ mod stub {
/// Stub implementation for non-Windows targets; sandboxing only works on Windows.
pub fn run_windows_sandbox_capture(
_policy_json_or_preset: &str,
_sandbox_policy_cwd: &Path,
_codex_home: &Path,
_command: Vec<String>,
_cwd: &Path,
_env_map: HashMap<String, String>,
_timeout_ms: Option<u64>,
_request: ElevatedSandboxCaptureRequest<'_>,
) -> Result<CaptureResult> {
bail!("Windows sandbox is only available on Windows")
}

View File

@@ -9,10 +9,13 @@ use windows::core::BSTR;
use windows::Win32::Foundation::VARIANT_TRUE;
use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2;
use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule3;
use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRules;
use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2;
use windows::Win32::NetworkManagement::WindowsFirewall::NetFwRule;
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_ACTION_BLOCK;
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_ANY;
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_TCP;
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_UDP;
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_PROFILE2_ALL;
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_RULE_DIR_OUT;
use windows::Win32::System::Com::CoCreateInstance;
@@ -27,9 +30,128 @@ use codex_windows_sandbox::SetupFailure;
// This is the stable identifier we use to find/update the rule idempotently.
// It intentionally does not change between installs.
const OFFLINE_BLOCK_RULE_NAME: &str = "codex_sandbox_offline_block_outbound";
const OFFLINE_BLOCK_LOOPBACK_TCP_RULE_NAME: &str = "codex_sandbox_offline_block_loopback_tcp";
const OFFLINE_BLOCK_LOOPBACK_UDP_RULE_NAME: &str = "codex_sandbox_offline_block_loopback_udp";
// Friendly text shown in the firewall UI.
const OFFLINE_BLOCK_RULE_FRIENDLY: &str = "Codex Sandbox Offline - Block Outbound";
const OFFLINE_BLOCK_RULE_FRIENDLY: &str = "Codex Sandbox Offline - Block Non-Loopback Outbound";
const OFFLINE_BLOCK_LOOPBACK_TCP_RULE_FRIENDLY: &str =
"Codex Sandbox Offline - Block Loopback TCP (Except Proxy)";
const OFFLINE_BLOCK_LOOPBACK_UDP_RULE_FRIENDLY: &str = "Codex Sandbox Offline - Block Loopback UDP";
const OFFLINE_PROXY_ALLOW_RULE_NAME: &str = "codex_sandbox_offline_allow_loopback_proxy";
const LOOPBACK_REMOTE_ADDRESSES: &str = "127.0.0.0/8,::1";
const NON_LOOPBACK_REMOTE_ADDRESSES: &str =
"0.0.0.0-126.255.255.255,128.0.0.0-255.255.255.255,::,::2-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff";
struct BlockRuleSpec<'a> {
internal_name: &'a str,
friendly_desc: &'a str,
protocol: i32,
local_user_spec: &'a str,
offline_sid: &'a str,
remote_addresses: Option<&'a str>,
remote_ports: Option<&'a str>,
}
pub fn ensure_offline_proxy_allowlist(
offline_sid: &str,
proxy_ports: &[u16],
allow_local_binding: bool,
log: &mut File,
) -> Result<()> {
let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})");
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
if hr.is_err() {
return Err(anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallComInitFailed,
format!("CoInitializeEx failed: {hr:?}"),
)));
}
let result = unsafe {
(|| -> Result<()> {
let policy: INetFwPolicy2 = CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER)
.map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallPolicyAccessFailed,
format!("CoCreateInstance NetFwPolicy2 failed: {err:?}"),
))
})?;
let rules = policy.Rules().map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallPolicyAccessFailed,
format!("INetFwPolicy2::Rules failed: {err:?}"),
))
})?;
if allow_local_binding {
// Remove the legacy overlapping allow rule before returning to the local-binding
// mode so stale proxy exceptions do not linger.
remove_rule_if_present(&rules, OFFLINE_PROXY_ALLOW_RULE_NAME, log)?;
remove_rule_if_present(&rules, OFFLINE_BLOCK_LOOPBACK_UDP_RULE_NAME, log)?;
remove_rule_if_present(&rules, OFFLINE_BLOCK_LOOPBACK_TCP_RULE_NAME, log)?;
return Ok(());
}
ensure_block_rule(
&rules,
&BlockRuleSpec {
internal_name: OFFLINE_BLOCK_LOOPBACK_UDP_RULE_NAME,
friendly_desc: OFFLINE_BLOCK_LOOPBACK_UDP_RULE_FRIENDLY,
protocol: NET_FW_IP_PROTOCOL_UDP.0,
local_user_spec: &local_user_spec,
offline_sid,
remote_addresses: Some(LOOPBACK_REMOTE_ADDRESSES),
remote_ports: None,
},
log,
)?;
// Install a broad TCP loopback block before narrowing it to the allowed proxy port
// complement. If the narrowing update fails, the sandbox remains fail-closed.
ensure_block_rule(
&rules,
&BlockRuleSpec {
internal_name: OFFLINE_BLOCK_LOOPBACK_TCP_RULE_NAME,
friendly_desc: OFFLINE_BLOCK_LOOPBACK_TCP_RULE_FRIENDLY,
protocol: NET_FW_IP_PROTOCOL_TCP.0,
local_user_spec: &local_user_spec,
offline_sid,
remote_addresses: Some(LOOPBACK_REMOTE_ADDRESSES),
remote_ports: None,
},
log,
)?;
// Remove the legacy overlapping allow rule only after the explicit block rules are in
// place so transitions back to proxy-only mode do not fail open.
remove_rule_if_present(&rules, OFFLINE_PROXY_ALLOW_RULE_NAME, log)?;
if let Some(blocked_remote_ports) = blocked_loopback_tcp_remote_ports(proxy_ports) {
ensure_block_rule(
&rules,
&BlockRuleSpec {
internal_name: OFFLINE_BLOCK_LOOPBACK_TCP_RULE_NAME,
friendly_desc: OFFLINE_BLOCK_LOOPBACK_TCP_RULE_FRIENDLY,
protocol: NET_FW_IP_PROTOCOL_TCP.0,
local_user_spec: &local_user_spec,
offline_sid,
remote_addresses: Some(LOOPBACK_REMOTE_ADDRESSES),
remote_ports: Some(&blocked_remote_ports),
},
log,
)?;
}
Ok(())
})()
};
unsafe {
CoUninitialize();
}
result
}
pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Result<()> {
let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})");
@@ -61,11 +183,15 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul
// Block all outbound IP protocols for this user.
ensure_block_rule(
&rules,
OFFLINE_BLOCK_RULE_NAME,
OFFLINE_BLOCK_RULE_FRIENDLY,
NET_FW_IP_PROTOCOL_ANY.0,
&local_user_spec,
offline_sid,
&BlockRuleSpec {
internal_name: OFFLINE_BLOCK_RULE_NAME,
friendly_desc: OFFLINE_BLOCK_RULE_FRIENDLY,
protocol: NET_FW_IP_PROTOCOL_ANY.0,
local_user_spec: &local_user_spec,
offline_sid,
remote_addresses: Some(NON_LOOPBACK_REMOTE_ADDRESSES),
remote_ports: None,
},
log,
)?;
Ok(())
@@ -78,16 +204,22 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul
result
}
fn ensure_block_rule(
rules: &windows::Win32::NetworkManagement::WindowsFirewall::INetFwRules,
internal_name: &str,
friendly_desc: &str,
protocol: i32,
local_user_spec: &str,
offline_sid: &str,
log: &mut File,
) -> Result<()> {
fn remove_rule_if_present(rules: &INetFwRules, internal_name: &str, log: &mut File) -> Result<()> {
let name = BSTR::from(internal_name);
if unsafe { rules.Item(&name) }.is_ok() {
unsafe { rules.Remove(&name) }.map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleCreateOrAddFailed,
format!("Rules::Remove failed for {internal_name}: {err:?}"),
))
})?;
log_line(log, &format!("firewall rule removed name={internal_name}"))?;
}
Ok(())
}
fn ensure_block_rule(rules: &INetFwRules, spec: &BlockRuleSpec<'_>, log: &mut File) -> Result<()> {
let name = BSTR::from(spec.internal_name);
let rule: INetFwRule3 = match unsafe { rules.Item(&name) } {
Ok(existing) => existing.cast().map_err(|err| {
anyhow::Error::new(SetupFailure::new(
@@ -112,13 +244,7 @@ fn ensure_block_rule(
))
})?;
// Set all properties before adding the rule so we don't leave half-configured rules.
configure_rule(
&new_rule,
friendly_desc,
protocol,
local_user_spec,
offline_sid,
)?;
configure_rule(&new_rule, spec)?;
unsafe { rules.Add(&new_rule) }.map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleCreateOrAddFailed,
@@ -130,26 +256,24 @@ fn ensure_block_rule(
};
// Always re-apply fields to keep the setup idempotent.
configure_rule(&rule, friendly_desc, protocol, local_user_spec, offline_sid)?;
configure_rule(&rule, spec)?;
let remote_addresses_log = spec.remote_addresses.unwrap_or("*");
let remote_ports_log = spec.remote_ports.unwrap_or("*");
log_line(
log,
&format!(
"firewall rule configured name={internal_name} protocol={protocol} LocalUserAuthorizedList={local_user_spec}"
"firewall rule configured name={} protocol={} RemoteAddresses={remote_addresses_log} RemotePorts={remote_ports_log} LocalUserAuthorizedList={}",
spec.internal_name, spec.protocol, spec.local_user_spec
),
)?;
Ok(())
}
fn configure_rule(
rule: &INetFwRule3,
friendly_desc: &str,
protocol: i32,
local_user_spec: &str,
offline_sid: &str,
) -> Result<()> {
fn configure_rule(rule: &INetFwRule3, spec: &BlockRuleSpec<'_>) -> Result<()> {
unsafe {
rule.SetDescription(&BSTR::from(friendly_desc))
rule.SetDescription(&BSTR::from(spec.friendly_desc))
.map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleCreateOrAddFailed,
@@ -180,13 +304,29 @@ fn configure_rule(
format!("SetProfiles failed: {err:?}"),
))
})?;
rule.SetProtocol(protocol).map_err(|err| {
rule.SetProtocol(spec.protocol).map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleCreateOrAddFailed,
format!("SetProtocol failed: {err:?}"),
))
})?;
rule.SetLocalUserAuthorizedList(&BSTR::from(local_user_spec))
let remote_addresses = spec.remote_addresses.unwrap_or("*");
rule.SetRemoteAddresses(&BSTR::from(remote_addresses))
.map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleCreateOrAddFailed,
format!("SetRemoteAddresses failed: {err:?}"),
))
})?;
let remote_ports = spec.remote_ports.unwrap_or("*");
rule.SetRemotePorts(&BSTR::from(remote_ports))
.map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleCreateOrAddFailed,
format!("SetRemotePorts failed: {err:?}"),
))
})?;
rule.SetLocalUserAuthorizedList(&BSTR::from(spec.local_user_spec))
.map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleCreateOrAddFailed,
@@ -203,17 +343,59 @@ fn configure_rule(
))
})?;
let actual_str = actual.to_string();
if !actual_str.contains(offline_sid) {
if !actual_str.contains(spec.offline_sid) {
return Err(anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleVerifyFailed,
format!(
"offline firewall rule user scope mismatch: expected SID {offline_sid}, got {actual_str}"
"offline firewall rule user scope mismatch: expected SID {}, got {actual_str}",
spec.offline_sid
),
)));
}
Ok(())
}
fn blocked_loopback_tcp_remote_ports(proxy_ports: &[u16]) -> Option<String> {
let mut allowed_ports = proxy_ports
.iter()
.copied()
.filter(|port| *port != 0)
.collect::<Vec<_>>();
allowed_ports.sort_unstable();
allowed_ports.dedup();
let mut blocked_ranges = Vec::new();
let mut start = 1_u32;
for port in allowed_ports {
let port = u32::from(port);
if port < start {
continue;
}
if port > start {
blocked_ranges.push(port_range_string(start, port - 1));
}
start = port + 1;
}
if start <= u32::from(u16::MAX) {
blocked_ranges.push(port_range_string(start, u32::from(u16::MAX)));
}
if blocked_ranges.is_empty() {
None
} else {
Some(blocked_ranges.join(","))
}
}
fn port_range_string(start: u32, end: u32) -> String {
if start == end {
start.to_string()
} else {
format!("{start}-{end}")
}
}
fn log_line(log: &mut File, msg: &str) -> Result<()> {
let ts = chrono::Utc::now().to_rfc3339();
writeln!(log, "[{ts}] {msg}")?;

View File

@@ -3,9 +3,11 @@ use crate::logging::debug_log;
use crate::policy::SandboxPolicy;
use crate::setup::gather_read_roots;
use crate::setup::gather_write_roots;
use crate::setup::offline_proxy_settings_from_env;
use crate::setup::run_elevated_setup;
use crate::setup::sandbox_users_path;
use crate::setup::setup_marker_path;
use crate::setup::SandboxNetworkIdentity;
use crate::setup::SandboxUserRecord;
use crate::setup::SandboxUsersFile;
use crate::setup::SetupMarker;
@@ -101,7 +103,10 @@ fn decode_password(record: &SandboxUserRecord) -> Result<String> {
Ok(pwd)
}
fn select_identity(policy: &SandboxPolicy, codex_home: &Path) -> Result<Option<SandboxIdentity>> {
fn select_identity(
network_identity: SandboxNetworkIdentity,
codex_home: &Path,
) -> Result<Option<SandboxIdentity>> {
let _marker = match load_marker(codex_home)? {
Some(m) if m.version_matches() => m,
_ => return Ok(None),
@@ -110,10 +115,9 @@ fn select_identity(policy: &SandboxPolicy, codex_home: &Path) -> Result<Option<S
Some(u) if u.version_matches() => u,
_ => return Ok(None),
};
let chosen = if !policy.has_full_network_access() {
users.offline
} else {
users.online
let chosen = match network_identity {
SandboxNetworkIdentity::Offline => users.offline,
SandboxNetworkIdentity::Online => users.online,
};
let password = decode_password(&chosen)?;
Ok(Some(SandboxIdentity {
@@ -128,20 +132,24 @@ pub fn require_logon_sandbox_creds(
command_cwd: &Path,
env_map: &HashMap<String, String>,
codex_home: &Path,
proxy_enforced: bool,
) -> Result<SandboxCreds> {
let sandbox_dir = crate::setup::sandbox_dir(codex_home);
let needed_read = gather_read_roots(command_cwd, policy);
let needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
let network_identity = SandboxNetworkIdentity::from_policy(policy, proxy_enforced);
let desired_offline_proxy_settings =
offline_proxy_settings_from_env(env_map, network_identity);
// NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the
// restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for
// granting the sandbox group access to this directory without granting the capability SID.
let mut setup_reason: Option<String> = None;
let mut _existing_marker: Option<SetupMarker> = None;
let mut existing_marker: Option<SetupMarker> = None;
let mut identity = match load_marker(codex_home)? {
Some(marker) if marker.version_matches() => {
_existing_marker = Some(marker.clone());
let selected = select_identity(policy, codex_home)?;
existing_marker = Some(marker.clone());
let selected = select_identity(network_identity, codex_home)?;
if selected.is_none() {
setup_reason =
Some("sandbox users missing or incompatible with marker version".to_string());
@@ -153,6 +161,23 @@ pub fn require_logon_sandbox_creds(
None
}
};
if network_identity.uses_offline_identity() {
if let (Some(marker), Some(_)) = (&existing_marker, &identity) {
if marker.proxy_ports != desired_offline_proxy_settings.proxy_ports
|| marker.allow_local_binding
!= desired_offline_proxy_settings.allow_local_binding
{
setup_reason = Some(format!(
"offline firewall settings changed (stored_ports={:?}, desired_ports={:?}, stored_allow_local_binding={}, desired_allow_local_binding={})",
marker.proxy_ports,
desired_offline_proxy_settings.proxy_ports,
marker.allow_local_binding,
desired_offline_proxy_settings.allow_local_binding
));
identity = None;
}
}
}
if identity.is_none() {
if let Some(reason) = &setup_reason {
@@ -164,18 +189,30 @@ pub fn require_logon_sandbox_creds(
crate::logging::log_note("sandbox setup required", Some(&sandbox_dir));
}
run_elevated_setup(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
Some(needed_read.clone()),
Some(needed_write.clone()),
crate::setup::SandboxSetupRequest {
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
proxy_enforced,
},
crate::setup::SetupRootOverrides {
read_roots: Some(needed_read.clone()),
write_roots: Some(needed_write.clone()),
},
)?;
identity = select_identity(policy, codex_home)?;
identity = select_identity(network_identity, codex_home)?;
}
// Always refresh ACLs (non-elevated) for current roots via the setup binary.
crate::setup::run_setup_refresh(policy, policy_cwd, command_cwd, env_map, codex_home)?;
crate::setup::run_setup_refresh(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
proxy_enforced,
)?;
let identity = identity.ok_or_else(|| {
anyhow!(
"Windows sandbox setup is missing or out of date; rerun the sandbox setup with elevation"

View File

@@ -59,6 +59,8 @@ pub use dpapi::unprotect as dpapi_unprotect;
#[cfg(target_os = "windows")]
pub use elevated_impl::run_windows_sandbox_capture as run_windows_sandbox_capture_elevated;
#[cfg(target_os = "windows")]
pub use elevated_impl::ElevatedSandboxCaptureRequest;
#[cfg(target_os = "windows")]
pub use hide_users::hide_current_user_profile_dir;
#[cfg(target_os = "windows")]
pub use hide_users::hide_newly_created_users;
@@ -89,6 +91,10 @@ pub use setup::sandbox_dir;
#[cfg(target_os = "windows")]
pub use setup::sandbox_secrets_dir;
#[cfg(target_os = "windows")]
pub use setup::SandboxSetupRequest;
#[cfg(target_os = "windows")]
pub use setup::SetupRootOverrides;
#[cfg(target_os = "windows")]
pub use setup::SETUP_VERSION;
#[cfg(target_os = "windows")]
pub use setup_error::extract_failure as extract_setup_failure;

View File

@@ -63,6 +63,8 @@ pub fn provision_sandbox_users(
codex_home: &Path,
offline_username: &str,
online_username: &str,
proxy_ports: &[u16],
allow_local_binding: bool,
log: &mut File,
) -> Result<()> {
ensure_sandbox_users_group(log)?;
@@ -80,6 +82,8 @@ pub fn provision_sandbox_users(
&offline_password,
online_username,
&online_password,
proxy_ports,
allow_local_binding,
)?;
Ok(())
}
@@ -388,6 +392,8 @@ struct SetupMarker {
offline_username: String,
online_username: String,
created_at: String,
proxy_ports: Vec<u16>,
allow_local_binding: bool,
read_roots: Vec<PathBuf>,
write_roots: Vec<PathBuf>,
}
@@ -398,6 +404,8 @@ fn write_secrets(
offline_pwd: &str,
online_user: &str,
online_pwd: &str,
proxy_ports: &[u16],
allow_local_binding: bool,
) -> Result<()> {
let sandbox_dir = sandbox_dir(codex_home);
std::fs::create_dir_all(&sandbox_dir).map_err(|err| {
@@ -447,6 +455,8 @@ fn write_secrets(
offline_username: offline_user.to_string(),
online_username: online_user.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
proxy_ports: proxy_ports.to_vec(),
allow_local_binding,
read_roots: Vec::new(),
write_roots: Vec::new(),
};

View File

@@ -83,6 +83,10 @@ struct Payload {
command_cwd: PathBuf,
read_roots: Vec<PathBuf>,
write_roots: Vec<PathBuf>,
#[serde(default)]
proxy_ports: Vec<u16>,
#[serde(default)]
allow_local_binding: bool,
real_user: String,
#[serde(default)]
mode: SetupMode,
@@ -511,6 +515,8 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
&payload.codex_home,
&payload.offline_username,
&payload.online_username,
&payload.proxy_ports,
payload.allow_local_binding,
log,
);
if let Err(err) = provision_result {
@@ -573,6 +579,21 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
};
let mut refresh_errors: Vec<String> = Vec::new();
if !refresh_only {
let proxy_allowlist_result = firewall::ensure_offline_proxy_allowlist(
&offline_sid_str,
&payload.proxy_ports,
payload.allow_local_binding,
log,
);
if let Err(err) = proxy_allowlist_result {
if extract_setup_failure(&err).is_some() {
return Err(err);
}
return Err(anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallRuleCreateOrAddFailed,
format!("ensure offline proxy allowlist failed: {err}"),
)));
}
let firewall_result = firewall::ensure_offline_outbound_block(&offline_sid_str, log);
if let Err(err) = firewall_result {
if extract_setup_failure(&err).is_some() {

View File

@@ -1,5 +1,6 @@
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::c_void;
@@ -67,21 +68,39 @@ pub fn sandbox_users_path(codex_home: &Path) -> PathBuf {
sandbox_secrets_dir(codex_home).join("sandbox_users.json")
}
pub struct SandboxSetupRequest<'a> {
pub policy: &'a SandboxPolicy,
pub policy_cwd: &'a Path,
pub command_cwd: &'a Path,
pub env_map: &'a HashMap<String, String>,
pub codex_home: &'a Path,
pub proxy_enforced: bool,
}
#[derive(Default)]
pub struct SetupRootOverrides {
pub read_roots: Option<Vec<PathBuf>>,
pub write_roots: Option<Vec<PathBuf>>,
}
pub fn run_setup_refresh(
policy: &SandboxPolicy,
policy_cwd: &Path,
command_cwd: &Path,
env_map: &HashMap<String, String>,
codex_home: &Path,
proxy_enforced: bool,
) -> Result<()> {
run_setup_refresh_inner(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
None,
None,
SandboxSetupRequest {
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
proxy_enforced,
},
SetupRootOverrides::default(),
)
}
@@ -92,53 +111,51 @@ pub fn run_setup_refresh_with_extra_read_roots(
env_map: &HashMap<String, String>,
codex_home: &Path,
extra_read_roots: Vec<PathBuf>,
proxy_enforced: bool,
) -> Result<()> {
let mut read_roots = gather_read_roots(command_cwd, policy);
read_roots.extend(extra_read_roots);
run_setup_refresh_inner(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
Some(read_roots),
Some(Vec::new()),
SandboxSetupRequest {
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
proxy_enforced,
},
SetupRootOverrides {
read_roots: Some(read_roots),
write_roots: Some(Vec::new()),
},
)
}
fn run_setup_refresh_inner(
policy: &SandboxPolicy,
policy_cwd: &Path,
command_cwd: &Path,
env_map: &HashMap<String, String>,
codex_home: &Path,
read_roots_override: Option<Vec<PathBuf>>,
write_roots_override: Option<Vec<PathBuf>>,
request: SandboxSetupRequest<'_>,
overrides: SetupRootOverrides,
) -> Result<()> {
// Skip in danger-full-access.
if matches!(
policy,
request.policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
return Ok(());
}
let (read_roots, write_roots) = build_payload_roots(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
read_roots_override,
write_roots_override,
);
let (read_roots, write_roots) = build_payload_roots(&request, overrides);
let network_identity =
SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced);
let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity);
let payload = ElevationPayload {
version: SETUP_VERSION,
offline_username: OFFLINE_USERNAME.to_string(),
online_username: ONLINE_USERNAME.to_string(),
codex_home: codex_home.to_path_buf(),
command_cwd: command_cwd.to_path_buf(),
codex_home: request.codex_home.to_path_buf(),
command_cwd: request.command_cwd.to_path_buf(),
read_roots,
write_roots,
proxy_ports: offline_proxy_settings.proxy_ports,
allow_local_binding: offline_proxy_settings.allow_local_binding,
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
refresh_only: true,
};
@@ -148,7 +165,7 @@ fn run_setup_refresh_inner(
// Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC.
let mut cmd = Command::new(&exe);
cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null());
let cwd = std::env::current_dir().unwrap_or_else(|_| codex_home.to_path_buf());
let cwd = std::env::current_dir().unwrap_or_else(|_| request.codex_home.to_path_buf());
log_note(
&format!(
"setup refresh: spawning {} (cwd={}, payload_len={})",
@@ -156,14 +173,14 @@ fn run_setup_refresh_inner(
cwd.display(),
b64.len()
),
Some(&sandbox_dir(codex_home)),
Some(&sandbox_dir(request.codex_home)),
);
let status = cmd
.status()
.map_err(|e| {
log_note(
&format!("setup refresh: failed to spawn {}: {e}", exe.display()),
Some(&sandbox_dir(codex_home)),
Some(&sandbox_dir(request.codex_home)),
);
e
})
@@ -171,7 +188,7 @@ fn run_setup_refresh_inner(
if !status.success() {
log_note(
&format!("setup refresh: exited with status {status:?}"),
Some(&sandbox_dir(codex_home)),
Some(&sandbox_dir(request.codex_home)),
);
return Err(anyhow!("setup refresh failed with status {}", status));
}
@@ -185,6 +202,10 @@ pub struct SetupMarker {
pub online_username: String,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub proxy_ports: Vec<u16>,
#[serde(default)]
pub allow_local_binding: bool,
}
impl SetupMarker {
@@ -336,11 +357,106 @@ struct ElevationPayload {
command_cwd: PathBuf,
read_roots: Vec<PathBuf>,
write_roots: Vec<PathBuf>,
#[serde(default)]
proxy_ports: Vec<u16>,
#[serde(default)]
allow_local_binding: bool,
real_user: String,
#[serde(default)]
refresh_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OfflineProxySettings {
pub proxy_ports: Vec<u16>,
pub allow_local_binding: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SandboxNetworkIdentity {
Offline,
Online,
}
impl SandboxNetworkIdentity {
pub(crate) fn from_policy(policy: &SandboxPolicy, proxy_enforced: bool) -> Self {
if proxy_enforced || !policy.has_full_network_access() {
Self::Offline
} else {
Self::Online
}
}
pub(crate) fn uses_offline_identity(self) -> bool {
matches!(self, Self::Offline)
}
}
const PROXY_ENV_KEYS: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"WS_PROXY",
"WSS_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"ws_proxy",
"wss_proxy",
];
const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
pub(crate) fn offline_proxy_settings_from_env(
env_map: &HashMap<String, String>,
network_identity: SandboxNetworkIdentity,
) -> OfflineProxySettings {
if !network_identity.uses_offline_identity() {
return OfflineProxySettings {
proxy_ports: vec![],
allow_local_binding: false,
};
}
OfflineProxySettings {
proxy_ports: proxy_ports_from_env(env_map),
allow_local_binding: env_map
.get(ALLOW_LOCAL_BINDING_ENV_KEY)
.is_some_and(|value| value == "1"),
}
}
pub(crate) fn proxy_ports_from_env(env_map: &HashMap<String, String>) -> Vec<u16> {
let mut ports = BTreeSet::new();
for key in PROXY_ENV_KEYS {
if let Some(value) = env_map.get(*key) {
if let Some(port) = loopback_proxy_port_from_url(value) {
ports.insert(port);
}
}
}
ports.into_iter().collect()
}
fn loopback_proxy_port_from_url(url: &str) -> Option<u16> {
let authority = url.trim().split_once("://")?.1.split('/').next()?;
let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
if let Some(host) = host_port.strip_prefix('[') {
let (host, rest) = host.split_once(']')?;
if host != "::1" {
return None;
}
let port = rest.strip_prefix(':')?.parse::<u16>().ok()?;
return (port != 0).then_some(port);
}
let (host, port) = host_port.rsplit_once(':')?;
if !(host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1") {
return None;
}
let port = port.parse::<u16>().ok()?;
(port != 0).then_some(port)
}
fn quote_arg(arg: &str) -> String {
let needs = arg.is_empty()
|| arg
@@ -520,39 +636,31 @@ fn run_setup_exe(
}
pub fn run_elevated_setup(
policy: &SandboxPolicy,
policy_cwd: &Path,
command_cwd: &Path,
env_map: &HashMap<String, String>,
codex_home: &Path,
read_roots_override: Option<Vec<PathBuf>>,
write_roots_override: Option<Vec<PathBuf>>,
request: SandboxSetupRequest<'_>,
overrides: SetupRootOverrides,
) -> Result<()> {
// Ensure the shared sandbox directory exists before we send it to the elevated helper.
let sbx_dir = sandbox_dir(codex_home);
let sbx_dir = sandbox_dir(request.codex_home);
std::fs::create_dir_all(&sbx_dir).map_err(|err| {
failure(
SetupErrorCode::OrchestratorSandboxDirCreateFailed,
format!("failed to create sandbox dir {}: {err}", sbx_dir.display()),
)
})?;
let (read_roots, write_roots) = build_payload_roots(
policy,
policy_cwd,
command_cwd,
env_map,
codex_home,
read_roots_override,
write_roots_override,
);
let (read_roots, write_roots) = build_payload_roots(&request, overrides);
let network_identity =
SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced);
let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity);
let payload = ElevationPayload {
version: SETUP_VERSION,
offline_username: OFFLINE_USERNAME.to_string(),
online_username: ONLINE_USERNAME.to_string(),
codex_home: codex_home.to_path_buf(),
command_cwd: command_cwd.to_path_buf(),
codex_home: request.codex_home.to_path_buf(),
command_cwd: request.command_cwd.to_path_buf(),
read_roots,
write_roots,
proxy_ports: offline_proxy_settings.proxy_ports,
allow_local_binding: offline_proxy_settings.allow_local_binding,
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
refresh_only: false,
};
@@ -562,28 +670,28 @@ pub fn run_elevated_setup(
format!("failed to determine elevation state: {err}"),
)
})?;
run_setup_exe(&payload, needs_elevation, codex_home)
run_setup_exe(&payload, needs_elevation, request.codex_home)
}
fn build_payload_roots(
policy: &SandboxPolicy,
policy_cwd: &Path,
command_cwd: &Path,
env_map: &HashMap<String, String>,
codex_home: &Path,
read_roots_override: Option<Vec<PathBuf>>,
write_roots_override: Option<Vec<PathBuf>>,
request: &SandboxSetupRequest<'_>,
overrides: SetupRootOverrides,
) -> (Vec<PathBuf>, Vec<PathBuf>) {
let write_roots = if let Some(roots) = write_roots_override {
let write_roots = if let Some(roots) = overrides.write_roots {
canonical_existing(&roots)
} else {
gather_write_roots(policy, policy_cwd, command_cwd, env_map)
gather_write_roots(
request.policy,
request.policy_cwd,
request.command_cwd,
request.env_map,
)
};
let write_roots = filter_sensitive_write_roots(write_roots, codex_home);
let mut read_roots = if let Some(roots) = read_roots_override {
let write_roots = filter_sensitive_write_roots(write_roots, request.codex_home);
let mut read_roots = if let Some(roots) = overrides.read_roots {
canonical_existing(&roots)
} else {
gather_read_roots(command_cwd, policy)
gather_read_roots(request.command_cwd, request.policy)
};
let write_root_set: HashSet<PathBuf> = write_roots.iter().cloned().collect();
read_roots.retain(|root| !write_root_set.contains(root));
@@ -612,13 +720,109 @@ fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> V
#[cfg(test)]
mod tests {
use super::loopback_proxy_port_from_url;
use super::offline_proxy_settings_from_env;
use super::profile_read_roots;
use super::proxy_ports_from_env;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn loopback_proxy_url_parsing_supports_common_forms() {
assert_eq!(
loopback_proxy_port_from_url("http://localhost:3128"),
Some(3128)
);
assert_eq!(
loopback_proxy_port_from_url("https://127.0.0.1:8080"),
Some(8080)
);
assert_eq!(
loopback_proxy_port_from_url("socks5h://user:pass@[::1]:1080"),
Some(1080)
);
}
#[test]
fn loopback_proxy_url_parsing_rejects_non_loopback_and_zero_port() {
assert_eq!(
loopback_proxy_port_from_url("http://example.com:3128"),
None
);
assert_eq!(loopback_proxy_port_from_url("http://127.0.0.1:0"), None);
assert_eq!(loopback_proxy_port_from_url("localhost:8080"), None);
}
#[test]
fn proxy_ports_from_env_dedupes_and_sorts() {
let mut env = HashMap::new();
env.insert(
"HTTP_PROXY".to_string(),
"http://127.0.0.1:8080".to_string(),
);
env.insert(
"http_proxy".to_string(),
"http://localhost:8080".to_string(),
);
env.insert("ALL_PROXY".to_string(), "socks5h://[::1]:1081".to_string());
env.insert(
"HTTPS_PROXY".to_string(),
"https://example.com:9999".to_string(),
);
assert_eq!(proxy_ports_from_env(&env), vec![1081, 8080]);
}
#[test]
fn offline_proxy_settings_ignore_proxy_env_when_online_identity_selected() {
let mut env = HashMap::new();
env.insert(
"HTTP_PROXY".to_string(),
"http://127.0.0.1:8080".to_string(),
);
env.insert(
"CODEX_NETWORK_ALLOW_LOCAL_BINDING".to_string(),
"1".to_string(),
);
assert_eq!(
offline_proxy_settings_from_env(&env, super::SandboxNetworkIdentity::Online),
super::OfflineProxySettings {
proxy_ports: vec![],
allow_local_binding: false,
}
);
}
#[test]
fn offline_proxy_settings_capture_proxy_ports_and_local_binding_for_offline_identity() {
let mut env = HashMap::new();
env.insert(
"HTTP_PROXY".to_string(),
"http://127.0.0.1:8080".to_string(),
);
env.insert(
"ALL_PROXY".to_string(),
"socks5h://127.0.0.1:1081".to_string(),
);
env.insert(
"CODEX_NETWORK_ALLOW_LOCAL_BINDING".to_string(),
"1".to_string(),
);
assert_eq!(
offline_proxy_settings_from_env(&env, super::SandboxNetworkIdentity::Offline),
super::OfflineProxySettings {
proxy_ports: vec![1081, 8080],
allow_local_binding: true,
}
);
}
#[test]
fn profile_read_roots_excludes_configured_top_level_entries() {
let tmp = TempDir::new().expect("tempdir");