diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index b8c52f9898..7f595bf19c 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -23,7 +23,7 @@ it does not implement the Linux `ConnectSupervisor` protocol. |---|---| | Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | | UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | -| Network policy | With `egress_proxy = true` on `process_container`, split into MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | +| Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | | Process policy | Unsupported; MXC supplies OS isolation only. | | Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | @@ -60,6 +60,10 @@ pc_relay_target_port = 0 # (SYSTEMROOT/WINDIR/PATH/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an # EMPTY env for runtimes that need a fully curated per-sandbox environment. pc_minimal_env = false +# processContainer only: compatibility fallback for unrestricted outbound TCP. +# A sandbox with egress_proxy enabled but no explicit network rules rejects +# this fallback instead of silently changing governed egress to allow-all. +pc_network_allow = false # processContainer only: include "allowLocalNetwork": true in the MXC # network section. This compatibility setting broadens network access and is # not required by the BaseContainer qualification profile. @@ -72,8 +76,14 @@ etw_audit = false ``` When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback -`IP:PORT` seed. The driver preserves the configured IP and allocates a unique -ephemeral port for each sandbox's authenticated host CONNECT proxy. +`IP:PORT` seed. For policies with explicit network rules, the driver preserves +the configured IP and allocates a unique ephemeral port for that sandbox's +authenticated host CONNECT proxy. + +`pc_network_allow = true` is an explicit unrestricted-egress compatibility +fallback. If it is combined with `egress_proxy = true`, a sandbox policy +without explicit network rules is rejected synchronously rather than falling +through from governed egress to `defaultPolicy = "allow"`. Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: @@ -87,7 +97,7 @@ The `command` array is required and preserves Windows argument boundaries. `cwd` UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). -`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: the driver reserves a unique ephemeral loopback port for every sandbox. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. The current policy permits all loopback ports, so sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. +`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: for a sandbox policy with explicit network rules, the driver reserves a unique ephemeral loopback port. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. A policy without network rules keeps MXC's default network posture and receives neither a host listener nor proxy environment variables. The current governed-egress policy permits all loopback ports, so governed sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a @@ -142,8 +152,9 @@ or MXC-specific gateway composition variant. Provider resolver state uses a separate, create-scoped in-process handoff because it intentionally cannot be represented in the public compute-driver protobuf. -When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus loopback-only egress, +When `egress_proxy` is enabled and the policy contains explicit network rules, +`EmbeddedPolicyMapper` uses `split_policy` instead: MXC receives filesystem +grants plus loopback-only egress, and the driver starts a host CONNECT proxy from the trimmed network-only `SandboxPolicy`. Policies containing `network_middlewares` are rejected synchronously until this host-proxy path can receive the gateway's diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml index 6160be6f5c..683a8c0c66 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -10,9 +10,8 @@ # system paths and share_dir). # - TCP socket binding on port 22000 (governed by pc_capabilities in the # gateway TOML, not by filesystem policy here). -# - Outbound TCP through the egress proxy, for openshell-supervisor-relay -# to dial the driver's on-demand relay — governed by egress_proxy in the -# TOML. +# - Private/loopback client access for openshell-supervisor-relay to dial +# the driver's on-demand relay — granted by pc_capabilities in the TOML. # - No writes to the host filesystem. # # workload directory (passed by run-ws-agent-test.ps1, default C:\work\openshell-mxc-ws) @@ -24,15 +23,9 @@ # patches this path and the per-sandbox driver config when -AgentDir overrides # the default below. # -# This example intentionally omits network_policies, not because the driver -# would reject it: with egress_proxy = true (set in mxc-ws-gateway.toml), the -# driver takes the lossless split path (policy_map::split_policy) and -# delegates network_policies verbatim to the OpenShell host CONNECT proxy for -# enforcement -- an "info" loss item, not an error, so it would be accepted. -# (Only the no-proxy coarse path, or an unsupported rule shape, can turn a -# network_policies entry into a rejected "error" loss item -- see -# policy_map/map.rs.) This scenario just doesn't need host-enforced network -# rules beyond the loopback/pc_capabilities grant above. +# This example intentionally omits network_policies because it does not need +# governed Internet egress. The driver therefore does not start a host CONNECT +# proxy or inject proxy environment variables for this sandbox. version: 1 filesystem_policy: diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs index dbd63f1426..1c09e7956a 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs +++ b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs @@ -282,7 +282,8 @@ async fn run_relay_proxy(relay_url: String, local_url: String, mut stop_rx: ones const LOCAL_CONNECT_ATTEMPTS: u32 = 15; const LOCAL_CONNECT_TIMEOUT: Duration = Duration::from_millis(500); const LOCAL_CONNECT_BACKOFF: Duration = Duration::from_millis(300); - // Connect to the gateway relay (outbound via egress_proxy). + // Connect directly to the gateway relay over the private network access + // granted by the qualification profile's privateNetworkClientServer capability. let relay_ws = match tokio_tungstenite::connect_async(&relay_url).await { Ok((ws, _)) => { eprintln!("[spawner] relay connected: {relay_url}"); diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml index afd04a44cd..8a2aa4e9b8 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -48,11 +48,10 @@ pc_capabilities = ["privateNetworkClientServer"] # process_container only: keep standard privilege level (not LPA). pc_least_privilege = false -# Egress proxy for outbound TCP connectivity -- required for -# openshell-supervisor-relay to dial out to the driver's on-demand relay -# (see mxc-openclaw-gateway.toml, which uses the same pattern). -egress_proxy = true -egress_proxy_addr = "127.0.0.1:18080" +# No governed Internet egress is needed. The relay reaches the driver's +# on-demand private-interface listener through privateNetworkClientServer above. +egress_proxy = false +egress_proxy_addr = "" # The workload command and cwd are supplied per sandbox by # run-ws-agent-test.ps1 through `sandbox create --driver-config-json`. diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml index 13e7cd571e..e036cc90ef 100644 --- a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -7,9 +7,10 @@ # else is default-deny. run-ocsf-audit.ps1 copies this policy into the result # bundle and replaces the default grant with -ShareDir for that run. # -# No network_policies block is needed here: the per-sandbox egress proxy is driven -# by `egress_proxy = true` in mxc-ocsf-audit.toml (that is what makes MXC emit the -# SandboxProxyConfigured event we map to OCSF), not by a policy rule. +# The explicit network rule activates the per-sandbox egress proxy configured +# in mxc-ocsf-audit.toml. That proxy configuration makes MXC emit the +# SandboxProxyConfigured event mapped to OCSF. run-ocsf-audit.ps1 removes this +# block for its explicit -NoProxy comparison. version: 1 filesystem_policy: @@ -17,3 +18,13 @@ filesystem_policy: read_only: [] read_write: - "C:/work/openshell-mxc-demo" + +network_policies: + audit_probe: + name: audit-probe + endpoints: + - host: example.com + port: 443 + protocol: tcp + binaries: + - path: "C:/Windows/System32/cmd.exe" diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 591c38bf24..00d32e10ef 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -144,6 +144,9 @@ try { $shareDirPolicy = $ShareDir.Replace('\', '/') $shareDirJson = ConvertTo-Json $shareDirPolicy -Compress $policyText = Get-Content $policySrc -Raw + if (-not $proxyOn) { + $policyText = [regex]::Replace($policyText, '(?ms)^network_policies:\s*.*\z', '') + } $defaultGrant = ' - "C:/work/openshell-mxc-demo"' if (-not $policyText.Contains($defaultGrant)) { throw "policy template does not contain the expected default ShareDir grant" diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index 0564d17fc5..0372864928 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -359,15 +359,12 @@ try { } Ok "wxc-exec: $WxcExecPath" - # A real run exercises process_container with egress_proxy = true - # (mxc-ws-gateway.toml). MXC schema 0.8.0-alpha's network_json() - # (mxc.rs) now emits a direct egress.allow rule for 127.0.0.0/8 - # instead of runtimeConfig.networkProxy when a proxy is configured, - # so the driver no longer calls the elevation-only - # NetworkIsolationSetAppContainerConfig -- process_container + - # egress_proxy selects the BaseContainer/PSEC tier and runs - # non-elevated. Elevation is therefore no longer required here; keep - # logging the elevation state for diagnostics only. + # A real run exercises process_container with egress_proxy disabled + # (mxc-ws-gateway.toml). The sandbox connects directly to the driver's + # route-selected private-interface relay listener through the + # privateNetworkClientServer capability; the governed host CONNECT + # proxy is not part of this qualification path. Elevation is not + # required here; keep logging the elevation state for diagnostics only. $wid = [Security.Principal.WindowsIdentity]::GetCurrent() $wp = New-Object Security.Principal.WindowsPrincipal($wid) $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 072b1debf1..513a92d858 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -82,7 +82,10 @@ pub struct MxcComputeConfig { /// `processContainer` only: inject a network section with /// `defaultPolicy: "allow"` so the `AppContainer` has unrestricted outbound /// TCP access. Required when `pc_capabilities` alone is insufficient to - /// enable network access in the target wxc-exec build. + /// enable network access in the target wxc-exec build. When `egress_proxy` + /// is also enabled, sandbox policies without explicit network rules are + /// rejected instead of falling back from governed egress to unrestricted + /// access. pub pc_network_allow: bool, /// `processContainer` only: include `"allowLocalNetwork": true` in the /// MXC network section. Required for node.js (and other runtimes that @@ -123,9 +126,11 @@ pub struct MxcComputeConfig { /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, - /// Enable Pattern-C governed egress. When true, MXC permits loopback-only - /// egress, the driver injects proxy environment variables, and the host - /// CONNECT proxy receives the full network policy. + /// Enable Pattern-C governed egress for sandbox policies that contain + /// explicit network rules. MXC permits loopback-only egress, the driver + /// injects proxy environment variables, and the host CONNECT proxy receives + /// the full network policy. Policies without network rules do not start a + /// listener or receive proxy environment variables. pub egress_proxy: bool, /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is /// enabled. The driver preserves the loopback IP and allocates a unique @@ -462,6 +467,26 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result) -> bool { + policy.is_some_and(|policy| { + !policy.network_policies.is_empty() || !policy.network_middlewares.is_empty() + }) +} + +fn governed_egress_addr( + config: &MxcComputeConfig, + policy: Option<&SandboxPolicy>, +) -> Result, tonic::Status> { + let configured = configured_egress_addr(config)?; + let policy_activates_egress = policy_activates_governed_egress(policy); + if configured.is_some() && !policy_activates_egress && config.pc_network_allow { + return Err(tonic::Status::invalid_argument( + "mxc egress_proxy cannot be combined with pc_network_allow for a sandbox policy without explicit network rules; refusing unrestricted egress fallback", + )); + } + Ok(configured.filter(|_| policy_activates_egress)) +} + fn allocate_sandbox_proxy_addr( configured: SocketAddr, ) -> std::io::Result<(SocketAddr, std::net::TcpListener)> { @@ -816,7 +841,7 @@ impl MxcComputeBackend { } } - fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + fn validate_sandbox_fields(sandbox: &DriverSandbox) -> Result<(), tonic::Status> { if let Some(spec) = &sandbox.spec { if effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), @@ -859,9 +884,9 @@ impl MxcComputeBackend { } pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - self.validate_sandbox_fields(sandbox)?; + Self::validate_sandbox_fields(sandbox)?; let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let egress_addr = configured_egress_addr(&self.config)?; + let egress_addr = governed_egress_addr(&self.config, policy)?; self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; Ok(()) } @@ -890,9 +915,13 @@ impl MxcComputeBackend { .remove(&sandbox_id); validate_provider_child_env_keys(provider_credentials.as_ref())?; - self.validate_sandbox_fields(sandbox)?; + Self::validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; - let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + let (egress_addr, reserved_proxy_listener) = match governed_egress_addr( + &self.config, + policy, + )? { Some(configured_addr) => { let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( |error| { @@ -909,7 +938,6 @@ impl MxcComputeBackend { // Policy translation is deterministic and side-effect free. Do it before // inserting the registry entry or launching MXC so invalid requests fail // synchronously at the CreateSandbox boundary. - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; if provider_credentials .as_ref() @@ -917,7 +945,7 @@ impl MxcComputeBackend { && egress_addr.is_none() { return Err(tonic::Status::failed_precondition( - "mxc provider credentials require governed egress; enable egress_proxy so placeholders can be resolved by the host proxy", + "mxc provider credentials require governed egress; enable egress_proxy and configure at least one network policy so placeholders can be resolved by the host proxy", )); } @@ -1425,9 +1453,8 @@ async fn run_lifecycle( let proxy_addr = mapped.proxy_addr; let proxy_auth = proxy_addr.map(|_| SandboxProxyAuth::generate()); let trimmed_policy = mapped.trimmed_policy.clone(); - let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = - (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) + let host_proxy = if let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = + (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) { drop(reserved_proxy_listener.take()); match openshell_supervisor_network::host::start_host_proxy( @@ -1447,7 +1474,14 @@ async fn run_lifecycle( ) .await { - Ok(handle) => Some(handle), + Ok(handle) => { + info!( + sandbox = %sandbox_name, + address = %addr, + "MXC host egress proxy started" + ); + Some(handle) + } Err(error) => { set_failed( ®istry, @@ -2366,7 +2400,9 @@ mod lifecycle_tests { use std::time::Duration; fn driver_sandbox(id: &str) -> DriverSandbox { - driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + driver_sandbox_with_command(id, "", vec![shell, "/c".into(), "exit 0".into()]) } #[tokio::test] @@ -2432,6 +2468,19 @@ mod lifecycle_tests { } } + fn long_running_command(share: &str) -> (String, Vec) { + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + let command = vec![ + shell.clone(), + "/d".into(), + "/s".into(), + "/c".into(), + format!(r#"cd /d "{share}" && ping -n 61 127.0.0.1 >nul"#), + ]; + (shell, command) + } + fn github_provider_credentials() -> ProviderCredentialState { ProviderCredentialState::from_bound_environment( 42, @@ -2659,6 +2708,8 @@ mod lifecycle_tests { .expect("provider credential staging lock poisoned") .insert("sb-provider-env".to_string(), credentials); + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); let mut policy = fs_policy(&[]); policy.network_policies.insert( "github".to_string(), @@ -2668,13 +2719,12 @@ mod lifecycle_tests { host: "api.github.com".to_string(), port: 443, protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "full".to_string(), provider_credentialed: true, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "cmd".to_string(), - ..Default::default() - }], + binaries: vec![NetworkBinary { path: shell }], }, ); let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); @@ -2695,11 +2745,15 @@ mod lifecycle_tests { .create_sandbox(&sandbox) .await .expect("create accepted"); - wait_for(&backend, "sb-provider-env", |_| { + let recorded = wait_for(&backend, "sb-provider-env", |_| { crate::mxc::mock_recorded_config("sb-provider-env").is_some() }) - .await - .expect("MXC config should be recorded"); + .await; + assert!( + recorded.is_some(), + "MXC config should be recorded; sandbox: {:#?}", + backend.get_sandbox("sb-provider-env").await + ); let recorded = crate::mxc::mock_recorded_config("sb-provider-env").expect("mock recorded config"); @@ -3065,121 +3119,195 @@ mod lifecycle_tests { } #[tokio::test] - async fn split_path_provisions_with_proxy_redirect() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + async fn explicit_network_policies_start_and_cleanup_host_proxy() { + for (sandbox_id, host, pc_network_allow) in [ + ("sb-egress-allow", "example.com", false), + ("sb-egress-no-match", "allowed.invalid", true), + ] { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let (shell, command) = long_running_command(&share); + let config = MxcComputeConfig { + backend: MxcBackend::ProcessContainer, + pc_network_allow, + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }; + let backend = MxcComputeBackend::new_mocked(config); + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "explicit".into(), + NetworkPolicyRule { + name: "explicit".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + ports: vec![443], + protocol: "tcp".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { path: shell }], + }, + ); + let sandbox = with_policy( + driver_sandbox_with_command(sandbox_id, &share, command), + policy.clone(), + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, sandbox_id, |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("explicit network policy sandbox should reach Ready=True"); + + let recorded = + crate::mxc::mock_recorded_config(sandbox_id).expect("mock recorded config"); + assert_eq!(recorded["network"]["egress"]["default"], "deny"); + assert_eq!( + recorded["network"]["egress"]["allow"], + serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) + ); + assert!(recorded["network"].get("proxy").is_none()); + + let proxy_addr = { + let registry = backend.registry.lock().await; + let entry = registry.get(sandbox_id).expect("registry entry"); + assert!( + entry.host_proxy.is_some(), + "{sandbox_id}: governed egress must hold a live host proxy" + ); + assert_eq!( + entry.trimmed_policy.as_ref().unwrap().network_policies, + policy.network_policies + ); + entry.proxy_addr.expect("proxy address") + }; + tokio::time::timeout( + Duration::from_secs(2), + tokio::net::TcpStream::connect(proxy_addr), + ) + .await + .expect("proxy listener connect timed out") + .expect("proxy listener must accept connections"); + + let child_env = recorded["process"]["env"].as_array().expect("child env"); + let proxy_env = child_env + .iter() + .filter_map(serde_json::Value::as_str) + .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) + .expect("HTTP_PROXY must direct clients to the authenticated proxy"); + let (credentials, address) = proxy_env + .strip_prefix("http://openshell:") + .and_then(|value| value.split_once('@')) + .expect("proxy URL must contain the per-sandbox credential"); + assert!(!credentials.is_empty()); + assert_eq!(address, proxy_addr.to_string()); + + backend + .stop_sandbox(sandbox_id) + .await + .expect("stop should clean up the governed egress proxy"); + assert!( + backend + .registry + .lock() + .await + .get(sandbox_id) + .is_some_and(|entry| entry.host_proxy.is_none()), + "{sandbox_id}: stop must drop the host proxy handle" + ); + assert!( + backend + .delete_sandbox(&sandbox.id, &sandbox.name) + .await + .expect("delete after stop"), + "sandbox should be removed" + ); + } + } + + #[tokio::test] + async fn empty_network_policy_does_not_activate_host_proxy() { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let config = MxcComputeConfig { + let (_shell, command) = long_running_command(&share); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { backend: MxcBackend::ProcessContainer, egress_proxy: true, egress_proxy_addr: "127.0.0.1:18080".into(), ..Default::default() - }; - let backend = MxcComputeBackend::new_mocked(config); - let mut stream = backend.watch_sandboxes().await; - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ports: vec![443], - protocol: "rest".into(), - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".into(), - ..Default::default() - }], - }, - ); + }); let sandbox = with_policy( - driver_sandbox_with_command("sb-egress", &share, cmd), - policy.clone(), + driver_sandbox_with_command("sb-egress-default", &share, command), + fs_policy(&[&share]), ); backend .create_sandbox(&sandbox) .await .expect("create accepted"); - - let ready = wait_for(&backend, "sb-egress", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + wait_for(&backend, &sandbox.name, |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") }) - .await; + .await + .expect("default-policy sandbox should reach Ready=True"); + + let recorded = crate::mxc::mock_recorded_config(&sandbox.id).expect("mock recorded config"); + let registry = backend.registry.lock().await; + let entry = registry.get(&sandbox.id).expect("registry entry"); + assert!(entry.proxy_addr.is_none()); + assert!(entry.host_proxy.is_none()); + assert!(entry.trimmed_policy.is_none()); + drop(registry); + assert!(recorded.get("network").is_none()); assert!( - ready.is_some(), - "egress split sandbox should reach Ready=True" + recorded["process"]["env"] + .as_array() + .expect("child env") + .iter() + .filter_map(serde_json::Value::as_str) + .all(|entry| !PROXY_ENV_KEYS.iter().any(|key| { + entry + .split_once('=') + .is_some_and(|(entry_key, _)| entry_key.eq_ignore_ascii_case(key)) + })) ); - let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["version"], "0.8.0-alpha"); - assert_eq!(recorded["network"]["egress"]["default"], "deny"); - assert_eq!( - recorded["network"]["egress"]["allow"], - serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) - ); - assert!(recorded["network"].get("allowedHosts").is_none()); - assert!(recorded["network"].get("blockedHosts").is_none()); - assert!(recorded["network"].get("proxy").is_none()); - assert!(recorded.get("networkProxy").is_none()); - - let reg = backend.registry.lock().await; - let entry = reg.get("sb-egress").expect("registry entry"); - let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); - assert_eq!( - entry_proxy_addr.ip(), - std::net::IpAddr::from([127, 0, 0, 1]) + backend.stop_sandbox(&sandbox.name).await.expect("stop"); + assert!( + backend + .delete_sandbox(&sandbox.id, &sandbox.name) + .await + .expect("delete after stop") ); - assert_ne!(entry_proxy_addr.port(), 0); - let child_env = recorded["process"]["env"].as_array().expect("child env"); - let proxy_env = child_env - .iter() - .filter_map(serde_json::Value::as_str) - .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) - .expect("HTTP_PROXY must direct clients to the authenticated proxy"); - let (credentials, address) = proxy_env - .strip_prefix("http://openshell:") - .and_then(|value| value.split_once('@')) - .expect("proxy URL must contain the per-sandbox credential"); - assert!(!credentials.is_empty()); - assert_eq!(address, entry_proxy_addr.to_string()); - assert_eq!( - entry.trimmed_policy.as_ref().unwrap().network_policies, - policy.network_policies + } + + #[test] + fn empty_network_policy_rejects_unrestricted_fallback() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::ProcessContainer, + pc_network_allow: true, + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }); + let sandbox = with_policy( + driver_sandbox("sb-egress-unrestricted-fallback"), + fs_policy(&[]), ); - drop(reg); - let mut saw_redirect = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload - && pe - .event - .as_ref() - .is_some_and(|e| e.reason == "EgressRedirect") - { - saw_redirect = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!(saw_redirect, "expected EgressRedirect platform event"); + let error = backend + .validate_sandbox_create(&sandbox) + .expect_err("mixed egress configuration must fail closed without network rules"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("egress_proxy")); + assert!(error.message().contains("pc_network_allow")); + assert!(error.message().contains("unrestricted egress fallback")); } #[tokio::test] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index eecc1f7155..dd65b06358 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -407,10 +407,6 @@ impl WxcExecInvoker { } } - pub(crate) const fn is_mock(&self) -> bool { - self.mock - } - /// Test-only constructor that forces mock mode without touching the /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across /// parallel tests under edition 2024's `unsafe` `set_var`). diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs index da0a000188..45b0776290 100644 --- a/crates/openshell-driver-mxc/src/relay.rs +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -3,16 +3,17 @@ //! WebSocket relay embedded in the gateway for MXC `ProcessContainer` sandboxes. //! -//! When `egress_proxy = true` the `AppContainer` has outbound TCP via the -//! `OpenShell` host CONNECT proxy. The driver binds a relay listener on demand -//! (`start_relay`, e.g. from `ForwardSink::open_dynamic_forward`) and tells -//! the in-sandbox spawner its address over the stdin/stdout control channel; -//! the spawner connects outward to it as a WebSocket CLIENT (Phase A). Host -//! clients connect as raw TCP (Phase B); the relay tunnels their bytes -//! through Phase A so the in-sandbox agent can pipe them directly to the -//! target service. Each relay is per-request and short-lived — bound fresh -//! for each `openshell forward service` call, torn down when that forward -//! ends. +//! The `AppContainer` needs private-network client access (for example, the +//! `privateNetworkClientServer` capability used by the qualification profile). +//! The driver binds a relay listener on demand (`start_relay`, e.g. from +//! `ForwardSink::open_dynamic_forward`) and tells the in-sandbox spawner its +//! address over the stdin/stdout control channel; the spawner connects directly +//! to it as a WebSocket CLIENT (Phase A). Governed egress and its host CONNECT +//! proxy are not part of this relay path. Host clients connect as raw TCP +//! (Phase B); the relay tunnels their bytes through Phase A so the in-sandbox +//! agent can pipe them directly to the target service. Each relay is +//! per-request and short-lived — bound fresh for each `openshell forward +//! service` call, torn down when that forward ends. //! //! ```text //! host TCP client -> relay (gateway, raw TCP accept) diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 36b6edb804..abd048c73c 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -885,10 +885,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { access: "read-only".to_string(), ..Default::default() }], - binaries: vec![NetworkBinary { - path: cmd_string, - ..Default::default() - }], + binaries: vec![NetworkBinary { path: cmd_string }], }, )]), ..Default::default() diff --git a/mise.lock b/mise.lock index a5e38be2c2..77d25ea64e 100644 --- a/mise.lock +++ b/mise.lock @@ -168,6 +168,12 @@ url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9 url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501885637" provenance = "github-attestations" +[tools."github:nextest-rs/nextest"."platforms.windows-arm64"] +checksum = "sha256:58c1637ba2396e6c556aa0092f9aa4388695594b8ddda5a4b8b39212574678ce" +url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-aarch64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501886128" +provenance = "github-attestations" + [tools."github:nextest-rs/nextest"."platforms.windows-x64"] checksum = "sha256:c670ba18e8731fd2eff33a47af33a0fa53d1afa6d0678344e82dc6f8fc7344ac" url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-x86_64-pc-windows-msvc.zip" @@ -439,15 +445,19 @@ backend = "aqua:GoogleContainerTools/skaffold" "vars.version_prefix" = "v" [tools.skaffold."platforms.linux-arm64"] +checksum = "sha256:0062e1ff61f35200168454fa5a836306dd30b51acf30999d5d8836770d70ad2f" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-linux-arm64" [tools.skaffold."platforms.linux-x64"] +checksum = "sha256:de75d5697d4a14d6a5e87105671ef9a30990270ab14ad301913f8f8d41a1ecca" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-linux-amd64" [tools.skaffold."platforms.macos-arm64"] +checksum = "sha256:73478a431c17674d2f75d2c6e2fcbf35aa019de4ad6027e5573cc0f581767fbc" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-darwin-arm64" [tools.skaffold."platforms.windows-x64"] +checksum = "sha256:a257da2988782b4c1971bb58b9c7bb52176f8a0b587b6520dec0064f271fa416" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-windows-amd64.exe" [[tools.uv]] @@ -455,13 +465,15 @@ version = "0.10.12" backend = "aqua:astral-sh/uv" [tools.uv."platforms.linux-arm64"] -checksum = "sha256:55bd1c1c10ec8b95a8c184f5e18b566703c6ab105f0fc118aaa4d748aabf28e4" -url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-musl.tar.gz" +checksum = "sha256:0ed7d20f49f6b9b60d45fdfcac28f3ac01a671a6ef08672401ed2833423fea2a" +url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491938" provenance = "github-attestations" [tools.uv."platforms.linux-x64"] -checksum = "sha256:adccf40b5d1939a5e0093081ec2307ea24235adf7c2d96b122c561fa37711c46" -url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:ec72570c9d1f33021aa80b176d7baba390de2cfeb1abcbefca346d563bf17484" +url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491996" provenance = "github-attestations" [tools.uv."platforms.macos-arm64"]