From 2c9e1a5775caedcfccd64ba7dc928ed63d6ed058 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 11 Sep 2026 17:09:23 +0000 Subject: [PATCH] Add managed network policy support to the Windows MXC sandbox (#44872) ## What changed Add `create_command_args()` to encode argv, permissions, policy cwd, and optional managed network context through the existing bounded environment transport. For managed networking, generate a policy that allows IPv4 and IPv6 loopback while denying direct non-loopback egress and general inbound access. Require nonempty, nonzero proxy ports and reject `allow_local_binding=false` at both launcher and policy boundaries because native host-loopback access is bidirectional. Document the MXC launch contract, platform requirements, and limitations. ## Testing Add portable tests covering managed network transport and policy translation, plus rejection of missing proxy ports, zero ports, and unsupported local-binding restrictions at both boundaries. GitOrigin-RevId: 8d31b98f94a2ba3769369aa50de8e53dc143bac4 --- codex-rs/Cargo.lock | 1 + codex-rs/mxc-sandbox/Cargo.toml | 1 + codex-rs/mxc-sandbox/README.md | 75 ++++++++++++++++ codex-rs/mxc-sandbox/src/lib.rs | 65 ++++++++++++++ codex-rs/mxc-sandbox/src/policy.rs | 40 ++++++++- codex-rs/mxc-sandbox/src/policy_tests.rs | 95 +++++++++++++++++++++ codex-rs/mxc-sandbox/src/transport_tests.rs | 1 + 7 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 codex-rs/mxc-sandbox/README.md diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7f027e2217..c769f0c1ee 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4070,6 +4070,7 @@ version = "0.0.0" dependencies = [ "anyhow", "appcontainer_common", + "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", "codex-utils-path-uri", diff --git a/codex-rs/mxc-sandbox/Cargo.toml b/codex-rs/mxc-sandbox/Cargo.toml index 01c04cd1dc..0518fa7976 100644 --- a/codex-rs/mxc-sandbox/Cargo.toml +++ b/codex-rs/mxc-sandbox/Cargo.toml @@ -16,6 +16,7 @@ ignored = ["tracelogging"] [dependencies] anyhow = { workspace = true } +codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } diff --git a/codex-rs/mxc-sandbox/README.md b/codex-rs/mxc-sandbox/README.md new file mode 100644 index 0000000000..291d504177 --- /dev/null +++ b/codex-rs/mxc-sandbox/README.md @@ -0,0 +1,75 @@ +# Native Windows MXC sandbox + +This crate routes a command through the current Codex executable and directly +into Microsoft's MXC `BaseContainerRunner`. It requires a working Windows +process security environment (PSEC). It never invokes MXC's AppContainer +dispatcher, edits host ACLs, creates sandbox users, runs setup, or requests +elevation. The existing Codex Windows sandboxes remain separate backends. + +Windows executors record `codex.windows_mxc.available` once per process with an +`available=true|false` tag. This measures capability independently of selection. +The exec-server handshake also exposes `windows_mxc_available`; older or +unsupported Windows executors reject MXC requests before execution. + +`is_available()` uses MXC's cached create/close probe, rather than an OS build +number or the SDK's broad `platform_support()` result. The latter also reports +older AppContainer backends as supported. A requested deny path additionally +requires the native `PSE_SUPPORT_FS_DENY` capability; otherwise the command +fails before launch. + +The wrapper inherits the command's pipes or ConPTY console. MXC creates its +child suspended, assigns a kill-on-close job before resuming it, and retains +the native policy through workload completion. Filesystem permissions come +from the canonical Codex permission profile, including protected metadata +carveouts. Supported managed network access allows IPv4 and IPv6 loopback +clients and servers, including the dedicated proxy listeners, while denying +direct non-loopback egress and general inbound network access. + +## Launch contract + +`create_command_args()` wraps the command like the Seatbelt backend, using the +executor's Codex executable as the native SDK helper. It carries one typed +request: the canonical `PermissionProfile`, policy cwd, proxy context, and exact +argv. The helper inherits command cwd, environment, and stdio from the shared +process path; policy cwd can differ from command cwd. + +The request uses launcher-only environment chunks to leave Windows' command-line +budget to the workload. Payloads are limited to 1,000,000 UTF-8 bytes and chunks +to 4096 bytes; the helper removes them before native process creation. Use +`codex sandbox windows` to debug through the same preparation path as execution. + +## Limits and Windows validation + +- Deny globs use the existing Windows sandbox resolver to expand matching files + and directories into concrete paths before launch. This has the same snapshot + semantics and scan limits as the existing Windows sandbox. +- Native deny paths depend on the host's capability probe. An installed Windows + update alone is not treated as evidence that every policy feature is enabled. +- This adapter rejects managed networking with `allow_local_binding=false`: + its host-loopback permission is bidirectional. MXC's proxy-peer identity mode + is not integrated here. With local binding enabled, direct DNS remains denied, + matching the existing Windows sandbox. +- Windows volume-root grants do not recurse. The adapter grants the root and + its immediate children; directories added or newly mounted during a running + command are not implicitly granted. +- The native API represents paths and environment values as Unicode strings. + Non-Unicode values fail instead of undergoing lossy conversion. +- An explicitly empty child environment is rejected: the SDK replaces an empty + environment list with profile defaults and has no explicit-empty option. +- The upstream runner terminates remaining descendants when the foreground + process exits, as well as on cancellation. Both existing Windows backends + preserve descendants after normal exit, so detached servers currently lose + that behavior under MXC. Retaining descendants safely requires a longer-lived + owner for the native policy and job. +- The command itself remains subject to Windows' command-line length limit. +- Portable tests validate policy translation and wrapper arguments. Actual + enforcement, nested access overrides, alternate path encodings, junctions + and hardlinks, protected metadata, ConPTY behavior, process-tree cancellation, + proxy isolation, and + comparison with the existing Windows and Unix sandboxes require the smoke + suite on supported Windows and the corresponding platform hosts. + Test the normal `powershell.exe` and `pwsh.exe` command paths, not only `cmd.exe`. + +The MXC git revision is pinned with the workspace dependencies. Native launch +errors are returned without dumping the SDK's diagnostic buffer, which may +contain command or environment data. diff --git a/codex-rs/mxc-sandbox/src/lib.rs b/codex-rs/mxc-sandbox/src/lib.rs index a4b101c970..c3f2e55cc5 100644 --- a/codex-rs/mxc-sandbox/src/lib.rs +++ b/codex-rs/mxc-sandbox/src/lib.rs @@ -3,7 +3,13 @@ #[cfg(windows)] pub mod native; +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::models::PermissionProfile; +use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; /// Typed inputs for the native policy adapter. @@ -11,15 +17,74 @@ pub mod transport; use serde::Deserialize; use serde::Serialize; +pub const CODEX_WINDOWS_MXC_ARG1: &str = "--__codex-windows-mxc"; + +const CLIENT_ONLY_LOOPBACK_UNSUPPORTED: &str = "MXC cannot enforce managed networking with allow_local_binding=false: native host-loopback access is bidirectional"; + +fn validate_managed_network(network: &ManagedNetworkSandboxContext) -> Result<()> { + ensure!( + !network.loopback_ports.is_empty(), + "MXC managed networking requires dedicated proxy ports" + ); + ensure!( + !network.loopback_ports.contains(&0), + "MXC proxy ports must be nonzero" + ); + ensure!( + network.allow_local_binding, + CLIENT_ONLY_LOOPBACK_UNSUPPORTED + ); + Ok(()) +} + #[derive(Serialize, Deserialize)] pub struct MxcCommand { pub permissions: PermissionProfile, pub sandbox_policy_cwd: PathBuf, + pub managed_network: Option, pub command: Vec, } pub mod policy; +/// Inputs used to build an MXC helper invocation. +#[derive(Debug)] +pub struct CreateMxcCommandArgsParams<'a> { + pub command: Vec, + pub permission_profile: &'a PermissionProfile, + pub sandbox_policy_cwd: &'a Path, + pub managed_network: Option<&'a ManagedNetworkSandboxContext>, + pub env: &'a mut HashMap, +} + +/// Wrap exact argv and add bounded launcher-only environment variables. The +/// helper removes these variables before starting the sandboxed command. +pub fn create_command_args(args: CreateMxcCommandArgsParams<'_>) -> Result> { + let CreateMxcCommandArgsParams { + command, + permission_profile, + sandbox_policy_cwd, + managed_network, + env, + } = args; + sandbox_policy_cwd + .to_str() + .context("MXC requires a Unicode policy working directory")?; + if let Some(network) = managed_network { + validate_managed_network(network)?; + } + transport::encode( + &MxcCommand { + permissions: permission_profile.clone(), + sandbox_policy_cwd: sandbox_policy_cwd.to_owned(), + managed_network: managed_network.cloned(), + command, + }, + env, + )?; + Ok(vec![CODEX_WINDOWS_MXC_ARG1.to_owned()]) +} + /// Whether the executor can create a native MXC process security environment. /// This deliberately excludes MXC's older AppContainer fallback backends. pub fn is_available() -> bool { diff --git a/codex-rs/mxc-sandbox/src/policy.rs b/codex-rs/mxc-sandbox/src/policy.rs index 55ee549fa8..0831bf37b2 100644 --- a/codex-rs/mxc-sandbox/src/policy.rs +++ b/codex-rs/mxc-sandbox/src/policy.rs @@ -25,9 +25,12 @@ use wxc_common::models::ContainerPolicy; use wxc_common::models::ExecutionRequest; use wxc_common::models::FallbackPolicy; use wxc_common::models::NetworkAction; +use wxc_common::models::NetworkCidr; use wxc_common::models::NetworkEgressPolicy; use wxc_common::models::NetworkIngressPolicy; +use wxc_common::models::NetworkPeer; use wxc_common::models::NetworkPolicy; +use wxc_common::models::NetworkRule; use crate::MxcCommand; @@ -209,20 +212,49 @@ pub fn build_request( // API receives one effective access mode for each path identity. read.retain(|key, _| !write.contains_key(key) && !deny.contains_key(key)); let network_enabled = permissions.network_sandbox_policy().is_enabled(); - let egress_default = if network_enabled { + let proxied = command.managed_network.is_some(); + if let Some(network) = &command.managed_network { + crate::validate_managed_network(network) + .map_err(|error| PolicyError::PolicyResolution(error.to_string()))?; + } + let egress_default = if network_enabled && !proxied { NetworkAction::Allow } else { NetworkAction::Deny }; - let ingress_default = if network_enabled { + let ingress_default = if network_enabled && !proxied { NetworkAction::Allow } else { NetworkAction::Deny }; - let egress = NetworkEgressPolicy { + let mut egress = NetworkEgressPolicy { default: egress_default, ..Default::default() }; + if proxied { + // PSEC host loopback is bidirectional. This shape is supported only + // when the caller already allows local clients and servers. Keep + // private-network ingress denied and allow no direct DNS bypass. + egress.allow.push(NetworkRule { + to: vec![ + NetworkPeer { + cidr: NetworkCidr { + address: std::net::Ipv4Addr::new(127, 0, 0, 0).into(), + prefix_length: 8, + }, + except: Vec::new(), + }, + NetworkPeer { + cidr: NetworkCidr { + address: std::net::Ipv6Addr::LOCALHOST.into(), + prefix_length: 128, + }, + except: Vec::new(), + }, + ], + ports: Vec::new(), + }); + } let mut request = ExecutionRequest { script_code: cmdline_from_argv_for_context( &command.command, @@ -250,7 +282,7 @@ pub fn build_request( network_egress: Some(egress), network_ingress: Some(NetworkIngressPolicy { default: ingress_default, - host_loopback: if network_enabled { + host_loopback: if network_enabled || proxied { NetworkAction::Allow } else { NetworkAction::Deny diff --git a/codex-rs/mxc-sandbox/src/policy_tests.rs b/codex-rs/mxc-sandbox/src/policy_tests.rs index dd684ccd9b..4603fe49fb 100644 --- a/codex-rs/mxc-sandbox/src/policy_tests.rs +++ b/codex-rs/mxc-sandbox/src/policy_tests.rs @@ -1,9 +1,11 @@ //! Portable policy and launcher regressions, with Windows identity coverage. +use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use anyhow::Result; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicyContext; use codex_protocol::protocol::FileSystemAccessMode; @@ -15,8 +17,16 @@ use codex_protocol::protocol::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; +use wxc_common::models::NetworkAction; +use wxc_common::models::NetworkCidr; +use wxc_common::models::NetworkEgressPolicy; +use wxc_common::models::NetworkIngressPolicy; +use wxc_common::models::NetworkPeer; +use wxc_common::models::NetworkRule; +use crate::CreateMxcCommandArgsParams; use crate::MxcCommand; +use crate::create_command_args; use crate::policy::PolicyError; use crate::policy::build_request; use crate::policy::materialize_volume_roots; @@ -91,6 +101,7 @@ fn command(permissions: &PermissionProfile, cwd: &Path) -> MxcCommand { MxcCommand { permissions: permissions.clone(), sandbox_policy_cwd: cwd.to_owned(), + managed_network: None, command: vec!["program.exe".to_owned(), "--arg".to_owned()], } } @@ -346,6 +357,90 @@ fn full_access_enumerates_children_of_every_volume() -> Result<()> { Ok(()) } +#[test] +fn managed_network_allows_authorized_loopback_without_lan_or_dns_access() -> Result<()> { + let root = tempfile::tempdir()?; + let profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(Vec::new()), + NetworkSandboxPolicy::Enabled, + ); + let proxy = ManagedNetworkSandboxContext { + loopback_ports: vec![43123, 43124], + allow_local_binding: true, + }; + let mut env = HashMap::new(); + create_command_args(CreateMxcCommandArgsParams { + command: vec!["program.exe".to_owned()], + permission_profile: &profile, + sandbox_policy_cwd: root.path(), + managed_network: Some(&proxy), + env: &mut env, + })?; + let parsed = crate::transport::decode(&mut env)?; + let request = build_request(&parsed, root.path(), Vec::new(), &[], &[])?; + assert_eq!( + request.policy.network_egress, + Some(NetworkEgressPolicy { + default: NetworkAction::Deny, + allow: vec![NetworkRule { + to: vec![ + NetworkPeer { + cidr: "127.0.0.0/8".parse()?, + except: Vec::new() + }, + NetworkPeer { + cidr: NetworkCidr { + address: std::net::Ipv6Addr::LOCALHOST.into(), + prefix_length: 128 + }, + except: Vec::new() + }, + ], + ports: Vec::new(), + }], + deny: Vec::new(), + }) + ); + assert_eq!( + request.policy.network_ingress, + Some(NetworkIngressPolicy { + default: NetworkAction::Deny, + host_loopback: NetworkAction::Allow, + }) + ); + Ok(()) +} + +#[test] +fn invalid_managed_network_is_rejected_at_both_boundaries() -> Result<()> { + let root = tempfile::tempdir()?; + let cwd = root.path(); + let profile = PermissionProfile::read_only(); + for (loopback_ports, allow_local_binding) in + [(Vec::new(), true), (vec![0], true), (vec![43123], false)] + { + let proxy = ManagedNetworkSandboxContext { + loopback_ports, + allow_local_binding, + }; + let mut env = HashMap::new(); + assert!( + create_command_args(CreateMxcCommandArgsParams { + command: Vec::new(), + permission_profile: &profile, + sandbox_policy_cwd: cwd, + managed_network: Some(&proxy), + env: &mut env, + }) + .is_err() + ); + let mut parsed = command(&profile, cwd); + parsed.managed_network = Some(proxy); + assert!(build_request(&parsed, cwd, Vec::new(), &[], &[]).is_err()); + } + Ok(()) +} + #[test] fn deny_globs_expand_files_and_directories_before_launch() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/codex-rs/mxc-sandbox/src/transport_tests.rs b/codex-rs/mxc-sandbox/src/transport_tests.rs index 1009292fb3..1c8a520963 100644 --- a/codex-rs/mxc-sandbox/src/transport_tests.rs +++ b/codex-rs/mxc-sandbox/src/transport_tests.rs @@ -21,6 +21,7 @@ fn command(args: Vec) -> MxcCommand { MxcCommand { permissions: PermissionProfile::read_only(), sandbox_policy_cwd: PathBuf::from("workspace"), + managed_network: None, command: args, } }