diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index de1229f70..cd96e1049 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -34,6 +34,14 @@ mechanics belong to the sandbox or isolation backend. Keep the optional Unix SSH access adapter separate from portable session orchestration. Shared Sandbox Protocol audit validation defaults to strict Linux evidence; concrete platform validators must be selected by the implementing backend. + +MXC control connects outward from the boundary to a generation-scoped host +loopback listener, matching the Windows relay's connection direction. Keep +the reverse connector in the MXC isolation backend and share it across policy +discovery and attachment. Shared transport hooks must retain pinned TLS peer +verification and supervisor JWT authentication; do not change host firewall +rules to repair host-to-AppContainer control connectivity. + The dedicated Windows sandbox binary links the Windows-only `openshell-mxc-boundary` library for process operations, containment confirmation, and loopback forwarding. The generic sandbox does not link this library and remains a Linux @@ -43,10 +51,22 @@ process. Include boundary-library regression tests in native workspace tests; their real process/socket checks are not qualification of MXC enforcement. Keep MXC audit schemas and their validators in the Windows boundary library. -Register its `MxcRuntimeBackend` under `openshell-mxc` at supervisor composition; -it reuses the shared authenticated Sandbox Protocol, whose default Linux +Register its `MxcRuntimeBackend` under `openshell-mxc` at supervisor composition. +Use the private `BackendSetup` / `PreparedBackend` extension for launch decoding, +policy discovery, and client construction. Shared startup must not decode the +MXC descriptor or allocate its connector. Retain the same reverse-TCP connector +in prepared launch state through discovery, attachment, and reconnects. +It reuses the shared authenticated Sandbox Protocol, whose default Linux backend remains separate. Shared protocol code -must not interpret MXC evidence. Do not reintroduce driver-owned proxy startup +must not interpret MXC evidence. + +Keep proxy provisioning in MXC-owned host-launch and one-use bootstrap +envelopes. Private Windows backend setup supplies concrete listener options to +shared supervisor networking. Do not add proxy hooks to `BoundBoundary`, proxy +fields to shared Sandbox Protocol descriptors, or placeholders to other drivers. +Proxy startup remains after authenticated attachment and boundary confirmation. + +Do not reintroduce driver-owned proxy startup or gateway create-time credential snapshots. Gateway JSONL audit output is portable and an explicit operator opt-in, independent of the selected driver. Keep platform directory conventions in shared path utilities and ETW capture @@ -60,11 +80,12 @@ provider-publication generations. Do not order opaque credential revisions numerically. MXC rejects unsupported provider file delivery before launch or environment replacement; it must not acknowledge files it did not install. -Preserve main's explicit outer-fence evidence contract. The current MXC mapper -allows broad host loopback and has no verified live-revocation evidence, so -configuration flags cannot establish the required guarantees. Keep confirmation -fail-closed until native qualification proves each property; a supported PSEC -host alone does not close these implementation gaps. +Preserve main's shared outer-fence evidence contract. At the user's explicit +request, MXC temporarily asserts the required guarantees for Windows-branch +parity testing. These MXC-only assertions are stubs, not verified enforcement: +broad host loopback and unverified live revocation remain known gaps. Keep the +TODOs and unverified audit mechanism visible; never report passing E2E as proof +that these gaps are closed. A supported PSEC host alone does not close them. ## Current Repository Shape @@ -260,7 +281,7 @@ jobs in the current mirror push run, or push a new mirrored commit. The binaries The ARM64 check/build steps in this x64-host contract are cross-builds. The wrapper discovers and adds host-native LLVM and Ninja to `PATH`, requires the ARM64 compiler and Spectre-mitigated libraries, lets ARM64 crypto crates select -`clang-cl`, and downloads the official prebuilt ARM64 Z3 static library. +`clang-cl`, and downloads the official prebuilt ARM64 Z3 package. On ARM64 hosts, validate the native ARM64 check, build, and test path. The wrapper rejects test targets that do not match the host architecture, so x64 @@ -337,6 +358,7 @@ without the capability reject explicit UI without implementing UI-specific handling. Keep native UI validation and mapping in MXC. Omitted UI settings deny graphical UI, clipboard access, and input injection. UI changes require recreating the sandbox. + Windows must continue to reject unsupported compute drivers clearly. The gateway's `compute-driver-mxc` feature independently links and registers @@ -389,6 +411,7 @@ Verify the SSH host key beforehand. Never send the private SSH key or bundle the harness signing-key directories. `-SkipBuild` is an explicit stale-artifact opt-in, not evidence that artifacts match current sources. See the MXC driver README for the command and remote dependency contract. + For MXC validation, also run `windows:e2e:mxc` after the native release build. The harness uses .NET port discovery, disposable TOML and CLI registration, and owner-only Ed25519 keys generated by OpenSSL on @@ -397,8 +420,9 @@ bundles and are deleted unless `-KeepRunning` is explicitly requested. E2E has no mock mode and rejects `OPENSHELL_MXC_MOCK_WXC=1`. Unit-test mocks are not E2E coverage. Every selected scenario must pass for a successful exit; -any skip reports `INCOMPLETE` and exits non-zero. Never bypass audit to make -tests pass. Real runtime scenarios require native ProcessContainer PSEC egress +any skip reports `INCOMPLETE` and exits non-zero. Do not broaden the temporary +MXC network-assertion waiver to other audit or authentication checks. Real +runtime scenarios require native ProcessContainer PSEC egress filtering and ingress host-loopback support. `wxc-exec --probe` is authoritative, not the Windows build number. All-skipped real runs are not passes. Separate test-internal `SKIP` messages from Cargo's passed count in the real integration @@ -419,6 +443,17 @@ exposure, two exact GET responses through gateway routing, and HTTP 404 after endpoint deletion. This is distinct from `openshell forward service`; neither scenario tests file upload or exclusive ingress enforcement. +The real integration HTTPS test runs the same authenticated gateway harness with +the opt-in `https-ca` scenario, not an unauthenticated standalone driver. Build +native release binaries first, or select them with `OPENSHELL_MXC_TEST_BINARY_DIR`. +It requires Internet access to example.com and Windows curl >= 8.3 to explicitly +expand the injected CA bundle into `--cacert` (Schannel ignores that environment +variable by default). Require both a nonempty response and the OpenShell proxy +CA issuer; never replace these assertions with an insecure TLS request. This +fixture permits unavailable CRL information using best-effort Schannel revocation +for the ephemeral private CA; this is not revocation qualification. This +scenario is not part of the default six offline-capable E2E scenarios. + When reporting `windows:ci`, distinguish these categories: - Passed tests from the full x64 workspace test log. diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index a34cb2eb3..179eaef08 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -110,6 +110,7 @@ top-level workspace targets for check/test: --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm +--exclude openshell-sandbox --exclude openshell-vfio ``` diff --git a/Cargo.lock b/Cargo.lock index 087d10d60..733d71a2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4689,6 +4689,8 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sha2 0.10.9", + "tempfile", "tokio", "tokio-rustls", "tokio-stream", @@ -4851,7 +4853,6 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", - "url", "uuid", ] diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 6d75626d6..ffb7dae76 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -46,7 +46,6 @@ struct ComputeDriverInfoView { struct ComputeDriverCapabilitiesView { driver_name: String, driver_version: String, - supports_ui_policy: bool, } #[derive(Debug, Clone)] @@ -418,7 +417,6 @@ pub async fn gateway_info( capabilities: ComputeDriverCapabilitiesView { driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, - supports_ui_policy: capabilities.supports_ui_policy, }, } }) @@ -516,15 +514,6 @@ fn print_compute_driver_info(drivers: &[ComputeDriverInfoView]) { "Driver version:".dimmed(), driver.capabilities.driver_version ); - println!( - " {} {}", - "UI policy:".dimmed(), - if driver.capabilities.supports_ui_policy { - "supported" - } else { - "unsupported" - } - ); } } @@ -543,7 +532,6 @@ fn gateway_info_to_json(view: &GatewayInfoView) -> serde_json::Value { "capabilities": { "driver_name": &driver.capabilities.driver_name, "driver_version": &driver.capabilities.driver_version, - "supports_ui_policy": driver.capabilities.supports_ui_policy, }, })) .collect::>(), @@ -1917,7 +1905,6 @@ mod tests { capabilities: ComputeDriverCapabilitiesView { driver_name: "podman".to_string(), driver_version: "0.0.75".to_string(), - supports_ui_policy: false, }, }], extensions: vec![ExtensionInfoView { @@ -1946,10 +1933,6 @@ mod tests { json["compute_drivers"][0]["capabilities"]["driver_version"], "0.0.75" ); - assert_eq!( - json["compute_drivers"][0]["capabilities"]["supports_ui_policy"], - false - ); } #[test] diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 81b150062..21158ee96 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1676,16 +1676,8 @@ enum SandboxCommands { names: Vec, /// Delete all sandboxes. - #[arg(long, conflicts_with_all = ["names", "expected_id", "expected_resource_version"])] + #[arg(long, conflicts_with = "names")] all: bool, - - /// Delete only if the current sandbox has this exact immutable ID. - #[arg(long = "expected-id", value_name = "ID")] - expected_id: Option, - - /// Delete only if the current sandbox has this resource version. - #[arg(long = "expected-resource-version", value_name = "VERSION")] - expected_resource_version: Option, }, /// Stop a sandbox while preserving its workspace. @@ -3576,18 +3568,11 @@ async fn run_async() -> Result<()> { ) .await?; } - SandboxCommands::Delete { - names, - all, - expected_id, - expected_resource_version, - } => { + SandboxCommands::Delete { names, all } => { run::sandbox_delete( endpoint, &names, all, - expected_id.as_deref(), - expected_resource_version, &cli.workspace, &tls, &ctx.name, @@ -5513,48 +5498,6 @@ mod tests { )); } - #[test] - fn sandbox_delete_accepts_identity_preconditions() { - let cli = Cli::try_parse_from([ - "openshell", - "sandbox", - "delete", - "demo", - "--expected-id", - "sb-123", - "--expected-resource-version", - "17", - ]) - .expect("identity-guarded sandbox delete should parse"); - - assert!(matches!( - cli.command, - Some(Commands::Sandbox { - command: Some(SandboxCommands::Delete { - ref names, - all: false, - expected_id: Some(ref expected_id), - expected_resource_version: Some(17), - }) - }) if names == &["demo"] && expected_id == "sb-123" - )); - } - - #[test] - fn sandbox_delete_all_rejects_identity_preconditions() { - assert!( - Cli::try_parse_from([ - "openshell", - "sandbox", - "delete", - "--all", - "--expected-id", - "sb-123", - ]) - .is_err() - ); - } - #[test] fn sandbox_list_accepts_output_json() { let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index cbda2d9d0..807a79cb7 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -412,9 +412,7 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = - sandbox_delete(server, &names, false, None, None, workspace, tls, gateway).await - { + if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { if let Ok(exit_code) = session_result.as_ref() { return Err(miette::miette!( "sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}" @@ -3881,35 +3879,14 @@ fn labels_display(labels: &HashMap) -> String { } /// Delete a sandbox by name, or all sandboxes when `all` is true. -#[allow(clippy::too_many_arguments)] // user-facing CLI command with explicit identity guards pub async fn sandbox_delete( server: &str, names: &[String], all: bool, - expected_id: Option<&str>, - expected_resource_version: Option, workspace: &str, tls: &TlsOptions, gateway: &str, ) -> Result<()> { - if (expected_id.is_some() || expected_resource_version.is_some()) && (all || names.len() != 1) { - return Err(miette!( - "--expected-id and --expected-resource-version require exactly one sandbox name" - )); - } - if expected_id.is_some_and(str::is_empty) { - return Err(miette!("--expected-id must not be empty")); - } - if expected_resource_version.is_some() && expected_id.is_none() { - return Err(miette!( - "--expected-resource-version requires --expected-id" - )); - } - if expected_resource_version == Some(0) { - return Err(miette!( - "--expected-resource-version must be greater than zero" - )); - } let mut client = grpc_client(server, tls).await?; let names_to_delete: Vec = if all { diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index fbd9198ab..e9584cd6e 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -64,7 +64,6 @@ struct SandboxState { /// so a catalog lookup failure can be told apart from an empty catalog. fail_list_provider_profiles: Arc, deleted_names: Arc>>>, - delete_requests: Arc>>, create_requests: Arc>>, expose_service_requests: Arc>>, fail_delete_sandbox_message: Arc>>, @@ -390,11 +389,6 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); - self.state - .delete_requests - .lock() - .await - .push(request.clone()); self.state .deleted_names .lock() @@ -1600,10 +1594,6 @@ async fn deleted_names(server: &TestServer) -> Vec> { server.openshell.state.deleted_names.lock().await.clone() } -async fn delete_requests(server: &TestServer) -> Vec { - server.openshell.state.delete_requests.lock().await.clone() -} - async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } @@ -1713,8 +1703,6 @@ async fn sandbox_delete_continues_after_entry_failure() { &server.endpoint, &["failing-sandbox".to_string(), "later-sandbox".to_string()], false, - None, - None, "default", &tls, "openshell", diff --git a/crates/openshell-core/README.md b/crates/openshell-core/README.md index b4cdb05f5..1c61a72ef 100644 --- a/crates/openshell-core/README.md +++ b/crates/openshell-core/README.md @@ -58,6 +58,19 @@ configuration validation live in `openshell_core::middleware`. Policy admission and the supervisor runtime consume the same contract without introducing a dependency from the policy crate to the supervisor implementation. +## Extension Capabilities + +Extensions advertise namespaced string capabilities through `PeerMetadata`. +Use shared constants in `extension_protocol` and pass optional capabilities to +`extension_metadata`; do not add per-driver booleans for policy features. +Negotiation validates and retains the extension's supported capability list. + +`POLICY_UI_V1` (`openshell.policy.ui.v1`) declares complete startup UI-policy +support. The gateway checks the effective policy before driver validation or +creation and rejects any explicit UI section when the capability is absent. +Supporting drivers validate and enforce the concrete settings. Omitted UI +preserves existing driver behavior; changing UI requires sandbox recreation. + ## Protobuf Struct Conversion Use `openshell_core::proto_struct` when crossing between `serde_json` values and diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index aef96b46d..4aabee1cd 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -19,6 +19,10 @@ pub const PROTOCOL_MINOR: u32 = 0; /// configured signer before accepting sandbox creation. pub const COMPUTE_LAUNCH_AUTHENTICATION: &str = "openshell.compute.launch-authentication"; +/// Compute drivers advertising this capability enforce the complete startup UI +/// policy contract. Absence means explicit UI policy must be rejected. +pub const POLICY_UI_V1: &str = "openshell.policy.ui.v1"; + const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128; const MAX_CAPABILITY_BYTES: usize = 128; @@ -418,6 +422,36 @@ mod tests { ); } + #[test] + fn ui_policy_capability_uses_existing_optional_negotiation() { + let gateway = gateway_metadata(ExtensionFamily::Compute); + let extension = extension_metadata( + ExtensionFamily::Compute, + "openshell/test-ui-driver", + "test", + [POLICY_UI_V1.to_string()], + ); + let result = negotiate( + ExtensionFamily::Compute, + "test-ui-driver", + &gateway, + Some(extension), + ) + .unwrap(); + assert!( + result + .supported_capabilities + .iter() + .any(|value| value == POLICY_UI_V1) + ); + assert!( + !result + .required_capabilities + .iter() + .any(|value| value == POLICY_UI_V1) + ); + } + #[test] fn missing_metadata_and_major_skew_are_actionable() { let (gateway, mut extension) = compatible(); diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 549c71c4d..d921b8c34 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -132,7 +132,6 @@ impl DockerBoundarySpec { }, tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, - direct_proxy: None, resource_claims, outer_fence, }, diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index ba5343463..6a94e6aba 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -289,7 +289,6 @@ impl KubernetesSandboxRuntimeBoundarySpec { }, tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, - direct_proxy: None, resource_claims, outer_fence, }, diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index e2462cc4f..29c948878 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -31,6 +31,10 @@ The resolved create-time `SandboxPolicy` is carried by `DriverSandboxSpec.policy`. The driver provisions a one-shot ProcessContainer, launches the Windows boundary and separate host supervisor, and monitors both. The boundary launches the workload only after authenticated confirmation. +The driver defaults its paired runtimes to `info` logging so native workload +launches remain observable. An explicit sandbox log-level setting takes +precedence. The boundary emits structured process activity and the Windows +branch's `MXC agent launched` acknowledgement only after native spawn succeeds. The driver resolves its gateway endpoint and TLS server-name defaults from generic gateway inputs. The host supervisor authenticates its gateway session @@ -43,15 +47,25 @@ embed the supervisor, and the sandbox does not link the compute driver. Dynamic forwarding uses the shared protocol stream, not a dedicated relay executable or a reverse connection to a new gateway listener. +The boundary establishes its control TCP connection outward from MXC to a +generation-scoped loopback listener in the host isolation backend, matching the +Windows branch relay's connection direction. The shared transport connector +extension preserves pinned boundary TLS identity and supervisor JWT checks; +the driver neither brokers protocol requests nor runs supervision. Discovery +and attachment reuse the backend-owned listener, including reconnects. No host +firewall rule changes are required for this control path. + ## Enforcement boundaries -The current port is not runtime-qualified against main's outer-fence contract. +The current port temporarily asserts main's required outer-fence guarantees +to permit Windows-branch compatibility testing. This is an unconditional +MXC-only stub, not verified enforcement evidence or production qualification. Broad host-loopback access does not establish `NoUnmanagedEgressPath`, and -live access revocation has no verified native evidence. The driver therefore -does not publish those guarantees, and main's confirmation gate prevents -workload activation. Configuration intent must not be promoted to enforcement -proof. A qualified PSEC host is necessary but does not by itself close these -implementation gaps. +live access revocation has no verified native evidence. The provisioning TODO +tracks replacing these assertions with real enforcement. Audit metadata labels +egress as `mxc-windows-parity-unverified-egress-stub`. AppContainer identity, +authenticated transport, and generation checks remain required. Do not expose +sensitive host-local services to workloads using this compatibility path. - MXC supplies the default-deny filesystem fence, AppContainer token, UI policy, and loopback-only network fence. @@ -73,6 +87,13 @@ implementation gaps. ## Configuration +MXC does not implement external-resource label admission. Do not configure +`resource_admission` under its driver table; unsupported fields are rejected. +Host filesystem grants rely on workload policy and a trusted gateway operator, +not resource labels. Caller-supplied command configuration still requires +`allow_driver_config = true`. Native isolation, authenticated transport, and +outer-fence confirmation remain required. + The packaged `openshell-supervisor.exe` and `openshell-windows-sandbox.exe` default to siblings of `openshell-gateway.exe`. Override their paths for development builds. @@ -113,9 +134,11 @@ openshell sandbox create --name mxc-demo --policy policy.yaml ` --driver-config-json $config --no-tty ``` -The command is required. The working directory is required because it contains -the generation-scoped bootstrap staging directory. Environment belongs in -`--env` or `--env-from`, not gateway configuration. +The command and working directory are required. The working directory contains +the generation-scoped bootstrap staging directory. When no generic canonical +command is supplied, the driver passes `mxc.command` to the supervisor rather +than starting a scratch workload. An explicit generic canonical command retains +precedence. Environment belongs in `--env` or `--env-from`, not gateway configuration. When gateway TLS is enabled, configure the gateway-owned `guest_tls_ca`. The host supervisor verifies the gateway certificate with that CA and uses @@ -133,6 +156,24 @@ not required; driver-owned guest TLS fields are rejected. | ETW/OCSF audit | Optional Windows Sandboxing ETW consumer | | Gateway restart recovery | Not yet supported; live MXC generations remain in-memory | +UI controls are startup settings in the sandbox policy: + +```yaml +ui: + allow_graphical_ui: true + clipboard: read + allow_input_injection: false +``` + +Omitted UI settings deny all three capabilities. Clipboard accepts `none`, +`read`, `write`, or `all`, from the workload's perspective. Clipboard and input +injection require graphical UI. Unknown fields/values and unsupported +containment targets are rejected before provisioning. MXC advertises +`openshell.policy.ui.v1` through existing extension metadata. The gateway rejects +explicit UI policy (including `{}`) for drivers missing this capability; those +drivers need no UI-specific handling. UI policy itself does not require the +`allow_driver_config` opt-in. Recreate the sandbox to change these settings. + ## Validation Windows release build tasks stage the pinned, target-matching `libz3.dll` beside @@ -183,6 +224,7 @@ coverage remain nonzero exits. Missing host-loopback evidence is not a pass, and a qualified host does not close the outer-fence implementation gaps above. ### Local Windows host + Run the Windows build lane on a native Windows MSVC host: ```powershell @@ -219,6 +261,17 @@ to execute successfully. Unit-test mocks are separate from E2E coverage. `network-policy` checks admission and workload execution, not real network enforcement. All-skipped real runs report `INCOMPLETE`, not `PASS`. +The real-MXC HTTPS test uses the authenticated E2E gateway/supervisor harness. +Build native release binaries first; `OPENSHELL_MXC_TEST_BINARY_DIR` optionally +selects another binary directory. Its opt-in `-Scenario https-ca` requires +Internet access to `https://example.com` and Windows curl 8.3 or newer. Curl +explicitly loads the boundary-injected CA bundle and records the peer certificate; +success requires a nonempty response and the OpenShell proxy CA issuer. It does +not install a host trust root or disable CA/hostname verification. The fixture +uses best-effort Schannel revocation because the ephemeral private CA has no CRL +distribution point; it does not qualify revocation enforcement. The default six E2E +scenarios remain independent of Internet access. + The `forwarding` scenario starts a TCP listener inside MXC, then verifies two nonce-bound request/reply exchanges through `openshell forward service`, the gateway, supervisor, and Windows boundary. Both listener ports are dynamically diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/https-ca.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/https-ca.yaml new file mode 100644 index 000000000..09c13aa82 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/https-ca.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +version: 1 +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" +network_policies: + https_example: + name: https-example + endpoints: + - host: example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - path: '__CURL_EXE__' diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 7c492fd6a..d67a271c5 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -11,8 +11,11 @@ version = 2 # and environment variables through the standard sandbox --env option. [openshell.drivers.mxc] -# Path to wxc-exec.exe. Required for live runs. Leave the default for mock-mode -# smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). +# Explicit administrator opt-in for this disposable development gateway. +# MXC workload command and cwd are supplied through per-sandbox driver config. +allow_driver_config = true + +# Path to wxc-exec.exe. Required for real runtime tests; mocks are not E2E. wxc_exec_path = "C:\\mxc\\wxc-exec.exe" # The RFC 0012 MXC runtime requires ProcessContainer. diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 673c61096..bbe7ed286 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -36,6 +36,7 @@ # network-policy - supervisor-owned network policy permits admission. # forwarding - two TCP request/reply exchanges through OpenShell ingress. # service-forwarding - named HTTP service routing and endpoint deletion. +# https-ca - opt-in outbound HTTPS using the injected proxy CA. [CmdletBinding()] param( @@ -752,6 +753,19 @@ try { } ) + # External connectivity is deliberately opt-in; the default six scenarios + # remain runnable without Internet access. Use the same authenticated + # gateway launch path as every other workload, not a bare driver fixture. + if ($Scenario -eq 'https-ca') { + $allScenarios += @{ + Name = 'https-ca'; PolicyFile = Join-Path $policyDir 'https-ca.yaml' + SandboxId = 'ca'; Backends = 'process_container'; Kind = 'https' + PosTarget = Join-Path $DemoDir 'example.html' + CertificateTarget = Join-Path $DemoDir 'peer-certificate.txt' + ErrorTarget = Join-Path $DemoDir 'curl-stderr.txt' + Description = 'curl trusts the injected CA and observes the OpenShell HTTPS proxy certificate' + } + } if ($Scenario) { # Wrap in @() so an exact single match stays an array: without it a lone # match is a bare hashtable, its .Count is unreliable on PS 5.1, and @@ -800,6 +814,10 @@ try { $defaultDemoDir.Replace('\', '/'), $DemoDir.Replace('\', '/') ) + $curlExe = Join-Path $env:SYSTEMROOT 'System32/curl.exe' + # Preserve the exact Windows executable path used in the launch; + # network binary identities are compared as strings, not FS paths. + $policyText = $policyText.Replace('__CURL_EXE__', $curlExe.Replace("'", "''")) Set-Content -Path $policyUsed -Value $policyText -Encoding UTF8 # Per-scenario gateway logs land in the bundle under their own names. @@ -820,6 +838,22 @@ try { } else { $command = @($cmdExe, "/c", "echo denied 1> $denied") } + } elseif ($sc.Kind -eq 'https') { + if (-not (Test-Path -LiteralPath $curlExe)) { throw "Missing Windows curl: $curlExe" } + Remove-Item -LiteralPath $sc.PosTarget, $sc.CertificateTarget, $sc.ErrorTarget -Force -ErrorAction SilentlyContinue + # Launch curl directly so the policy identity is the socket + # owner. Schannel ignores CURL_CA_BUNDLE by default, so curl + # imports and expands it itself (curl >= 8.3). No shell child, + # host trust-store installation, or insecure TLS override. + # The ephemeral private CA has no CRL distribution point. + # Best-effort revocation tolerates that absence, while keeping + # certificate/hostname validation and known-revoked rejection. + $command = @($curlExe, '--disable', '--fail', '--silent', '--show-error', '--max-time', '45', + '--ssl-revoke-best-effort', + '--variable', '%CURL_CA_BUNDLE', '--expand-cacert', '{{CURL_CA_BUNDLE}}', + '--stderr', $sc.ErrorTarget, + '--output', $sc.PosTarget, '--write-out', "%output{$($sc.CertificateTarget)}%{certs}\nOpenShell curl exit: %{exitcode}\n", + 'https://example.com/') } elseif ($sc.Kind -in @("forwarding", "service-forwarding")) { Remove-Item -LiteralPath $sc.ReadyFile -Force -ErrorAction SilentlyContinue $forwardNonce = [guid]::NewGuid().ToString('N') @@ -879,7 +913,29 @@ try { $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" # Evaluate. - if ($sc.Kind -eq "positive") { + if ($sc.Kind -eq 'https') { + try { + if ($createExitCode -ne 0) { throw "sandbox create failed: $createOutStr" } + if (-not (Wait-File $sc.CertificateTarget 60)) { throw 'HTTPS certificate absent' } + $certificate = Get-Content -LiteralPath $sc.CertificateTarget -Raw + Copy-Item -LiteralPath $sc.CertificateTarget -Destination (Join-Path $resultDir 'https-peer-certificate.txt') + if (Test-Path -LiteralPath $sc.ErrorTarget) { + Copy-Item -LiteralPath $sc.ErrorTarget -Destination (Join-Path $resultDir 'https-curl-stderr.txt') + } + if ($certificate -notmatch '(?m)^OpenShell curl exit: 0\r?$') { + $curlError = Get-Content -LiteralPath $sc.ErrorTarget -Raw -ErrorAction SilentlyContinue + throw "curl did not complete successfully: $curlError" + } + if ($certificate -notmatch 'OpenShell Sandbox CA') { throw 'HTTPS peer was not issued by the OpenShell proxy CA' } + if (-not (Wait-File $sc.PosTarget 10)) { throw 'HTTPS response absent' } + if ((Get-Item -LiteralPath $sc.PosTarget).Length -eq 0) { throw 'HTTPS response empty' } + Ok 'https-ca: response and OpenShell proxy certificate verified' + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = 'PASS'; Reason = 'real HTTPS through injected proxy CA' } + } catch { + Bad "https-ca: $($_.Exception.Message)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = 'FAIL'; Reason = $_.Exception.Message } + } + } elseif ($sc.Kind -eq "positive") { $present = Wait-File $sc.PosTarget 30 if ($present -and $createExitCode -eq 0) { Ok "$($sc.Name): in-policy write produced artifact" diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 30cbdf6bd..3ddddc1c9 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -16,7 +16,7 @@ use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ - DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, + DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxSpec, DriverSandboxStatus, GetCapabilitiesResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; @@ -67,10 +67,8 @@ impl MxcBackend { #[serde(default, deny_unknown_fields)] #[allow(clippy::struct_excessive_bools)] // independent operator options in the existing TOML schema pub struct MxcComputeConfig { - /// Permit caller-supplied driver JSON. Does not waive resource admission. + /// Permit caller-supplied driver JSON. Does not waive workload policy. pub allow_driver_config: bool, - /// Operator-owned external attachment approval policy. - pub resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig, /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, pub supervisor_binary_path: String, @@ -104,8 +102,6 @@ impl Default for MxcComputeConfig { Self { wxc_exec_path: "wxc-exec.exe".into(), allow_driver_config: false, - resource_admission: - openshell_core::resource_admission::ResourceAdmissionConfig::default(), supervisor_binary_path: sibling_binary("openshell-supervisor.exe"), sandbox_binary_path: sibling_binary("openshell-windows-sandbox.exe"), state_dir, @@ -122,6 +118,18 @@ impl Default for MxcComputeConfig { } impl MxcComputeConfig { + /// MXC does not implement external-resource label admission. Keep the + /// independent caller configuration gate in the gateway/driver contract. + pub fn admission_policy(&self) -> openshell_core::resource_admission::DriverAdmissionConfig { + openshell_core::resource_admission::DriverAdmissionConfig { + allow_driver_config: self.allow_driver_config, + resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig { + enabled: false, + required_labels: std::collections::BTreeMap::new(), + }, + } + } + /// Validate static MXC configuration without requiring launch credentials. /// /// Keep executable lookup independent of the gateway's PATH, as in the @@ -262,16 +270,13 @@ impl MxcComputeBackend { pub fn capabilities(&self) -> GetCapabilitiesResponse { GetCapabilitiesResponse { - resource_admission_policy: openshell_core::resource_admission::DriverAdmissionConfig { - allow_driver_config: self.config.allow_driver_config, - resource_admission: self.config.resource_admission.clone(), - } - .acknowledgement(), + resource_admission_policy: self.config.admission_policy().acknowledgement(), extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::Compute, "openshell/mxc", openshell_core::VERSION, - [], + (self.config.backend == MxcBackend::ProcessContainer) + .then(|| openshell_core::extension_protocol::POLICY_UI_V1.to_string()), )), driver_name: DRIVER_NAME.to_string(), driver_version: DRIVER_VERSION.to_string(), @@ -282,23 +287,14 @@ impl MxcComputeBackend { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, - supports_ui_policy: true, } } pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - self.config - .resource_admission - .validate() - .map_err(tonic::Status::failed_precondition)?; openshell_core::resource_admission::check_sandbox_driver_config( self.config.allow_driver_config, sandbox, )?; - // Host filesystem grants still have no authoritative label resolver. - self.config - .resource_admission - .reject_unlabelable("MXC host filesystem grants")?; if !self.invoker.is_mock() { self.config .validate_configuration() @@ -712,11 +708,8 @@ async fn run_lifecycle_inner( let descriptor_path = context.host_state_dir.join(HOST_RUNTIME_DESCRIPTOR_FILE); std::fs::write( &descriptor_path, - provisioning - .runtime_descriptor - .backend_descriptor() - .map_err(|error| error.to_string())? - .payload, + serde_json::to_vec(&provisioning.runtime_descriptor) + .map_err(|error| format!("encode MXC launch data: {error}"))?, ) .map_err(|error| format!("write MXC runtime descriptor: {error}"))?; // The supervisor owns the proxy listener. Release only that reservation @@ -730,7 +723,7 @@ async fn run_lifecycle_inner( "--bootstrap".to_string(), boundary_config_path.display().to_string(), "--log-level".to_string(), - openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "warn"), + openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "info"), ]); let mut filesystem = MxcFilesystem { readwrite_paths: mapped.readwrite_paths, @@ -834,6 +827,8 @@ fn spawn_supervisor( .arg(descriptor_path) .arg("--auth-bundle-file") .arg(auth_bundle_path) + .arg("--workdir") + .arg(&context.sandbox_config.cwd) .env( openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND, openshell_mxc_boundary::BACKEND_NAME, @@ -850,7 +845,7 @@ fn spawn_supervisor( .env(openshell_core::sandbox_env::SANDBOX, &context.sandbox.name) .env( openshell_core::sandbox_env::LOG_LEVEL, - openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "warn"), + openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "info"), ); if let Some(ca) = &context.gateway.tls { command.env(openshell_core::sandbox_env::TLS_CA, ca); @@ -884,6 +879,7 @@ fn mxc_main_process_config( } main } + enum RuntimePairExit { Shutdown, Boundary(std::process::ExitStatus), @@ -1339,18 +1335,15 @@ mod tests { fn host_grants_config() -> MxcComputeConfig { MxcComputeConfig { allow_driver_config: true, - resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig { - enabled: false, - ..Default::default() - }, ..Default::default() } } #[tokio::test] - async fn admission_rejects_host_grants_even_with_driver_config_enabled() { + async fn driver_config_gate_remains_required_without_resource_admission() { let (mut backend, sandbox, _dir) = preflight_fixture(); - backend.config.resource_admission.enabled = true; + assert!(backend.validate_sandbox_create(&sandbox).is_ok()); + backend.config.allow_driver_config = false; let error = backend.validate_sandbox_create(&sandbox).unwrap_err(); assert_eq!(error.code(), tonic::Code::FailedPrecondition); assert!(backend.create_sandbox(&sandbox).await.is_err()); @@ -1358,6 +1351,24 @@ mod tests { assert!(!backend.config.state_dir.exists()); } + #[test] + fn resource_admission_is_unsupported_and_acknowledged() { + let config = MxcComputeConfig::default(); + let policy = config.admission_policy(); + assert!(!policy.resource_admission.enabled); + assert!(policy.resource_admission.required_labels.is_empty()); + assert!(!policy.allow_driver_config); + policy + .verify_acknowledgement(&policy.acknowledgement()) + .unwrap(); + assert!( + serde_json::from_value::(serde_json::json!({ + "resource_admission": { "enabled": true } + })) + .is_err() + ); + } + #[test] fn static_configuration_requires_an_absolute_wxc_path() { for path in ["", "wxc-exec.exe", "relative\\wxc-exec.exe"] { @@ -1426,6 +1437,98 @@ mod tests { assert!(!backend.config.state_dir.exists()); } + fn with_ui_policy( + mut sandbox: DriverSandbox, + ui: openshell_core::proto::UiPolicy, + ) -> DriverSandbox { + sandbox.spec.as_mut().unwrap().policy.as_mut().unwrap().ui = Some(ui); + sandbox + } + + #[test] + fn ui_policy_reaches_mapper_and_capability_is_advertised() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + let (backend, sandbox, _dir) = preflight_fixture(); + assert!( + backend + .capabilities() + .extension + .unwrap() + .supported_capabilities + .iter() + .any(|capability| capability == openshell_core::extension_protocol::POLICY_UI_V1) + ); + for clipboard in [ + UiClipboardAccess::None, + UiClipboardAccess::Read, + UiClipboardAccess::Write, + UiClipboardAccess::All, + ] { + let sandbox = with_ui_policy( + sandbox.clone(), + UiPolicy { + allow_graphical_ui: true, + clipboard: clipboard as i32, + allow_input_injection: true, + }, + ); + backend + .validate_sandbox_create(&sandbox) + .expect("valid UI policy"); + let mapped = backend + .map_sandbox_policy( + &sandbox.id, + sandbox.spec.as_ref().unwrap().policy.as_ref(), + "127.0.0.1:3128".parse().unwrap(), + ) + .unwrap(); + let ui = mapped.ui.unwrap(); + assert!(!ui.disable); + assert!(ui.injection); + assert!(!backend.config.state_dir.exists()); + } + } + + #[test] + fn ui_policy_rejects_invalid_settings_before_provisioning() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + let (backend, sandbox, _dir) = preflight_fixture(); + for ui in [ + UiPolicy { + clipboard: 999, + ..Default::default() + }, + UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }, + UiPolicy { + allow_input_injection: true, + ..Default::default() + }, + ] { + let sandbox = with_ui_policy(sandbox.clone(), ui); + let error = backend.validate_sandbox_create(&sandbox).unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(!backend.config.state_dir.exists()); + } + } + + #[test] + fn isolation_session_does_not_advertise_ui_policy() { + let (mut backend, _sandbox, _dir) = preflight_fixture(); + backend.config.backend = MxcBackend::IsolationSession; + assert!( + !backend + .capabilities() + .extension + .unwrap() + .supported_capabilities + .iter() + .any(|capability| capability == openshell_core::extension_protocol::POLICY_UI_V1) + ); + } + #[tokio::test] async fn provisioning_rejects_missing_authentication_before_side_effects() { let (backend, sandbox, _dir) = preflight_fixture(); diff --git a/crates/openshell-driver-mxc/src/isolation.rs b/crates/openshell-driver-mxc/src/isolation.rs index f18a4077e..1aef7cb60 100644 --- a/crates/openshell-driver-mxc/src/isolation.rs +++ b/crates/openshell-driver-mxc/src/isolation.rs @@ -8,9 +8,9 @@ use std::net::SocketAddr; use std::path::PathBuf; use openshell_isolation_interface::contract::{ - BackendError, BinaryIdentity, DirectProxyConfiguration, ExecutableIdentity, - OuterFenceGuarantees, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; +use openshell_mxc_boundary::launch::{MxcBoundaryConfig, MxcLaunchDescriptor, MxcProxyConfig}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, @@ -20,6 +20,7 @@ use serde::Serialize; #[derive(Serialize)] #[allow(clippy::struct_excessive_bools)] // separately serialized enforcement properties, not state flags struct MxcOuterFenceEvidence<'a> { + compatibility_assertion: &'a str, generation: &'a str, containment: &'a str, default_deny_filesystem: bool, @@ -47,8 +48,8 @@ pub struct MxcBoundarySpec { } pub struct MxcBoundaryProvisioning { - pub boundary_config: BoundaryConfig, - pub runtime_descriptor: SandboxRuntimeDescriptor, + pub boundary_config: MxcBoundaryConfig, + pub runtime_descriptor: MxcLaunchDescriptor, } impl MxcBoundarySpec { @@ -74,6 +75,10 @@ impl MxcBoundarySpec { resource_digest, )?; let resource_claims = BTreeMap::from([ + ( + "mxc.control_transport".to_string(), + "reverse_tcp".to_string(), + ), ("mxc.generation".to_string(), self.generation.clone()), ( "mxc.appcontainer_profile".to_string(), @@ -81,6 +86,7 @@ impl MxcBoundarySpec { ), ]); let evidence = serde_json::to_vec(&MxcOuterFenceEvidence { + compatibility_assertion: "windows-parity-unverified-egress-and-revocation", generation: &self.generation, containment: "process_container", default_deny_filesystem: true, @@ -91,60 +97,87 @@ impl MxcBoundarySpec { .map_err(|error| { BackendError::Descriptor(format!("encode MXC outer-fence evidence: {error}")) })?; - // Configuration intent is not enforcement evidence. In particular, - // allowing all host loopback ports does not prove NoUnmanagedEgressPath, - // and this launch path has no verified live-revocation mechanism. - // Keep main's confirmation gate closed until native evidence can - // establish every required guarantee. - let outer_fence = - OuterFenceGuarantees::from_enforcement_evidence(&self.generation, [], &evidence)?; - let direct_proxy = DirectProxyConfiguration { + let outer_fence = windows_parity_outer_fence(&self.generation, &evidence)?; + let proxy = MxcProxyConfig { bind_addr: self.proxy_addr, authorization: self.proxy_authorization, - binary_identity: BinaryIdentity { - executable: ExecutableIdentity { - path: self.workload_binary, - digest: None, - }, - ancestors: Vec::new(), - cmdline_paths: Vec::new(), - }, + workload_binary: self.workload_binary, }; + proxy.validate()?; Ok(MxcBoundaryProvisioning { - boundary_config: BoundaryConfig { - boundary_id: self.boundary_id.clone(), - generation: self.generation.clone(), - session_id: self.session_id, - session_rotation: self.session_rotation, - auth_epoch: self.auth_epoch, - gateway_id: self.gateway_id, - verification_keys: self.verification_keys, - listener: BoundaryListener::TlsTcp { - address: self.control_addr, - tls: self.sandbox_tls, + boundary_config: MxcBoundaryConfig { + proxy_url: self.proxy_url, + runtime: BoundaryConfig { + boundary_id: self.boundary_id.clone(), + generation: self.generation.clone(), + session_id: self.session_id, + session_rotation: self.session_rotation, + auth_epoch: self.auth_epoch, + gateway_id: self.gateway_id, + verification_keys: self.verification_keys, + listener: BoundaryListener::TlsTcp { + address: self.control_addr, + tls: self.sandbox_tls, + }, + resource_claims: resource_claims.clone(), + resource_claim_files: BTreeMap::new(), + workload_identity: workload_identity.clone(), + outer_fence: outer_fence.clone(), + child_env: self.child_env, }, - resource_claims: resource_claims.clone(), - resource_claim_files: BTreeMap::new(), - workload_identity: workload_identity.clone(), - outer_fence: outer_fence.clone(), - direct_proxy_url: Some(self.proxy_url), - child_env: self.child_env, }, - runtime_descriptor: SandboxRuntimeDescriptor { - boundary_id: self.boundary_id, - generation: self.generation, - session_id: self.session_id, - workload_identity, - transport: SandboxTransport::Tcp { - authority: self.control_addr.to_string(), - addresses: vec![self.control_addr], + runtime_descriptor: MxcLaunchDescriptor { + proxy, + runtime: SandboxRuntimeDescriptor { + boundary_id: self.boundary_id, + generation: self.generation, + session_id: self.session_id, + workload_identity, + transport: SandboxTransport::Tcp { + authority: self.control_addr.to_string(), + addresses: vec![self.control_addr], + }, + tls: self.supervisor_tls, + host_gateway_ip: Some(self.proxy_addr.ip()), + resource_claims, + outer_fence, }, - tls: self.supervisor_tls, - host_gateway_ip: Some(self.proxy_addr.ip()), - direct_proxy: Some(direct_proxy), - resource_claims, - outer_fence, }, }) } } + +fn windows_parity_outer_fence( + generation: &str, + evidence: &[u8], +) -> Result { + // TODO(mxc-network-fence): Remove this temporary Windows-branch parity stub + // once supervisor-only egress and live revocation are implemented. Broad + // 127.0.0.1 access permits bypassing the proxy through other host services; + // these assertions are NOT verified enforcement evidence. Keep this waiver + // confined to MXC rather than weakening the shared confirmation contract. + OuterFenceGuarantees::from_enforcement_evidence( + generation, + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + evidence, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_parity_stub_preserves_generation_binding() { + let fence = windows_parity_outer_fence("generation-a", b"compatibility-stub").unwrap(); + assert!(fence.validate("generation-a").is_ok()); + assert!(fence.validate("generation-b").is_err()); + assert!(windows_parity_outer_fence("", b"compatibility-stub").is_err()); + assert!(windows_parity_outer_fence("generation-a", b"").is_err()); + } +} diff --git a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs index 6690129ad..317c59709 100644 --- a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs @@ -42,6 +42,30 @@ fn websocket_fixture_grants_read_only_workload_without_egress() { assert!(mapped.ui.unwrap().disable); } +#[test] +fn https_fixture_authorizes_direct_curl_with_read_only_https() { + let yaml = std::fs::read_to_string(examples().join("e2e-policies/https-ca.yaml")) + .unwrap() + .replace("__CURL_EXE__", r"C:\Windows\System32\curl.exe"); + let (policy, mapped) = map_fixture(&yaml); + let rule = &policy.network_policies["https_example"]; + assert_eq!(rule.binaries[0].path, r"C:\Windows\System32\curl.exe"); + assert_eq!(rule.endpoints[0].host, "example.com"); + assert_eq!(rule.endpoints[0].ports, [443]); + assert_eq!( + rule.endpoints[0].access, + NetworkAccessPreset::ReadOnly as i32 + ); + assert_eq!( + rule.endpoints[0].enforcement, + NetworkEnforcementMode::Enforce as i32 + ); + assert_eq!( + mapped.trimmed_policy.unwrap().network_policies, + policy.network_policies + ); +} + #[test] fn openclaw_fixture_preserves_explicit_ui_and_node_network_grants() { let yaml = @@ -178,11 +202,16 @@ $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile( $env:OPENSHELL_BUILD_SCRIPT, [ref]$tokens, [ref]$errors) if ($errors.Count) { throw "build script parse errors: $($errors.Message -join '; ')" } -$function = $ast.Find({ param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Stage-Z3Runtime' -}, $true) -if ($null -eq $function) { throw 'missing staging function' } -Invoke-Expression $function.Extent.Text +foreach ($name in @('Get-Sha256', 'Stage-Z3Runtime')) { + $function = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if ($null -eq $function) { throw "missing function: $name" } + Invoke-Expression $function.Extent.Text +} +# Mise's Windows PowerShell may not expose this module cmdlet. Packaging must +# retain its checksum checks without depending on it. +function Get-FileHash { throw 'Get-FileHash must not be used' } $TargetDir = $env:OPENSHELL_TEST_ROOT $PrebuiltZ3Version = '5.1.0' $env:Z3_LIBRARY_PATH_OVERRIDE = $null @@ -197,11 +226,11 @@ $bytes[68] = 0x64; $bytes[69] = 0xAA [IO.File]::WriteAllBytes($source, $bytes) Stage-Z3Runtime 'aarch64-pc-windows-msvc' $destination = Join-Path $release 'libz3.dll' -if ((Get-FileHash $destination).Hash -ne (Get-FileHash $source).Hash) { throw 'copy mismatch' } +if ((Get-Sha256 $destination) -ne (Get-Sha256 $source)) { throw 'copy mismatch' } # A stale adjacent DLL must be overwritten from the pinned cache. [IO.File]::WriteAllBytes($destination, (New-Object byte[] 128)) Stage-Z3Runtime 'aarch64-pc-windows-msvc' -if ((Get-FileHash $destination).Hash -ne (Get-FileHash $source).Hash) { throw 'stale DLL retained' } +if ((Get-Sha256 $destination) -ne (Get-Sha256 $source)) { throw 'stale DLL retained' } $bytes[68] = 0x64; $bytes[69] = 0x86 [IO.File]::WriteAllBytes($source, $bytes) $rejected = $false diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index f6aa1c9af..485c45a07 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -31,15 +31,9 @@ #![cfg(target_os = "windows")] use base64::Engine as _; -use openshell_core::proto::compute::v1::{DriverSandbox, DriverSandboxSpec, DriverSandboxTemplate}; -use openshell_core::proto::{ - FilesystemPolicy, NetworkAccessPreset, NetworkBinary, NetworkEndpoint, NetworkEnforcementMode, - NetworkPolicyRule, NetworkTlsMode, SandboxPolicy, -}; -use openshell_driver_mxc::{MxcComputeBackend, MxcComputeConfig}; +use openshell_core::proto::{FilesystemPolicy, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::Duration; // ── Path resolution ────────────────────────────────────────────────────────── @@ -819,12 +813,11 @@ fn pc_oneshot_in_policy_write_succeeds() { ); } -/// Run an HTTPS request through the real driver and `ProcessContainer`. The -/// workload explicitly reads the injected bundle before curl uses it, proving -/// that the driver's internal TLS share is reachable from the `AppContainer`. -#[tokio::test] +/// Run HTTPS through a real gateway, authenticated host supervisor, and MXC. +/// Curl consumes the injected bundle and records the proxy's peer certificate. +#[test] #[ignore = "requires real wxc-exec and outbound HTTPS"] -async fn pc_https_egress_reads_injected_ca_bundle() { +fn pc_https_egress_reads_injected_ca_bundle() { let Some(wxc) = wxc_path() else { eprintln!("SKIP: wxc-exec not found"); return; @@ -840,7 +833,6 @@ async fn pc_https_egress_reads_injected_ca_bundle() { } let system_root = std::env::var("SYSTEMROOT").expect("SYSTEMROOT must be set on Windows"); - let cmd = PathBuf::from(&system_root).join("System32").join("cmd.exe"); let curl = PathBuf::from(system_root).join("System32").join("curl.exe"); if !curl.exists() { eprintln!("SKIP: Windows curl.exe not found at {}", curl.display()); @@ -850,104 +842,38 @@ async fn pc_https_egress_reads_injected_ca_bundle() { let output_dir = tempfile::tempdir().expect("HTTPS output directory"); let output_path = output_dir.path().join("example.html"); let certificate_path = output_dir.path().join("peer-certificate.txt"); - let output_dir_string = output_dir.path().to_string_lossy().into_owned(); - let output_path_string = output_path.to_string_lossy().into_owned(); - let certificate_path_string = certificate_path.to_string_lossy().into_owned(); - let cmd_string = cmd.to_string_lossy().into_owned(); - let curl_string = curl.to_string_lossy().into_owned(); - let script = format!( - "type \"%CURL_CA_BUNDLE%\" 1>NUL && \ - \"{}\" --fail --silent --show-error --cacert \"%CURL_CA_BUNDLE%\" \ - https://example.com/ --output \"{output_path_string}\" \ - --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\"", - curl.display() - ); - let command = vec![cmd_string, "/d".to_string(), "/c".to_string(), script]; - let serde_json::Value::Object(driver_config) = serde_json::json!({ - "command": command, - "cwd": output_dir_string, - }) else { - unreachable!(); + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let target_root = + std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| repo.join("target"), PathBuf::from); + let target = if cfg!(target_arch = "aarch64") { + "aarch64-pc-windows-msvc" + } else { + "x86_64-pc-windows-msvc" }; - - let policy = SandboxPolicy { - version: 1, - filesystem: Some(FilesystemPolicy { - include_workdir: false, - read_only: Vec::new(), - read_write: vec![output_dir_string], - }), - network_policies: std::collections::HashMap::from([( - "https_example".to_string(), - NetworkPolicyRule { - name: "https-example".to_string(), - endpoints: vec![NetworkEndpoint { - host: "example.com".to_string(), - ports: vec![443], - protocol: "rest".to_string(), - tls: NetworkTlsMode::Unspecified as i32, - enforcement: NetworkEnforcementMode::Enforce as i32, - access: NetworkAccessPreset::ReadOnly as i32, - ..Default::default() - }], - // curl owns the socket; cmd only launches it (#4199). - binaries: vec![NetworkBinary { path: curl_string }], - }, - )]), - ..Default::default() - }; - let sandbox = DriverSandbox { - id: "pc-https-ca".to_string(), - name: "pc-https-ca".to_string(), - spec: Some(DriverSandboxSpec { - template: Some(DriverSandboxTemplate { - driver_config: Some( - openshell_core::proto_struct::json_object_to_struct(driver_config) - .expect("driver config"), - ), - ..Default::default() - }), - policy: Some(policy), - ..Default::default() - }), - ..Default::default() - }; - - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let config = MxcComputeConfig { - wxc_exec_path: wxc.to_string_lossy().into_owned(), - // This fixture intentionally submits a caller-selected curl command. - allow_driver_config: true, - ..Default::default() - }; - let backend = MxcComputeBackend::new(openshell_core::config::DEFAULT_GATEWAY_NAME, config); - backend - .create_sandbox(&sandbox) - .await - .expect("real HTTPS sandbox create accepted"); - - let mut terminal_condition = None; - for _ in 0..600 { - if let Some(observed) = backend.get_sandbox("pc-https-ca").await - && let Some(condition) = observed - .status - .and_then(|status| status.conditions.into_iter().find(|c| c.r#type == "Ready")) - && matches!( - condition.reason.as_str(), - "AgentCompleted" | "ExecFailed" | "ProvisionFailed" - ) - { - terminal_condition = Some(condition); - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - - let condition = terminal_condition.expect("HTTPS sandbox should reach a terminal condition"); - assert_eq!( - condition.reason, "AgentCompleted", - "HTTPS workload failed: {}", - condition.message + let binaries = std::env::var_os("OPENSHELL_MXC_TEST_BINARY_DIR") + .map_or_else(|| target_root.join(target).join("release"), PathBuf::from); + let port = std::net::TcpListener::bind("127.0.0.1:0") + .expect("allocate HTTPS fixture gateway port") + .local_addr() + .unwrap() + .port(); + let result = Command::new("powershell.exe") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(repo.join("crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1")) + .args(["-Scenario", "https-ca", "-Port", &port.to_string()]) + .arg("-WxcExecPath") + .arg(wxc) + .arg("-BinaryDir") + .arg(binaries) + .arg("-DemoDir") + .arg(output_dir.path()) + .output() + .expect("run authenticated HTTPS E2E harness"); + assert!( + result.status.success(), + "HTTPS E2E failed (build native release binaries first):\n{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) ); assert!(output_path.exists(), "curl should write the HTTPS response"); assert!( diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index c89f9284d..a660ad98c 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -133,7 +133,6 @@ impl VmBoundarySpec { // reserved host aliases terminate at its loopback address // after crossing the authenticated boundary channel. host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), - direct_proxy: None, resource_claims, outer_fence, }, diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index b7f80b5f6..463edd3bc 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -153,6 +153,14 @@ struct MxcFactory; #[cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] #[async_trait::async_trait] impl openshell_server::ComputeDriverFactory for MxcFactory { + fn admission_policy( + &self, + context: openshell_server::ComputeDriverConfigContext<'_>, + ) -> openshell_core::Result { + let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + Ok(config.admission_policy()) + } + fn supports_config_preflight(&self) -> bool { true } diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 9f619c091..246394d28 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -370,15 +370,6 @@ pub trait BoundBoundary: Send { /// Retained by the supervisor before consuming `Bound`. fn network_mediation_source(&self) -> Arc; - /// Driver-provisioned direct proxy listener for backends whose outer - /// fence can route workload traffic to a host listener but cannot stage - /// individual socket opens. The supervisor owns this listener and its - /// policy evaluation; the generation-scoped authorization prevents other - /// local processes from entering the sandbox's policy context. - fn direct_proxy_configuration(&self) -> Option { - None - } - /// Trusted host-side dial target for the well-known host-gateway aliases. /// /// Backends return this when the mediation service runs outside the @@ -395,31 +386,6 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Authenticated host listener used by an isolation backend's explicit-proxy -/// path. -/// -/// This is control-plane material and must be delivered through the protected -/// runtime descriptor, never command-line arguments or logs. -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DirectProxyConfiguration { - pub bind_addr: SocketAddr, - /// Exact HTTP `Proxy-Authorization` value required from this generation. - pub authorization: String, - /// Driver-resolved identity applied to direct-listener requests. - pub binary_identity: BinaryIdentity, -} - -impl fmt::Debug for DirectProxyConfiguration { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("DirectProxyConfiguration") - .field("bind_addr", &self.bind_addr) - .field("authorization", &"") - .field("binary_identity", &self.binary_identity) - .finish() - } -} - /// Backend-neutral guarantees established by the component that owns the outer /// network fence. /// @@ -512,6 +478,7 @@ pub struct EnforcedProperty { pub enforced: bool, pub mechanism: String, } + impl EnforcedProperty { #[must_use] pub fn new(enforced: bool, mechanism: impl Into) -> Self { diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index c1873db08..2fb1f3fdb 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -383,6 +383,7 @@ fn complete_outer_fence(generation: &str, evidence: &[u8]) -> OuterFenceGuarante ) .unwrap() } + fn confirmation() -> BoundaryConfirmation { BoundaryConfirmation { generation: "generation-1".to_string(), diff --git a/crates/openshell-mxc-boundary/Cargo.toml b/crates/openshell-mxc-boundary/Cargo.toml index c11a19743..300f6423d 100644 --- a/crates/openshell-mxc-boundary/Cargo.toml +++ b/crates/openshell-mxc-boundary/Cargo.toml @@ -32,9 +32,13 @@ rustls-pemfile = { workspace = true } tokio-rustls = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } +sha2 = { workspace = true } tracing = { workspace = true } url = { workspace = true } -windows = { workspace = true } +windows = { workspace = true, features = ["Win32_Security_Authorization"] } + +[target.'cfg(target_os = "windows")'.dev-dependencies] +tempfile = "3" [lints] workspace = true diff --git a/crates/openshell-mxc-boundary/README.md b/crates/openshell-mxc-boundary/README.md index 07a4c34b8..473213e48 100644 --- a/crates/openshell-mxc-boundary/README.md +++ b/crates/openshell-mxc-boundary/README.md @@ -42,6 +42,7 @@ payload; the authenticated workload proxy URL stays in the MXC bootstrap. Windows supervisor composition turns these into ordinary shared CONNECT listener options. Shared isolation traits and Sandbox Protocol descriptors contain no MXC proxy fields, and other compute drivers need no placeholders. + MXC adopts main's authenticated image-policy discovery and ordered provider environment publication. Command-based MXC has no rootfs image policy to discover. Publication generations prevent stale refreshes from replacing newer @@ -68,9 +69,17 @@ still requires native ProcessContainer networking and permits all ports on `127.0.0.1`; it is not isolation from unrelated host-loopback services. Main requires explicit proof of no unmanaged egress path and verified -revocation. The current driver publishes no established outer-fence guarantees -because its configuration alone does not prove those properties. Confirmation -therefore remains fail-closed, including on an otherwise qualified PSEC host. +revocation. For Windows-branch parity testing, the MXC driver temporarily +asserts those guarantees without establishing them. Network audit assertions +are likewise compatibility stubs, labeled as unverified in mechanism metadata. +This does not restrict access to unrelated host-loopback services or establish +live revocation. TODOs in provisioning and confirmation track removing the +stubs; shared backend validation and native AppContainer checks remain intact. + +The boundary measures its AppContainer SID from the current Windows process +token for audit identity. It rejects uncontained processes; a configured +container/profile name is not a substitute for that native token measurement. +This measurement alone does not establish network-fence guarantees. Unit tests exercise real Windows process launch, output retention, and a real ephemeral TCP forwarding target. These tests do not qualify MXC enforcement; diff --git a/crates/openshell-mxc-boundary/src/audit.rs b/crates/openshell-mxc-boundary/src/audit.rs index a795eb0a5..f7766d346 100644 --- a/crates/openshell-mxc-boundary/src/audit.rs +++ b/crates/openshell-mxc-boundary/src/audit.rs @@ -7,10 +7,11 @@ use openshell_isolation_interface::contract::{BackendError, BoundaryProperties, use openshell_sandbox_backend::audit::BoundaryAuditValidator; use serde::{Deserialize, Serialize}; -/// Windows `ProcessContainer` evidence measured by the MXC boundary. +/// Windows `ProcessContainer` audit reported by the MXC boundary. /// /// MXC supplies the outer filesystem and network fence; the in-container -/// sandbox supplies authenticated lifecycle and process I/O. +/// sandbox supplies authenticated lifecycle and process I/O. Network assertions +/// currently include a temporary Windows-parity stub, not measured exclusivity. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] #[allow( @@ -59,7 +60,7 @@ impl MxcSandboxAuditEvidence { ), egress_interception: EnforcedProperty::new( self.default_deny_egress && self.loopback_proxy_only, - "mxc-wfp-loopback-proxy-fence", + "mxc-windows-parity-unverified-egress-stub", ), request_attribution: EnforcedProperty::new( self.generation_scoped_attribution, diff --git a/crates/openshell-mxc-boundary/src/backend.rs b/crates/openshell-mxc-boundary/src/backend.rs index daf3ab3bf..3a1cc5cae 100644 --- a/crates/openshell-mxc-boundary/src/backend.rs +++ b/crates/openshell-mxc-boundary/src/backend.rs @@ -8,8 +8,88 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use openshell_isolation_interface::contract::{ - BackendError, BoundBoundary, IsolationBackend, SandboxContext, VerifiedBackendDescriptor, + BackendError, BoundBoundary, BoundaryDuplexStream, IsolationBackend, SandboxContext, + VerifiedBackendDescriptor, }; +use openshell_sandbox_backend::{ + BoundaryTransportConnector, + boundary_protocol::{SandboxRuntimeDescriptor, SandboxTransport}, +}; + +/// The Windows relay establishes control outward from MXC; preserve that +/// direction without embedding supervision or policy in the compute driver. +#[derive(Debug, Default)] +pub struct MxcReverseTcpConnector { + listener: tokio::sync::Mutex>>, +} + +#[async_trait] +impl BoundaryTransportConnector for MxcReverseTcpConnector { + async fn connect( + &self, + descriptor: &SandboxRuntimeDescriptor, + ) -> Result { + if descriptor + .resource_claims + .get("mxc.control_transport") + .map(String::as_str) + != Some("reverse_tcp") + { + return Err(BackendError::Descriptor( + "MXC reverse control provisioning is required".into(), + )); + } + let SandboxTransport::Tcp { addresses, .. } = &descriptor.transport else { + return Err(BackendError::Descriptor( + "MXC reverse control requires TCP".into(), + )); + }; + let [address] = addresses.as_slice() else { + return Err(BackendError::Descriptor( + "MXC reverse control requires one address".into(), + )); + }; + if !address.ip().is_loopback() || address.port() == 0 { + return Err(BackendError::Descriptor( + "MXC reverse control requires a concrete loopback endpoint".into(), + )); + } + let listener = { + let mut current = self.listener.lock().await; + if let Some(listener) = current.as_ref() { + if listener + .local_addr() + .map_err(|e| BackendError::Unavailable(e.to_string()))? + != *address + { + return Err(BackendError::Descriptor( + "MXC reverse control endpoint changed".into(), + )); + } + listener.clone() + } else { + let listener = + Arc::new(tokio::net::TcpListener::bind(address).await.map_err(|e| { + BackendError::Unavailable(format!("bind MXC reverse control: {e}")) + })?); + *current = Some(listener.clone()); + listener + } + }; + let (stream, _) = + tokio::time::timeout(std::time::Duration::from_secs(30), listener.accept()) + .await + .map_err(|_| { + BackendError::Unavailable("MXC reverse control accept timed out".into()) + })? + .map_err(|e| { + BackendError::Unavailable(format!("accept MXC reverse control: {e}")) + })?; + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + // TLS peer verification and JWT authentication remain shared and mandatory. + Ok(Box::new(stream)) + } +} /// The host-side MXC isolation backend. The compute driver owns provisioning; /// this backend owns attachment and interpretation of Windows confirmation. @@ -25,6 +105,7 @@ impl MxcRuntimeBackend { ca_file_paths: Arc>>, provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, + connector: Arc, ) -> Self { Self { transport: openshell_sandbox_backend::OpenShellRuntimeBackend::new( @@ -32,7 +113,8 @@ impl MxcRuntimeBackend { provider_credentials, sandbox_bearer, ) - .with_audit_validator(Arc::new(crate::audit::MxcBoundaryAuditValidator)), + .with_audit_validator(Arc::new(crate::audit::MxcBoundaryAuditValidator)) + .with_transport_connector(connector), } } } @@ -74,6 +156,7 @@ mod tests { std::collections::HashMap::default(), ), openshell_core::jwt::SessionBearerTokenSlot::empty(), + Arc::new(MxcReverseTcpConnector::default()), )) } @@ -113,4 +196,83 @@ mod tests { Err(BackendError::Descriptor(_)) )); } + + #[tokio::test] + async fn reverse_connector_reuses_listener_and_rejects_endpoint_changes() { + use openshell_isolation_interface::contract::{ + OuterFenceGuarantees, ResolvedWorkloadIdentity, + }; + use openshell_sandbox_backend::boundary_protocol::SandboxTlsClientConfig; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = reservation.local_addr().unwrap(); + drop(reservation); + let descriptor = SandboxRuntimeDescriptor { + boundary_id: "test".into(), + generation: "test".into(), + session_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(), + workload_identity: ResolvedWorkloadIdentity::new( + 1, + 1, + Vec::new(), + "test".into(), + "test".into(), + ) + .unwrap(), + transport: SandboxTransport::Tcp { + authority: address.to_string(), + addresses: vec![address], + }, + tls: SandboxTlsClientConfig { + server_name: "test".into(), + trust_anchor_pem: "test".into(), + }, + host_gateway_ip: None, + resource_claims: std::collections::BTreeMap::from([( + "mxc.control_transport".into(), + "reverse_tcp".into(), + )]), + outer_fence: OuterFenceGuarantees::from_enforcement_evidence("test", [], b"test") + .unwrap(), + }; + let connector = Arc::new(MxcReverseTcpConnector::default()); + for _ in 0..2 { + let host_connector = connector.clone(); + let host_descriptor = descriptor.clone(); + let host = + tokio::spawn( + async move { host_connector.connect(&host_descriptor).await.unwrap() }, + ); + let mut peer = tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + if let Ok(stream) = tokio::net::TcpStream::connect(address).await { + break stream; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let mut stream = host.await.unwrap(); + peer.write_all(b"hello").await.unwrap(); + let mut received = [0; 5]; + stream.read_exact(&mut received).await.unwrap(); + assert_eq!(&received, b"hello"); + } + let mut other = descriptor.clone(); + let SandboxTransport::Tcp { addresses, .. } = &mut other.transport else { + unreachable!() + }; + addresses[0] = "127.0.0.1:1".parse().unwrap(); + assert!(matches!( + connector.connect(&other).await, + Err(BackendError::Descriptor(_)) + )); + other.resource_claims.clear(); + assert!(matches!( + connector.connect(&other).await, + Err(BackendError::Descriptor(_)) + )); + } } diff --git a/crates/openshell-mxc-boundary/src/identity.rs b/crates/openshell-mxc-boundary/src/identity.rs new file mode 100644 index 000000000..609db7d02 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/identity.rs @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Measured Windows process containment identity; never an authored profile name. + +use windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, LocalFree}; +use windows::Win32::Security::Authorization::ConvertSidToStringSidW; +use windows::Win32::Security::{ + GetLengthSid, GetTokenInformation, IsValidSid, TOKEN_APPCONTAINER_INFORMATION, TOKEN_QUERY, + TokenAppContainerSid, TokenIsAppContainer, +}; +use windows::Win32::System::Threading::{ + GetCurrentProcessId, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, +}; + +struct OwnedHandle(HANDLE); + +#[allow(unsafe_code)] +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: this guard owns a real handle acquired by a successful Win32 + // call, and closes it exactly once. It never wraps pseudo handles. + let _ = unsafe { CloseHandle(self.0) }; + } +} + +#[allow(unsafe_code)] +pub fn current_appcontainer_sid() -> Result { + // SAFETY: GetCurrentProcessId has no pointer or lifetime preconditions. + process_appcontainer_sid(unsafe { GetCurrentProcessId() }) +} + +#[allow(unsafe_code)] +fn process_appcontainer_sid(process_id: u32) -> Result { + // SAFETY: owned handles are closed by guards on every exit path. Query + // buffers are correctly sized/aligned and remain alive while SID pointers + // are used. The SID extent is checked before conversion. + unsafe { + let process = OwnedHandle( + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_id) + .map_err(|error| format!("open MXC process identity: {error}"))?, + ); + let mut token = HANDLE::default(); + OpenProcessToken(process.0, TOKEN_QUERY, &raw mut token) + .map_err(|error| format!("open MXC process token: {error}"))?; + let token = OwnedHandle(token); + let mut contained = 0_u32; + let mut returned = 0_u32; + GetTokenInformation( + token.0, + TokenIsAppContainer, + Some(std::ptr::from_mut(&mut contained).cast()), + 4, + &raw mut returned, + ) + .map_err(|error| format!("query MXC AppContainer token: {error}"))?; + if returned != 4 || contained != 1 { + return Err("MXC boundary process is not an AppContainer".to_string()); + } + + // A SID has at most 15 subauthorities. This aligned buffer is larger + // than TOKEN_APPCONTAINER_INFORMATION plus the largest Windows SID. + let mut buffer = [0_usize; 32]; + let length = u32::try_from(size_of_val(&buffer)).expect("fixed token buffer fits u32"); + GetTokenInformation( + token.0, + TokenAppContainerSid, + Some(buffer.as_mut_ptr().cast()), + length, + &raw mut returned, + ) + .map_err(|error| format!("query MXC AppContainer SID: {error}"))?; + if returned < u32::try_from(size_of::()).unwrap() + || returned > length + { + return Err("MXC AppContainer SID buffer length is invalid".to_string()); + } + let info = &*buffer.as_ptr().cast::(); + let start = buffer.as_ptr() as usize; + let end = start + returned as usize; + let sid = info.TokenAppContainer.0 as usize; + // Check the fixed SID header before reading its subauthority count. + if sid < start || sid > end.saturating_sub(8) { + return Err("MXC AppContainer SID pointer is invalid".to_string()); + } + let count = *info.TokenAppContainer.0.cast::().add(1); + let sid_length = 8 + usize::from(count) * 4; + if count > 15 + || sid_length > end - sid + || !IsValidSid(info.TokenAppContainer).as_bool() + || GetLengthSid(info.TokenAppContainer) as usize != sid_length + { + return Err("MXC AppContainer SID is invalid".to_string()); + } + let mut text = windows::core::PWSTR::null(); + ConvertSidToStringSidW(info.TokenAppContainer, &raw mut text) + .map_err(|error| format!("format MXC AppContainer SID: {error}"))?; + let result = text.to_string(); + let _ = LocalFree(Some(HLOCAL(text.0.cast()))); + let sid = result.map_err(|error| format!("decode MXC AppContainer SID: {error}"))?; + if !sid.starts_with("S-1-15-2-") { + return Err("MXC token SID is not an AppContainer identity".to_string()); + } + Ok(sid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_uncontained_host_process_identity() { + assert!(current_appcontainer_sid().is_err()); + } + + #[test] + fn rejects_invalid_process_identity() { + assert!(process_appcontainer_sid(0).is_err()); + } + + #[test] + #[ignore = "requires execution inside a real native MXC ProcessContainer"] + fn measures_native_appcontainer_identity() { + let sid = current_appcontainer_sid().expect("native AppContainer SID must be measurable"); + assert!(sid.starts_with("S-1-15-2-")); + } + + #[test] + #[ignore = "requires native MXC with all host loopback and egress denied"] + fn native_same_container_loopback() { + current_appcontainer_sid().expect("must run inside native MXC"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let client = + std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(3)) + .expect("same-container loopback must work without host-loopback permission"); + let (_server, _) = listener.accept().unwrap(); + drop(client); + } +} diff --git a/crates/openshell-mxc-boundary/src/launch.rs b/crates/openshell-mxc-boundary/src/launch.rs new file mode 100644 index 000000000..a5c798727 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/launch.rs @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC-owned host launch data, separate from the shared Sandbox Protocol. + +use openshell_isolation_interface::contract::{ + BackendError, BinaryIdentity, ExecutableIdentity, Sha256Digest, +}; +use openshell_sandbox_backend::boundary_protocol::{BoundaryConfig, SandboxRuntimeDescriptor}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use std::{fmt, net::SocketAddr, path::PathBuf}; + +/// Protected, generation-bound launch envelope decoded by Windows composition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MxcLaunchDescriptor { + pub runtime: SandboxRuntimeDescriptor, + pub proxy: MxcProxyConfig, +} + +/// One-use Windows bootstrap; proxy credentials are never in shared protocol data. +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MxcBoundaryConfig { + pub runtime: BoundaryConfig, + pub proxy_url: String, +} + +impl MxcBoundaryConfig { + pub fn encode(&self) -> Result, BackendError> { + serde_json::to_vec(self) + .map_err(|e| BackendError::Descriptor(format!("encode MXC bootstrap: {e}"))) + } +} + +impl fmt::Debug for MxcBoundaryConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MxcBoundaryConfig") + .field("runtime", &self.runtime) + .field("proxy_url", &"") + .finish() + } +} + +/// Only MXC needs to provision an authenticated host CONNECT listener. +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MxcProxyConfig { + pub bind_addr: SocketAddr, + pub authorization: String, + pub workload_binary: PathBuf, +} + +impl fmt::Debug for MxcProxyConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MxcProxyConfig") + .field("bind_addr", &self.bind_addr) + .field("authorization", &"") + .field("workload_binary", &self.workload_binary) + .finish() + } +} + +impl MxcLaunchDescriptor { + pub fn decode(payload: &[u8]) -> Result { + let descriptor: Self = serde_json::from_slice(payload) + .map_err(|e| BackendError::Descriptor(format!("decode MXC launch data: {e}")))?; + descriptor.proxy.validate()?; + Ok(descriptor) + } +} + +impl MxcProxyConfig { + /// Supply actual digest evidence for the configured main workload. This + /// retains MXC's existing fixed-main attribution; it is not per-connection + /// socket-owner or descendant identity discovery. + pub fn binary_identity(&self) -> Result { + self.validate()?; + let bytes = std::fs::read(&self.workload_binary).map_err(|error| { + BackendError::Descriptor(format!("read MXC workload identity: {error}")) + })?; + let digest: Sha256Digest = + format!("{:x}", Sha256::digest(&bytes)) + .parse() + .map_err(|error| { + BackendError::Descriptor(format!("hash MXC workload identity: {error}")) + })?; + Ok(BinaryIdentity { + executable: ExecutableIdentity { + path: self.workload_binary.clone(), + digest: Some(digest), + }, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }) + } + + pub fn validate(&self) -> Result<(), BackendError> { + if !self.bind_addr.ip().is_loopback() + || self.bind_addr.port() == 0 + || self.authorization.trim().is_empty() + || self.authorization.contains(['\r', '\n']) + || !self.workload_binary.is_absolute() + { + return Err(BackendError::Descriptor( + "MXC proxy requires a loopback listener, single-line authorization, and an absolute workload binary".into(), + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn proxy() -> MxcProxyConfig { + MxcProxyConfig { + bind_addr: "127.0.0.1:3128".parse().unwrap(), + authorization: "Basic generation-secret".into(), + workload_binary: "C:/Windows/System32/cmd.exe".into(), + } + } + + #[test] + fn proxy_configuration_is_validated_by_mxc() { + assert!(proxy().validate().is_ok()); + for address in ["0.0.0.0:3128", "192.0.2.1:3128", "127.0.0.1:0"] { + let mut config = proxy(); + config.bind_addr = address.parse().unwrap(); + assert!(config.validate().is_err()); + } + for authorization in ["", " ", "Basic token\r\nInjected: header"] { + let mut config = proxy(); + config.authorization = authorization.into(); + assert!(config.validate().is_err()); + } + let mut config = proxy(); + config.workload_binary = "relative.exe".into(); + assert!(config.validate().is_err()); + } + + #[test] + fn proxy_identity_hashes_workload_and_rejects_unreadable_evidence() { + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("workload.exe"); + let mut config = proxy(); + config.workload_binary = executable.clone(); + assert!(config.binary_identity().is_err()); + std::fs::write(&executable, b"first workload").unwrap(); + let first = config.binary_identity().unwrap(); + assert_eq!(first.executable.path, executable); + assert_eq!( + first.executable.digest, + Some( + format!("{:x}", Sha256::digest(b"first workload")) + .parse() + .unwrap() + ) + ); + std::fs::write(&executable, b"replacement workload").unwrap(); + assert_ne!( + first.executable.digest, + config.binary_identity().unwrap().executable.digest + ); + } + + #[test] + fn launch_credentials_are_redacted_and_unknown_fields_rejected() { + assert!(!format!("{:?}", proxy()).contains("generation-secret")); + let mut value = serde_json::to_value(proxy()).unwrap(); + value["unexpected"] = serde_json::json!(true); + assert!(serde_json::from_value::(value).is_err()); + assert!(MxcLaunchDescriptor::decode(b"{}").is_err()); + } +} diff --git a/crates/openshell-mxc-boundary/src/lib.rs b/crates/openshell-mxc-boundary/src/lib.rs index 68e0692fd..003f9e2e5 100644 --- a/crates/openshell-mxc-boundary/src/lib.rs +++ b/crates/openshell-mxc-boundary/src/lib.rs @@ -12,6 +12,10 @@ pub mod audit; #[cfg(target_os = "windows")] pub mod backend; +#[cfg(target_os = "windows")] +mod identity; +#[cfg(target_os = "windows")] +pub mod launch; /// Exact admission name for the MXC isolation backend; never a Linux fallback. pub const BACKEND_NAME: &str = "openshell-mxc"; diff --git a/crates/openshell-mxc-boundary/src/runtime.rs b/crates/openshell-mxc-boundary/src/runtime.rs index cd9fc5cd7..68703ba92 100644 --- a/crates/openshell-mxc-boundary/src/runtime.rs +++ b/crates/openshell-mxc-boundary/src/runtime.rs @@ -9,7 +9,6 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::io; -use std::mem::size_of_val; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -51,7 +50,7 @@ const OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); pub fn run_boundary(config_path: &Path) -> Result<(), String> { let bytes = std::fs::read(config_path) .map_err(|error| format!("read boundary config {}: {error}", config_path.display()))?; - let config: BoundaryConfig = serde_json::from_slice(&bytes) + let config: crate::launch::MxcBoundaryConfig = serde_json::from_slice(&bytes) .map_err(|error| format!("decode boundary config {}: {error}", config_path.display()))?; validate_config(&config)?; let runtime_directory = config_path @@ -67,7 +66,7 @@ pub fn run_boundary(config_path: &Path) -> Result<(), String> { .build() .map_err(|error| format!("create Windows boundary runtime: {error}"))?; runtime.block_on(async move { - let (address, tls) = match &config.listener { + let (address, tls) = match &config.runtime.listener { BoundaryListener::TlsTcp { address, tls } => (*address, tls.clone()), BoundaryListener::Unix { .. } | BoundaryListener::Vsock { .. } => { return Err("MXC requires a TLS TCP boundary listener".to_string()); @@ -77,14 +76,21 @@ pub fn run_boundary(config_path: &Path) -> Result<(), String> { let boundary = Arc::new(BoundaryRuntime::new(config, runtime_directory)?); tracing::info!(%address, "MXC Sandbox Protocol reverse control ready"); loop { - let (stream, _) = listener - .accept() - .await - .map_err(|error| format!("accept MXC boundary connection: {error}"))?; - openshell_core::net::set_tcp_nodelay_best_effort(&stream); + // Match the windows branch relay: MXC dials the host, avoiding + // host-to-AppContainer inbound authorization requirements. TLS + // roles do not change: this boundary still proves its pinned identity. + let stream = + match openshell_core::net::connect_tcp_nodelay_best_effort(&[address]).await { + Ok(stream) => stream, + Err(error) => { + tracing::debug!(%error, "MXC reverse control host not ready"); + tokio::time::sleep(Duration::from_millis(200)).await; + continue; + } + }; let acceptor = tokio_rustls::TlsAcceptor::from(tls.clone()); let boundary = boundary.clone(); - tokio::spawn(async move { + { let result = async { let stream = tokio::time::timeout(Duration::from_secs(5), acceptor.accept(stream)) @@ -99,12 +105,22 @@ pub fn run_boundary(config_path: &Path) -> Result<(), String> { if let Err(error) = result { tracing::debug!(%error, "MXC boundary connection ended"); } - }); + } + tokio::time::sleep(Duration::from_millis(200)).await; } }) } -fn validate_config(config: &BoundaryConfig) -> Result<(), String> { +fn validate_config(launch: &crate::launch::MxcBoundaryConfig) -> Result<(), String> { + let config = &launch.runtime; + if config + .resource_claims + .get("mxc.control_transport") + .map(String::as_str) + != Some("reverse_tcp") + { + return Err("MXC boundary requires reverse TCP control provisioning".to_string()); + } if config.boundary_id.trim().is_empty() || config.generation.trim().is_empty() || config.gateway_id.trim().is_empty() @@ -119,6 +135,7 @@ fn validate_config(config: &BoundaryConfig) -> Result<(), String> { match &config.listener { BoundaryListener::TlsTcp { address, tls } if address.port() != 0 + && address.ip().is_loopback() && tls.certificate_chain_path.is_absolute() && tls.private_key_path.is_absolute() => {} BoundaryListener::TlsTcp { .. } => { @@ -128,10 +145,8 @@ fn validate_config(config: &BoundaryConfig) -> Result<(), String> { return Err("MXC boundary supports only TLS TCP transport".to_string()); } } - let Some(proxy_url) = config.direct_proxy_url.as_deref() else { - return Err("MXC boundary requires a generation-scoped direct proxy".to_string()); - }; - let url = proxy_url + let url = launch + .proxy_url .parse::() .map_err(|error| format!("validate MXC direct proxy URL: {error}"))?; if url.scheme() != "http" @@ -625,17 +640,15 @@ impl BoundaryRuntime { } fn confirmation(&self) -> Result { + let appcontainer_sid = crate::identity::current_appcontainer_sid()?; let evidence = MxcSandboxAuditEvidence { - process_container: current_process_is_appcontainer()?, - appcontainer_profile: self - .config - .resource_claims - .get("mxc.appcontainer_profile") - .cloned() - .unwrap_or_else(|| self.config.generation.clone()), + process_container: !appcontainer_sid.is_empty(), + appcontainer_profile: appcontainer_sid, default_deny_filesystem: true, + // TODO(mxc-network-fence): Windows parity assertions, not measured + // exclusive mediation. MXC still allows all host 127.0.0.1 ports. default_deny_egress: true, - loopback_proxy_only: self.config.direct_proxy_url.is_some(), + loopback_proxy_only: !self.proxy_url.is_empty(), authenticated_control: true, generation_scoped_attribution: true, }; @@ -959,41 +972,6 @@ impl BoundaryRuntime { } } -#[allow(unsafe_code)] -fn current_process_is_appcontainer() -> Result { - use windows::Win32::Foundation::{CloseHandle, HANDLE}; - use windows::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TokenIsAppContainer}; - use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; - - let mut is_appcontainer = 0_u32; - let buffer_size = u32::try_from(size_of_val(&is_appcontainer)) - .map_err(|error| format!("size MXC AppContainer token buffer: {error}"))?; - // SAFETY: the token handle is initialized by OpenProcessToken, queried into - // a correctly sized u32 buffer, and closed on every path after acquisition. - unsafe { - let mut token = HANDLE::default(); - OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) - .map_err(|error| format!("open MXC sandbox process token: {error}"))?; - let mut returned = 0_u32; - let query = GetTokenInformation( - token, - TokenIsAppContainer, - Some(std::ptr::from_mut(&mut is_appcontainer).cast()), - buffer_size, - &raw mut returned, - ); - let close = CloseHandle(token); - query.map_err(|error| format!("query MXC sandbox AppContainer token: {error}"))?; - close.map_err(|error| format!("close MXC sandbox process token: {error}"))?; - if returned != buffer_size { - return Err(format!( - "query MXC sandbox AppContainer token returned {returned} bytes" - )); - } - Ok(is_appcontainer != 0) - } -} - async fn serve_one( mut stream: tokio::io::DuplexStream, runtime: Arc, @@ -1306,6 +1284,27 @@ impl ManagedProcess { let mut child = command .spawn() .map_err(|error| format!("spawn MXC workload executable {program:?}: {error}"))?; + if matches!(kind, ProcessKindWire::Main) { + openshell_ocsf::ocsf_emit!( + openshell_ocsf::ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(openshell_ocsf::ActivityId::Open) + .action(openshell_ocsf::ActionId::Allowed) + .disposition(openshell_ocsf::DispositionId::Allowed) + .severity(openshell_ocsf::SeverityId::Informational) + .status(openshell_ocsf::StatusId::Success) + .launch_type(openshell_ocsf::LaunchTypeId::Spawn) + .process(openshell_ocsf::Process::new( + &program, + i64::from(child.id().unwrap_or_default()), + )) + .message("MXC agent launched") + .build() + ); + // Preserve the windows branch's launch acknowledgement for existing + // operators and E2E tooling. Process OCSF shorthand omits the message; + // emit this acknowledgement only after the native spawn succeeds. + tracing::info!(process_id = %id, "MXC agent launched"); + } let stdin = child.stdin.take(); let stdout = child .stdout diff --git a/crates/openshell-mxc-boundary/tests/composition.rs b/crates/openshell-mxc-boundary/tests/composition.rs index a58ffbe47..25c9269a0 100644 --- a/crates/openshell-mxc-boundary/tests/composition.rs +++ b/crates/openshell-mxc-boundary/tests/composition.rs @@ -21,14 +21,36 @@ fn shared_runtime_keeps_platform_audit_interpretation_in_backend_extension() { let runtime = include_str!("../../openshell-sandbox-backend/src/runtime.rs"); assert!(runtime.contains("self.audit_validator.validate(&confirmation.backend_audit)?")); assert!(runtime.contains("confirmation.properties != properties")); - let composition = include_str!("../../openshell-supervisor/src/isolation_backends.rs"); + let composition = include_str!("../../openshell-supervisor/src/backend_setup/mxc.rs"); assert!(composition.contains("MxcRuntimeBackend::new")); + assert!(composition.contains("impl BackendSetup for MxcBackendSetup")); + assert!(composition.contains("impl PreparedBackend for MxcLaunch")); + let supervisor = include_str!("../../openshell-supervisor/src/lib.rs"); + assert!(!supervisor.contains("serde_json::from_slice(&backend_descriptor.payload)")); + assert!(!supervisor.contains("OpenShellRuntimeBackend::discover_policy")); + assert!(!supervisor.contains("MxcReverseTcpConnector")); let backend = include_str!("../src/backend.rs"); assert!(backend.contains("impl IsolationBackend for MxcRuntimeBackend")); assert!(backend.contains("MxcBoundaryAuditValidator")); assert!(backend.contains("self.transport.attach(descriptor, sandbox).await")); } +#[test] +fn proxy_launch_data_does_not_extend_shared_isolation_contracts() { + let contract = include_str!("../../openshell-isolation-interface/src/contract.rs"); + let protocol = include_str!("../../openshell-sandbox-backend/src/boundary_protocol.rs"); + assert!(!contract.contains("DirectProxyConfiguration")); + assert!(!contract.contains("direct_proxy_configuration")); + assert!(!protocol.contains("direct_proxy")); + for driver in [ + include_str!("../../openshell-driver-docker/src/isolation.rs"), + include_str!("../../openshell-driver-kubernetes/src/isolation.rs"), + include_str!("../../openshell-driver-vm/src/isolation/mod.rs"), + ] { + assert!(!driver.contains("direct_proxy")); + } +} + #[test] fn only_dedicated_sandbox_delegates_to_the_windows_boundary_library() { let manifest = include_str!("../../openshell-sandbox/Cargo.toml"); diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 69033f376..2186210cb 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -24,6 +24,7 @@ the execution outcome unknown. Exec envelopes without an expiration time are rejected. Update the supervisor and sandbox runtime together when deploying this protocol change. + ## Boundary audit validation `OpenShellRuntimeBackend` implements the authenticated host side of the shared diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index a30f83b8e..70d491d31 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -59,6 +59,7 @@ pub const BOUNDARY_CONNECTION_WINDOW_BYTES: u32 = BOUNDARY_MAX_CONCURRENT_STREAM + BOUNDARY_CONNECTION_WINDOW_RESERVE_BYTES; // HTTP/2 caps any flow-control window at 2^31 - 1. const _: () = assert!(BOUNDARY_CONNECTION_WINDOW_BYTES <= i32::MAX as u32); + /// Capability masks measured from `/proc//status` by the `OpenShell` /// co-located runtime. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -439,12 +440,6 @@ pub struct SandboxRuntimeDescriptor { /// network supervisor cannot use the boundary's resolver view. #[serde(default)] pub host_gateway_ip: Option, - /// Optional generation-scoped explicit proxy owned by the host - /// supervisor. Backends set this only when their outer fence routes the - /// workload to this listener and the boundary cannot provide staged - /// socket mediation. - #[serde(default)] - pub direct_proxy: Option, /// Driver-specific immutable resource coordinates bound at attach (for /// example pod UID, VM generation, or container ID). #[serde(default)] @@ -463,7 +458,6 @@ impl fmt::Debug for SandboxRuntimeDescriptor { .field("transport", &self.transport) .field("tls", &self.tls) .field("host_gateway_ip", &self.host_gateway_ip) - .field("direct_proxy", &self.direct_proxy) .field("resource_claims", &self.resource_claims) .field("outer_fence", &self.outer_fence) .finish() @@ -518,13 +512,8 @@ pub struct BoundaryConfig { pub resource_claim_files: std::collections::BTreeMap, /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, - /// Backend-neutral projection of the validated outer fence. + /// Backend-neutral projection of the validated outer network fence. pub outer_fence: OuterFenceGuarantees, - /// Authenticated proxy URL injected into workload children. It is staged - /// only in this protected one-use configuration and is never inherited by - /// the trusted sandbox process itself. - #[serde(default)] - pub direct_proxy_url: Option, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] pub child_env: std::collections::HashMap, @@ -553,10 +542,6 @@ impl fmt::Debug for BoundaryConfig { .field("resource_claim_files", &self.resource_claim_files) .field("workload_identity", &self.workload_identity) .field("outer_fence", &self.outer_fence) - .field( - "direct_proxy_url", - &self.direct_proxy_url.as_ref().map(|_| ""), - ) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() } @@ -1537,6 +1522,7 @@ mod tests { assert!(audit.validate().is_err()); assert!(!audit.properties().egress_interception.enforced); } + #[test] fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() { for encoded in [ diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 396175289..b20ca44f3 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -130,6 +130,7 @@ impl OpenShellRuntimeBackend { self.connector = Some(connector); self } + /// Select the backend implementation that validates opaque audit evidence. #[must_use] pub fn with_audit_validator( @@ -158,7 +159,6 @@ impl IsolationBackend for OpenShellRuntimeBackend { })?; validate_runtime_descriptor(&runtime_descriptor, &sandbox)?; let host_gateway_ip = runtime_descriptor.host_gateway_ip; - let direct_proxy = runtime_descriptor.direct_proxy.clone(); let resource_claims = runtime_descriptor.resource_claims.clone(); let generation = runtime_descriptor.generation.clone(); let session_id = runtime_descriptor.session_id; @@ -189,7 +189,6 @@ impl IsolationBackend for OpenShellRuntimeBackend { sandbox_id: sandbox.sandbox_id, mediation: Arc::new(RemoteNetworkMediation { client }), host_gateway_ip, - direct_proxy, ca_file_paths: self.ca_file_paths.clone(), provider_credentials: self.provider_credentials.clone(), identity: sandbox.identity, @@ -259,18 +258,6 @@ fn validate_runtime_descriptor( } } validate_client_tls(&runtime_descriptor.tls)?; - if let Some(proxy) = &runtime_descriptor.direct_proxy - && (!proxy.bind_addr.ip().is_loopback() - || proxy.bind_addr.port() == 0 - || proxy.authorization.trim().is_empty() - || proxy.authorization.contains(['\r', '\n']) - || !proxy.binary_identity.executable.path.is_absolute()) - { - return Err(BackendError::Descriptor( - "direct proxy requires a loopback listener, a single-line authorization value, and an absolute binary identity" - .to_string(), - )); - } Ok(()) } @@ -348,7 +335,6 @@ struct RemoteBound { sandbox_id: String, mediation: Arc, host_gateway_ip: Option, - direct_proxy: Option, ca_file_paths: Arc>>, provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, @@ -368,12 +354,6 @@ impl BoundBoundary for RemoteBound { self.host_gateway_ip } - fn direct_proxy_configuration( - &self, - ) -> Option { - self.direct_proxy.clone() - } - async fn confirm(self: Box) -> Result { let response = self.client.call_idempotent(Request::Confirm).await?; let Response::Confirmed { confirmation } = response else { @@ -2827,7 +2807,6 @@ mod tests { }, tls, host_gateway_ip: None, - direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), } @@ -3091,7 +3070,6 @@ mod tests { }, tls: certificate.client_tls.clone(), host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), - direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }; @@ -3112,7 +3090,6 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, - direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }; @@ -3135,7 +3112,6 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, - direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }; @@ -3158,7 +3134,6 @@ mod tests { }, tls: test_certificate().client_tls, host_gateway_ip: None, - direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }; @@ -3467,7 +3442,6 @@ mod tests { }, tls: certificate.client_tls, host_gateway_ip: None, - direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }, @@ -3661,7 +3635,6 @@ mod tests { }, tls: certificate.client_tls, host_gateway_ip: None, - direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }, diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 392840d07..88bce43fa 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -61,7 +61,6 @@ tokio-rustls = { workspace = true } base64 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -url = { workspace = true } # Logging tracing = { workspace = true } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 2d856455f..76d042152 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -352,9 +352,6 @@ pub struct ComputeDriverInfoSnapshot { pub rootfs_tar_staging_dir: String, /// Maximum rootfs tar file size in bytes. pub rootfs_tar_max_bytes: u64, - /// Whether this configured driver instance completely enforces the portable - /// UI policy contract. - pub supports_ui_policy: bool, } // Startup recovery owns persisted desired state before normal observations @@ -785,7 +782,6 @@ impl ComputeRuntime { resource_capabilities: capabilities.resource_capabilities, rootfs_tar_staging_dir: capabilities.rootfs_tar_staging_dir, rootfs_tar_max_bytes: capabilities.rootfs_tar_max_bytes, - supports_ui_policy: capabilities.supports_ui_policy, }; let default_image = capabilities.default_image; let rootfs_tar_staging = Arc::new(rootfs_tar::RootfsTarStagingRegistry::new( @@ -1145,8 +1141,14 @@ impl ComputeRuntime { .or_else(|| sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref())) .and_then(|policy| policy.ui.as_ref()) .is_some(); - if has_explicit_ui && !self.driver_info.supports_ui_policy { - return Err(Status::invalid_argument(format!( + let supports_ui = self + .driver_info + .negotiated_extension + .supported_capabilities + .iter() + .any(|capability| capability == openshell_core::extension_protocol::POLICY_UI_V1); + if has_explicit_ui && !supports_ui { + return Err(Status::failed_precondition(format!( "compute driver '{}' does not support the complete UI policy contract; remove the explicit ui section or select a supporting driver/backend", self.driver_info.name ))); @@ -7428,7 +7430,6 @@ impl ComputeDriver for NoopTestDriver { "test", [], )), - supports_ui_policy: false, }, )) } @@ -7606,7 +7607,6 @@ pub fn new_test_runtime_with_driver( resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, - supports_ui_policy: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -7631,7 +7631,6 @@ pub fn new_test_runtime_with_driver( mod tests { use super::*; use futures::stream; - use openshell_core::proto::SandboxPolicy as PublicSandboxPolicy; use openshell_core::proto::compute::v1::{ CreateSandboxResponse, DeleteSandboxResponse, GetCapabilitiesResponse, GetSandboxRequest, GetSandboxResponse, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, @@ -8048,7 +8047,6 @@ mod tests { } metadata }), - supports_ui_policy: false, })) } @@ -8452,7 +8450,6 @@ mod tests { "test", [], )), - supports_ui_policy: false, })) } @@ -8714,7 +8711,6 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, - supports_ui_policy: false, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -8757,7 +8753,7 @@ mod tests { .await .expect_err("an unsupported driver must reject explicit UI policy"); - assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(error.code(), Code::FailedPrecondition); assert!(error.message().contains("complete UI policy contract")); assert_eq!( driver.validate_create_calls.load(Ordering::Relaxed), @@ -8776,7 +8772,7 @@ mod tests { .await .expect_err("an internal caller must not bypass UI capability validation"); - assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(error.code(), Code::FailedPrecondition); assert_eq!( driver.create_calls.load(Ordering::Relaxed), 0, @@ -8803,7 +8799,7 @@ mod tests { .await .expect_err("an unsupported driver must reject the effective UI policy"); - assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(error.code(), Code::FailedPrecondition); assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 0); } @@ -8811,7 +8807,11 @@ mod tests { async fn explicit_ui_policy_reaches_driver_when_capability_is_complete() { let driver = Arc::new(TestDriver::default()); let mut runtime = test_runtime(driver.clone()).await; - runtime.driver_info.supports_ui_policy = true; + runtime + .driver_info + .negotiated_extension + .supported_capabilities + .push(openshell_core::extension_protocol::POLICY_UI_V1.to_string()); runtime .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-supported")) @@ -8821,6 +8821,27 @@ mod tests { assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); } + #[tokio::test] + async fn effective_ui_policy_rejects_before_unsupported_driver_create() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record( + "sb-effective-ui-create", + "ui-policy", + SandboxPhase::Provisioning, + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new(PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }); + let error = runtime + .create_sandbox_with_runtime_inputs(sandbox, None, false, runtime_inputs) + .await + .expect_err("effective UI must be checked on internal creation"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert_eq!(driver.create_calls.load(Ordering::Relaxed), 0); + } + #[tokio::test] async fn absent_ui_policy_preserves_unsupported_driver_behavior() { let driver = Arc::new(TestDriver::default()); @@ -8834,6 +8855,7 @@ mod tests { assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); } + #[tokio::test] async fn create_runtime_inputs_reach_driver_without_persisting_effective_policy_or_secrets() { let driver = Arc::new(TestDriver::default()); diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index f78c81d64..fee2c5e8f 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -288,7 +288,6 @@ impl OpenShell for OpenShellService { .resource_capabilities .as_ref() .map(|resources| public_resource_capabilities(*resources)), - supports_ui_policy: driver.supports_ui_policy, }), }) .collect(); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index ef3e26b14..a5dbd446a 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -8695,18 +8695,6 @@ mod tests { assert!(inputs.launch_authentication.is_none()); } - #[tokio::test] - async fn create_runtime_inputs_preserve_base_policy_without_credential_sink() { - let state = test_server_state().await; - let policy = openshell_policy::restrictive_default_policy(); - let sandbox = test_sandbox("create-policy", "create-policy", policy.clone(), Vec::new()); - let inputs = resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox) - .await - .expect("driver-independent effective policy"); - assert_eq!(inputs.effective_policy, Some(policy)); - assert!(inputs.launch_authentication.is_none()); - } - #[tokio::test] async fn create_runtime_inputs_apply_global_policy_and_preserve_startup_ui() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 9980c14a8..0cd4abf2d 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -161,7 +161,6 @@ impl FakeComputeDriver { gateway_manages_lifecycle: false, supports_sandbox_authentication: false, driver_reports_runtime_readiness: false, - supports_ui_policy: false, resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs index c7de3fe0e..80759131d 100644 --- a/crates/openshell-supervisor-process/src/main_session.rs +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -758,7 +758,6 @@ mod tests { )); } - #[cfg(unix)] #[tokio::test] async fn signaling_without_boundary_is_rejected() { let session = MainSession::inert(); diff --git a/crates/openshell-supervisor/README.md b/crates/openshell-supervisor/README.md index d84650799..eebaed0de 100644 --- a/crates/openshell-supervisor/README.md +++ b/crates/openshell-supervisor/README.md @@ -4,7 +4,9 @@ The supervisor loads and reconciles policy, maintains provider credentials, appl ## Backend startup -The public `run_sandbox` entry point selects the OpenShell Sandbox Protocol backend and collects its startup inputs into a private `SandboxRunConfig`. Shared startup receives that config and the trusted backend setup separately. The `backend_setup` module owns that backend's launch-data decoder, workload policy discovery, and client construction. Descriptor contents cannot select an implementation. +The public `run_sandbox` entry point selects the platform backend and collects its startup inputs into a private `SandboxRunConfig`. Shared startup receives that config and the trusted backend setup separately. The `backend_setup` module owns the selected backend's launch-data decoder, workload policy discovery, and client construction. Descriptor contents cannot select an implementation. + +Windows composition selects MXC; other platforms retain the standard OpenShell Sandbox Protocol backend. The MXC setup retains one reverse-TCP connector through discovery, attachment, and reconnects, and builds the host-side isolation backend with its Windows audit validator. Provisioning remains in the compute driver; supervision remains in a separate host process. Shared startup checks the admitted backend name before passing the opaque payload to its decoder. It then compares the decoded sandbox, session, and runtime generation with the trusted launch inputs before installing credentials or discovering workload policy. A mismatch stops startup. @@ -14,7 +16,14 @@ The supervisor admits policy and prepares credentials before constructing and at The client receives the supervisor's live provider state, bearer-token slot, and CA-path slot. Provider refresh, token rotation, and later CA publication must remain visible through those shared handles. Startup does not create independent copies of their current values. -The setup interface stays private to the supervisor. It adds no runtime backend registration, endpoint configuration, or public factory API. The public `run_sandbox` signature and standard backend selection remain unchanged. +The setup interface stays private to the supervisor. It adds no runtime backend registration, endpoint configuration, or public factory API. The public `run_sandbox` signature remains unchanged. + +MXC proxy settings are decoded by Windows backend setup from an MXC-owned +launch envelope. The private startup result carries the transport payload and +concrete CONNECT listener options separately. Shared networking consumes those +options after attachment and confirmation; the host supervisor still owns policy +evaluation, live credentials, and listener lifetime. Neither `BoundBoundary` nor +the shared Sandbox Protocol descriptor exposes a proxy-configuration hook. The private startup result separates the backend transport payload from optional authenticated CONNECT listener settings. Shared networking starts the listener diff --git a/crates/openshell-supervisor/src/backend_setup.rs b/crates/openshell-supervisor/src/backend_setup.rs index c312ca6a3..80385a7d8 100644 --- a/crates/openshell-supervisor/src/backend_setup.rs +++ b/crates/openshell-supervisor/src/backend_setup.rs @@ -16,6 +16,21 @@ use openshell_isolation_interface::contract::{ ResolvedWorkloadIdentity, SandboxContext, SandboxPolicy, }; +#[cfg(target_os = "windows")] +mod mxc; + +/// Trusted binary composition chooses the platform implementation, never the payload. +pub fn platform_setup() -> &'static dyn BackendSetup { + #[cfg(target_os = "windows")] + { + &mxc::MxcBackendSetup + } + #[cfg(not(target_os = "windows"))] + { + &OpenShellBackendSetup + } +} + /// Coordinates decoded by the selected trusted backend. Shared startup checks /// these against admission before credentials or discovery reach that backend. pub struct LaunchIdentity { @@ -201,8 +216,10 @@ impl SelectedBackend { /// The standard binary selects the `OpenShell` Sandbox Protocol. Its wire schema /// and concrete client stay here rather than in the shared startup sequence. +#[cfg(any(test, not(target_os = "windows")))] pub struct OpenShellBackendSetup; +#[cfg(any(test, not(target_os = "windows")))] impl BackendSetup for OpenShellBackendSetup { fn backend_name(&self) -> &str { openshell_sandbox_backend::BACKEND_NAME @@ -233,8 +250,10 @@ impl BackendSetup for OpenShellBackendSetup { } } +#[cfg(any(test, not(target_os = "windows")))] struct OpenShellLaunch(openshell_sandbox_backend::boundary_protocol::SandboxRuntimeDescriptor); +#[cfg(any(test, not(target_os = "windows")))] #[tonic::async_trait] impl PreparedBackend for OpenShellLaunch { async fn discover_policy( diff --git a/crates/openshell-supervisor/src/backend_setup/mxc.rs b/crates/openshell-supervisor/src/backend_setup/mxc.rs new file mode 100644 index 000000000..e24b62521 --- /dev/null +++ b/crates/openshell-supervisor/src/backend_setup/mxc.rs @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Trusted Windows composition of the MXC isolation backend. + +use super::{BackendServices, BackendSetup, BuiltBackend, LaunchIdentity, PreparedBackend}; +use openshell_core::jwt::SessionBearerTokenSlot; +use openshell_isolation_interface::contract::BackendError; +use openshell_mxc_boundary::backend::{MxcReverseTcpConnector, MxcRuntimeBackend}; +use openshell_mxc_boundary::launch::MxcLaunchDescriptor; +use openshell_supervisor_network::run::ProxyListenerConfig; +use std::sync::Arc; + +pub(super) struct MxcBackendSetup; + +impl BackendSetup for MxcBackendSetup { + fn backend_name(&self) -> &str { + openshell_mxc_boundary::BACKEND_NAME + } + + fn decode( + &self, + payload: &[u8], + ) -> Result<(LaunchIdentity, Box), BackendError> { + let descriptor = MxcLaunchDescriptor::decode(payload)?; + let identity = LaunchIdentity { + sandbox_id: descriptor.runtime.boundary_id.clone(), + generation: descriptor.runtime.generation.clone(), + session_id: descriptor.runtime.session_id, + workload_identity: descriptor.runtime.workload_identity.clone(), + vm_policy_identity: None, + }; + Ok(( + identity, + Box::new(MxcLaunch { + descriptor, + connector: Arc::new(MxcReverseTcpConnector::default()), + }), + )) + } +} + +struct MxcLaunch { + descriptor: MxcLaunchDescriptor, + // Retain the same listener across discovery, attachment, and reconnects. + connector: Arc, +} + +#[tonic::async_trait] +impl PreparedBackend for MxcLaunch { + async fn discover_policy( + &self, + bearer: SessionBearerTokenSlot, + ) -> Result<(Option, bool), BackendError> { + openshell_sandbox_backend::OpenShellRuntimeBackend::discover_policy_with_connector( + self.descriptor.runtime.clone(), + bearer, + Some(self.connector.clone()), + ) + .await + } + + fn build(self: Box, services: BackendServices) -> Result { + let payload = self.descriptor.runtime.backend_descriptor()?.payload; + let proxy = self.descriptor.proxy; + let binary_identity = proxy.binary_identity()?; + Ok(BuiltBackend { + payload, + proxy_listener: Some(ProxyListenerConfig { + bind_addr: proxy.bind_addr, + authorization: proxy.authorization.into(), + binary_identity, + }), + backend: Arc::new(MxcRuntimeBackend::new( + services.ca_file_paths, + services.provider_credentials, + services.sandbox_bearer, + self.connector, + )), + }) + } +} diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index 16359e59b..fb136c931 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -626,6 +626,98 @@ fn standard_setup_decodes_native_descriptor_and_preserves_vm_identity() { } } +#[cfg(target_os = "windows")] +#[test] +fn mxc_setup_preserves_admitted_identity_and_constructs_only_mxc() { + use openshell_sandbox_backend::boundary_protocol::{ + SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTransport, + }; + + let setup = TestSetup::new(); + let descriptor = SandboxRuntimeDescriptor { + boundary_id: setup.sandbox_id.clone(), + generation: setup.auth.runtime_generation.to_string(), + session_id: setup.auth.session_id, + workload_identity: identity(), + transport: SandboxTransport::Tcp { + authority: "unused-test-runtime".into(), + addresses: vec!["127.0.0.1:1".parse().unwrap()], + }, + tls: SandboxTlsClientConfig { + server_name: "unused-test-runtime".into(), + trust_anchor_pem: String::new(), + }, + host_gateway_ip: None, + resource_claims: BTreeMap::from([("mxc.control_transport".into(), "reverse_tcp".into())]), + outer_fence: OuterFenceGuarantees::from_enforcement_evidence( + "generation-1", + [], + b"test-evidence", + ) + .unwrap(), + }; + let launch = openshell_mxc_boundary::launch::MxcLaunchDescriptor { + runtime: descriptor, + proxy: openshell_mxc_boundary::launch::MxcProxyConfig { + bind_addr: "127.0.0.1:3128".parse().unwrap(), + authorization: "Basic test-generation-token".into(), + workload_binary: "C:/Windows/System32/cmd.exe".into(), + }, + }; + let descriptor = BackendDescriptor { + backend_name: openshell_mxc_boundary::BACKEND_NAME.into(), + payload: serde_json::to_vec(&launch).unwrap(), + }; + assert_eq!( + platform_setup().backend_name(), + openshell_mxc_boundary::BACKEND_NAME + ); + assert!(mxc::MxcBackendSetup.decode(b"{}").is_err()); + let selected = SelectedBackend::select( + &mxc::MxcBackendSetup, + descriptor.clone(), + Some(openshell_mxc_boundary::BACKEND_NAME), + Some(&setup.sandbox_id), + &setup.auth, + ) + .unwrap(); + assert!(selected.vm_policy_identity().is_none()); + assert_eq!(selected.identity.workload_identity, identity()); + let built = selected.prepared.build(setup.services()).unwrap(); + assert_eq!( + built.backend.backend_name(), + openshell_mxc_boundary::BACKEND_NAME + ); + let transport: SandboxRuntimeDescriptor = serde_json::from_slice(&built.payload).unwrap(); + assert_eq!(transport, launch.runtime); + assert!( + !String::from_utf8(built.payload) + .unwrap() + .contains("test-generation-token") + ); + let proxy = built.proxy_listener.unwrap(); + assert_eq!(proxy.bind_addr, launch.proxy.bind_addr); + assert_eq!(proxy.authorization.as_ref(), launch.proxy.authorization); + assert_eq!( + proxy.binary_identity.executable.path, + launch.proxy.workload_binary + ); + + // Selecting a Windows implementation does not waive main's identity checks. + let mut foreign_auth = setup.auth.clone(); + foreign_auth.session_id = SandboxSessionId::new(); + assert!( + SelectedBackend::select( + &mxc::MxcBackendSetup, + descriptor, + Some(openshell_mxc_boundary::BACKEND_NAME), + Some(&setup.sandbox_id), + &foreign_auth, + ) + .is_err() + ); +} + /// Auth slots and shutdown signals are process-wide. Run the real startup in a /// child so parallel tests cannot replace credentials or consume its SIGTERM. #[cfg(unix)] diff --git a/crates/openshell-supervisor/src/isolation_backends.rs b/crates/openshell-supervisor/src/isolation_backends.rs deleted file mode 100644 index 753806639..000000000 --- a/crates/openshell-supervisor/src/isolation_backends.rs +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Binary composition of platform isolation-backend implementations. - -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -use openshell_isolation_interface::contract::IsolationBackend; - -pub fn sandbox_backend( - ca_file_paths: Arc>>, - provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, - sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, -) -> Arc { - #[cfg(not(target_os = "windows"))] - let backend = openshell_sandbox_backend::OpenShellRuntimeBackend::new( - ca_file_paths, - provider_credentials, - sandbox_bearer, - ); - #[cfg(target_os = "windows")] - let backend = openshell_mxc_boundary::backend::MxcRuntimeBackend::new( - ca_file_paths, - provider_credentials, - sandbox_bearer, - ); - Arc::new(backend) -} diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 06687ff4a..c6ccf19e4 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -21,7 +21,6 @@ mod activity_aggregator; mod backend_setup; mod denial_aggregator; mod endpoint_status; -mod isolation_backends; mod mechanistic_mapper; mod provider_readiness; @@ -311,6 +310,7 @@ fn prepare_control_readiness_path(_path: &std::path::Path) -> Result<()> { "Unix readiness sockets are unsupported on this host" )) } + impl Drop for ControlReadiness { fn drop(&mut self) { self.task.abort(); @@ -670,7 +670,7 @@ pub async fn run_sandbox( ) -> Result { // Shared startup retains policy and networking state; box it to keep callers' futures small. Box::pin(run_sandbox_with_backend( - &backend_setup::OpenShellBackendSetup, + backend_setup::platform_setup(), SandboxRunConfig { command, workdir, diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index d29b80f3d..6675fe8a5 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -646,7 +646,7 @@ enabled = true "openshell.ai/sandbox-attachable-workspace" = "${workspace}" ``` -Use the same fields under the selected Docker, Podman, VM, MXC, or external +Use the same fields under the selected Docker, Podman, VM, or external driver table. Omitted admission settings use the labels above. A supplied map replaces the defaults, and every entry must match. An explicitly empty map is invalid while enforcement is enabled. `${workspace}` is the entire label value @@ -702,8 +702,10 @@ The exception does not approve unrelated host mounts or non-GPU devices. Raw host bind mounts and supplemental image mounts have no trusted label resolver and are rejected while admission is enabled. Publisher image labels cannot grant operator approval. Docker's existing unsupported image-mount -restriction remains. MXC host filesystem grants likewise require disabling -admission until a resolver exists. MXC's command config and VM upload workflows +restriction remains. MXC does not implement external-resource label admission +and rejects `resource_admission` configuration rather than implying that labels +are checked. Its host filesystem grants rely on workload policy and a trusted +gateway operator. MXC's command config and VM upload workflows encoded in driver JSON require explicit driver-config opt-in. `allow_driver_config` applies to nonempty caller config, including saved @@ -1197,6 +1199,10 @@ The MXC driver is Windows-only and opt-in. It links into the gateway and invokes The dedicated `openshell-windows-sandbox.exe` uses a Windows boundary library for process operations and forwarding. It replaces the generic sandbox binary in the Windows runtime pair; the supervisor remains a separate host process, not part of the compute driver. No separate supervisor-relay executable is required. Set `wxc_exec_path` to an absolute path; configuration preflight rejects an omitted or relative path. Existing MXC configurations that explicitly select `openshell-sandbox.exe` must update `sandbox_binary_path` to the Windows executable. +The Windows sandbox initiates its authenticated control connection to a generation-scoped loopback listener in the host isolation backend. This follows the Windows relay's connection direction without changing host firewall rules. TLS identity verification and supervisor bearer authentication remain required. + +This development branch temporarily asserts MXC outer-fence guarantees for Windows-branch compatibility testing. These assertions do not establish supervisor-only egress or verified live revocation. Workloads can bypass the proxy to reach other host-local services. Do not use this compatibility path as a production-qualified security boundary; passing E2E does not close these known gaps. + ```toml [openshell] version = 2 @@ -1235,6 +1241,22 @@ The MXC driver requires MXC 1.0.0 and emits its stable directional network schem Supply the workload command and working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["C:\\Windows\\System32\\cmd.exe","/d","/c","echo hello"],"cwd":"C:\\work"}}`. Both are required. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. +Configure UI controls in the sandbox policy, not the `mxc` driver JSON: + +```yaml +ui: + allow_graphical_ui: true + clipboard: read + allow_input_injection: false +``` + +Omitted fields deny access. Clipboard accepts `none`, `read`, `write`, or `all`, +from the sandbox's perspective. Clipboard and input injection require graphical +UI; invalid settings fail before provisioning. Delete and recreate the sandbox +to change UI settings. MXC advertises `openshell.policy.ui.v1` in its existing +extension capabilities. The gateway rejects explicit UI policy, including +`ui: {}`, when the selected driver does not advertise that capability. + The driver assigns distinct Sandbox Protocol and proxy listeners plus fresh credentials to every generation. The host proxy rejects missing, invalid, duplicate, or cross-sandbox proxy credentials before forwarding. MXC denies direct Internet egress and permits only the `127.0.0.1/32` route required by the authenticated transport and proxy. That loopback exception does not isolate unrelated host services. The current explicit-proxy path attributes descendant traffic to the admitted main workload binary rather than resolving each Windows socket owner. Treat the gateway host as trusted and avoid policies that rely on different network rights for child executables. Attached provider credentials use the ordinary live supervisor refresh path. Static values stay in the host supervisor's endpoint-bound resolver and are injected only for matching requests. Dynamic token grants remain request-time operations in the host proxy. diff --git a/docs/how-it-works/policies/overview.mdx b/docs/how-it-works/policies/overview.mdx index d5800bf0a..4d5757af0 100644 --- a/docs/how-it-works/policies/overview.mdx +++ b/docs/how-it-works/policies/overview.mdx @@ -20,7 +20,7 @@ Policies](/how-it-works/policies/manage-policies). ## What a Policy Controls -A policy file sets `version: 1` and has up to five other top-level sections. +A policy file sets `version: 1` and has up to six other top-level sections. Each section controls a different part of the sandbox and takes effect at a specific time: @@ -29,10 +29,11 @@ specific time: | `filesystem_policy` | Paths that sandbox processes can read, or read and write. | Landlock LSM in the kernel. | At sandbox startup. | | `landlock` | Whether the sandbox still starts, without your filesystem rules, if OpenShell cannot apply them. | Sandbox runtime. | At sandbox startup. | | `process` | User and group that sandbox processes run as. | Docker and Podman, when they create the sandbox. | At sandbox creation. | +| `ui` | Graphical UI, clipboard, and synthetic input. | A compute driver advertising complete UI-policy support. | At sandbox creation. | | `network_policies` | Destinations each binary can reach, and the requests it can send. | Sandbox network proxy. | While the sandbox runs. | | `network_middlewares` | Additional inspection, transformation, or blocking of traffic that network rules allow. | Sandbox network proxy. | While the sandbox runs. | -Filesystem, Landlock, and process settings are fixed once the sandbox starts. +Filesystem, Landlock, process, and UI settings are fixed once the sandbox starts. Network rules and middleware can change while it runs, as described in [How Changes Take Effect](/how-it-works/policies/manage-policies#how-changes-take-effect). diff --git a/docs/how-it-works/policies/schema.mdx b/docs/how-it-works/policies/schema.mdx index 693efb209..9bc700797 100644 --- a/docs/how-it-works/policies/schema.mdx +++ b/docs/how-it-works/policies/schema.mdx @@ -19,6 +19,7 @@ version: 1 filesystem_policy: { ... } landlock: { ... } process: { ... } +ui: { ... } network_policies: { ... } network_middlewares: { ... } ``` @@ -29,6 +30,7 @@ network_middlewares: { ... } | `filesystem_policy` | object | No | Startup | Paths the workload can read and write. | | `landlock` | object | No | Startup | Landlock enforcement behavior. | | `process` | object | No | Startup | User and group that the workload runs as. | +| `ui` | object | No | Startup | Graphical UI, clipboard, and input injection on a supporting runtime. | | `network_policies` | map | No | Live | Which binaries can reach which network endpoints. | | `network_middlewares` | map | No | Live | Middleware applied to allowed traffic. | @@ -100,6 +102,32 @@ process: run_as_group: "1500" ``` +## UI + +UI controls require the selected compute driver to advertise +`openshell.policy.ui.v1` in its extension capabilities. Unsupported drivers +reject any explicit UI section, including +`ui: {}`, before provisioning. Omit the section to preserve their existing +behavior. + +| Field | Type | Default | Description | +|---|---|---|---| +| `allow_graphical_ui` | bool | `false` | Allows a graphical UI surface. | +| `clipboard` | string | `none` | `none`, `read`, `write`, or `all`, from the sandbox's perspective. | +| `allow_input_injection` | bool | `false` | Allows synthetic input. | + +Clipboard access and input injection require `allow_graphical_ui: true`. +Unknown fields and values are rejected. UI settings are startup-only; recreate +the sandbox to change them. Global policies cannot set UI and preserve each +sandbox's startup UI settings. + +```yaml showLineNumbers={false} +ui: + allow_graphical_ui: true + clipboard: read + allow_input_injection: false +``` + ## Network Policies A map of named rules. The key is the rule's name. Each rule allows every listed diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index 595d1b88c..096b38c80 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -260,11 +260,6 @@ openshell sandbox create --from registry.example.com/agents/my-agent:1.0 Refer to [Default Policy](/how-it-works/policies/default-policy), [Run Your First Agent](/about/run-your-first-agent), and the [bring-your-own-container example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container). -Reusable image and resource settings belong in sandbox templates; see -[Templates](/how-it-works/sandboxes/templates) for the current SDK workflow. -The pre-0.1.0 `base` image alias and CLI template commands do not apply to -this release; pass an explicit image reference with `--from`. - ## Connect to a Sandbox Attach to the canonical main process in a running sandbox: @@ -972,20 +967,6 @@ When a multi-sandbox delete fails for one entry, the CLI reports that sandbox's failure and continues with the remaining names. The command exits with an error after it attempts every requested deletion if any entry failed. -Automation that already observed a sandbox's immutable identity can prevent a -same-name replacement from being deleted: - -```shell -openshell sandbox delete my-sandbox \ - --expected-id \ - --expected-resource-version -``` - -Identity preconditions require exactly one sandbox name, and a resource version -may be supplied only with the immutable ID. The gateway returns an `ABORTED` -error without changing the sandbox or calling its compute driver when either -value no longer matches. - ## Sandbox Lifecycle Every sandbox moves through a defined set of phases: diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 255309320..60bda5691 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -108,10 +108,6 @@ message GetCapabilitiesResponse { // Gateways require an exact policy match before activating the driver. // Empty denotes a legacy driver and requires explicit admission opt-out. string resource_admission_policy = 13; - // Whether this configured driver instance completely enforces the current - // portable SandboxPolicy.ui contract. Partial support must report false so - // the gateway rejects every explicit UI section before provisioning. - bool supports_ui_policy = 14; } message AuthenticateSandboxRequest { diff --git a/proto/openshell.proto b/proto/openshell.proto index 20ba537ce..79cc8f170 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -971,10 +971,6 @@ message ComputeDriverCapabilities { // Static portable resource request forms reported by the driver. ResourceCapabilities resource_capabilities = 3; - - // Whether the configured driver instance completely enforces the portable - // SandboxPolicy.ui contract. - bool supports_ui_policy = 4; } // Static portable resource request forms reported by a compute driver. diff --git a/rfc/0013-native-windows-mxc/README.md b/rfc/0013-native-windows-mxc/README.md index 08f54d2d6..c4706cfd5 100644 --- a/rfc/0013-native-windows-mxc/README.md +++ b/rfc/0013-native-windows-mxc/README.md @@ -80,7 +80,13 @@ probe, as runtime readiness. ### Outer fence and confirmation MXC receives the mapped filesystem, UI, and network constraints before -`openshell-sandbox` starts. The boundary consumes and deletes its one-use +workload startup. UI remains part of `SandboxPolicy`; the gateway requires the +selected driver's existing extension metadata to advertise +`openshell.policy.ui.v1` for any explicit UI section. Unsupported drivers need +no UI-specific handling. MXC validates and maps the native UI settings before +provisioning; omitted capabilities deny and changes require recreation. + +The boundary consumes and deletes its one-use configuration and TLS private key before releasing workload code. It confirms the ProcessContainer generation, resource claims, filesystem fence, egress fence, authenticated control transport, and controller-loss behavior through @@ -95,7 +101,10 @@ descriptors are stored beneath an owner-only Windows DACL. MXC denies direct Internet egress and permits the loopback route used by the Sandbox Protocol and explicit proxy. The host supervisor owns a distinct proxy listener and random authorization value for every sandbox generation. -`openshell-sandbox` injects the proxy URL and public CA paths only into workload +These launch settings stay in MXC-owned envelopes. Private Windows supervisor +composition supplies listener options to shared networking without extending +`BoundBoundary` or the shared Sandbox Protocol descriptor. The dedicated +`openshell-windows-sandbox` injects the proxy URL and public CA paths only into workload children. The supervisor retains private CA keys and provider secrets, applies network policy, and refreshes provider state through the ordinary session. diff --git a/sdk/go/openshell/v1/internal/converter/health.go b/sdk/go/openshell/v1/internal/converter/health.go index c1cd07fea..8e1c2b98f 100644 --- a/sdk/go/openshell/v1/internal/converter/health.go +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -83,7 +83,6 @@ func ComputeDriverInfoFromProto(d *pb.ComputeDriverInfo) types.ComputeDriverInfo if caps := d.GetCapabilities(); caps != nil { result.DriverName = caps.GetDriverName() result.DriverVersion = caps.GetDriverVersion() - result.SupportsUIPolicy = caps.GetSupportsUiPolicy() } return result } diff --git a/sdk/go/openshell/v1/internal/converter/health_test.go b/sdk/go/openshell/v1/internal/converter/health_test.go index 3fc3a0964..ab8e0c241 100644 --- a/sdk/go/openshell/v1/internal/converter/health_test.go +++ b/sdk/go/openshell/v1/internal/converter/health_test.go @@ -20,9 +20,8 @@ func TestGatewayInfoFromProto(t *testing.T) { { Name: "k8s", Capabilities: &pb.ComputeDriverCapabilities{ - DriverName: "kubernetes", - DriverVersion: "2.1.0", - SupportsUiPolicy: true, + DriverName: "kubernetes", + DriverVersion: "2.1.0", }, }, { @@ -55,14 +54,12 @@ func TestGatewayInfoFromProto(t *testing.T) { assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) assert.Equal(t, "kubernetes", info.ComputeDrivers[0].DriverName) assert.Equal(t, "2.1.0", info.ComputeDrivers[0].DriverVersion) - assert.True(t, info.ComputeDrivers[0].SupportsUIPolicy) assert.Equal(t, "docker", info.ComputeDrivers[1].Name) assert.Equal(t, "docker-engine", info.ComputeDrivers[1].DriverName) require.Len(t, info.Extensions, 1) assert.Equal(t, v1.ExtensionKindComputeDriver, info.Extensions[0].Kind) assert.Equal(t, "openshell/kubernetes", info.Extensions[0].ImplementationName) assert.Equal(t, uint32(1), info.Extensions[0].ProtocolMajor) - assert.False(t, info.ComputeDrivers[1].SupportsUIPolicy) } func TestGatewayInfoFromProto_NoDrivers(t *testing.T) { @@ -133,7 +130,6 @@ func TestComputeDriverInfoFromProto_NilCapabilities(t *testing.T) { assert.Equal(t, "bare-metal", info.Name) assert.Empty(t, info.DriverName) assert.Empty(t, info.DriverVersion) - assert.False(t, info.SupportsUIPolicy) } func TestCurrentUserFromProto(t *testing.T) { diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go index 1b112ace3..3039f1c80 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -54,10 +54,9 @@ type ExtensionInfo struct { // ComputeDriverInfo describes a compute backend available on the gateway. type ComputeDriverInfo struct { - Name string - DriverName string - DriverVersion string - SupportsUIPolicy bool + Name string + DriverName string + DriverVersion string } // CurrentUser holds the authenticated caller's identity. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index ae5a8246d..d9359c253 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1993,11 +1993,8 @@ type ComputeDriverCapabilities struct { DriverVersion string `protobuf:"bytes,2,opt,name=driver_version,json=driverVersion,proto3" json:"driver_version,omitempty"` // Static portable resource request forms reported by the driver. ResourceCapabilities *ResourceCapabilities `protobuf:"bytes,3,opt,name=resource_capabilities,json=resourceCapabilities,proto3" json:"resource_capabilities,omitempty"` - // Whether the configured driver instance completely enforces the portable - // SandboxPolicy.ui contract. - SupportsUiPolicy bool `protobuf:"varint,4,opt,name=supports_ui_policy,json=supportsUiPolicy,proto3" json:"supports_ui_policy,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ComputeDriverCapabilities) Reset() { @@ -2051,13 +2048,6 @@ func (x *ComputeDriverCapabilities) GetResourceCapabilities() *ResourceCapabilit return nil } -func (x *ComputeDriverCapabilities) GetSupportsUiPolicy() bool { - if x != nil { - return x.SupportsUiPolicy - } - return false -} - // Static portable resource request forms reported by a compute driver. // An omitted domain means the driver does not report that domain. type ResourceCapabilities struct { @@ -11703,8 +11693,8 @@ type UpdateConfigRequest struct { // The new policy to apply. // // Sandbox scope (`global=false`): - // - only network_policies may differ from create-time - // policy; static fields must match version 1. + // - only network_policies may differ from create-time + // policy; static fields must match version 1. // // Global scope (`global=true`): // - applies to all sandboxes in full (no merge). @@ -18284,13 +18274,12 @@ const file_openshell_proto_rawDesc = "" + "\x15required_capabilities\x18\b \x03(\tR\x14requiredCapabilities\"t\n" + "\x11ComputeDriverInfo\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12K\n" + - "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xea\x01\n" + + "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xbc\x01\n" + "\x19ComputeDriverCapabilities\x12\x1f\n" + "\vdriver_name\x18\x01 \x01(\tR\n" + "driverName\x12%\n" + "\x0edriver_version\x18\x02 \x01(\tR\rdriverVersion\x12W\n" + - "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\x12,\n" + - "\x12supports_ui_policy\x18\x04 \x01(\bR\x10supportsUiPolicy\"\xca\x01\n" + + "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\"\xca\x01\n" + "\x14ResourceCapabilities\x127\n" + "\x03cpu\x18\x01 \x01(\v2%.openshell.v1.CpuResourceCapabilitiesR\x03cpu\x12@\n" + "\x06memory\x18\x02 \x01(\v2(.openshell.v1.MemoryResourceCapabilitiesR\x06memory\x127\n" + diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index b60695994..b2390654a 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -288,11 +288,10 @@ ui: ``` Treat clipboard direction from the sandbox's perspective. Warn that UI is a -static sandbox-creation control. It is currently enforceable only by the MXC -driver's OpenShell `process_container` backend, which emits MXC's -`processcontainer` containment value and advertises complete support. MXC -`isolation_session` and non-Windows drivers advertise no support, so the gateway -rejects any explicit UI section, including `{}`, before provisioning. Omit the +static sandbox-creation control. It requires the selected compute driver to +advertise `openshell.policy.ui.v1` through existing extension metadata. A driver +without that capability rejects any explicit UI section, including `{}`, before +provisioning. Omit the section rather than emitting deny-only UI for those drivers; omission preserves their existing behavior. diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index c946dbe3b..cbb5d498f 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -480,11 +480,6 @@ openshell logs my-sandbox --since 5m openshell sandbox delete my-sandbox openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once openshell sandbox delete --all - -# Fail closed if the observed sandbox was replaced or changed -openshell sandbox delete my-sandbox \ - --expected-id \ - --expected-resource-version ``` `deletion accepted` means cleanup is still pending. Inspect the sandbox until @@ -492,10 +487,6 @@ it disappears before assuming completion. An already-absent sandbox succeeds; missing workspaces and authorization failures remain errors. Do not blindly retry by name if another process might have recreated that name. -Identity preconditions are valid only for one named sandbox, and a resource -version requires the immutable ID. A mismatch returns `ABORTED` before -OpenShell mutates gateway state or calls the compute driver. - ### Stop and start sandboxes Use stop to halt compute while retaining the sandbox and its persistent @@ -529,6 +520,12 @@ This is the most important multi-step workflow. It enables a tight feedback cycl **Key concept**: Policies have static fields (immutable after activation: `filesystem_policy`, `landlock`, `process`, `ui`) and two dynamic fields: `network_policies` and `network_middlewares`. Both dynamic fields can be updated without recreating the sandbox when the selected compute driver supports live policy updates. Drivers without the standard supervisor fetch revisions through the sandbox configuration API and report whether they loaded them. +Explicit `ui` requires the selected driver to advertise +`openshell.policy.ui.v1` in its existing supported extension capabilities, +visible through `openshell gateway info`. Unsupported drivers reject even +`ui: {}` before provisioning. Omit UI unless requested. UI changes require +sandbox recreation. + If startup reports `ConfigurationInvalid`, inspect `openshell sandbox get` and repair the complete policy or provider set through the gateway. The workload has not started on its first activation, so static fields can also be replaced @@ -543,11 +540,6 @@ wait timeout is separate from this gateway deadline. Follow the published [policy repair guidance](https://docs.nvidia.com/openshell/latest/how-it-works/policies/overview) and confirm current replacement/detach syntax with installed CLI help. -UI capabilities require a driver/backend advertising complete UI-policy support. -MXC's `process_container` backend enforces them at provisioning; changing UI -capabilities after activation requires recreating the sandbox. Backends without -UI-policy support reject an explicit UI section before provisioning. - An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires the Docker or Podman runtime; unsupported runtimes reject the policy before starting the @@ -619,7 +611,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP request/response and WebSocket bindings, and `fail_open` or `fail_closed` behavior -`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, `process`, or `ui` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. +`network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect HTTP requests, HTTP responses, or client WebSocket text messages when the implementation advertises the matching binding. The built-in diff --git a/tasks/scripts/test-mxc-remote-tests.ps1 b/tasks/scripts/test-mxc-remote-tests.ps1 new file mode 100644 index 000000000..097136080 --- /dev/null +++ b/tasks/scripts/test-mxc-remote-tests.ps1 @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Pure helper regressions. This does not replace real remote E2E coverage. +$ErrorActionPreference = 'Stop' +$count = 0 +foreach ($name in @('test-mxc-remote.ps1', 'run-mxc-remote.ps1')) { + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot $name), [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw ($errors | Out-String) } + # Load pure functions only; do not start builds or SSH sessions. + foreach ($function in $ast.FindAll({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] }, $true)) { + if ($function.Name -in @('Quote-PS', 'Find-Z3Runtime', 'Read-CacheHashes', 'Get-PeMachine')) { Invoke-Expression $function.Extent.Text } + } + $count++ +} +if ((Quote-PS "C:\Users\O'Brien\file") -ne "'C:\Users\O''Brien\file'") { throw 'PowerShell literal quoting regression.' } +$count++ +$hashA = 'a' * 64 +$hashB = 'b' * 64 +$hashes = @(Read-CacheHashes ('["' + $hashA + '","' + $hashB + '"]')) +if ($hashes.Count -ne 2 -or $hashes -notcontains $hashA -or $hashes -notcontains $hashB) { throw 'JSON cache array enumeration regression.' } +$count++ +if (@(Read-CacheHashes '[]').Count -ne 0) { throw 'Empty cache response regression.' } +$count++ +if (@(Read-CacheHashes ('["' + $hashA + '"]')).Count -ne 1) { throw 'Single cache hash regression.' } +$count++ +function Pe-Bytes([int] $Machine) { + $bytes = New-Object byte[] 128 + $bytes[0] = 0x4d; $bytes[1] = 0x5a; $bytes[0x3c] = 0x40 + $bytes[0x40] = 0x50; $bytes[0x41] = 0x45 + $bytes[0x44] = $Machine -band 255; $bytes[0x45] = ($Machine -shr 8) -band 255 + return ,$bytes +} +$fixture = [IO.Path]::GetTempFileName() +try { + if ((Find-Z3Runtime '' '' $fixture) -ne $fixture) { throw 'Explicit Z3 DLL path regression.' } + $count++ + $rejected = $false + try { Find-Z3Runtime ([IO.Path]::GetTempPath()) ('missing-' + [guid]::NewGuid().ToString('N')) '' | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw 'Missing Z3 DLL accepted.' } + $count++ + foreach ($machine in @(0xaa64, 0x8664)) { + [IO.File]::WriteAllBytes($fixture, (Pe-Bytes $machine)) + if ((Get-PeMachine $fixture) -ne $machine) { throw 'PE architecture regression.' } + $count++ + } + [IO.File]::WriteAllBytes($fixture, [byte[]]@(1, 2, 3, 4)) + $rejected = $false + try { Get-PeMachine $fixture | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw 'Invalid executable accepted.' } + $count++ + # Reader cleanup must permit rewriting/deleting even after rejection. +} finally { Remove-Item -LiteralPath $fixture } +$cacheFixture = Join-Path ([IO.Path]::GetTempPath()) ('openshell-z3-test-' + [guid]::NewGuid().ToString('N')) +$target = 'aarch64-pc-windows-msvc' +$first = Join-Path $cacheFixture "$target/release/build/z3-sys-first/out/z3-5.1.0/bin/libz3.dll" +$second = Join-Path $cacheFixture "$target/release/build/z3-sys-second/out/z3-5.1.0/bin/libz3.dll" +try { + New-Item -ItemType Directory -Path (Split-Path $first), (Split-Path $second) -Force | Out-Null + [IO.File]::WriteAllBytes($first, (Pe-Bytes 0xaa64)) + if ((Find-Z3Runtime $cacheFixture $target '') -ne $first) { throw 'Target-specific Z3 discovery regression.' } + $count++ + Copy-Item -LiteralPath $first -Destination $second + if (-not (Find-Z3Runtime $cacheFixture $target '')) { throw 'Identical cached Z3 runtimes rejected.' } + $count++ + [IO.File]::WriteAllBytes($second, (Pe-Bytes 0x8664)) + $rejected = $false + try { Find-Z3Runtime $cacheFixture $target '' | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw 'Ambiguous Z3 runtimes accepted.' } + $count++ +} finally { + # Only tear down the explicitly created fixture paths, not a recursive root. + foreach ($path in @($first, $second)) { if (Test-Path $path) { Remove-Item -LiteralPath $path } } + Get-ChildItem -LiteralPath $cacheFixture -Directory -Recurse | Sort-Object { $_.FullName.Length } -Descending | ForEach-Object { Remove-Item -LiteralPath $_.FullName } + Remove-Item -LiteralPath $cacheFixture +} +Write-Host "$count remote-script regression checks passed. No runtime qualification claimed." diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index f30577ed7..4ff92c229 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -50,7 +50,7 @@ if (-not [int]::TryParse($BuildJobsValue, [ref] $WindowsBuildJobs) -or $WindowsB } $WindowsCargoMutex = [System.Threading.Mutex]::new($false, "Local\OpenShellWindowsMsvcCargo") -$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-vfio" +$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-vfio" $WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes $WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async" $PrebuiltZ3WorkspaceFeatures = "--features openshell-prover/prebuilt-z3" @@ -528,7 +528,7 @@ function Stage-Z3Runtime([string] $RustTarget) { $candidates = @(Get-ChildItem -Path (Join-Path $release "build/z3-sys-*/out/z3-$PrebuiltZ3Version/bin/libz3.dll") -File -ErrorAction SilentlyContinue) } if ($candidates.Count -eq 0) { throw "Matching libz3.dll not found for $RustTarget." } - $hashes = @($candidates | ForEach-Object { (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash } | Select-Object -Unique) + $hashes = @($candidates | ForEach-Object { Get-Sha256 $_.FullName } | Select-Object -Unique) if ($hashes.Count -ne 1) { throw "Ambiguous Z3 runtimes for $RustTarget; use a clean target cache or Z3_LIBRARY_PATH_OVERRIDE." } $source = $candidates[0].FullName $reader = [IO.BinaryReader]::new([IO.File]::OpenRead($source)) @@ -546,7 +546,7 @@ function Stage-Z3Runtime([string] $RustTarget) { if ([IO.Path]::GetFullPath($source) -ne [IO.Path]::GetFullPath($destination)) { Copy-Item -LiteralPath $source -Destination $destination -Force } - if ((Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash -ne $hashes[0]) { throw 'Staged Z3 DLL hash mismatch.' } + if ((Get-Sha256 $destination) -ne $hashes[0]) { throw 'Staged Z3 DLL hash mismatch.' } Write-Host "==> Staged $destination (SHA256=$($hashes[0]))" }