Route Windows sandbox proxy traffic by restricting SID (#34613)

## Why

Elevated Windows sandboxes need stable managed-proxy ports while preserving the network policy and environment attribution of each sandboxed process.

## What changed

- Keep shared HTTP and SOCKS5 loopback ingress listeners alive across managed-proxy instances.
- Add a per-route restricting SID to elevated sandbox tokens and dispatch incoming connections to the matching proxy policy after attributing the client process.
- Reject connections without exactly one registered route, remove routes when their proxy handle is dropped, and keep unsandboxed Windows launches off the managed ingress.
- Provision the elevated sandbox with the configured proxy ports and local-binding setting, honoring the selected profile and CLI overrides.

## Testing

- Add Windows unit tests for TCP ownership attribution, route selection, restricting-token propagation, and setup settings.
- Add an end-to-end Windows test covering stable ports, isolated environment policies, HTTP and SOCKS5 routing, and route teardown.

GitOrigin-RevId: 783fac6e0f904dc9bb1955b75d4a5895e8bb9690
This commit is contained in:
iceweasel-oai
2026-07-21 21:06:04 +00:00
committed by copyberry
parent dfd2d8133c
commit 999a715089
40 changed files with 2792 additions and 191 deletions
+2
View File
@@ -3606,6 +3606,7 @@ dependencies = [
"codex-utils-absolute-path",
"codex-utils-home-dir",
"codex-utils-rustls-provider",
"codex-windows-sandbox",
"globset",
"pretty_assertions",
"rama-core",
@@ -3629,6 +3630,7 @@ dependencies = [
"tokio",
"tracing",
"url",
"windows-sys 0.52.0",
]
[[package]]
+1
View File
@@ -488,6 +488,7 @@ async fn run_command_under_windows_session(
windows_sandbox_level: WindowsSandboxLevel::from_config(config),
proxy_settings_mode: WindowsSandboxProxySettingsMode::Reconcile,
proxy_enforced: false,
network_proxy_restricting_sid: None,
timeout_ms: None,
read_roots_override: None,
read_roots_include_platform_defaults: false,
+47 -14
View File
@@ -1445,6 +1445,14 @@ async fn cli_main(
.await?;
}
Some(Subcommand::Sandbox(mut sandbox_cli)) => {
let config_profile = sandbox_cli
.config_profile
.as_ref()
.or(interactive.config_profile_v2.as_ref());
prepend_config_flags(
&mut sandbox_cli.config_overrides,
root_config_overrides.clone(),
);
#[cfg(target_os = "windows")]
if let Some(setup_cli) = sandbox_setup::parse_setup_command(&sandbox_cli.command)? {
reject_remote_mode_for_subcommand(
@@ -1452,7 +1460,11 @@ async fn cli_main(
root_remote_auth_token_env.as_deref(),
"sandbox setup",
)?;
sandbox_setup::run(setup_cli).await?;
let cli_overrides = sandbox_cli
.config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
sandbox_setup::run(setup_cli, config_profile.cloned(), cli_overrides).await?;
return Ok(());
}
reject_remote_mode_for_subcommand(
@@ -1460,15 +1472,7 @@ async fn cli_main(
root_remote_auth_token_env.as_deref(),
"sandbox",
)?;
let config_profile = sandbox_cli
.config_profile
.as_ref()
.or(interactive.config_profile_v2.as_ref());
let loader_overrides = loader_overrides_for_profile(config_profile)?;
prepend_config_flags(
&mut sandbox_cli.config_overrides,
root_config_overrides.clone(),
);
#[cfg(target_os = "macos")]
codex_cli::run_command_under_seatbelt(
sandbox_cli,
@@ -1879,16 +1883,29 @@ fn loader_overrides_for_profile(
match profile_v2 {
Some(profile_v2) => {
let codex_home = find_codex_home()?;
Ok(LoaderOverrides {
user_config_path: Some(resolve_profile_v2_config_path(&codex_home, profile_v2)),
user_config_profile: Some(profile_v2.clone()),
..Default::default()
})
Ok(loader_overrides_for_profile_at_codex_home(
Some(profile_v2),
&codex_home,
))
}
None => Ok(LoaderOverrides::default()),
}
}
fn loader_overrides_for_profile_at_codex_home(
profile_v2: Option<&ProfileV2Name>,
codex_home: &std::path::Path,
) -> LoaderOverrides {
match profile_v2 {
Some(profile_v2) => LoaderOverrides {
user_config_path: Some(resolve_profile_v2_config_path(codex_home, profile_v2)),
user_config_profile: Some(profile_v2.clone()),
..Default::default()
},
None => LoaderOverrides::default(),
}
}
fn maybe_print_under_development_feature_warning(codex_home: &std::path::Path, feature: &str) {
let Some(spec) = FEATURES.iter().find(|spec| spec.key == feature) else {
return;
@@ -2706,6 +2723,22 @@ mod tests {
Ok(profile_v2_for_subcommand(&cli.interactive, subcommand)?.map(ToString::to_string))
}
#[test]
fn profile_loader_overrides_use_explicit_codex_home() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
let profile: ProfileV2Name = "work".parse()?;
let overrides =
loader_overrides_for_profile_at_codex_home(Some(&profile), codex_home.path());
assert_eq!(
overrides.user_config_path,
Some(resolve_profile_v2_config_path(codex_home.path(), &profile))
);
assert_eq!(overrides.user_config_profile, Some(profile));
Ok(())
}
#[test]
fn profile_v2_is_rejected_for_config_management_subcommands() {
assert!(profile_v2_for_args(&["codex", "--profile", "work", "features", "list"]).is_err());
+27 -3
View File
@@ -1,10 +1,14 @@
use std::path::PathBuf;
use anyhow::Context;
use clap::ArgAction;
use clap::ArgGroup;
use clap::Parser;
use codex_core::config::ConfigBuilder;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
use codex_utils_cli::ProfileV2Name;
use toml::Value as TomlValue;
#[derive(Debug, Parser)]
#[command(group(
@@ -54,9 +58,13 @@ impl SandboxSetupCommand {
}
}
pub(crate) async fn run(cmd: SandboxSetupCommand) -> anyhow::Result<()> {
pub(crate) async fn run(
cmd: SandboxSetupCommand,
config_profile: Option<ProfileV2Name>,
cli_overrides: Vec<(String, TomlValue)>,
) -> anyhow::Result<()> {
match cmd.setup_level()? {
SandboxSetupLevel::Elevated => run_elevated(cmd).await,
SandboxSetupLevel::Elevated => run_elevated(cmd, config_profile, cli_overrides).await,
}
}
@@ -75,12 +83,28 @@ pub(crate) fn parse_setup_command(
.map_err(anyhow::Error::from)
}
async fn run_elevated(cmd: SandboxSetupCommand) -> anyhow::Result<()> {
async fn run_elevated(
cmd: SandboxSetupCommand,
config_profile: Option<ProfileV2Name>,
cli_overrides: Vec<(String, TomlValue)>,
) -> anyhow::Result<()> {
let identity = resolve_sandbox_setup_identity(&cmd)?;
let config = ConfigBuilder::default()
.codex_home(identity.codex_home.clone())
.fallback_cwd(Some(identity.codex_home.clone()))
.loader_overrides(super::loader_overrides_for_profile_at_codex_home(
config_profile.as_ref(),
&identity.codex_home,
))
.cli_overrides(cli_overrides)
.build()
.await
.context("failed to load target user's Codex config for sandbox provisioning")?;
codex_core::windows_sandbox::run_elevated_provisioning_setup(
identity.codex_home.as_path(),
identity.real_user.as_str(),
config.permissions.network.as_ref(),
)?;
ConfigEditsBuilder::new(identity.codex_home.as_path())
.set_windows_sandbox_mode("elevated")
@@ -14,6 +14,8 @@ use codex_network_proxy::NetworkProxyHandle;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::build_config_state;
use codex_network_proxy::host_and_port_from_network_addr;
#[cfg(any(target_os = "windows", test))]
use codex_network_proxy::managed_proxy_ports;
use codex_network_proxy::normalize_host;
use codex_network_proxy::validate_policy_against_constraints;
use codex_protocol::models::PermissionProfile;
@@ -85,6 +87,16 @@ impl NetworkProxySpec {
self.config.enable_socks5
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn configured_proxy_ports(&self) -> std::io::Result<Vec<u16>> {
managed_proxy_ports(&self.config).map_err(std::io::Error::other)
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn allow_local_binding(&self) -> bool {
self.config.allow_local_binding
}
pub(crate) fn from_config_and_constraints(
config: NetworkProxyConfig,
requirements: Option<NetworkConstraints>,
+13
View File
@@ -632,6 +632,18 @@ async fn exec_windows_sandbox(
network_proxy_environment_error(network_environment_id.as_deref(), err)
})?;
}
let network_proxy_restricting_sid = network
.as_ref()
.map(|network| {
network
.network_proxy_restricting_sid(network_environment_id.as_deref())
.ok_or_else(|| {
CodexErr::Io(io::Error::other(
"managed Windows proxy route is missing its restricting SID",
))
})
})
.transpose()?;
// Windows sandbox capture still receives timeout and cancellation separately.
let (cancellation, timeout_ms) = if capture_policy.uses_expiration() {
@@ -686,6 +698,7 @@ async fn exec_windows_sandbox(
cancellation,
use_private_desktop: windows_sandbox_private_desktop,
proxy_enforced,
network_proxy_restricting_sid,
read_roots_override: elevated_read_roots_override.as_deref(),
read_roots_include_platform_defaults:
elevated_read_roots_include_platform_defaults,
+8 -1
View File
@@ -903,7 +903,14 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R
use tokio::io::AsyncReadExt as _;
use tokio::io::AsyncWriteExt as _;
let mut stream = tokio::net::TcpStream::connect(started_proxy.proxy().http_addr()).await?;
let prepared = started_proxy
.proxy()
.prepare_for_remote_environment(std::collections::HashMap::new(), "test-bridge")?;
let proxy_addr = prepared.env["HTTP_PROXY"]
.strip_prefix("http://")
.expect("HTTP proxy URL")
.parse::<std::net::SocketAddr>()?;
let mut stream = tokio::net::TcpStream::connect(proxy_addr).await?;
stream
.write_all(
b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n",
+2 -23
View File
@@ -19,7 +19,8 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY;
use codex_network_proxy::PROXY_ENV_KEYS;
#[cfg(target_os = "macos")]
use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path;
pub(crate) use codex_network_proxy::is_managed_proxy_env_var;
pub(crate) use codex_network_proxy::strip_managed_proxy_env;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_sandboxing::SandboxCommand;
@@ -69,28 +70,6 @@ pub(crate) fn exec_env_for_sandbox_permissions(
env
}
pub(crate) fn is_managed_proxy_env_var(key: &str, value: &str) -> bool {
if PROXY_ENV_KEYS.contains(&key) {
return true;
}
if CUSTOM_CA_ENV_KEYS.contains(&key) {
return is_managed_mitm_ca_trust_bundle_path(value);
}
#[cfg(target_os = "macos")]
{
key == PROXY_GIT_SSH_COMMAND_ENV_KEY
&& value.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER)
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
pub(crate) fn strip_managed_proxy_env(env: &mut HashMap<String, String>) {
env.retain(|key, value| !is_managed_proxy_env_var(key, value));
}
/// Prepends `path_entry` to `PATH`, removing duplicate and empty existing
/// entries.
///
@@ -1031,6 +1031,35 @@ impl UnifiedExecProcessManager {
if request.command.is_empty() {
return Err(UnifiedExecError::MissingCommandLine);
}
let network_proxy_restricting_sid = {
#[cfg(target_os = "windows")]
{
if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken {
request
.network
.as_ref()
.map(|network| {
network
.network_proxy_restricting_sid(
request.network_environment_id.as_deref(),
)
.ok_or_else(|| {
UnifiedExecError::create_process(
"managed Windows proxy route is missing its restricting SID"
.to_string(),
)
})
})
.transpose()?
} else {
None
}
}
#[cfg(not(target_os = "windows"))]
{
None::<String>
}
};
let windows_sandbox = if request.sandbox
== codex_sandboxing::SandboxType::WindowsRestrictedToken
{
@@ -1039,6 +1068,7 @@ impl UnifiedExecProcessManager {
workspace_roots: &request.windows_sandbox_workspace_roots,
windows_sandbox_level: request.windows_sandbox_level,
proxy_enforced: request.network.is_some(),
network_proxy_restricting_sid: network_proxy_restricting_sid.as_deref(),
proxy_settings_mode: codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile,
filesystem_overrides: request.windows_sandbox_filesystem_overrides.as_ref(),
use_private_desktop: request.windows_sandbox_private_desktop,
+28 -3
View File
@@ -123,9 +123,30 @@ pub fn run_elevated_setup(
)
}
#[cfg(any(target_os = "windows", test))]
fn provisioning_settings(
network: Option<&crate::config::NetworkProxySpec>,
) -> std::io::Result<codex_windows_sandbox::WindowsSandboxProvisioningSettings> {
let Some(network) = network.filter(|network| network.enabled()) else {
return Ok(codex_windows_sandbox::WindowsSandboxProvisioningSettings::default());
};
Ok(codex_windows_sandbox::WindowsSandboxProvisioningSettings {
proxy_ports: network.configured_proxy_ports()?,
allow_local_binding: network.allow_local_binding(),
})
}
#[cfg(target_os = "windows")]
pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> anyhow::Result<()> {
codex_windows_sandbox::run_elevated_provisioning_setup(codex_home, real_user)
pub fn run_elevated_provisioning_setup(
codex_home: &Path,
real_user: &str,
network: Option<&crate::config::NetworkProxySpec>,
) -> anyhow::Result<()> {
codex_windows_sandbox::run_elevated_provisioning_setup(
codex_home,
real_user,
provisioning_settings(network)?,
)
}
#[cfg(not(target_os = "windows"))]
@@ -140,7 +161,11 @@ pub fn run_elevated_setup(
}
#[cfg(not(target_os = "windows"))]
pub fn run_elevated_provisioning_setup(_codex_home: &Path, _real_user: &str) -> anyhow::Result<()> {
pub fn run_elevated_provisioning_setup(
_codex_home: &Path,
_real_user: &str,
_network: Option<&crate::config::NetworkProxySpec>,
) -> anyhow::Result<()> {
anyhow::bail!("elevated Windows sandbox setup is only supported on Windows")
}
@@ -2,6 +2,8 @@ use super::*;
use codex_config::types::WindowsToml;
use codex_features::Features;
use codex_features::FeaturesToml;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::models::PermissionProfile;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -115,3 +117,44 @@ fn resolve_windows_sandbox_private_desktop_respects_explicit_cfg_value() {
assert!(!resolve_windows_sandbox_private_desktop(&cfg));
}
#[test]
fn provisioning_settings_omit_the_disabled_socks_proxy() {
let config = NetworkProxyConfig {
enabled: true,
proxy_url: "http://127.0.0.1:43128".to_string(),
enable_socks5: false,
socks_url: "socks5h://127.0.0.1:48081".to_string(),
allow_local_binding: true,
..Default::default()
};
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
config,
/*requirements*/ None,
&PermissionProfile::workspace_write(),
)
.expect("managed proxy config should resolve");
assert_eq!(
provisioning_settings(Some(&spec)).expect("provisioning settings should resolve"),
codex_windows_sandbox::WindowsSandboxProvisioningSettings {
proxy_ports: vec![43128],
allow_local_binding: true,
}
);
}
#[test]
fn provisioning_settings_are_empty_when_managed_network_is_disabled() {
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
/*requirements*/ None,
&PermissionProfile::workspace_write(),
)
.expect("managed proxy config should resolve");
assert_eq!(
provisioning_settings(Some(&spec)).expect("provisioning settings should resolve"),
codex_windows_sandbox::WindowsSandboxProvisioningSettings::default()
);
}
@@ -8,6 +8,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::managed_network_requirements_loader;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
@@ -22,6 +23,7 @@ use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::time::Duration;
use test_case::test_case;
use tokio::net::TcpListener;
@@ -46,6 +48,12 @@ enum PushedExecScenario {
ReplayGap,
}
#[derive(Debug)]
struct PushedExecServerResult {
process_read_requests: usize,
process_start: Value,
}
async fn read_exec_server_json(websocket: &mut WebSocketStream<TcpStream>) -> Value {
loop {
match timeout(Duration::from_secs(5), websocket.next())
@@ -96,11 +104,18 @@ async fn accept_initialized_exec_server(listener: TcpListener) -> WebSocketStrea
async fn send_environment_info(websocket: &mut WebSocketStream<TcpStream>) {
let info = read_exec_server_json(websocket).await;
assert_eq!(info["method"], "environment/info");
respond_environment_info(websocket, &info["id"]).await;
}
async fn respond_environment_info(websocket: &mut WebSocketStream<TcpStream>, id: &Value) {
send_exec_server_json(
websocket,
json!({
"id": info["id"],
"result": { "shell": { "name": "zsh", "path": "/bin/zsh" } }
"id": id,
"result": {
"shell": { "name": "zsh", "path": "/bin/zsh" },
"capabilities": { "networkProxyLaunch": true }
}
}),
)
.await;
@@ -109,7 +124,7 @@ async fn send_environment_info(websocket: &mut WebSocketStream<TcpStream>) {
async fn serve_exec_with_pushed_events(
listener: TcpListener,
scenario: PushedExecScenario,
) -> usize {
) -> PushedExecServerResult {
let mut websocket = accept_initialized_exec_server(listener).await;
send_environment_info(&mut websocket).await;
@@ -117,6 +132,9 @@ async fn serve_exec_with_pushed_events(
let request = read_exec_server_json(&mut websocket).await;
match request["method"].as_str() {
Some("process/start") => break request,
Some("environment/info") => {
respond_environment_info(&mut websocket, &request["id"]).await;
}
Some("fs/getMetadata") => {
send_exec_server_json(
&mut websocket,
@@ -360,20 +378,25 @@ async fn serve_exec_with_pushed_events(
}),
)
.await;
return process_read_requests;
return PushedExecServerResult {
process_read_requests,
process_start,
};
}
method => panic!("unexpected exec-server request: {method:?}"),
}
}
}
#[test_case(PushedExecScenario::Complete ; "complete_event_stream")]
#[test_case(PushedExecScenario::DirectDenied ; "direct_sandbox_denial")]
#[test_case(PushedExecScenario::LegacyExit ; "legacy_exit_metadata")]
#[test_case(PushedExecScenario::ReplayGap ; "truncated_event_replay")]
#[test_case(PushedExecScenario::Complete, false ; "complete_event_stream")]
#[test_case(PushedExecScenario::DirectDenied, false ; "direct_sandbox_denial")]
#[test_case(PushedExecScenario::LegacyExit, false ; "legacy_exit_metadata")]
#[test_case(PushedExecScenario::ReplayGap, false ; "truncated_event_replay")]
#[test_case(PushedExecScenario::Complete, true ; "managed_network_uses_executor_proxy_launch")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_command_consumes_pushed_remote_process_events(
scenario: PushedExecScenario,
managed_network: bool,
) -> Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let server = start_mock_server().await;
@@ -386,7 +409,7 @@ async fn exec_command_consumes_pushed_remote_process_events(
CALL_ID,
"exec_command",
&json!({
"cmd": "ignored by fake exec-server",
"cmd": "pwd",
"yield_time_ms": 1_000,
})
.to_string(),
@@ -403,22 +426,46 @@ async fn exec_command_consumes_pushed_remote_process_events(
.await;
let exec_server_url = format!("ws://{}", listener.local_addr()?);
let exec_server = tokio::spawn(serve_exec_with_pushed_events(listener, scenario));
let mut builder = test_codex()
.with_exec_server_url(exec_server_url)
.with_config(|config| {
config.project_doc_max_bytes = 0;
config.use_experimental_unified_exec_tool = true;
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
});
let mut builder = test_codex().with_exec_server_url(exec_server_url);
if managed_network {
builder = builder
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_pre_build_hook(|home| {
fs::write(
home.join("config.toml"),
r#"default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.network]
enabled = true
mode = "full"
allow_local_binding = true
"#,
)
.expect("write managed-network test config");
});
}
let mut builder = builder.with_config(|config| {
config.project_doc_max_bytes = 0;
config.use_experimental_unified_exec_tool = true;
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
});
let test = timeout(Duration::from_secs(5), builder.build(&server))
.await
.context("thread startup should connect to the fake exec-server")??;
let turn_permission_profile = if managed_network {
test.session_configured.permission_profile.clone()
} else {
PermissionProfile::Disabled
};
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
turn_permission_fields(turn_permission_profile, test.config.cwd.as_path());
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
@@ -445,22 +492,47 @@ async fn exec_command_consumes_pushed_remote_process_events(
})
.await?;
let mut saw_exec_command_begin = false;
loop {
let event = timeout(Duration::from_secs(5), test.codex.next_event())
.await
.context("turn should complete")??
.msg;
match event {
EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => {
saw_exec_command_begin = true;
if !managed_network {
loop {
let event = timeout(Duration::from_secs(5), test.codex.next_event())
.await
.context("turn should complete")??
.msg;
match event {
EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => {
saw_exec_command_begin = true;
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
let process_read_requests = timeout(Duration::from_secs(5), exec_server)
let cleanup_timeout = if managed_network {
Duration::from_secs(15)
} else {
Duration::from_secs(5)
};
let exec_server_result = timeout(cleanup_timeout, exec_server)
.await
.context("fake exec-server should observe process cleanup")??;
if managed_network {
let params = &exec_server_result.process_start["params"];
assert_eq!(params["enforceManagedNetwork"], true);
assert_eq!(params["managedNetwork"], Value::Null);
assert_eq!(params["env"]["HTTP_PROXY"], Value::Null);
assert_eq!(params["networkProxy"]["proxy"]["enabled"], true);
assert_eq!(params["networkProxy"]["proxy"]["mode"], "full");
assert_eq!(params["networkProxy"]["environmentId"], "remote");
assert!(params["networkProxy"]["executionId"].as_str().is_some());
timeout(Duration::from_secs(5), async {
while response_mock.requests().len() < 2 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.context("model should receive the remote exec output")?;
return Ok(());
}
let request = response_mock
.last_request()
.context("model should receive the exec_command output")?;
@@ -468,6 +540,7 @@ async fn exec_command_consumes_pushed_remote_process_events(
.function_call_output_content_and_success(CALL_ID)
.context("exec_command output should be model visible")?;
let output = output.context("exec_command output should contain text")?;
let process_read_requests = exec_server_result.process_read_requests;
match scenario {
PushedExecScenario::Complete => {
assert_ne!(success, Some(false));
@@ -1372,6 +1372,8 @@ mod tests {
tokio::net::TcpStream::connect(proxy_addr)
.await
.expect("proxy should remain available to a child holding inherited output streams");
#[cfg(target_os = "windows")]
assert!(proxy.network_proxy_restricting_sid(None).is_some());
drop(process.stdout_tx);
drop(process.stderr_tx);
@@ -1382,6 +1384,9 @@ mod tests {
.await
.expect("process should close");
assert!(closed_response.closed);
#[cfg(target_os = "windows")]
assert_eq!(proxy.network_proxy_restricting_sid(None), None);
#[cfg(not(target_os = "windows"))]
assert!(tokio::net::TcpStream::connect(proxy_addr).await.is_err());
backend.shutdown().await;
}
+43 -8
View File
@@ -9,6 +9,8 @@ use codex_network_proxy::NetworkProxyHandle;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path;
#[cfg(target_os = "windows")]
use codex_network_proxy::strip_managed_proxy_env;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxDirectSpawnTransformRequest;
@@ -48,6 +50,7 @@ struct PreparedWindowsSandboxRequest {
workspace_roots: Vec<AbsolutePathBuf>,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
proxy_enforced: bool,
network_proxy_restricting_sid: Option<String>,
filesystem_overrides: Option<WindowsSandboxFilesystemOverrides>,
use_private_desktop: bool,
}
@@ -61,6 +64,7 @@ impl PreparedExecRequest {
workspace_roots: &request.workspace_roots,
windows_sandbox_level: request.windows_sandbox_level,
proxy_enforced: request.proxy_enforced,
network_proxy_restricting_sid: request.network_proxy_restricting_sid.as_deref(),
proxy_settings_mode: WindowsSandboxProxySettingsMode::Reconcile,
filesystem_overrides: request.filesystem_overrides.as_ref(),
use_private_desktop: request.use_private_desktop,
@@ -73,12 +77,24 @@ pub(crate) async fn prepare_exec_request(
env: HashMap<String, String>,
runtime_paths: Option<&ExecServerRuntimePaths>,
) -> Result<PreparedExecRequest, JSONRPCErrorError> {
let (env, managed_network, network_proxy_handle) = prepare_managed_network(
params.managed_network.as_ref(),
params.network_proxy.as_ref(),
env,
)
.await?;
#[cfg(target_os = "windows")]
let mut env = env;
#[cfg(target_os = "windows")]
let network_proxy = if params.sandbox.is_none() {
// Shared Windows ingress selects a route from the sandbox token's SID. Native launches
// have no route SID, so leave them direct.
if params.network_proxy.is_some() {
strip_managed_proxy_env(&mut env);
}
None
} else {
params.network_proxy.as_ref()
};
#[cfg(not(target_os = "windows"))]
let network_proxy = params.network_proxy.as_ref();
let (env, managed_network, network_proxy_handle, network_proxy_restricting_sid) =
prepare_managed_network(params.managed_network.as_ref(), network_proxy, env).await?;
let Some(sandbox_context) = params.sandbox.as_ref() else {
return Ok(PreparedExecRequest {
command: params.argv.clone(),
@@ -246,6 +262,7 @@ pub(crate) async fn prepare_exec_request(
workspace_roots: native_workspace_roots,
windows_sandbox_level: sandbox_context.windows_sandbox_level,
proxy_enforced,
network_proxy_restricting_sid,
filesystem_overrides,
use_private_desktop: sandbox_context.windows_sandbox_private_desktop,
})
@@ -272,11 +289,12 @@ async fn prepare_managed_network(
HashMap<String, String>,
Option<ManagedNetworkSandboxContext>,
Option<NetworkProxyHandle>,
Option<String>,
),
JSONRPCErrorError,
> {
let Some(network_proxy) = network_proxy.cloned() else {
return Ok((env, managed_network.cloned(), None));
return Ok((env, managed_network.cloned(), None, None));
};
let state = NetworkProxyState::from_remote_launch_config(network_proxy)
.map_err(|err| invalid_params(format!("invalid network proxy config: {err}")))?;
@@ -289,12 +307,29 @@ async fn prepare_managed_network(
.run()
.await
.map_err(|err| internal_error(format!("failed to start executor network proxy: {err}")))?;
#[cfg(target_os = "windows")]
let network_proxy_restricting_sid = Some(
proxy
.network_proxy_restricting_sid(/*environment_id*/ None)
.ok_or_else(|| {
internal_error(
"managed Windows proxy route is missing its restricting SID".to_string(),
)
})?,
);
#[cfg(not(target_os = "windows"))]
let network_proxy_restricting_sid = None;
let prepared = proxy
.prepare_for_optional_environment(env, /*environment_id*/ None)
.map_err(|err| {
internal_error(format!("failed to prepare executor network proxy: {err}"))
})?;
Ok((prepared.env, Some(prepared.sandbox_context), Some(handle)))
Ok((
prepared.env,
Some(prepared.sandbox_context),
Some(handle),
network_proxy_restricting_sid,
))
}
fn native_path(path: &PathUri, label: &str) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
@@ -231,7 +231,7 @@ async fn native_request_preserves_native_launch_fields() {
}
#[tokio::test]
async fn remote_proxy_config_starts_executor_local_proxy() {
async fn native_request_handles_remote_proxy_config_for_platform() {
let cwd: AbsolutePathBuf = std::env::current_dir()
.expect("current directory")
.try_into()
@@ -261,12 +261,22 @@ async fn remote_proxy_config_starts_executor_local_proxy() {
),
};
let stale_proxy = "http://127.0.0.1:9".to_string();
let env = HashMap::from([("HTTP_PROXY".to_string(), stale_proxy.clone())]);
let env = HashMap::from([
("HTTP_PROXY".to_string(), stale_proxy.clone()),
("TEST_ENV".to_string(), "value".to_string()),
]);
let prepared = prepare_exec_request(&params, env, /*runtime_paths*/ None)
.await
.expect("prepare request with executor-local proxy");
if cfg!(target_os = "windows") {
assert_eq!(prepared.env.get("HTTP_PROXY"), None);
assert_eq!(prepared.env.get("TEST_ENV"), Some(&"value".to_string()));
assert!(prepared.network_proxy_handle.is_none());
return;
}
let http_proxy = prepared.env.get("HTTP_PROXY").expect("HTTP proxy env");
assert_ne!(http_proxy, &stale_proxy);
assert!(http_proxy.starts_with("http://127.0.0.1:"));
@@ -297,6 +307,7 @@ async fn remote_proxy_config_starts_executor_local_proxy() {
.expect("shut down executor proxy");
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn disabled_remote_proxy_config_is_rejected_before_exporting_ports() {
let cwd: AbsolutePathBuf = std::env::current_dir()
@@ -350,6 +361,12 @@ async fn managed_network_selects_elevated_windows_spawn() {
cwd_uri.clone(),
);
sandbox.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken;
let proxy_config = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig {
enabled: true,
enable_socks5: false,
..NetworkProxyConfig::default()
})
.expect("supported remote proxy config");
let params = ExecParams {
process_id: ProcessId::from("process-managed-network"),
argv: vec!["cmd.exe".to_string(), "/c".to_string(), "exit".to_string()],
@@ -362,21 +379,31 @@ async fn managed_network_selects_elevated_windows_spawn() {
sandbox: Some(sandbox),
enforce_managed_network: true,
managed_network: None,
network_proxy: None,
network_proxy: Some(RemoteNetworkProxyLaunchConfig::new(proxy_config)),
};
let prepared = prepare_exec_request(&params, HashMap::new(), Some(&runtime_paths))
let mut prepared = prepare_exec_request(&params, HashMap::new(), Some(&runtime_paths))
.await
.expect("prepare sandboxed request");
let spawn = prepared
.windows_sandbox_spawn_request()
.expect("Windows sandbox spawn request");
{
let spawn = prepared
.windows_sandbox_spawn_request()
.expect("Windows sandbox spawn request");
assert_eq!(
spawn.windows_sandbox_level,
WindowsSandboxLevel::RestrictedToken
);
assert!(spawn.proxy_enforced);
assert_eq!(spawn.permission_profile, &permissions);
assert_eq!(spawn.workspace_roots, std::slice::from_ref(&cwd));
assert_eq!(
spawn.windows_sandbox_level,
WindowsSandboxLevel::RestrictedToken
);
assert!(spawn.proxy_enforced);
assert!(spawn.network_proxy_restricting_sid.is_some());
assert_eq!(spawn.permission_profile, &permissions);
assert_eq!(spawn.workspace_roots, std::slice::from_ref(&cwd));
}
prepared
.network_proxy_handle
.take()
.expect("running executor proxy")
.shutdown()
.await
.expect("shut down executor proxy");
}
+11
View File
@@ -51,3 +51,14 @@ security-framework = "3"
[target.'cfg(windows)'.dependencies]
schannel = "0.1"
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_NetworkManagement_IpHelper",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_System_Threading",
] }
[target.'cfg(windows)'.dev-dependencies]
codex-windows-sandbox = { path = "../windows-sandbox-rs" }
+46
View File
@@ -430,6 +430,26 @@ pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result<RuntimeConfig> {
})
}
/// Returns the sorted loopback ports used by the configured managed proxy listeners.
pub fn managed_proxy_ports(cfg: &NetworkProxyConfig) -> Result<Vec<u16>> {
let runtime = resolve_runtime(cfg)?;
if runtime.http_addr.port() == 0 {
bail!("network.proxy_url must use a fixed non-zero port for managed proxy provisioning");
}
let mut ports = vec![runtime.http_addr.port()];
if cfg.enable_socks5 {
if runtime.socks_addr.port() == 0 {
bail!(
"network.socks_url must use a fixed non-zero port for managed proxy provisioning"
);
}
ports.push(runtime.socks_addr.port());
}
ports.sort_unstable();
ports.dedup();
Ok(ports)
}
fn resolve_addr(url: &str, default_port: u16) -> Result<SocketAddr> {
let addr_parts = parse_host_port(url, default_port)?;
let host = if addr_parts.host.eq_ignore_ascii_case("localhost") {
@@ -605,6 +625,32 @@ mod tests {
);
}
#[test]
fn managed_proxy_ports_reject_ephemeral_ports() {
let mut config = NetworkProxyConfig {
proxy_url: "http://127.0.0.1:0".to_string(),
..Default::default()
};
assert_eq!(
managed_proxy_ports(&config).unwrap_err().to_string(),
"network.proxy_url must use a fixed non-zero port for managed proxy provisioning"
);
config.proxy_url = "http://127.0.0.1:3128".to_string();
config.socks_url = "socks5h://127.0.0.1:48081".to_string();
assert_eq!(managed_proxy_ports(&config).unwrap(), vec![3128, 48081]);
config.socks_url = "socks5h://127.0.0.1:0".to_string();
assert_eq!(
managed_proxy_ports(&config).unwrap_err().to_string(),
"network.socks_url must use a fixed non-zero port for managed proxy provisioning"
);
config.enable_socks5 = false;
assert_eq!(managed_proxy_ports(&config).unwrap(), vec![3128]);
}
#[test]
fn network_proxy_config_uses_struct_defaults_for_missing_fields() {
let config: NetworkProxyConfig = serde_json::from_str(r#"{ "enabled": true }"#).unwrap();
+18 -12
View File
@@ -40,6 +40,7 @@ use rama_core::error::ErrorExt as _;
use rama_core::error::OpaqueError;
use rama_core::extensions::ExtensionsMut;
use rama_core::extensions::ExtensionsRef;
use rama_core::service::BoxService;
use rama_core::service::service_fn;
use rama_core::stream::Stream;
use rama_http::Body;
@@ -66,6 +67,7 @@ use rama_net::proxy::ProxyRequest;
use rama_net::proxy::ProxyTarget;
use rama_net::proxy::StreamForwardService;
use rama_net::stream::SocketInfo;
use rama_tcp::TcpStream;
use rama_tcp::client::Request as TcpRequest;
use rama_tcp::server::TcpListener;
use rama_tls_rustls::client::TlsConnectorDataBuilder;
@@ -124,12 +126,25 @@ async fn run_http_proxy_with_listener(
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
environment_id: Option<String>,
) -> Result<()> {
ensure_rustls_crypto_provider();
let addr = listener
.local_addr()
.context("read HTTP proxy listener local addr")?;
info!("HTTP proxy listening on {addr}");
listener
.serve(http_proxy_service(state, policy_decider, environment_id))
.await;
Ok(())
}
pub(crate) fn http_proxy_service(
state: Arc<NetworkProxyState>,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
environment_id: Option<String>,
) -> BoxService<TcpStream, (), rama_core::error::BoxError> {
ensure_rustls_crypto_provider();
// This proxy listener only needs HTTP/1 proxy semantics. Using Rama's auto builder
// forces every accepted socket through the HTTP version sniffing pre-read path before proxy
// request parsing, which can stall some local clients on macOS before CONNECT/absolute-form
@@ -156,16 +171,7 @@ async fn run_http_proxy_with_listener(
})),
);
info!("HTTP proxy listening on {addr}");
listener
.serve(BindConnectionAttribution::new(
http_service,
state,
environment_id,
))
.await;
Ok(())
BindConnectionAttribution::new(http_service, state, environment_id).boxed()
}
async fn http_connect_accept(
+7
View File
@@ -19,6 +19,10 @@ mod runtime;
mod socks5;
mod state;
mod upstream;
#[cfg(target_os = "windows")]
mod windows_proxy_ingress;
#[cfg(target_os = "windows")]
mod windows_tcp_attribution;
pub use attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
pub use attribution::write_attribution_frame;
@@ -32,6 +36,7 @@ pub use config::NetworkProxyConfig;
pub use config::NetworkUnixSocketPermission;
pub use config::NetworkUnixSocketPermissions;
pub use config::host_and_port_from_network_addr;
pub use config::managed_proxy_ports;
pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY;
pub use credential_broker::brokered_credential_dummy_env_keys;
pub use credential_broker::brokered_credential_env_keys;
@@ -67,7 +72,9 @@ pub use proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
pub use proxy::PROXY_URL_ENV_KEYS;
pub use proxy::PreparedManagedNetwork;
pub use proxy::has_proxy_url_env_vars;
pub use proxy::is_managed_proxy_env_var;
pub use proxy::proxy_url_env_value;
pub use proxy::strip_managed_proxy_env;
pub use remote_config::RemoteNetworkProxyConfig;
pub use remote_config::RemoteNetworkProxyLaunchConfig;
pub use runtime::BlockedRequest;
File diff suppressed because it is too large Load Diff
+20 -11
View File
@@ -29,6 +29,7 @@ use rama_core::error::BoxError;
use rama_core::extensions::Extensions;
use rama_core::extensions::ExtensionsMut;
use rama_core::extensions::ExtensionsRef;
use rama_core::service::BoxService;
use rama_core::service::service_fn;
use rama_net::address::HostWithPort;
use rama_net::client::EstablishedClientConnection;
@@ -129,6 +130,23 @@ async fn run_socks5_with_listener(
}
}
listener
.serve(socks5_proxy_service(
state,
policy_decider,
environment_id,
enable_socks5_udp,
))
.await;
Ok(())
}
pub(crate) fn socks5_proxy_service(
state: Arc<NetworkProxyState>,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
environment_id: Option<String>,
enable_socks5_udp: bool,
) -> BoxService<TcpStream, (), BoxError> {
let tcp_connector = TargetCheckedTcpConnector::new(state.clone());
let policy_tcp_connector = service_fn({
let policy_decider = policy_decider.clone();
@@ -163,19 +181,10 @@ async fn run_socks5_with_listener(
}
}));
let socks_acceptor = base.with_udp_associator(udp_relay);
listener
.serve(BindConnectionAttribution::new(
socks_acceptor,
state,
environment_id,
))
.await;
BindConnectionAttribution::new(socks_acceptor, state, environment_id).boxed()
} else {
listener
.serve(BindConnectionAttribution::new(base, state, environment_id))
.await;
BindConnectionAttribution::new(base, state, environment_id).boxed()
}
Ok(())
}
async fn handle_socks5_tcp(
@@ -0,0 +1,368 @@
use crate::proxy::reserve_windows_managed_listeners;
use crate::proxy::reserve_windows_managed_socks_listener;
use crate::proxy::windows_managed_loopback_addr;
use crate::windows_tcp_attribution::restricting_sids_for_tcp_connection;
use anyhow::Context;
use anyhow::Result;
use rama_core::Service;
use rama_core::error::BoxError;
use rama_core::service::BoxService;
use rama_net::stream::Socket;
use rama_tcp::TcpStream;
use rama_tcp::server::TcpListener;
use std::collections::HashMap;
use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::Mutex;
#[cfg(test)]
use std::sync::Weak;
use tokio::runtime::Handle;
use tokio::task::JoinHandle;
use tracing::info;
pub(crate) type WindowsRouteService = BoxService<TcpStream, (), BoxError>;
// Production keeps the listeners alive for the process lifetime so their ports remain stable even
// when no routes are registered. Crate tests use independent Tokio runtimes and requested ports, so
// they retain only a weak reference and can tear each ingress down between tests.
#[cfg(not(test))]
static SHARED_INGRESS: LazyLock<Mutex<Option<Arc<WindowsProxyIngress>>>> =
LazyLock::new(|| Mutex::new(None));
#[cfg(test)]
static SHARED_INGRESS: LazyLock<Mutex<Weak<WindowsProxyIngress>>> =
LazyLock::new(|| Mutex::new(Weak::new()));
#[derive(Clone)]
struct RouteServices {
http: WindowsRouteService,
socks: Option<WindowsRouteService>,
}
type RouteRegistry = Arc<Mutex<HashMap<String, Arc<RouteServices>>>>;
#[derive(Clone, Copy)]
enum ProxyProtocol {
Http,
Socks,
}
#[derive(Clone)]
struct IngressDispatcher {
routes: RouteRegistry,
protocol: ProxyProtocol,
}
impl Service<TcpStream> for IngressDispatcher {
type Output = ();
type Error = BoxError;
async fn serve(&self, stream: TcpStream) -> Result<(), BoxError> {
let local_addr = stream.local_addr()?;
let peer_addr = stream.peer_addr()?;
let restricting_sids = restricting_sids_for_tcp_connection(local_addr, peer_addr)?;
let route = {
let routes = self
.routes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
registered_route_for_sids(&routes, &restricting_sids)?
};
let service = match self.protocol {
ProxyProtocol::Http => route.http.clone(),
ProxyProtocol::Socks => route.socks.clone().ok_or_else(|| {
io::Error::new(
io::ErrorKind::PermissionDenied,
"network proxy route does not enable SOCKS5",
)
})?,
};
service.serve(stream).await
}
}
pub(crate) struct WindowsProxyIngress {
http_addr: SocketAddr,
routes: RouteRegistry,
runtime: Handle,
http_task: JoinHandle<()>,
socks: Mutex<SocksListenerState>,
}
struct SocksListenerState {
addr: SocketAddr,
task: Option<JoinHandle<()>>,
}
impl WindowsProxyIngress {
pub(crate) fn shared(
requested_http_addr: SocketAddr,
requested_socks_addr: SocketAddr,
reserve_socks_listener: bool,
) -> Result<Arc<Self>> {
let requested_http_addr = windows_managed_loopback_addr(requested_http_addr);
let requested_socks_addr = windows_managed_loopback_addr(requested_socks_addr);
let mut shared = SHARED_INGRESS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
#[cfg(not(test))]
if let Some(ingress) = shared.as_ref()
&& ingress.is_running()
{
if reserve_socks_listener {
ingress.ensure_socks_listener(requested_socks_addr)?;
}
return Ok(Arc::clone(ingress));
}
#[cfg(not(test))]
shared.take();
#[cfg(test)]
if let Some(ingress) = shared.upgrade()
&& ingress.is_running()
{
if reserve_socks_listener {
ingress.ensure_socks_listener(requested_socks_addr)?;
}
return Ok(ingress);
}
let listeners = reserve_windows_managed_listeners(
requested_http_addr,
requested_socks_addr,
reserve_socks_listener,
)
.context("reserve shared managed Windows proxy ingress")?;
let http_addr = listeners.http_addr()?;
let socks_addr = listeners.socks_addr(requested_socks_addr)?;
let (http_listener, socks_listener) = listeners.into_listeners();
let http_listener =
TcpListener::try_from(http_listener).context("convert shared HTTP ingress listener")?;
let socks_listener = socks_listener
.map(TcpListener::try_from)
.transpose()
.context("convert shared SOCKS5 ingress listener")?;
let runtime =
Handle::try_current().context("start shared managed Windows proxy ingress")?;
let routes = Arc::new(Mutex::new(HashMap::new()));
let http_task = runtime.spawn(run_listener(
http_listener,
IngressDispatcher {
routes: Arc::clone(&routes),
protocol: ProxyProtocol::Http,
},
"HTTP",
http_addr,
));
let socks_task = socks_listener
.map(|listener| spawn_socks_listener(&runtime, &routes, listener, socks_addr));
let ingress = Arc::new(Self {
http_addr,
routes,
runtime,
http_task,
socks: Mutex::new(SocksListenerState {
addr: socks_addr,
task: socks_task,
}),
});
#[cfg(not(test))]
{
*shared = Some(Arc::clone(&ingress));
}
#[cfg(test)]
{
*shared = Arc::downgrade(&ingress);
}
Ok(ingress)
}
pub(crate) fn http_addr(&self) -> SocketAddr {
self.http_addr
}
pub(crate) fn socks_addr(&self) -> SocketAddr {
self.socks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.addr
}
pub(crate) fn active_socks_addr(&self) -> Option<SocketAddr> {
let socks = self
.socks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
socks
.task
.as_ref()
.filter(|task| !task.is_finished())
.map(|_| socks.addr)
}
pub(crate) fn register_route(
self: &Arc<Self>,
http: WindowsRouteService,
socks: Option<WindowsRouteService>,
) -> WindowsProxyRoute {
let services = Arc::new(RouteServices { http, socks });
let mut routes = self
.routes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let sid = loop {
let sid = random_restricting_sid();
if !routes.contains_key(&sid) {
break sid;
}
};
routes.insert(sid.clone(), Arc::clone(&services));
WindowsProxyRoute {
sid,
services,
ingress: Arc::clone(self),
}
}
fn is_running(&self) -> bool {
!self.http_task.is_finished()
&& self
.socks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.task
.as_ref()
.is_none_or(|task| !task.is_finished())
}
fn ensure_socks_listener(&self, requested_addr: SocketAddr) -> Result<()> {
let mut socks = self
.socks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(task) = socks.task.as_ref() {
anyhow::ensure!(
!task.is_finished(),
"shared managed Windows SOCKS5 ingress stopped"
);
return Ok(());
}
let listener = reserve_windows_managed_socks_listener(requested_addr)
.context("reserve shared managed Windows SOCKS5 ingress")?;
let addr = listener
.local_addr()
.context("read shared managed Windows SOCKS5 ingress address")?;
let listener = {
let _runtime = self.runtime.enter();
TcpListener::try_from(listener)
}
.context("convert shared SOCKS5 ingress listener")?;
let task = spawn_socks_listener(&self.runtime, &self.routes, listener, addr);
socks.addr = addr;
socks.task = Some(task);
Ok(())
}
}
impl Drop for WindowsProxyIngress {
fn drop(&mut self) {
self.http_task.abort();
let socks = self
.socks
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(socks_task) = socks.task.as_ref() {
socks_task.abort();
}
}
}
fn spawn_socks_listener(
runtime: &Handle,
routes: &RouteRegistry,
listener: TcpListener,
addr: SocketAddr,
) -> JoinHandle<()> {
runtime.spawn(run_listener(
listener,
IngressDispatcher {
routes: Arc::clone(routes),
protocol: ProxyProtocol::Socks,
},
"SOCKS5",
addr,
))
}
pub(crate) struct WindowsProxyRoute {
sid: String,
services: Arc<RouteServices>,
ingress: Arc<WindowsProxyIngress>,
}
impl WindowsProxyRoute {
pub(crate) fn sid(&self) -> &str {
&self.sid
}
}
impl Drop for WindowsProxyRoute {
fn drop(&mut self) {
let mut routes = self
.ingress
.routes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if routes
.get(&self.sid)
.is_some_and(|services| Arc::ptr_eq(services, &self.services))
{
routes.remove(&self.sid);
}
}
}
async fn run_listener(
listener: TcpListener,
dispatcher: IngressDispatcher,
protocol: &'static str,
addr: SocketAddr,
) {
info!("shared managed Windows {protocol} proxy ingress listening on {addr}");
listener.serve(dispatcher).await;
}
fn registered_route_for_sids(
routes: &HashMap<String, Arc<RouteServices>>,
restricting_sids: &[String],
) -> io::Result<Arc<RouteServices>> {
let mut matching_routes = restricting_sids
.iter()
.filter_map(|sid| routes.get(sid).cloned());
let route = matching_routes.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::PermissionDenied,
"proxy client token has no registered network proxy route SID",
)
})?;
if matching_routes.next().is_some() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"proxy client token has multiple registered network proxy route SIDs",
));
}
Ok(route)
}
fn random_restricting_sid() -> String {
let a = rand::random::<u32>();
let b = rand::random::<u32>();
let c = rand::random::<u32>();
let d = rand::random::<u32>();
format!("S-1-5-21-{a}-{b}-{c}-{d}")
}
#[cfg(test)]
#[path = "windows_proxy_ingress_tests.rs"]
mod tests;
@@ -0,0 +1,43 @@
use super::*;
use rama_core::service::service_fn;
#[test]
fn selects_exactly_one_registered_route() {
let route = route_services();
let routes = HashMap::from([("registered".to_string(), Arc::clone(&route))]);
let selected = registered_route_for_sids(
&routes,
&["unrelated".to_string(), "registered".to_string()],
)
.expect("one registered route should be selected");
assert!(Arc::ptr_eq(&selected, &route));
}
#[test]
fn rejects_missing_or_ambiguous_registered_routes() {
let first = route_services();
let second = route_services();
let routes = HashMap::from([("first".to_string(), first), ("second".to_string(), second)]);
let Err(missing) = registered_route_for_sids(&routes, &["missing".to_string()]) else {
panic!("an unknown SID should fail closed");
};
let Err(ambiguous) =
registered_route_for_sids(&routes, &["first".to_string(), "second".to_string()])
else {
panic!("multiple registered SIDs should fail closed");
};
assert_eq!(missing.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(ambiguous.kind(), io::ErrorKind::PermissionDenied);
}
fn route_services() -> Arc<RouteServices> {
let service = service_fn(|_stream: TcpStream| async { Ok::<(), BoxError>(()) }).boxed();
Arc::new(RouteServices {
http: service,
socks: None,
})
}
@@ -0,0 +1,315 @@
use std::ffi::c_void;
use std::io;
use std::mem::offset_of;
use std::mem::size_of;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::SocketAddrV4;
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::FromRawHandle;
use std::os::windows::io::OwnedHandle;
use std::os::windows::io::RawHandle;
use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
use windows_sys::Win32::Foundation::GetLastError;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::HLOCAL;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Foundation::NO_ERROR;
use windows_sys::Win32::Foundation::PSID;
use windows_sys::Win32::NetworkManagement::IpHelper::GetExtendedTcpTable;
use windows_sys::Win32::NetworkManagement::IpHelper::MIB_TCPROW_OWNER_PID;
use windows_sys::Win32::NetworkManagement::IpHelper::MIB_TCPTABLE_OWNER_PID;
use windows_sys::Win32::NetworkManagement::IpHelper::TCP_TABLE_OWNER_PID_CONNECTIONS;
use windows_sys::Win32::Networking::WinSock::AF_INET;
use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
use windows_sys::Win32::Security::GetTokenInformation;
use windows_sys::Win32::Security::SID_AND_ATTRIBUTES;
use windows_sys::Win32::Security::TOKEN_GROUPS;
use windows_sys::Win32::Security::TOKEN_QUERY;
use windows_sys::Win32::Security::TokenRestrictedSids;
use windows_sys::Win32::System::Threading::OpenProcess;
use windows_sys::Win32::System::Threading::OpenProcessToken;
use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
/// Returns the restricting SIDs on the process that opened an accepted loopback connection.
///
/// `accepted_local_addr` and `accepted_peer_addr` must come from the accepted server socket. The
/// owning-PID table describes the client side in the opposite direction, so the lookup matches the
/// exact reversed four-tuple.
pub(crate) fn restricting_sids_for_tcp_connection(
accepted_local_addr: SocketAddr,
accepted_peer_addr: SocketAddr,
) -> io::Result<Vec<String>> {
let (SocketAddr::V4(accepted_local_addr), SocketAddr::V4(accepted_peer_addr)) =
(accepted_local_addr, accepted_peer_addr)
else {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"Windows proxy connection attribution currently supports IPv4 only",
));
};
let process_id = owning_process_id(accepted_local_addr, accepted_peer_addr)?;
restricting_sids_for_process(process_id)
}
fn owning_process_id(
accepted_local_addr: SocketAddrV4,
accepted_peer_addr: SocketAddrV4,
) -> io::Result<u32> {
let mut byte_len = 0_u32;
let result = unsafe {
GetExtendedTcpTable(
std::ptr::null_mut(),
&mut byte_len,
0,
AF_INET as u32,
TCP_TABLE_OWNER_PID_CONNECTIONS,
0,
)
};
if result != ERROR_INSUFFICIENT_BUFFER {
return Err(win32_error("query IPv4 TCP owner table size", result));
}
let buffer = loop {
let mut buffer = aligned_buffer(byte_len as usize)?;
let result = unsafe {
GetExtendedTcpTable(
buffer.as_mut_ptr().cast::<c_void>(),
&mut byte_len,
0,
AF_INET as u32,
TCP_TABLE_OWNER_PID_CONNECTIONS,
0,
)
};
match result {
NO_ERROR => break buffer,
ERROR_INSUFFICIENT_BUFFER => continue,
_ => return Err(win32_error("read IPv4 TCP owner table", result)),
}
};
let rows = parse_tcp_owner_rows(&buffer, byte_len as usize)?;
unique_client_process_id(rows, accepted_local_addr, accepted_peer_addr)
}
fn parse_tcp_owner_rows(buffer: &[usize], byte_len: usize) -> io::Result<&[MIB_TCPROW_OWNER_PID]> {
let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
if byte_len > size_of_val(buffer) || byte_len < rows_offset {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid IPv4 TCP owner table length",
));
}
let row_count = unsafe { std::ptr::read_unaligned(buffer.as_ptr().cast::<u32>()) } as usize;
let rows_byte_len = row_count
.checked_mul(size_of::<MIB_TCPROW_OWNER_PID>())
.and_then(|len| rows_offset.checked_add(len))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"IPv4 TCP owner table length overflow",
)
})?;
if rows_byte_len > byte_len {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"truncated IPv4 TCP owner table",
));
}
let rows = unsafe {
let rows_ptr = buffer
.as_ptr()
.cast::<u8>()
.add(rows_offset)
.cast::<MIB_TCPROW_OWNER_PID>();
std::slice::from_raw_parts(rows_ptr, row_count)
};
Ok(rows)
}
fn unique_client_process_id(
rows: &[MIB_TCPROW_OWNER_PID],
accepted_local_addr: SocketAddrV4,
accepted_peer_addr: SocketAddrV4,
) -> io::Result<u32> {
let mut matching_process_ids = rows
.iter()
.filter(|row| client_row_matches(row, accepted_local_addr, accepted_peer_addr))
.map(|row| row.dwOwningPid);
let process_id = matching_process_ids.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"accepted connection is absent from the IPv4 TCP owner table",
)
})?;
if matching_process_ids.next().is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"accepted connection has multiple IPv4 TCP owner rows",
));
}
Ok(process_id)
}
fn client_row_matches(
row: &MIB_TCPROW_OWNER_PID,
accepted_local_addr: SocketAddrV4,
accepted_peer_addr: SocketAddrV4,
) -> bool {
ipv4_addr_matches(row.dwLocalAddr, *accepted_peer_addr.ip())
&& tcp_port(row.dwLocalPort) == accepted_peer_addr.port()
&& ipv4_addr_matches(row.dwRemoteAddr, *accepted_local_addr.ip())
&& tcp_port(row.dwRemotePort) == accepted_local_addr.port()
}
fn ipv4_addr_matches(table_addr: u32, socket_addr: Ipv4Addr) -> bool {
table_addr.to_ne_bytes() == socket_addr.octets()
}
fn tcp_port(table_port: u32) -> u16 {
u16::from_be(table_port as u16)
}
fn restricting_sids_for_process(process_id: u32) -> io::Result<Vec<String>> {
let process_handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) };
let process = owned_handle(process_handle, "open proxy client process")?;
let mut token_handle: HANDLE = 0;
let opened = unsafe {
OpenProcessToken(
process.as_raw_handle() as HANDLE,
TOKEN_QUERY,
&mut token_handle,
)
};
if opened == 0 {
return Err(last_error("open proxy client process token"));
}
let token = owned_handle(token_handle, "open proxy client process token")?;
let mut byte_len = 0_u32;
let queried = unsafe {
GetTokenInformation(
token.as_raw_handle() as HANDLE,
TokenRestrictedSids,
std::ptr::null_mut(),
0,
&mut byte_len,
)
};
if queried != 0 || unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER {
return Err(last_error("query proxy client restricting SID buffer size"));
}
let mut buffer = aligned_buffer(byte_len as usize)?;
let queried = unsafe {
GetTokenInformation(
token.as_raw_handle() as HANDLE,
TokenRestrictedSids,
buffer.as_mut_ptr().cast::<c_void>(),
byte_len,
&mut byte_len,
)
};
if queried == 0 {
return Err(last_error("read proxy client restricting SIDs"));
}
parse_token_groups(&buffer, byte_len as usize)?
.iter()
.map(|entry| sid_to_string(entry.Sid))
.collect()
}
fn parse_token_groups(buffer: &[usize], byte_len: usize) -> io::Result<&[SID_AND_ATTRIBUTES]> {
let groups_offset = offset_of!(TOKEN_GROUPS, Groups);
if byte_len > size_of_val(buffer) || byte_len < groups_offset {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid restricting SID buffer length",
));
}
let group_count = unsafe { std::ptr::read_unaligned(buffer.as_ptr().cast::<u32>()) } as usize;
let groups_byte_len = group_count
.checked_mul(size_of::<SID_AND_ATTRIBUTES>())
.and_then(|len| groups_offset.checked_add(len))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"restricting SID buffer length overflow",
)
})?;
if groups_byte_len > byte_len {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"truncated restricting SID buffer",
));
}
let groups = unsafe {
let groups_ptr = buffer
.as_ptr()
.cast::<u8>()
.add(groups_offset)
.cast::<SID_AND_ATTRIBUTES>();
std::slice::from_raw_parts(groups_ptr, group_count)
};
Ok(groups)
}
fn sid_to_string(sid: PSID) -> io::Result<String> {
let mut string_sid = std::ptr::null_mut();
if unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } == 0 {
return Err(last_error("convert proxy client restricting SID to string"));
}
let value = unsafe {
let mut len = 0;
while *string_sid.add(len) != 0 {
len += 1;
}
String::from_utf16_lossy(std::slice::from_raw_parts(string_sid, len))
};
unsafe {
LocalFree(string_sid as HLOCAL);
}
Ok(value)
}
fn aligned_buffer(byte_len: usize) -> io::Result<Vec<usize>> {
if byte_len == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Windows API returned an empty buffer length",
));
}
Ok(vec![0; byte_len.div_ceil(size_of::<usize>())])
}
fn owned_handle(handle: HANDLE, operation: &str) -> io::Result<OwnedHandle> {
if handle == 0 {
return Err(last_error(operation));
}
Ok(unsafe { OwnedHandle::from_raw_handle(handle as RawHandle) })
}
fn win32_error(operation: &str, error_code: u32) -> io::Error {
let error = io::Error::from_raw_os_error(error_code as i32);
io::Error::new(error.kind(), format!("{operation}: {error}"))
}
fn last_error(operation: &str) -> io::Error {
let error = io::Error::last_os_error();
io::Error::new(error.kind(), format!("{operation}: {error}"))
}
#[cfg(test)]
#[path = "windows_tcp_attribution_tests.rs"]
mod tests;
@@ -0,0 +1,112 @@
use super::*;
use pretty_assertions::assert_eq;
use std::net::TcpListener;
use std::net::TcpStream;
#[test]
fn parses_owner_table_and_matches_reversed_client_tuple() -> io::Result<()> {
let proxy_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 3128);
let client_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 49152);
let rows = [
tcp_row(proxy_addr, client_addr, 100),
tcp_row(client_addr, proxy_addr, 200),
];
let (buffer, byte_len) = owner_table_buffer(&rows);
let parsed = parse_tcp_owner_rows(&buffer, byte_len)?;
assert_eq!(
unique_client_process_id(parsed, proxy_addr, client_addr)?,
200
);
Ok(())
}
#[test]
fn rejects_multiple_matching_owner_rows() {
let proxy_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 3128);
let client_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 49152);
let rows = [
tcp_row(client_addr, proxy_addr, 200),
tcp_row(client_addr, proxy_addr, 201),
];
let error = unique_client_process_id(&rows, proxy_addr, client_addr)
.expect_err("duplicate connection rows should fail closed");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn rejects_truncated_owner_table() {
let byte_len = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
let mut buffer = aligned_buffer(byte_len).expect("aligned table buffer");
unsafe {
std::ptr::write_unaligned(buffer.as_mut_ptr().cast::<u32>(), 1);
}
let Err(error) = parse_tcp_owner_rows(&buffer, byte_len) else {
panic!("truncated connection row should fail closed");
};
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn resolves_loopback_connection_to_current_process() -> io::Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let client = TcpStream::connect(listener.local_addr()?)?;
let (accepted, _) = listener.accept()?;
let local_addr = accepted.local_addr()?;
let peer_addr = accepted.peer_addr()?;
let process_id = owning_process_id(socket_addr_v4(local_addr)?, socket_addr_v4(peer_addr)?)?;
let restricting_sids = restricting_sids_for_tcp_connection(local_addr, peer_addr)?;
assert_eq!(process_id, std::process::id());
assert!(restricting_sids.iter().all(|sid| sid.starts_with("S-")));
drop(client);
Ok(())
}
fn socket_addr_v4(addr: SocketAddr) -> io::Result<SocketAddrV4> {
match addr {
SocketAddr::V4(addr) => Ok(addr),
SocketAddr::V6(_) => Err(io::Error::new(
io::ErrorKind::InvalidData,
"test listener unexpectedly used IPv6",
)),
}
}
fn tcp_row(
local_addr: SocketAddrV4,
remote_addr: SocketAddrV4,
process_id: u32,
) -> MIB_TCPROW_OWNER_PID {
MIB_TCPROW_OWNER_PID {
dwState: 0,
dwLocalAddr: u32::from_ne_bytes(local_addr.ip().octets()),
dwLocalPort: local_addr.port().to_be() as u32,
dwRemoteAddr: u32::from_ne_bytes(remote_addr.ip().octets()),
dwRemotePort: remote_addr.port().to_be() as u32,
dwOwningPid: process_id,
}
}
fn owner_table_buffer(rows: &[MIB_TCPROW_OWNER_PID]) -> (Vec<usize>, usize) {
let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
let rows_byte_len = size_of_val(rows);
let byte_len = rows_offset + rows_byte_len;
let mut buffer = aligned_buffer(byte_len).expect("aligned table buffer");
unsafe {
let buffer_ptr = buffer.as_mut_ptr().cast::<u8>();
std::ptr::write_unaligned(buffer_ptr.cast::<u32>(), rows.len() as u32);
std::ptr::copy_nonoverlapping(
rows.as_ptr().cast::<u8>(),
buffer_ptr.add(rows_offset),
rows_byte_len,
);
}
(buffer, byte_len)
}
@@ -0,0 +1,590 @@
#![cfg(target_os = "windows")]
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigReloaderFuture;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkDecision;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkPolicyDecider;
use codex_network_proxy::NetworkPolicyRequest;
use codex_network_proxy::NetworkProtocol;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::build_config_state;
use codex_windows_sandbox::ConsoleMode;
use codex_windows_sandbox::LocalSid;
use codex_windows_sandbox::create_process_as_user;
use codex_windows_sandbox::create_readonly_token_with_caps_and_user_from;
use codex_windows_sandbox::get_current_token_for_restriction;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;
use std::io::Write;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::net::TcpStream;
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::FromRawHandle;
use std::os::windows::io::OwnedHandle;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use windows_sys::Win32::System::Threading::GetExitCodeProcess;
use windows_sys::Win32::System::Threading::TerminateProcess;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
const CHILD_MODE_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_CHILD";
const HTTP_ADDR_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_HTTP_ADDR";
const SOCKS_ADDR_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_SOCKS_ADDR";
const ORIGIN_PORT_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_ORIGIN_PORT";
const ALLOWED_HOST_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_ALLOWED_HOST";
const DENIED_HOST_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_DENIED_HOST";
const FIRST_ENVIRONMENT_ID: &str = "first-environment";
const SECOND_ENVIRONMENT_ID: &str = "second-environment";
const DECIDER_DENIED_HOST: &str = "not-allowed.invalid";
const CHILD_TIMEOUT_MS: u32 = 30_000;
const WAIT_OBJECT_0: u32 = 0;
#[derive(Clone)]
struct StaticReloader(ConfigState);
impl ConfigReloader for StaticReloader {
fn source_label(&self) -> String {
"test config".to_string()
}
fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
Box::pin(async { Ok(None) })
}
fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
let state = self.0.clone();
Box::pin(async move { Ok(state) })
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_tokens_select_stable_routes_and_cleanup() -> anyhow::Result<()> {
let (origin_port, origin_task) = start_http_origin().await?;
let (first_decider, first_requests) = recording_decider();
let (second_decider, second_requests) = recording_decider();
let first_requested = requested_addrs()?;
let first = build_proxy(
first_requested,
"localhost",
/*enable_socks5*/ false,
Some(first_decider),
)
.await?;
let initial_addrs = (first.http_addr(), first.socks_addr());
let first_handle = first.run().await?;
let first_sid = first
.network_proxy_restricting_sid(None)
.expect("running proxy should have a route SID");
first.prepare_for_optional_environment(HashMap::new(), Some(FIRST_ENVIRONMENT_ID))?;
let first_environment_sid = first
.network_proxy_restricting_sid(Some(FIRST_ENVIRONMENT_ID))
.expect("first environment should have a route SID");
let second_requested = requested_addrs()?;
assert_ne!(second_requested.0, initial_addrs.0);
let second = build_proxy(
second_requested,
"127.0.0.1",
/*enable_socks5*/ true,
Some(second_decider),
)
.await?;
let stable_addrs = (second.http_addr(), second.socks_addr());
assert_eq!(stable_addrs.0, initial_addrs.0);
assert_eq!(stable_addrs.1, second_requested.1);
assert_eq!((first.http_addr(), first.socks_addr()), stable_addrs);
let second_handle = second.run().await?;
let second_sid = second
.network_proxy_restricting_sid(None)
.expect("running proxy should have a route SID");
assert_ne!(second_sid, first_sid);
second.prepare_for_optional_environment(HashMap::new(), Some(SECOND_ENVIRONMENT_ID))?;
let second_environment_sid = second
.network_proxy_restricting_sid(Some(SECOND_ENVIRONMENT_ID))
.expect("second environment should have a route SID");
run_restricted_child(
&first_environment_sid,
stable_addrs,
origin_port,
Some(("localhost", DECIDER_DENIED_HOST)),
/*expect_socks*/ false,
)
.await?;
assert_recorded_requests(
&first_requests,
FIRST_ENVIRONMENT_ID,
DECIDER_DENIED_HOST,
origin_port,
&[NetworkProtocol::Http],
);
assert!(
second_requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty()
);
run_restricted_child(
&second_environment_sid,
stable_addrs,
origin_port,
Some(("127.0.0.1", DECIDER_DENIED_HOST)),
/*expect_socks*/ true,
)
.await?;
assert_recorded_requests(
&first_requests,
FIRST_ENVIRONMENT_ID,
DECIDER_DENIED_HOST,
origin_port,
&[NetworkProtocol::Http],
);
assert_recorded_requests(
&second_requests,
SECOND_ENVIRONMENT_ID,
DECIDER_DENIED_HOST,
origin_port,
&[NetworkProtocol::Http, NetworkProtocol::Socks5Tcp],
);
run_restricted_child(
&first_sid,
stable_addrs,
origin_port,
Some(("localhost", "127.0.0.1")),
/*expect_socks*/ false,
)
.await?;
run_restricted_child(
&second_sid,
stable_addrs,
origin_port,
Some(("127.0.0.1", "localhost")),
/*expect_socks*/ true,
)
.await?;
first_handle.shutdown().await?;
run_restricted_child(
&first_sid,
stable_addrs,
origin_port,
None,
/*expect_socks*/ false,
)
.await?;
run_restricted_child(
&first_environment_sid,
stable_addrs,
origin_port,
None,
/*expect_socks*/ false,
)
.await?;
run_restricted_child(
&second_sid,
stable_addrs,
origin_port,
Some(("127.0.0.1", "localhost")),
/*expect_socks*/ true,
)
.await?;
second_handle.shutdown().await?;
drop((first, second));
let third_requested = requested_addrs()?;
assert_ne!(third_requested, stable_addrs);
let third = build_proxy(
third_requested,
"localhost",
/*enable_socks5*/ false,
None,
)
.await?;
assert_eq!((third.http_addr(), third.socks_addr()), stable_addrs);
let third_handle = third.run().await?;
let third_sid = third
.network_proxy_restricting_sid(None)
.expect("running proxy should have a route SID");
assert_ne!(third_sid, first_sid);
assert_ne!(third_sid, second_sid);
run_restricted_child(
&third_sid,
stable_addrs,
origin_port,
Some(("localhost", "127.0.0.1")),
/*expect_socks*/ false,
)
.await?;
drop(third_handle);
assert_eq!(third.network_proxy_restricting_sid(None), None);
run_restricted_child(
&third_sid,
stable_addrs,
origin_port,
None,
/*expect_socks*/ false,
)
.await?;
origin_task.abort();
Ok(())
}
#[test]
fn restricted_child_exercises_http_and_socks() -> anyhow::Result<()> {
let Ok(mode) = std::env::var(CHILD_MODE_ENV) else {
return Ok(());
};
let http_addr = required_env(HTTP_ADDR_ENV)?.parse::<SocketAddr>()?;
let socks_addr = required_env(SOCKS_ADDR_ENV)?.parse::<SocketAddr>()?;
let origin_port = required_env(ORIGIN_PORT_ENV)?.parse::<u16>()?;
if mode == "missing-route" {
let authority = format!("localhost:{origin_port}");
assert!(http_status(http_addr, &authority).is_err());
assert!(socks_status(socks_addr, "localhost", origin_port).is_err());
return Ok(());
}
let allowed_host = required_env(ALLOWED_HOST_ENV)?;
let denied_host = required_env(DENIED_HOST_ENV)?;
let allowed_authority = format!("{allowed_host}:{origin_port}");
let denied_authority = format!("{denied_host}:{origin_port}");
assert_eq!(http_status(http_addr, &allowed_authority)?, 200);
assert_eq!(http_status(http_addr, &denied_authority)?, 403);
if mode == "http-only" {
assert!(socks_status(socks_addr, &allowed_host, origin_port).is_err());
return Ok(());
}
assert_eq!(
socks_status(socks_addr, &allowed_host, origin_port)?,
SocksOutcome::Connected
);
assert!(matches!(
socks_status(socks_addr, &denied_host, origin_port)?,
SocksOutcome::Denied(_)
));
Ok(())
}
async fn build_proxy(
requested_addrs: (SocketAddr, SocketAddr),
allowed_domain: &str,
enable_socks5: bool,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
) -> anyhow::Result<NetworkProxy> {
let (http_addr, socks_addr) = requested_addrs;
let mut config = NetworkProxyConfig {
enabled: true,
proxy_url: format!("http://{http_addr}"),
socks_url: format!("socks5://{socks_addr}"),
enable_socks5,
enable_socks5_udp: false,
allow_local_binding: true,
mode: NetworkMode::Full,
..NetworkProxyConfig::default()
};
config.set_allowed_domains(vec![allowed_domain.to_string()]);
let config_state = build_config_state(config, Default::default())?;
let reloader = Arc::new(StaticReloader(config_state.clone()));
let state = Arc::new(NetworkProxyState::with_reloader(config_state, reloader));
let mut builder = NetworkProxy::builder().state(state);
if let Some(policy_decider) = policy_decider {
builder = builder.policy_decider_arc(policy_decider);
}
builder.build().await
}
fn recording_decider() -> (
Arc<dyn NetworkPolicyDecider>,
Arc<Mutex<Vec<NetworkPolicyRequest>>>,
) {
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded_requests = Arc::clone(&requests);
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new(move |request: NetworkPolicyRequest| {
recorded_requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(request);
async { NetworkDecision::deny("integration test denial") }
});
(decider, requests)
}
fn assert_recorded_requests(
requests: &Arc<Mutex<Vec<NetworkPolicyRequest>>>,
environment_id: &str,
host: &str,
port: u16,
expected_protocols: &[NetworkProtocol],
) {
let requests = requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(requests.len(), expected_protocols.len());
let actual_protocols = requests
.iter()
.map(|request| request.protocol)
.collect::<Vec<_>>();
assert_eq!(actual_protocols, expected_protocols);
assert!(requests.iter().all(|request| {
request.environment_id.as_deref() == Some(environment_id)
&& request.host == host
&& request.port == port
}));
}
fn requested_addrs() -> std::io::Result<(SocketAddr, SocketAddr)> {
let http = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let socks = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
Ok((http.local_addr()?, socks.local_addr()?))
}
async fn start_http_origin() -> std::io::Result<(u16, tokio::task::JoinHandle<()>)> {
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let port = listener.local_addr()?.port();
let task = tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
tokio::spawn(async move {
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request).await;
let _ = stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK",
)
.await;
});
}
});
Ok((port, task))
}
async fn run_restricted_child(
route_sid: &str,
proxy_addrs: (SocketAddr, SocketAddr),
origin_port: u16,
policy: Option<(&str, &str)>,
expect_socks: bool,
) -> anyhow::Result<()> {
let route_sid = route_sid.to_string();
let policy = policy.map(|(allowed, denied)| (allowed.to_string(), denied.to_string()));
tokio::task::spawn_blocking(move || {
run_restricted_child_blocking(&route_sid, proxy_addrs, origin_port, policy, expect_socks)
})
.await??;
Ok(())
}
fn run_restricted_child_blocking(
route_sid: &str,
(http_addr, socks_addr): (SocketAddr, SocketAddr),
origin_port: u16,
policy: Option<(String, String)>,
expect_socks: bool,
) -> anyhow::Result<()> {
let route_sid = LocalSid::from_string(route_sid)?;
let capability_sid = LocalSid::from_string("S-1-5-21-10-20-30-40")?;
let base_token = unsafe {
OwnedHandle::from_raw_handle(get_current_token_for_restriction()? as *mut std::ffi::c_void)
};
let restricted_token = unsafe {
create_readonly_token_with_caps_and_user_from(
base_token.as_raw_handle() as isize,
&[capability_sid.as_ptr()],
&[route_sid.as_ptr()],
)?
};
let restricted_token =
unsafe { OwnedHandle::from_raw_handle(restricted_token as *mut std::ffi::c_void) };
let mut env = std::env::vars().collect::<HashMap<_, _>>();
env.insert(HTTP_ADDR_ENV.to_string(), http_addr.to_string());
env.insert(SOCKS_ADDR_ENV.to_string(), socks_addr.to_string());
env.insert(ORIGIN_PORT_ENV.to_string(), origin_port.to_string());
match policy {
Some((allowed, denied)) => {
let mode = if expect_socks { "policy" } else { "http-only" };
env.insert(CHILD_MODE_ENV.to_string(), mode.to_string());
env.insert(ALLOWED_HOST_ENV.to_string(), allowed);
env.insert(DENIED_HOST_ENV.to_string(), denied);
}
None => {
env.insert(CHILD_MODE_ENV.to_string(), "missing-route".to_string());
}
}
let test_exe = std::env::current_exe()?;
let command = vec![
test_exe.to_string_lossy().into_owned(),
"--exact".to_string(),
"restricted_child_exercises_http_and_socks".to_string(),
"--nocapture".to_string(),
"--test-threads=1".to_string(),
];
let cwd = std::env::current_dir()?;
let spawned = unsafe {
create_process_as_user(
restricted_token.as_raw_handle() as isize,
&command,
&cwd,
&env,
/*logs_base_dir*/ None,
/*stdio*/ None,
/*console_mode*/ ConsoleMode::Inherit,
/*use_private_desktop*/ false,
)?
};
let process = unsafe {
OwnedHandle::from_raw_handle(spawned.process_info.hProcess as *mut std::ffi::c_void)
};
let _thread = unsafe {
OwnedHandle::from_raw_handle(spawned.process_info.hThread as *mut std::ffi::c_void)
};
let wait = unsafe {
WaitForSingleObject(
process.as_raw_handle() as isize,
/*dwMilliseconds*/ CHILD_TIMEOUT_MS,
)
};
if wait != WAIT_OBJECT_0 {
unsafe {
TerminateProcess(process.as_raw_handle() as isize, 1);
}
}
let mut exit_code = 1_u32;
unsafe {
GetExitCodeProcess(process.as_raw_handle() as isize, &mut exit_code);
}
anyhow::ensure!(
wait == WAIT_OBJECT_0 && exit_code == 0,
"restricted proxy child failed (wait={wait}, exit={exit_code})"
);
Ok(())
}
fn required_env(key: &str) -> anyhow::Result<String> {
std::env::var(key).map_err(Into::into)
}
fn http_status(proxy_addr: SocketAddr, authority: &str) -> std::io::Result<u16> {
let mut stream = TcpStream::connect(proxy_addr)?;
configure_stream(&stream)?;
write!(
stream,
"GET http://{authority}/ HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n\r\n"
)?;
read_http_status(&mut stream)
}
#[derive(Debug, Eq, PartialEq)]
enum SocksOutcome {
Connected,
Denied(u8),
}
fn socks_status(
proxy_addr: SocketAddr,
host: &str,
origin_port: u16,
) -> std::io::Result<SocksOutcome> {
let mut stream = TcpStream::connect(proxy_addr)?;
configure_stream(&stream)?;
stream.write_all(&[5, 1, 0])?;
let mut greeting = [0_u8; 2];
stream.read_exact(&mut greeting)?;
if greeting != [5, 0] {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"SOCKS5 proxy rejected no-authentication method",
));
}
let mut request = vec![5, 1, 0];
if let Ok(ip) = host.parse::<Ipv4Addr>() {
request.push(1);
request.extend_from_slice(&ip.octets());
} else {
let host_len = u8::try_from(host.len()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "SOCKS5 hostname too long")
})?;
request.extend_from_slice(&[3, host_len]);
request.extend_from_slice(host.as_bytes());
}
request.extend_from_slice(&origin_port.to_be_bytes());
stream.write_all(&request)?;
let mut reply = [0_u8; 4];
stream.read_exact(&mut reply)?;
if reply[0] != 5 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"invalid SOCKS5 response version",
));
}
if reply[1] != 0 {
return Ok(SocksOutcome::Denied(reply[1]));
}
consume_socks_bound_address(&mut stream, reply[3])?;
Ok(SocksOutcome::Connected)
}
fn consume_socks_bound_address(stream: &mut TcpStream, address_type: u8) -> std::io::Result<()> {
let address_len = match address_type {
1 => 4,
3 => {
let mut len = [0_u8; 1];
stream.read_exact(&mut len)?;
usize::from(len[0])
}
4 => 16,
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"invalid SOCKS5 bound address type",
));
}
};
let mut address_and_port = vec![0_u8; address_len + 2];
stream.read_exact(&mut address_and_port)
}
fn configure_stream(stream: &TcpStream) -> std::io::Result<()> {
let timeout = Some(Duration::from_secs(5));
stream.set_read_timeout(timeout)?;
stream.set_write_timeout(timeout)
}
fn read_http_status(stream: &mut TcpStream) -> std::io::Result<u16> {
let mut status_line = String::new();
if BufReader::new(stream).read_line(&mut status_line)? == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"proxy closed before an HTTP status line",
));
}
status_line
.split_ascii_whitespace()
.nth(1)
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "missing HTTP status code")
})?
.parse::<u16>()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
}
+14
View File
@@ -532,6 +532,19 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn(
let inner_command = std::mem::take(&mut request.command);
let proxy_enforced = request.network.is_some();
let network_proxy_restricting_sid = request
.network
.as_ref()
.map(|network| {
network
.network_proxy_restricting_sid(request.network_environment_id.as_deref())
.ok_or_else(|| {
SandboxTransformError::WindowsSandboxPreparation(
"managed Windows proxy route is missing its restricting SID".to_string(),
)
})
})
.transpose()?;
let use_elevated =
windows_sandbox_uses_elevated_backend(request.windows_sandbox_level, proxy_enforced);
let overrides = if use_elevated {
@@ -576,6 +589,7 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn(
request.windows_sandbox_level,
request.windows_sandbox_private_desktop,
proxy_enforced,
network_proxy_restricting_sid.as_deref(),
proxy_settings_mode,
read_roots_override,
read_roots_include_platform_defaults,
+4
View File
@@ -19,6 +19,7 @@ pub struct WindowsSandboxSpawnRequest<'a> {
pub workspace_roots: &'a [AbsolutePathBuf],
pub windows_sandbox_level: WindowsSandboxLevel,
pub proxy_enforced: bool,
pub network_proxy_restricting_sid: Option<&'a str>,
pub proxy_settings_mode: WindowsSandboxProxySettingsMode,
pub filesystem_overrides: Option<&'a WindowsSandboxFilesystemOverrides>,
pub use_private_desktop: bool,
@@ -60,6 +61,9 @@ pub async fn spawn_process(request: SpawnRequest<'_>) -> Result<SpawnedProcess>
env_map: request.env.clone(),
windows_sandbox_level: windows.windows_sandbox_level,
proxy_enforced: windows.proxy_enforced,
network_proxy_restricting_sid: windows
.network_proxy_restricting_sid
.map(str::to_owned),
proxy_settings_mode: windows.proxy_settings_mode,
timeout_ms: None,
read_roots_override: overrides
@@ -273,19 +273,37 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result<IpcSpawnedProcess> {
if cap_psids.is_empty() {
anyhow::bail!("runner: empty capability SID list");
}
let network_proxy_restricting_sid = req
.network_proxy_restricting_sid
.as_deref()
.map(LocalSid::from_string)
.transpose()
.context("ConvertStringSidToSidW failed for network proxy restricting SID")?;
// The token helpers still take raw SID pointers, but we keep ownership in `LocalSid`
// wrappers for as long as possible. That way any failure after SID parsing but before the
// child is fully spawned still releases the backing LocalAlloc memory automatically.
let cap_psid_ptrs: Vec<*mut _> = cap_psids.iter().map(LocalSid::as_ptr).collect();
let additional_restricting_sid_ptrs: Vec<*mut _> = network_proxy_restricting_sid
.iter()
.map(LocalSid::as_ptr)
.collect();
let base = OwnedWinHandle::new(unsafe { get_current_token_for_restriction()? });
let h_token = OwnedWinHandle::new(unsafe {
match token_mode {
WindowsSandboxTokenMode::ReadOnlyCapability => {
create_readonly_token_with_caps_and_user_from(base.raw(), &cap_psid_ptrs)
create_readonly_token_with_caps_and_user_from(
base.raw(),
&cap_psid_ptrs,
&additional_restricting_sid_ptrs,
)
}
WindowsSandboxTokenMode::WritableRootsCapability => {
create_workspace_write_token_with_caps_and_user_from(base.raw(), &cap_psid_ptrs)
create_workspace_write_token_with_caps_and_user_from(
base.raw(),
&cap_psid_ptrs,
&additional_restricting_sid_ptrs,
)
}
}
}?);
@@ -26,7 +26,7 @@ use std::path::PathBuf;
const MAX_FRAME_LEN: usize = 8 * 1024 * 1024;
/// Protocol version shared by the parent process and elevated command runner.
pub const IPC_PROTOCOL_VERSION: u8 = 4;
pub const IPC_PROTOCOL_VERSION: u8 = 5;
/// Length-prefixed, JSON-encoded frame.
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -65,6 +65,9 @@ pub struct SpawnRequest {
pub codex_home: PathBuf,
pub real_codex_home: PathBuf,
pub cap_sids: Vec<String>,
/// Optional managed-network identity added only to the child's restricting SID set.
#[serde(default)]
pub network_proxy_restricting_sid: Option<String>,
pub timeout_ms: Option<u64>,
pub tty: bool,
#[serde(default)]
@@ -224,6 +227,7 @@ mod tests {
codex_home: PathBuf::from(r"C:\codex"),
real_codex_home: PathBuf::from(r"C:\Users\codex"),
cap_sids: vec!["S-1-15-3-1024-1".to_string()],
network_proxy_restricting_sid: Some("S-1-5-21-100-200-300-400".to_string()),
timeout_ms: Some(1000),
tty: false,
stdin_open: false,
@@ -245,6 +249,10 @@ mod tests {
};
assert_eq!(PermissionProfile::read_only(), payload.permission_profile);
assert_eq!(workspace_roots, payload.workspace_roots);
assert_eq!(
Some("S-1-5-21-100-200-300-400"),
payload.network_proxy_restricting_sid.as_deref()
);
}
#[test]
@@ -15,6 +15,7 @@ pub struct ElevatedSandboxProfileCaptureRequest<'a> {
pub cancellation: Option<crate::WindowsSandboxCancellationToken>,
pub use_private_desktop: bool,
pub proxy_enforced: bool,
pub network_proxy_restricting_sid: Option<String>,
pub read_roots_override: Option<&'a [PathBuf]>,
pub read_roots_include_platform_defaults: bool,
pub write_roots_override: Option<&'a [PathBuf]>,
@@ -110,6 +111,7 @@ mod windows_impl {
cancellation,
use_private_desktop,
proxy_enforced,
network_proxy_restricting_sid,
read_roots_override,
read_roots_include_platform_defaults,
write_roots_override,
@@ -190,6 +192,7 @@ mod windows_impl {
codex_home: sandbox_base.clone(),
real_codex_home: codex_home.to_path_buf(),
cap_sids,
network_proxy_restricting_sid,
timeout_ms,
tty: false,
stdin_open: false,
+9
View File
@@ -44,6 +44,15 @@ pub enum WindowsSandboxProxySettingsMode {
Preserve,
}
/// Network settings installed by an administrator during managed Windows sandbox setup.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WindowsSandboxProvisioningSettings {
/// Loopback proxy ports permitted for the offline sandbox identity.
pub proxy_ports: Vec<u16>,
/// Whether the offline sandbox identity may exchange arbitrary loopback traffic.
pub allow_local_binding: bool,
}
#[cfg(target_os = "windows")]
mod acl;
#[cfg(target_os = "windows")]
+25 -4
View File
@@ -705,6 +705,10 @@ const PROXY_ENV_KEYS: &[&str] = &[
"wss_proxy",
];
const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
// Internal wire format shared with network-proxy/src/proxy.rs. The value is a comma-separated,
// sorted list of non-zero loopback proxy ports used only when computing the Windows offline
// sandbox setup marker.
const WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY: &str = "CODEX_WINDOWS_SANDBOX_PROXY_PORTS";
pub(crate) fn offline_proxy_settings_from_env(
env_map: &HashMap<String, String>,
@@ -744,6 +748,14 @@ pub(crate) fn proxy_ports_from_env(env_map: &HashMap<String, String>) -> Vec<u16
ports.insert(port);
}
}
if let Some(value) = env_map.get(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY) {
ports.extend(
value
.split(',')
.filter_map(|port| port.trim().parse::<u16>().ok())
.filter(|port| *port != 0),
);
}
ports.into_iter().collect()
}
@@ -1034,7 +1046,11 @@ fn run_elevated_setup_inner(
run_setup_exe(&payload, needs_elevation, request.codex_home)
}
pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> Result<()> {
pub fn run_elevated_provisioning_setup(
codex_home: &Path,
real_user: &str,
settings: crate::WindowsSandboxProvisioningSettings,
) -> Result<()> {
let sbx_dir = sandbox_dir(codex_home);
std::fs::create_dir_all(&sbx_dir).map_err(|err| {
failure(
@@ -1063,8 +1079,8 @@ pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> Re
write_roots: Vec::new(),
deny_read_paths: Vec::new(),
deny_write_paths: Vec::new(),
proxy_ports: Vec::new(),
allow_local_binding: false,
proxy_ports: settings.proxy_ports,
allow_local_binding: settings.allow_local_binding,
otel: codex_otel::global_statsig_metrics_settings(),
real_user: real_user.to_string(),
mode: SetupMode::ProvisionOnly,
@@ -1264,6 +1280,7 @@ fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> V
#[cfg(test)]
mod tests {
use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS;
use super::WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY;
use super::build_payload_roots;
use super::find_setup_exe_for_current_exe;
use super::gather_full_read_roots_for_permissions;
@@ -1599,8 +1616,12 @@ mod tests {
"HTTPS_PROXY".to_string(),
"https://example.com:9999".to_string(),
);
env.insert(
WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY.to_string(),
"8080,43129,0,invalid, 43128,65536".to_string(),
);
assert_eq!(proxy_ports_from_env(&env), vec![1081, 8080]);
assert_eq!(proxy_ports_from_env(&env), vec![1081, 8080, 43128, 43129]);
}
#[test]
+33 -6
View File
@@ -381,7 +381,8 @@ pub unsafe fn create_workspace_write_token_with_caps_from(
create_token_with_caps_from(base_token, psid_capabilities, &[])
}
/// Create a restricted token that includes all provided capability SIDs plus the token user SID.
/// Create a restricted token that includes all provided capability SIDs, the token user SID, and
/// any additional restricting SIDs.
///
/// This is intended for the elevated sandbox backend, where the token user is the dedicated
/// sandbox account rather than the real signed-in user.
@@ -391,10 +392,13 @@ pub unsafe fn create_workspace_write_token_with_caps_from(
pub unsafe fn create_workspace_write_token_with_caps_and_user_from(
base_token: HANDLE,
psid_capabilities: &[*mut c_void],
additional_restricting_sids: &[*mut c_void],
) -> Result<HANDLE> {
let mut user_sid_bytes = get_user_sid_bytes(base_token)?;
let psid_user = user_sid_bytes.as_mut_ptr() as *mut c_void;
create_token_with_caps_from(base_token, psid_capabilities, &[psid_user])
create_token_with_caps_user_and_additional_restrictions_from(
base_token,
psid_capabilities,
additional_restricting_sids,
)
}
/// Create a restricted token that includes all provided capability SIDs.
@@ -408,7 +412,8 @@ pub unsafe fn create_readonly_token_with_caps_from(
create_token_with_caps_from(base_token, psid_capabilities, &[])
}
/// Create a restricted token that includes all provided capability SIDs plus the token user SID.
/// Create a restricted token that includes all provided capability SIDs, the token user SID, and
/// any additional restricting SIDs.
///
/// This is intended for the elevated sandbox backend, where the token user is the dedicated
/// sandbox account rather than the real signed-in user.
@@ -418,10 +423,26 @@ pub unsafe fn create_readonly_token_with_caps_from(
pub unsafe fn create_readonly_token_with_caps_and_user_from(
base_token: HANDLE,
psid_capabilities: &[*mut c_void],
additional_restricting_sids: &[*mut c_void],
) -> Result<HANDLE> {
create_token_with_caps_user_and_additional_restrictions_from(
base_token,
psid_capabilities,
additional_restricting_sids,
)
}
unsafe fn create_token_with_caps_user_and_additional_restrictions_from(
base_token: HANDLE,
psid_capabilities: &[*mut c_void],
additional_restricting_sids: &[*mut c_void],
) -> Result<HANDLE> {
let mut user_sid_bytes = get_user_sid_bytes(base_token)?;
let psid_user = user_sid_bytes.as_mut_ptr() as *mut c_void;
create_token_with_caps_from(base_token, psid_capabilities, &[psid_user])
let mut extra_restricting_sids = Vec::with_capacity(additional_restricting_sids.len() + 1);
extra_restricting_sids.push(psid_user);
extra_restricting_sids.extend_from_slice(additional_restricting_sids);
create_token_with_caps_from(base_token, psid_capabilities, &extra_restricting_sids)
}
unsafe fn create_token_with_caps_from(
@@ -472,6 +493,8 @@ unsafe fn create_token_with_caps_from(
return Err(anyhow!("CreateRestrictedToken failed: {}", GetLastError()));
}
// Additional restricting SIDs are identity markers, not capabilities. Deliberately exclude
// them from the default DACL so possessing a route identity cannot grant object access.
let mut dacl_sids: Vec<*mut c_void> = Vec::with_capacity(psid_capabilities.len() + 2);
dacl_sids.push(psid_logon);
dacl_sids.push(psid_everyone);
@@ -481,3 +504,7 @@ unsafe fn create_token_with_caps_from(
enable_single_privilege(new_token, "SeChangeNotifyPrivilege")?;
Ok(new_token)
}
#[cfg(test)]
#[path = "token_tests.rs"]
mod tests;
@@ -0,0 +1,69 @@
use super::*;
use windows_sys::Win32::Security::EqualSid;
use windows_sys::Win32::Security::TokenRestrictedSids;
unsafe fn token_has_restricting_sid(token: HANDLE, expected_sid: *mut c_void) -> Result<bool> {
let mut needed = 0;
GetTokenInformation(
token,
TokenRestrictedSids,
std::ptr::null_mut(),
0,
&mut needed,
);
if needed == 0 {
return Err(anyhow!(
"GetTokenInformation(TokenRestrictedSids) size query failed: {}",
GetLastError()
));
}
let mut buffer = vec![0_u8; needed as usize];
if GetTokenInformation(
token,
TokenRestrictedSids,
buffer.as_mut_ptr().cast(),
needed,
&mut needed,
) == 0
{
return Err(anyhow!(
"GetTokenInformation(TokenRestrictedSids) failed: {}",
GetLastError()
));
}
let group_count = std::ptr::read_unaligned(buffer.as_ptr().cast::<u32>()) as usize;
let after_count = buffer.as_ptr().add(std::mem::size_of::<u32>()) as usize;
let align = std::mem::align_of::<SID_AND_ATTRIBUTES>();
let entries_addr = (after_count + (align - 1)) & !(align - 1);
let restricting_sids =
std::slice::from_raw_parts(entries_addr as *const SID_AND_ATTRIBUTES, group_count);
Ok(restricting_sids
.iter()
.any(|entry| EqualSid(entry.Sid, expected_sid) != 0))
}
#[test]
fn elevated_token_includes_network_proxy_restricting_sid() -> Result<()> {
let capability_sid = LocalSid::from_string("S-1-5-21-10-20-30-40")?;
let network_proxy_sid = LocalSid::from_string("S-1-5-21-50-60-70-80")?;
let base_token = unsafe { get_current_token_for_restriction()? };
let restricted_token = unsafe {
create_readonly_token_with_caps_and_user_from(
base_token,
&[capability_sid.as_ptr()],
&[network_proxy_sid.as_ptr()],
)?
};
let has_network_proxy_sid =
unsafe { token_has_restricting_sid(restricted_token, network_proxy_sid.as_ptr()) };
unsafe {
CloseHandle(restricted_token);
CloseHandle(base_token);
}
assert!(has_network_proxy_sid?);
Ok(())
}
@@ -116,6 +116,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil
cwd: &Path,
mut env_map: HashMap<String, String>,
proxy_enforced: bool,
network_proxy_restricting_sid: Option<String>,
proxy_settings_mode: crate::WindowsSandboxProxySettingsMode,
timeout_ms: Option<u64>,
read_roots_override: Option<&[PathBuf]>,
@@ -171,6 +172,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil
codex_home: elevated.sandbox_base,
real_codex_home: codex_home.to_path_buf(),
cap_sids: elevated.cap_sids,
network_proxy_restricting_sid,
timeout_ms,
tty,
stdin_open,
@@ -76,6 +76,7 @@ fn retry_uses_original_unified_exec_request_and_stops_after_second_failure() {
codex_home: PathBuf::from(r"C:\Users\codex\.sandbox"),
real_codex_home: PathBuf::from(r"C:\Users\codex"),
cap_sids: vec!["S-1-15-3-1024-1".to_string()],
network_proxy_restricting_sid: Some("S-1-5-21-100-200-300-400".to_string()),
timeout_ms: Some(5_000),
tty: true,
stdin_open: true,
@@ -10,6 +10,7 @@
mod backends;
use anyhow::Result;
use anyhow::bail;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -31,6 +32,7 @@ pub struct WindowsSandboxSessionRequest<'a> {
pub env_map: HashMap<String, String>,
pub windows_sandbox_level: WindowsSandboxLevel,
pub proxy_enforced: bool,
pub network_proxy_restricting_sid: Option<String>,
pub proxy_settings_mode: crate::WindowsSandboxProxySettingsMode,
pub timeout_ms: Option<u64>,
pub read_roots_override: Option<&'a [PathBuf]>,
@@ -57,6 +59,7 @@ pub async fn spawn_windows_sandbox_session_for_level(
request.cwd,
request.env_map,
request.proxy_enforced,
request.network_proxy_restricting_sid,
request.proxy_settings_mode,
request.timeout_ms,
request.read_roots_override,
@@ -70,6 +73,9 @@ pub async fn spawn_windows_sandbox_session_for_level(
)
.await
} else {
if request.network_proxy_restricting_sid.is_some() {
bail!("network proxy restricting SID requires the elevated Windows sandbox backend");
}
spawn_windows_sandbox_session_legacy(
request.permission_profile,
request.workspace_roots,
@@ -129,6 +135,7 @@ pub async fn spawn_windows_sandbox_session_elevated_for_permission_profile(
cwd: &Path,
env_map: HashMap<String, String>,
proxy_enforced: bool,
network_proxy_restricting_sid: Option<String>,
timeout_ms: Option<u64>,
read_roots_override: Option<&[PathBuf]>,
read_roots_include_platform_defaults: bool,
@@ -147,6 +154,7 @@ pub async fn spawn_windows_sandbox_session_elevated_for_permission_profile(
cwd,
env_map,
proxy_enforced,
network_proxy_restricting_sid,
crate::WindowsSandboxProxySettingsMode::Reconcile,
timeout_ms,
read_roots_override,
@@ -24,6 +24,7 @@ const CODEX_HOME_FLAG: &str = "--codex-home";
const DENY_READ_PATHS_JSON_FLAG: &str = "--deny-read-paths-json";
const DENY_WRITE_PATHS_JSON_FLAG: &str = "--deny-write-paths-json";
const ENV_JSON_FLAG: &str = "--env-json";
const NETWORK_PROXY_RESTRICTING_SID_FLAG: &str = "--network-proxy-restricting-sid";
const PERMISSION_PROFILE_FLAG: &str = "--permission-profile";
const PRIVATE_DESKTOP_FLAG: &str = "--windows-sandbox-private-desktop";
const PRESERVE_PROXY_SETTINGS_FLAG: &str = "--preserve-proxy-settings";
@@ -44,6 +45,7 @@ pub fn create_windows_sandbox_command_args_for_permission_profile(
windows_sandbox_level: WindowsSandboxLevel,
windows_sandbox_private_desktop: bool,
proxy_enforced: bool,
network_proxy_restricting_sid: Option<&str>,
proxy_settings_mode: crate::WindowsSandboxProxySettingsMode,
read_roots_override: Option<&[PathBuf]>,
read_roots_include_platform_defaults: bool,
@@ -84,6 +86,10 @@ pub fn create_windows_sandbox_command_args_for_permission_profile(
if proxy_enforced {
args.push(PROXY_ENFORCED_FLAG.to_string());
}
if let Some(network_proxy_restricting_sid) = network_proxy_restricting_sid {
args.push(NETWORK_PROXY_RESTRICTING_SID_FLAG.to_string());
args.push(network_proxy_restricting_sid.to_string());
}
if proxy_settings_mode == crate::WindowsSandboxProxySettingsMode::Preserve {
args.push(PRESERVE_PROXY_SETTINGS_FLAG.to_string());
}
@@ -159,6 +165,7 @@ struct WindowsSandboxWrapperRequest {
windows_sandbox_level: WindowsSandboxLevel,
windows_sandbox_private_desktop: bool,
proxy_enforced: bool,
network_proxy_restricting_sid: Option<String>,
proxy_settings_mode: crate::WindowsSandboxProxySettingsMode,
read_roots_override: Option<Vec<PathBuf>>,
read_roots_include_platform_defaults: bool,
@@ -182,6 +189,7 @@ async fn run_windows_sandbox_wrapper_request(request: WindowsSandboxWrapperReque
env_map: request.env_map,
windows_sandbox_level: request.windows_sandbox_level,
proxy_enforced: request.proxy_enforced,
network_proxy_restricting_sid: request.network_proxy_restricting_sid,
proxy_settings_mode: request.proxy_settings_mode,
timeout_ms: None,
read_roots_override: request.read_roots_override.as_deref(),
@@ -208,6 +216,7 @@ fn parse_windows_sandbox_wrapper_args(args: Vec<String>) -> Result<WindowsSandbo
let mut windows_sandbox_level = None;
let mut windows_sandbox_private_desktop = false;
let mut proxy_enforced = false;
let mut network_proxy_restricting_sid = None;
let mut proxy_settings_mode = crate::WindowsSandboxProxySettingsMode::Reconcile;
let mut read_roots_override = None;
let mut read_roots_include_platform_defaults = false;
@@ -252,6 +261,9 @@ fn parse_windows_sandbox_wrapper_args(args: Vec<String>) -> Result<WindowsSandbo
proxy_settings_mode = crate::WindowsSandboxProxySettingsMode::Preserve;
}
PROXY_ENFORCED_FLAG => proxy_enforced = true,
NETWORK_PROXY_RESTRICTING_SID_FLAG => {
network_proxy_restricting_sid = Some(next_flag_value(&mut args, &arg)?);
}
READ_ROOTS_INCLUDE_PLATFORM_DEFAULTS_FLAG => {
read_roots_include_platform_defaults = true;
}
@@ -293,6 +305,7 @@ fn parse_windows_sandbox_wrapper_args(args: Vec<String>) -> Result<WindowsSandbo
.ok_or_else(|| anyhow!("missing required {SANDBOX_LEVEL_FLAG}"))?,
windows_sandbox_private_desktop,
proxy_enforced,
network_proxy_restricting_sid,
proxy_settings_mode,
read_roots_override,
read_roots_include_platform_defaults,
@@ -14,6 +14,7 @@ use super::COMMAND_CWD_FLAG;
use super::DENY_READ_PATHS_JSON_FLAG;
use super::DENY_WRITE_PATHS_JSON_FLAG;
use super::ENV_JSON_FLAG;
use super::NETWORK_PROXY_RESTRICTING_SID_FLAG;
use super::PERMISSION_PROFILE_FLAG;
use super::PRESERVE_PROXY_SETTINGS_FLAG;
use super::PRIVATE_DESKTOP_FLAG;
@@ -62,6 +63,7 @@ fn windows_wrapper_args_round_trip() {
WindowsSandboxLevel::Elevated,
/*windows_sandbox_private_desktop*/ true,
/*proxy_enforced*/ true,
/*network_proxy_restricting_sid*/ Some("S-1-5-21-100-200-300-400"),
crate::WindowsSandboxProxySettingsMode::Preserve,
Some(read_roots_override.as_slice()),
/*read_roots_include_platform_defaults*/ true,
@@ -80,6 +82,7 @@ fn windows_wrapper_args_round_trip() {
assert!(args.contains(&SANDBOX_LEVEL_FLAG.to_string()));
assert!(args.contains(&PRIVATE_DESKTOP_FLAG.to_string()));
assert!(args.contains(&PROXY_ENFORCED_FLAG.to_string()));
assert!(args.contains(&NETWORK_PROXY_RESTRICTING_SID_FLAG.to_string()));
assert!(args.contains(&PRESERVE_PROXY_SETTINGS_FLAG.to_string()));
assert!(args.contains(&READ_ROOTS_JSON_FLAG.to_string()));
assert!(args.contains(&READ_ROOTS_INCLUDE_PLATFORM_DEFAULTS_FLAG.to_string()));
@@ -101,6 +104,10 @@ fn windows_wrapper_args_round_trip() {
assert_eq!(parsed.windows_sandbox_level, WindowsSandboxLevel::Elevated);
assert_eq!(parsed.windows_sandbox_private_desktop, true);
assert_eq!(parsed.proxy_enforced, true);
assert_eq!(
parsed.network_proxy_restricting_sid.as_deref(),
Some("S-1-5-21-100-200-300-400")
);
assert_eq!(
parsed.proxy_settings_mode,
crate::WindowsSandboxProxySettingsMode::Preserve