From d08dce0e97dcd1574e0297984a7fd1e286610d3d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 1 Oct 2026 21:10:06 -0700 Subject: [PATCH 01/22] refactor(mxc): adopt main isolation backend contracts Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 77 +- .../build-openshell-mxc-windows/reference.md | 14 +- AGENTS.md | 3 +- CONTRIBUTING.md | 2 +- Cargo.lock | 32 +- Cargo.toml | 2 +- crates/openshell-cli/src/commands/gateway.rs | 17 + crates/openshell-cli/src/main.rs | 61 +- crates/openshell-cli/src/run.rs | 25 +- .../sandbox_create_lifecycle_integration.rs | 12 + crates/openshell-core/src/policy.rs | 27 + .../openshell-driver-docker/src/isolation.rs | 1 + .../src/isolation.rs | 1 + crates/openshell-driver-mxc/Cargo.toml | 8 +- crates/openshell-driver-mxc/README.md | 303 +- .../examples/e2e-policies/network-reject.yaml | 16 +- .../examples/install-nodejs-openclaw.ps1 | 157 ++ .../examples/mxc-gateway.toml | 15 +- .../examples/mxc-ocsf-audit.toml | 17 +- .../examples/ocsf-audit.yaml | 6 +- .../examples/run-mxc-e2e.ps1 | 217 +- .../examples/run-ocsf-audit.ps1 | 18 +- crates/openshell-driver-mxc/src/driver.rs | 2472 +++++++---------- crates/openshell-driver-mxc/src/isolation.rs | 149 + crates/openshell-driver-mxc/src/lib.rs | 12 +- crates/openshell-driver-mxc/src/mxc.rs | 790 ++---- crates/openshell-driver-mxc/src/policy.rs | 140 +- .../src/policy_map/loss.rs | 2 +- .../src/policy_map/map.rs | 197 +- .../src/policy_map/mod.rs | 4 +- .../tests/policy_mapper_examples.rs | 111 +- .../tests/policy_mapper_matrix.rs | 165 +- .../tests/wxc_exec_real.rs | 183 +- .../openshell-driver-vm/src/isolation/mod.rs | 1 + crates/openshell-gateway/src/lib.rs | 69 +- .../tests/mxc_config_preflight.rs | 100 + .../src/contract.rs | 35 +- .../tests/backend_conformance.rs | 1 - crates/openshell-mxc-boundary/Cargo.toml | 40 + crates/openshell-mxc-boundary/README.md | 54 + crates/openshell-mxc-boundary/src/audit.rs | 169 ++ crates/openshell-mxc-boundary/src/backend.rs | 116 + crates/openshell-mxc-boundary/src/lib.rs | 30 + crates/openshell-mxc-boundary/src/main.rs | 53 + crates/openshell-mxc-boundary/src/runtime.rs | 1668 +++++++++++ crates/openshell-mxc-boundary/tests/cli.rs | 53 + .../tests/composition.rs | 44 + crates/openshell-policy-schema/src/lib.rs | 42 + crates/openshell-policy/src/lib.rs | 164 +- crates/openshell-prover/src/containment.rs | 21 +- .../src/boundary_protocol.rs | 20 +- .../openshell-sandbox-backend/src/runtime.rs | 28 + crates/openshell-sandbox/Cargo.toml | 1 + .../openshell-sandbox/src/boundary_server.rs | 5 +- crates/openshell-sandbox/src/lib.rs | 7 +- crates/openshell-sandbox/src/main.rs | 4 +- crates/openshell-server/src/compute/mod.rs | 129 + crates/openshell-server/src/grpc/mod.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 228 +- crates/openshell-server/src/grpc/sandbox.rs | 1 + .../openshell-server/src/grpc/validation.rs | 43 +- crates/openshell-server/src/storage_proto.rs | 7 +- crates/openshell-server/src/test_support.rs | 1 + .../openshell-supervisor-network/src/host.rs | 379 --- .../openshell-supervisor-network/src/lib.rs | 2 - .../openshell-supervisor-network/src/opa.rs | 17 + .../src/main_session.rs | 1 + crates/openshell-supervisor/Cargo.toml | 3 + .../src/isolation_backends.rs | 29 + crates/openshell-supervisor/src/lib.rs | 25 +- docs/how-it-works/gateways/configuration.mdx | 65 +- docs/how-it-works/sandboxes/overview.mdx | 19 + proto/compute_driver.proto | 4 + proto/openshell.proto | 4 + proto/sandbox.proto | 30 + rfc/0013-native-windows-mxc/README.md | 584 ++-- sdk/go/openshell/v1/fake/sandbox.go | 4 + sdk/go/openshell/v1/fake/sandbox_test.go | 13 + .../v1/internal/converter/coverage_test.go | 1 + .../openshell/v1/internal/converter/health.go | 1 + .../v1/internal/converter/health_test.go | 8 +- .../openshell/v1/internal/converter/policy.go | 54 + .../v1/internal/converter/policy_test.go | 42 + sdk/go/openshell/v1/types/health.go | 7 +- sdk/go/openshell/v1/types/policy.go | 32 +- sdk/go/proto/openshellv1/openshell.pb.go | 23 +- sdk/go/proto/sandboxv1/sandbox.pb.go | 492 ++-- skills/generate-sandbox-policy/SKILL.md | 30 +- skills/generate-sandbox-policy/examples.md | 2 +- skills/openshell-cli/SKILL.md | 18 +- tasks/scripts/windows-msvc.ps1 | 27 +- tasks/windows.toml | 16 +- 92 files changed, 6752 insertions(+), 3571 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 create mode 100644 crates/openshell-driver-mxc/src/isolation.rs create mode 100644 crates/openshell-gateway/tests/mxc_config_preflight.rs create mode 100644 crates/openshell-mxc-boundary/Cargo.toml create mode 100644 crates/openshell-mxc-boundary/README.md create mode 100644 crates/openshell-mxc-boundary/src/audit.rs create mode 100644 crates/openshell-mxc-boundary/src/backend.rs create mode 100644 crates/openshell-mxc-boundary/src/lib.rs create mode 100644 crates/openshell-mxc-boundary/src/main.rs create mode 100644 crates/openshell-mxc-boundary/src/runtime.rs create mode 100644 crates/openshell-mxc-boundary/tests/cli.rs create mode 100644 crates/openshell-mxc-boundary/tests/composition.rs delete mode 100644 crates/openshell-supervisor-network/src/host.rs create mode 100644 crates/openshell-supervisor/src/isolation_backends.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index eef3b009ea..88eba4e108 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -1,6 +1,6 @@ --- name: build-openshell-mxc-windows -description: Maintain and validate OpenShell's build-only Windows MSVC lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MXC driver, policy translation, MSI, service, or supervisor runtime support on Windows. +description: Maintain and validate OpenShell's native Windows MSVC and MXC runtime lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, the MXC supervisor/sandbox pairing, unsupported Windows compute-driver contracts, or Windows build reports. This skill does not implement Docker, Kubernetes, Podman, VM, MSI, or service support on Windows. metadata: internal: true --- @@ -12,11 +12,13 @@ OpenShell repository. The Windows lane is already present in `main`; do not treat this skill as a first-time porting recipe unless the user explicitly asks for a new fork or a from-scratch bring-up. -The lane is build-only. It validates that OpenShell can compile and test on -Windows MSVC for the supported deliverables: +The lane validates that OpenShell can compile and test on Windows MSVC for the +supported deliverables: - `openshell-gateway.exe` - `openshell.exe` +- `openshell-supervisor.exe` (host RFC 0012 isolation backend) +- `openshell-windows-sandbox.exe` (MXC ProcessContainer boundary) It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. @@ -32,6 +34,32 @@ 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. +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 +runtime. Do not reintroduce a dedicated supervisor-relay executable or link +the compute driver into the sandbox. The supervisor remains a separate host +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 +backend remains separate. Shared protocol code +must not interpret MXC evidence. Do not reintroduce driver-owned proxy startup +or gateway create-time credential snapshots. Windows gateway JSONL audit output +is an explicit operator opt-in, independent of the selected driver. + +Adopt the shared protocol's authenticated policy discovery and monotonic +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. ## Current Repository Shape @@ -56,8 +84,8 @@ In scope: - Refreshing a local checkout to the latest upstream GitHub `main`. - Maintaining `tasks/windows.toml` and `tasks/scripts/windows-msvc.ps1`. - Running x64 and ARM64 MSVC checks. -- Building x64 and ARM64 release binaries for `openshell-gateway` and - `openshell`. +- Building x64 and ARM64 release binaries for `openshell-gateway`, `openshell`, + `openshell-supervisor`, and `openshell-windows-sandbox`. - Running workspace tests on a native x64 or ARM64 host. - Running focused unsupported-driver contract tests. - Reporting test counts, skipped/gated areas, warnings, artifacts, and logs. @@ -70,12 +98,9 @@ Out of scope: - Kubernetes support on Windows. - Podman, Podman machine, or Podman Desktop support on Windows. - VM, Hyper-V, WSL, libkrun, or VM-backed sandbox execution on Windows. -- New MXC compute driver crate. -- OpenShell to MXC policy translation. - Windows named-pipe driver IPC. - Windows Credential Manager or DPAPI integration. - MSI, WinGet, Windows service registration, or installer work. -- Windows supervisor runtime port. ## Hard Rules @@ -168,6 +193,16 @@ mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 ``` +The two `windows:test:mxc-real:*` tasks are host-specific and mutually +exclusive on a single host (each rejects the other architecture -- see the +table below): run `windows:test:mxc-real:x64` on an x64 host, or +`windows:test:mxc-real:arm64` on an ARM64 host, as part of validating this +subsystem -- run the one matching your host architecture, not both, and not +neither. Both are skip-safe (they print a SKIP reason and exit 0 when +`wxc-exec` or the matching backend isn't available), so running the +arch-appropriate task is always safe even without real MXC hardware. Neither +is part of `windows:ci`'s ordered contract, so invoke it explicitly. + For full validation, detect the Windows host architecture first and choose the native lane dynamically: @@ -176,12 +211,14 @@ $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture switch ($arch.ToString()) { "X64" { mise run --skip-tools windows:ci + mise run --skip-tools windows:test:mxc-real:x64 } "Arm64" { mise run --skip-tools windows:check:arm64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:arm64 mise run --skip-tools windows:test:unsupported:arm64 + mise run --skip-tools windows:test:mxc-real:arm64 mise run --skip-tools windows:artifacts } default { @@ -253,12 +290,14 @@ crypto dependency builds. |---|---| | `windows:check:x64` | `cargo check --workspace` for `x86_64-pc-windows-msvc`, excluding unsupported Windows packages as top-level workspace targets. | | `windows:check:arm64` | `cargo check --workspace` for `aarch64-pc-windows-msvc`, with the same top-level exclusions. | -| `windows:build:x64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for x64. | -| `windows:build:arm64` | Release-builds `openshell-gateway.exe` and `openshell.exe` for ARM64. | +| `windows:build:x64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, `openshell-supervisor.exe`, and `openshell-windows-sandbox.exe` for x64. | +| `windows:build:arm64` | Release-builds the same four binaries for ARM64. | | `windows:test:x64` | Runs native x64 workspace tests with `--no-fail-fast`, excluding unsupported Windows packages as top-level workspace targets. | | `windows:test:arm64` | Runs native ARM64 workspace tests with `--no-fail-fast` and the same package exclusions. Rejects non-ARM64 hosts. | | `windows:test:unsupported:x64` | Re-runs focused `openshell-gateway` tests for unsupported Windows driver behavior. | | `windows:test:unsupported:arm64` | Re-runs the same focused contracts natively on ARM64. Rejects non-ARM64 hosts. | +| `windows:test:mxc-real:x64` | Runs the serial, ignored real-`wxc-exec` integration suite natively on x64 through the MSVC wrapper. Rejects non-x64 hosts. | +| `windows:test:mxc-real:arm64` | Runs the same real-`wxc-exec` suite natively on ARM64. Rejects non-ARM64 hosts. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | | `windows:ci` | Runs the full ordered x64-host Windows CI lane, plus ARM64 check/build when not skipped. | @@ -303,6 +342,22 @@ validation; GitHub Actions does not re-run it after the full suite. ## Test Accounting Guidance +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 +PATH. Results live under `target/windows-e2e-results`; signing keys stay outside +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 +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 +suite, and report a verified unsupported-host rejection independently from +positive admission coverage. + When reporting `windows:ci`, distinguish these categories: - Passed tests from the full x64 workspace test log. @@ -328,6 +383,8 @@ Useful log files: | `test-aarch64-pc-windows-msvc.log` | Full native ARM64 workspace test output. | | `test-x86_64-pc-windows-msvc-unsupported-*.log` | Focused unsupported-driver contract output. | | `test-aarch64-pc-windows-msvc-unsupported-*.log` | Focused native ARM64 contract output. | +| `test-x86_64-pc-windows-msvc-mxc-real.log` | Native x64 real-MXC integration output. | +| `test-aarch64-pc-windows-msvc-mxc-real.log` | Native ARM64 real-MXC integration output. | The first check downloads the pinned official Z3 archive for the target architecture through `z3-sys`. GitHub Actions authenticates the lookup with its diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index 471eed009d..a34cb2eb31 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -1,7 +1,7 @@ # Reference: Windows MSVC maintenance lane Companion to [SKILL.md](SKILL.md). Use this file for quick lookup while -maintaining the existing build-only Windows MSVC lane. +maintaining the native Windows MSVC and MXC runtime lane. ## Lane Files @@ -75,7 +75,7 @@ Ninja to `PATH`, while the crypto crates select `clang-cl`. Use a short ## Unsupported Driver Rules -Windows is a build target only. These runtimes remain unsupported: +These Windows runtimes remain unsupported: - Docker - Kubernetes @@ -110,16 +110,14 @@ 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 ``` The gateway keeps platform configuration and unsupported-operation contracts -without depending on the Docker, Kubernetes, Podman, sandbox runtime, -standalone supervisor, supervisor process runtime, VM, or VFIO crates. The MXC -driver does depend on the cross-platform supervisor network library for its host -egress proxy. The Kubernetes Secrets and Vault libraries still compile as -gateway dependencies; only their standalone Unix-socket binaries and +without depending on the Docker, Kubernetes, Podman, VM, or VFIO runtime crates. +The MXC runtime compiles the supervisor, supervisor-process library, and sandbox +boundary on Windows. The Kubernetes Secrets and Vault libraries still compile +as gateway dependencies; only their standalone Unix-socket binaries and package-level tests are excluded as top-level targets. The supervisor and supervisor-process packages now participate as top-level diff --git a/AGENTS.md b/AGENTS.md index f703fd2424..9f202bf862 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-conformance/` | CLI conformance library | Reusable driver-agnostic scenarios and command runner | | `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary | | `crates/openshell-sandbox/` | Sandbox runtime | Capability-free workload launcher, process identity, and seccomp-mediated I/O | +| `crates/openshell-mxc-boundary/` | Windows sandbox boundary | Windows boundary library and dedicated openshell-windows-sandbox binary for MXC process operations, containment confirmation, and shared-protocol forwarding | | `crates/openshell-supervisor/` | Supervisor runtime | Gateway session, policy evaluation, credentials, and upstream networking | | `crates/openshell-binary-identity/` | Binary identity | Shared trusted procfs executable identity resolution for isolation backends | | `crates/openshell-isolation-interface/` | Isolation backend interface | RFC 0012 `IsolationBackend` trait and types; the supervisor-facing runtime contract | @@ -49,7 +50,7 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers | | `crates/openshell-driver-podman/` | Podman compute driver | In-process `ComputeDriver` backend for local Podman sandbox containers | | `crates/openshell-driver-vm/` | VM compute driver | Standalone libkrun-backed `ComputeDriver` subprocess (embeds its own rootfs + runtime) | -| `crates/openshell-driver-mxc/` | Microsoft MXC compute driver | In-process Windows AppContainer and isolation-session compute backend | +| `crates/openshell-driver-mxc/` | Microsoft MXC compute driver | In-process Windows ProcessContainer backend that pairs a host isolation-backend supervisor with `openshell-windows-sandbox` inside MXC | | `crates/openshell-prover/` | Policy prover | Policy verification and proof generation | | `crates/openshell-prover-cli/` | Policy prover CLI | Standalone local policy boundary checks | | `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c1e215b4b..1b0b17c8fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,7 +112,7 @@ Contributor and maintainer skills live in `.agents/skills/`. They are marked int | Triage | `triage-issue` | Assess, classify, and route community-filed issues | | Platform | `helm-dev-environment` | Start and manage the local Kubernetes development environment | | Platform | `tui-development` | Development guide for the ratatui-based terminal UI | -| Platform | `build-openshell-mxc-windows` | Maintain and validate the build-only x64 and ARM64 Windows MSVC lane | +| Platform | `build-openshell-mxc-windows` | Maintain and validate the x64 and ARM64 Windows MSVC and MXC runtime lane | | Documentation | `update-docs-from-commits` | Scan recent commits and draft doc updates for user-facing changes | | Maintenance | `sync-agent-infra` | Detect and fix drift across agent-first infrastructure files | | Reference | `sbom` | Generate SBOMs and resolve dependency licenses | diff --git a/Cargo.lock b/Cargo.lock index 29f6416693..e9b1c4406a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4467,13 +4467,17 @@ dependencies = [ name = "openshell-driver-mxc" version = "0.0.0" dependencies = [ + "anyhow", "base64 0.22.1", "futures", "noyalib", "openshell-core", + "openshell-isolation-interface", + "openshell-mxc-boundary", "openshell-ocsf", "openshell-policy", - "openshell-supervisor-network", + "openshell-sandbox-backend", + "rand 0.9.4", "rustls", "serde", "serde_json", @@ -4673,6 +4677,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "openshell-mxc-boundary" +version = "0.0.0" +dependencies = [ + "async-trait", + "clap", + "openshell-core", + "openshell-isolation-interface", + "openshell-ocsf", + "openshell-sandbox-backend", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "tokio", + "tokio-rustls", + "tokio-stream", + "tonic", + "tracing", + "tracing-subscriber", + "url", + "windows", +] + [[package]] name = "openshell-ocsf" version = "0.0.0" @@ -4827,6 +4855,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -5004,6 +5033,7 @@ dependencies = [ "openshell-core", "openshell-extension-core", "openshell-isolation-interface", + "openshell-mxc-boundary", "openshell-ocsf", "openshell-otel", "openshell-policy", diff --git a/Cargo.toml b/Cargo.toml index e016d7ea21..ccffa2c505 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ miette = { version = "7", features = ["fancy"] } thiserror = "2" # Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only) -windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index ffb7dae76e..6d75626d65 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -46,6 +46,7 @@ struct ComputeDriverInfoView { struct ComputeDriverCapabilitiesView { driver_name: String, driver_version: String, + supports_ui_policy: bool, } #[derive(Debug, Clone)] @@ -417,6 +418,7 @@ pub async fn gateway_info( capabilities: ComputeDriverCapabilitiesView { driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, + supports_ui_policy: capabilities.supports_ui_policy, }, } }) @@ -514,6 +516,15 @@ 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" + } + ); } } @@ -532,6 +543,7 @@ 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::>(), @@ -1905,6 +1917,7 @@ mod tests { capabilities: ComputeDriverCapabilitiesView { driver_name: "podman".to_string(), driver_version: "0.0.75".to_string(), + supports_ui_policy: false, }, }], extensions: vec![ExtensionInfoView { @@ -1933,6 +1946,10 @@ 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 5f20fc7f3c..44f5151c53 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1686,8 +1686,16 @@ enum SandboxCommands { names: Vec, /// Delete all sandboxes. - #[arg(long, conflicts_with = "names")] + #[arg(long, conflicts_with_all = ["names", "expected_id", "expected_resource_version"])] 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. @@ -3578,11 +3586,18 @@ async fn run_async() -> Result<()> { ) .await?; } - SandboxCommands::Delete { names, all } => { + SandboxCommands::Delete { + names, + all, + expected_id, + expected_resource_version, + } => { run::sandbox_delete( endpoint, &names, all, + expected_id.as_deref(), + expected_resource_version, &cli.workspace, &tls, &ctx.name, @@ -5512,6 +5527,48 @@ 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 807a79cb72..cbda2d9d00 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -412,7 +412,9 @@ async fn finalize_sandbox_create_session( } let names = [sandbox_name.to_string()]; - if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await { + if let Err(err) = + sandbox_delete(server, &names, false, None, None, 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}" @@ -3879,14 +3881,35 @@ 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 e9584cd6e9..fbd9198ab3 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -64,6 +64,7 @@ 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>>, @@ -389,6 +390,11 @@ 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() @@ -1594,6 +1600,10 @@ 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() } @@ -1703,6 +1713,8 @@ 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/src/policy.rs b/crates/openshell-core/src/policy.rs index af474325c5..66dde13ca6 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-core/src/policy.rs @@ -113,6 +113,10 @@ impl TryFrom for SandboxPolicy { type Error = miette::Report; fn try_from(proto: ProtoSandboxPolicy) -> Result { + // UI capabilities are intentionally absent from the portable supervisor + // runtime. Non-Windows compute paths do not expose them, so even a + // schema-level UI allowance cannot grant a UI surface there. A capable + // compute driver consumes the typed proto directly. // In cluster mode we always run with proxy networking so all egress // can be evaluated by OPA. let network = NetworkPolicy { @@ -192,6 +196,7 @@ impl From for ProcessPolicy { #[cfg(test)] mod tests { use super::*; + use crate::proto::{UiClipboardAccess, UiPolicy}; #[test] fn try_from_maps_known_compatibility_values() { @@ -233,4 +238,26 @@ mod tests { assert!(!is_valid_landlock_compatibility("nope")); assert!(!is_valid_landlock_compatibility("BestEffort")); } + + #[test] + fn portable_runtime_does_not_activate_ui_allowances() { + let converted = SandboxPolicy::try_from(ProtoSandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::All as i32, + allow_input_injection: true, + }), + ..Default::default() + }) + .expect("portable policy conversion succeeds"); + + assert_eq!(converted.version, 1); + assert!(matches!(converted.network.mode, NetworkMode::Proxy)); + assert!(converted.network.proxy.is_some()); + assert!(converted.filesystem.read_only.is_empty()); + assert!(converted.filesystem.read_write.is_empty()); + assert!(converted.process.run_as_user.is_none()); + assert!(converted.process.run_as_group.is_none()); + } } diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index d921b8c348..549c71c4de 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -132,6 +132,7 @@ 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 6a94e6abaa..ba5343463d 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -289,6 +289,7 @@ 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/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 895d7c8d4c..d16d40da45 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -27,16 +27,20 @@ tokio-stream = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } base64 = { workspace = true } +rand = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } -# ETW/TDH real-time consumer and host CONNECT proxy integration. +# ETW/TDH real-time consumer. [target.'cfg(target_os = "windows")'.dependencies] -openshell-supervisor-network = { path = "../openshell-supervisor-network" } +openshell-mxc-boundary = { path = "../openshell-mxc-boundary" } +openshell-isolation-interface = { path = "../openshell-isolation-interface" } +openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } windows = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } tokio = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. tempfile = "3" diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index e5276ef4fe..4d9d178bdc 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -1,8 +1,22 @@ # openshell-driver-mxc -OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. - -## Design +The Windows-only MXC compute driver runs each workload in a Microsoft MXC +ProcessContainer while preserving OpenShell's standard RFC 0012 runtime split: + +```text +gateway / MXC driver + | + | gateway authentication and policy + v +openshell-supervisor --role=isolation-backend (host) + | + | generation-scoped TLS + sandbox JWT + v +openshell-windows-sandbox (ProcessContainer) + | + v +workload +``` Caller driver config is disabled by default, so command-based MXC workflows need explicit administrator opt-in. Host filesystem grants have no trusted @@ -11,190 +25,137 @@ See [resource admission configuration](../../docs/how-it-works/gateways/configur for the independent controls and the security consequences of opting out. This driver implements the gateway's ordinary in-process `ComputeDriver` -contract and is linked into `openshell-gateway`. It sets -`driver_reports_runtime_readiness`, so the gateway accepts driver-reported -readiness without a supervisor session. The canonical create-time -`SandboxPolicy` is carried by `DriverSandboxSpec.policy`. `process_container` launches a one-shot -AppContainer and is the default. The opt-in `isolation_session` backend uses the -state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. -The driver launches and monitors the configured workload itself and self-reports -readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. - -## Capability Matrix - -| Capability | MXC driver | Closing it requires | -|---|---|---| -| Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | -| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | -| Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | -| Network middleware | ❌ rejected before launch because the MXC host proxy does not receive the gateway middleware registry | Gateway middleware-registry injection | -| Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | -| Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | gateway interactive-exec surgery (follow-on) | -| Bundled agent image | ❌ no OCI image; relies on Windows host install | — | -| Restart durability | ❌ in-memory registry; restart orphans live sessions | follow-on | -| Concurrent sandboxes | ⚠️ isolation_session v1 is single-session | MXC backend feature | - -The filesystem enforcement proof has two paths: - -- A write to a path granted by the sandbox policy succeeds. -- A `process_container` write outside the sandbox policy fails with Windows access denied, and the driver reports the failed workload. - -## Configuration (`[openshell.drivers.mxc]`) - -Gateway configuration contains only host runtime settings: +contract and is linked into `openshell-gateway`. Runtime readiness comes from +the authenticated host supervisor session, not a driver-reported condition. +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. + +Windows boundary execution is implemented by the `openshell-mxc-boundary` +library behind the dedicated `openshell-windows-sandbox.exe`. The compute driver does not +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. + +## Enforcement boundaries + +The current port is not runtime-qualified against main's outer-fence contract. +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. + +- MXC supplies the default-deny filesystem fence, AppContainer token, UI + policy, and loopback-only network fence. +- `openshell-windows-sandbox` consumes its bootstrap files before launching untrusted + code, authenticates the paired supervisor, and terminates workloads when an + authenticated supervisor cannot recover within the reconnect deadline. +- The host supervisor owns an authenticated per-generation explicit proxy. + Missing or cross-sandbox credentials receive HTTP 407 before policy + evaluation. MXC denies direct Internet egress. +- The current explicit-proxy path attributes traffic to the admitted main + workload binary. It does not distinguish descendant processes. MXC process + policy must therefore prevent an untrusted allowed child binary from + inheriting broader per-binary network rights. +- The loopback fence permits `127.0.0.1/32`; it does not isolate unrelated host + services bound to that address. Treat the gateway host as trusted. +- Host supervisor tokens and descriptors live beneath an owner-only Windows + DACL. Boundary bootstrap secrets live in the ProcessContainer staging path + and are deleted before workload launch. + +## Configuration + +The packaged `openshell-supervisor.exe` and `openshell-windows-sandbox.exe` default to +siblings of `openshell-gateway.exe`. Override their paths for development +builds. ```toml [openshell.drivers.mxc] -wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" -# Default: process_container. isolation_session is grant-only and opt-in. +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe" +sandbox_binary_path = "C:\\OpenShell\\openshell-windows-sandbox.exe" +# Defaults to %LOCALAPPDATA%\OpenShell\mxc. +state_dir = "C:\\Users\\operator\\AppData\\Local\\OpenShell\\mxc" +# Empty uses the gateway's loopback listener and TLS mode. +grpc_endpoint = "" backend = "process_container" -default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] -# Pattern-C governed egress. The address is a loopback seed; each sandbox -# receives a unique ephemeral proxy port. -egress_proxy = false -egress_proxy_addr = "" +pc_allow_local_network = true +pc_minimal_env = false debug = false etw_audit = false ``` -When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback -`IP:PORT` seed. The driver preserves the configured IP and allocates a unique -ephemeral port for each sandbox's `network.proxy` redirect. +Only `process_container` supports this architecture. `isolation_session` is +rejected during sandbox validation. + +Legacy configurations may retain `default_configuration_id`; it remains unused +by ProcessContainer and does not enable IsolationSession. New configurations +should omit that field. -Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: +Supply the workload command and working directory per sandbox: ```powershell -$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}' -openshell sandbox create --name mxc-demo --policy demo.yaml ` - --driver-config-json $config --env MODE=demo --no-tty +$config = '{"mxc":{"command":["C:\\Windows\\System32\\cmd.exe","/d","/c","echo hello"],"cwd":"C:\\work"}}' +openshell sandbox create --name mxc-demo --policy policy.yaml ` + --driver-config-json $config --no-tty ``` -The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Environment variables come from the standard sandbox and template environment maps; the driver never copies values from the gateway host environment. - -The host CONNECT proxy enforces network policy when governed egress is enabled. -Live policy replacement or merge updates remain unsupported; delete and recreate -the sandbox to apply a different policy. - -When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW -session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a -per-start discriminator. Starting another gateway never stops an existing -gateway's capture. Graceful shutdown stops the session by its owned handle. A -force-killed gateway can leave a stale session; the audit example removes only -matching sessions whose encoded owner process is no longer running. - -The gateway-local OCSF JSONL sink is available only for the Windows/MXC path -and is opt-in. Set `OPENSHELL_OCSF_JSON=1` to enable it and optionally set -`OPENSHELL_OCSF_LOG_DIR` to override its `%PROGRAMDATA%\OpenShell\logs` default. -Other gateway deployments do not initialize this local file sink. - -The ETW callback uses a non-blocking queue capped at 4,096 records and 16 MiB -of copied event data. Records that exceed either limit are dropped instead of -blocking the ETW pump or growing gateway memory. The gateway emits an immediate -warning identifying the audit coverage gap and rate-limits follow-up warnings -to once every 30 seconds while overload continues. - -Audit attribution bootstraps only when the driver-owned `wxc-exec` PID and its -kernel process start key both match the values attached to the ETW record; -command text is never an ownership key. This generation key prevents a recycled -PID from inheriting the previous process's attribution regardless of delivery -delay. The process monitor retires the live PID at exit. Established identity, -activity, and correlation-vector links remain available for five seconds so -already in-flight ETW records can arrive, but retired PID evidence cannot resolve -them. Records without matching generation evidence remain unattributed. - -## Prerequisites (live runs) - -- Windows 11 Insider build ≥ 26300.8553 -- `IsoSessionApp.dll` present and registered -- `wxc-exec.exe` built with `--features isolation_session` -- Any enforced App Control policy allows both `openshell-gateway.exe` and - `openshell.exe`. Diagnose executable blocks with event 3077 in the - `Microsoft-Windows-CodeIntegrity/Operational` log. - -For off-box smoke tests against the in-process mock shim (no `wxc-exec`, -no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. - -## Policy mapping - -The production driver maps the typed `SandboxPolicy` carried by the standard -driver request to MXC configuration before it inserts a registry entry or -invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` -without leaving a partial sandbox. There is no in-process policy side channel -or MXC-specific gateway composition variant. - -When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus a loopback `network.proxy` -redirect, and the driver starts a host CONNECT proxy from the trimmed -network-only `SandboxPolicy`. Policies containing `network_middlewares` are -rejected synchronously until this host-proxy path can receive the gateway's -built-in and remote middleware registry. The proxy uses the configured agent -command as the static sandbox process identity because MXC does not expose -Linux-style procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a -per-sandbox CA and injects `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, -`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent -process env. It does not add the generated CA directory to MXC read-only grants: -released `wxc-exec` BaseContainer builds require `WRITE_DAC` on every such -grant and reject the user-owned proxy temp directory. Instead, the driver adds -the sandbox-unique directory as an internal read-write share so HTTPS clients -can read the injected paths. The directory contains only public CA certificates; -the ephemeral CA private key remains in the host proxy's memory. The driver -seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the -gateway host before applying sandbox and TLS overrides, so required Windows -bootstrap values remain available without exposing the gateway's full -environment. The development export surface remains the -[`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production -`openshell policy export-mxc` subcommand yet. - -If governed egress is disabled, any network rule fails closed rather than launching without an enforcement path. - -Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. - -## Packaging the demo for the demo box - -Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble -the gateway EXE, CLI EXE, runtime DLLs (`libz3.dll`), `demo.yaml`, the -gateway config, and the runbook into one folder, then copy that folder to -the demo Windows host and follow `mxc-demo-runbook.md` inside it. The -script prints a SHA256 manifest so the operator can sanity-check what -landed before moving it. - -## Real-MXC test lane - -Three tasks drive real `wxc-exec.exe` hardware; all are **skip-safe** — any test -or scenario that requires an absent binary or backend prints a SKIP reason and -exits 0 rather than failing. - -| Task | What it runs | When to use | -|---|---|---| -| `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1`, including an HTTPS request through the host proxy | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | -| `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | -| `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | - -**Probe script:** `examples/probe-mxc-host.ps1` is an operator/CI preflight that emits a JSON capability report -(OS build, wxc-exec path/version, dry-run exit code, per-backend trial result, -and a `verdicts` object). Run it before the real-MXC lane to understand what -will PASS vs SKIP on a given host: - -The probe uses a unique, user-owned Windows temp directory for every run. -MXC treats config paths literally (it does not expand `%TEMP%`), and the -per-run directory keeps AppContainer+DACL fallback mutations narrowly scoped. +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. -```powershell -powershell -NoProfile -ExecutionPolicy Bypass ` - -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 -``` +When gateway TLS is enabled, configure the gateway-owned `guest_tls_ca`. +The host supervisor verifies the gateway certificate with that CA and uses +main's launch authentication bundle. Gateway client certificates and keys are +not required; driver-owned guest TLS fields are rejected. -**Skip semantics:** tests in `wxc_exec_real.rs` are marked -`#[ignore = "requires real wxc-exec"]` — the standard `windows:test:x64` suite -never runs them. `OPENSHELL_WXC_EXEC_PATH` overrides the default -`C:\mxc\wxc-exec.exe` lookup. See `docs4gtb/mxc-box-capabilities.md` for the -empirical capability snapshot of the development box (build 26200, processcontainer -velocity keys not enabled, isolation_session absent). +## Capabilities -## Deferred work +| Capability | Status | +|---|---| +| Filesystem and UI policy | Mapped to the MXC ProcessContainer fence | +| Network policy and provider credentials | Standard host supervisor proxy; proxy-aware workloads only | +| Exec, signals, retained output | Authenticated Sandbox Protocol; ConPTY resize is not yet supported | +| Dynamic forwarding | Standard supervisor `ForwardTcp` path through sandbox loopback connect | +| ETW/OCSF audit | Optional Windows Sandboxing ETW consumer | +| Gateway restart recovery | Not yet supported; live MXC generations remain in-memory | + +## Validation + +Run the Windows build lane on a native Windows MSVC host: + +```powershell +mise run --skip-tools windows:check:x64 +mise run --skip-tools windows:lint:x64 +mise run --skip-tools windows:build:x64 +mise run --skip-tools windows:test:mxc-real:x64 +mise run --skip-tools windows:e2e:mxc +``` -- **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) -- **Restart durability** (deprovision orphaned sessions on startup) → follow-on -- **GPU passthrough** → not pursued in host-side-governance design +The real-MXC tests are skip-safe when `wxc-exec.exe` or the required host +capabilities are absent. A complete integration run still requires a qualified +Windows MXC host; cross-compilation validates code shape but cannot validate +ProcessContainer networking or DACL behavior. + +The real runtime requires native ProcessContainer directional egress and ingress +host-loopback support, reported by `wxc-exec.exe --probe`. AppContainer fallback +cannot supply these guarantees. Count test-internal `SKIP` messages separately +from Cargo's passed count. + +The E2E runner uses native release artifacts (override with `-BinaryDir`), OpenSSL +on PATH for disposable Ed25519 keys, and per-run writable fixtures under +`target/windows-e2e-results`. It preserves the tracked gateway TOML and isolates +CLI registration via `XDG_CONFIG_HOME`. Signing keys have an owner-only DACL, +remain outside result bundles, and are removed unless `-KeepRunning` is requested. + +E2E runs real MXC only: there is no mock mode, and an inherited +`OPENSHELL_MXC_MOCK_WXC=1` is rejected. Any skipped selected scenario makes the +run incomplete and exits non-zero; a passing exit requires all selected scenarios +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`. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml index e5529eaeb6..517885d4e7 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml @@ -1,15 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# network-reject.yaml — Network policy rejection scenario. -# -# A filesystem grant plus a network_policies rule. The driver rejects sandbox -# create at map time with invalid_argument naming the network rule on every -# backend until MXC has a bound, enforcing egress path. -# -# This scenario requires NO live backend — it passes even on this box and in -# mock mode because the rejection happens in the policy mapper before wxc-exec -# is invoked. It is the only scenario that never SKIPs. +# A filesystem grant plus a supervisor-owned network policy. The historical +# filename is retained for existing demo packages. The network-policy scenario +# checks admission and execution, not HTTP or network enforcement. version: 1 filesystem_policy: @@ -20,10 +14,12 @@ filesystem_policy: network_policies: test_rule: - name: test-network-reject + name: test-network-policy endpoints: - host: api.example.com port: 443 protocol: rest enforcement: enforce access: read-only + binaries: + - path: "C:/Windows/System32/cmd.exe" diff --git a/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 b/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 new file mode 100644 index 0000000000..8e05b9f3f4 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/install-nodejs-openclaw.ps1 @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# install-nodejs-openclaw.ps1 - fetch a pinned Node.js and the OpenClaw npm +# package, laid out exactly how run-openclaw-forward-test.ps1 expects them. +# +# This is a PREREQUISITE step, not part of the test itself: run it once (or +# with -Force to re-fetch), then pass its printed -NodeExePath / +# -OpenClawInstallDir values straight through to run-openclaw-forward-test.ps1. +# If you already have a working Node.js + OpenClaw install elsewhere, you +# don't need this script at all -- just point run-openclaw-forward-test.ps1 at +# it directly. +# +# What it does: +# 1. Downloads the official Node.js Windows x64 zip build (no installer, no +# admin rights needed) for a pinned version, verifies its SHA256 against +# Node.js's published SHASUMS256.txt, and extracts it. +# 2. Uses that Node's bundled npm to install the "openclaw" package from the +# public npm registry into the same install directory (`npm install +# openclaw --prefix `), which lays it out at +# \node_modules\openclaw -- exactly what -OpenClawInstallDir expects. +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\install-nodejs-openclaw.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` +# -NodeExePath C:\openshell-openclaw-install\node\node.exe ` +# -OpenClawInstallDir C:\openshell-openclaw-install\node_modules\openclaw +# +# Needs outbound internet access to nodejs.org and registry.npmjs.org. If +# your box only reaches the internet through a corporate proxy, set the usual +# HTTP_PROXY/HTTPS_PROXY env vars before running this script -- both +# Invoke-WebRequest and npm respect them. + +[CmdletBinding()] +param( + # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw-install, + # not C:\work\openshell-openclaw-install) for the SAME reason + # run-openclaw-forward-test.ps1's -ShareDir must be: nothing here actually + # runs inside the AppContainer, but keeping this path shape consistent + # avoids surprises if you ever point -ShareDir at this same location. + [string] $InstallDir = "C:\openshell-openclaw-install", + # Pinned to the version this package's OpenClaw scenario was validated + # against (observed working: Node.js v22.22.3). Override if you need a + # different one, but that combination is untested by this package. + [string] $NodeVersion = "22.22.3", + # Pinned to the version this package's OpenClaw scenario was validated + # against (the package actually exercised by run-openclaw-forward-test.ps1 + # across this repo's live testing). Override (or pass "" for whatever + # "npm install openclaw" resolves to latest at run time) for ad hoc testing, + # but that's untested by this package. + [string] $OpenClawVersion = "2026.7.1", + # Re-download/re-install even if InstallDir already looks populated. + [switch] $Force +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest is dramatically faster with the progress bar off. + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +$installDirNorm = $InstallDir.TrimEnd('\','/').Replace('/', '\') +$nodeDir = Join-Path $installDirNorm "node" +$nodeExe = Join-Path $nodeDir "node.exe" +$npmCmd = Join-Path $nodeDir "npm.cmd" +$openClawDir = Join-Path $installDirNorm "node_modules\openclaw" +$downloadDir = Join-Path $installDirNorm "_download" + +try { + Step "Node.js v$NodeVersion for win-x64" + if ((Test-Path $nodeExe) -and -not $Force) { + $existing = & $nodeExe --version + Info "already installed at $nodeExe (version $existing) -- pass -Force to re-fetch" + } else { + New-Item -ItemType Directory -Force $downloadDir | Out-Null + $distBase = "https://nodejs.org/dist/v$NodeVersion" + $zipName = "node-v$NodeVersion-win-x64.zip" + $zipPath = Join-Path $downloadDir $zipName + + Info "downloading $distBase/$zipName" + Invoke-WebRequest -Uri "$distBase/$zipName" -OutFile $zipPath + + Info "verifying SHA256 against $distBase/SHASUMS256.txt" + $shasums = Invoke-WebRequest -Uri "$distBase/SHASUMS256.txt" -UseBasicParsing | Select-Object -ExpandProperty Content + $expectedLine = ($shasums -split "`n") | Where-Object { $_ -match [regex]::Escape($zipName) } | Select-Object -First 1 + if (-not $expectedLine) { throw "no SHASUMS256.txt entry found for $zipName -- refusing to install an unverified download" } + $expectedHash = ($expectedLine -split '\s+')[0].Trim().ToLowerInvariant() + $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expectedHash -ne $actualHash) { + throw "SHA256 mismatch for $zipName`n expected: $expectedHash`n actual: $actualHash`nDeleting the download; do not use it." + } + Ok "SHA256 verified: $actualHash" + + Info "extracting to $nodeDir" + if (Test-Path $nodeDir) { Remove-Item -Recurse -Force $nodeDir } + $extractStaging = Join-Path $downloadDir "extract" + if (Test-Path $extractStaging) { Remove-Item -Recurse -Force $extractStaging } + Expand-Archive -Path $zipPath -DestinationPath $extractStaging -Force + # The zip's own top-level entry is "node-v-win-x64\..."; flatten + # that one level so callers get a stable \node\node.exe path + # regardless of version. + $innerDir = Get-ChildItem $extractStaging -Directory | Select-Object -First 1 + if (-not $innerDir) { throw "unexpected zip layout: no top-level directory found after extraction" } + Move-Item $innerDir.FullName $nodeDir + Remove-Item -Recurse -Force $extractStaging, $zipPath -ErrorAction SilentlyContinue + + if (-not (Test-Path $nodeExe)) { throw "extraction completed but $nodeExe is missing -- unexpected zip layout" } + $installedVersion = & $nodeExe --version + Ok "installed node.exe ($installedVersion) at $nodeExe" + } + + Step "OpenClaw (npm)" + if (-not (Test-Path $npmCmd)) { throw "npm.cmd not found next to node.exe at $npmCmd -- Node.js install looks incomplete" } + # This Node.js install is a standalone zip extraction, not the installer -- + # nothing put it on PATH. npm spawns pre/postinstall scripts (OpenClaw and + # some of its native-addon deps have them) via cmd.exe, and those scripts + # invoke bare "node"; without $nodeDir on PATH that fails with "'node' is + # not recognized...", which in turn makes npm's own cleanup of the + # half-installed tree fail with a wall of unrelated-looking EPERM rmdir + # warnings. Prepend $nodeDir to PATH for this call only. + $env:Path = "$nodeDir;$env:Path" + if ((Test-Path (Join-Path $openClawDir "openclaw.mjs")) -and -not $Force) { + Info "already installed at $openClawDir -- pass -Force to re-install" + } else { + $pkgSpec = if ($OpenClawVersion) { "openclaw@$OpenClawVersion" } else { "openclaw" } + Info "npm install $pkgSpec --prefix $installDirNorm" + # --no-save: this prefix dir isn't a real npm project (no package.json we + # want npm managing); we just want node_modules\openclaw populated. + & $npmCmd install $pkgSpec --prefix $installDirNorm --no-save --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)" } + if (-not (Test-Path (Join-Path $openClawDir "openclaw.mjs"))) { + throw "npm install succeeded but $openClawDir\openclaw.mjs is missing -- is 'openclaw' really the right package name/layout on the registry you're using?" + } + Ok "installed OpenClaw at $openClawDir" + } + + Remove-Item -Recurse -Force $downloadDir -ErrorAction SilentlyContinue + + Step "Done" + Write-Host "" + Write-Host "Pass these to run-openclaw-forward-test.ps1:" -ForegroundColor Yellow + Write-Host " -NodeExePath `"$nodeExe`"" + Write-Host " -OpenClawInstallDir `"$openClawDir`"" + Write-Host "" + Write-Host "Example:" -ForegroundColor Yellow + Write-Host " powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ``" + Write-Host " -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ``" + Write-Host " -NodeExePath `"$nodeExe`" ``" + Write-Host " -OpenClawInstallDir `"$openClawDir`"" +} +catch { + Bad $_.Exception.Message + exit 1 +} diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 4fbd2c41f5..7c492fd6ac 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -1,6 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +[openshell] +version = 2 + # MXC gateway runtime configuration. # # Commands, working directories, and environment variables are sandbox-scoped. @@ -12,18 +15,18 @@ # smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). wxc_exec_path = "C:\\mxc\\wxc-exec.exe" -# process_container is the default because AppContainer enforces default-deny -# filesystem access. isolation_session is an explicit grant-only compatibility -# mode and does not deny access to paths omitted from the sandbox policy. +# The RFC 0012 MXC runtime requires ProcessContainer. backend = "process_container" +# The packaged runtime binaries default to siblings of openshell-gateway.exe. +# Override these paths when running from a different development layout. +# supervisor_binary_path = "C:\\path\\to\\openshell-supervisor.exe" +# sandbox_binary_path = "C:\\path\\to\\openshell-windows-sandbox.exe" + # process_container only: request a Less-Privileged AppContainer. # pc_least_privilege = false # process_container only: AppContainer capabilities to grant. # pc_capabilities = [] -# isolation_session only. Never use "small" (known OS bug). -default_configuration_id = "composable" - # Enable --debug on wxc-exec invocations. debug = false diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index a828264a5e..ac42b57fd9 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -9,8 +9,8 @@ # Detection Finding [2004] — written to a durable JSONL log, just like the Linux # OCSF pipeline. # -# run-ocsf-audit.ps1 patches wxc_exec_path, backend, etw_audit and the -# egress-proxy switch into a disposable copy of this file. Workload command and +# run-ocsf-audit.ps1 patches wxc_exec_path, backend, and etw_audit into a +# disposable copy of this file. Workload command and # cwd are sandbox-scoped and passed separately through --driver-config-json. [openshell.drivers.mxc] @@ -22,19 +22,10 @@ wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" # captures. (isolation_session is "dark" — it emits no provider events.) backend = "process_container" -default_configuration_id = "composable" - debug = false # Turn ON the Plane-A ETW -> OCSF audit consumer. This is the core of the example. etw_audit = true -# Per-sandbox governed egress. Enabling this makes the driver start a host CONNECT -# proxy and hand MXC a `network.proxy` redirect, which is what makes MXC emit the -# SandboxProxyConfigured event — the config event mapped to OCSF CONFIG [5019] -# that completes full event coverage. Requires backend = process_container and a -# loopback (127.0.0.1) seed address; the driver allocates a unique ephemeral port -# per sandbox from this seed. Run-ocsf-audit.ps1 disables this when passed -# -NoProxy. -egress_proxy = true -egress_proxy_addr = "127.0.0.1:18080" +# The driver always provisions the authenticated host supervisor proxy and +# generation-scoped Sandbox Protocol transport. diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml index 13e7cd571e..784ac77fb0 100644 --- a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -7,9 +7,9 @@ # else is default-deny. run-ocsf-audit.ps1 copies this policy into the result # bundle and replaces the default grant with -ShareDir for that run. # -# No network_policies block is needed here: the per-sandbox egress proxy is driven -# by `egress_proxy = true` in mxc-ocsf-audit.toml (that is what makes MXC emit the -# SandboxProxyConfigured event we map to OCSF), not by a policy rule. +# No network policy is needed for this filesystem-focused audit scenario. The +# RFC 0012 MXC runtime always provisions its authenticated control and proxy +# listeners independently of workload network authorization. version: 1 filesystem_policy: diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 46536ff4a3..57f76979f5 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -5,9 +5,9 @@ # # Runs a table of policy scenarios against the OpenShell MXC driver, emits # per-scenario PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero -# only on FAIL. +# unless every selected scenario passes against real MXC. # -# Every run collects its logs into a timestamped results-e2e-\ folder and +# Every run collects its logs beneath target/windows-e2e-results and # zips it (mirrors the sibling run-*.ps1 scripts). The bundle contains the console # transcript, the per-scenario gateway stdout/stderr, the exact TOML rendered for # each scenario, the policy fixture used, and a summary.txt with the verdict table. @@ -16,9 +16,9 @@ # isolated. Workload command/cwd are supplied per sandbox through # --driver-config-json; they are never patched into gateway configuration. # -# Scoring (why we do NOT gate on `sandbox create` exit code): -# The ground truth is the on-disk artifact, so positive scenarios pass on -# artifact PRESENT and deny scenarios pass on the denied write being ABSENT. +# Scoring: +# Positive scenarios require successful creation and a workload artifact. +# Deny scenarios require absent denied writes plus execution evidence. # A CONTROL write proves the agent ran when the policy grants a writable path; # an empty policy instead requires explicit driver-launch evidence. # @@ -26,7 +26,6 @@ # # Usage: # powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe -# .\run-mxc-e2e.ps1 -Mock # wiring-only, no real backend # .\run-mxc-e2e.ps1 -Scenario fs-rw # single scenario # # Scenarios & expected verdicts: @@ -34,18 +33,18 @@ # fs-readonly - write to read-only dir is denied; control write succeeds. # fs-default-deny - ungranted write is denied after the agent launches. # processcontainer only. -# network-reject - network_policies rule makes sandbox create fail. +# network-policy - supervisor-owned network policy permits admission. [CmdletBinding()] param( - [string] $DemoDir = "C:\work\openshell-mxc-e2e", + [string] $DemoDir, + [string] $BinaryDir, [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", [ValidateSet("isolation_session", "process_container")] [string] $Backend = "process_container", [string] $Scenario, [int] $Port = 17670, [string] $GatewayName = "openshell-mxc-e2e", - [switch] $Mock, [switch] $KeepRunning ) @@ -63,8 +62,22 @@ $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } # front so the transcript (started inside the guarded region below) captures the # whole run, including pre-flight failures. $stamp = Get-Date -Format "yyyyMMdd-HHmmss" -$resultDir = Join-Path $here "results-e2e-$stamp" +$repoRoot = [IO.Path]::GetFullPath((Join-Path $here "../../..")) +$resultsRoot = Join-Path $repoRoot "target/windows-e2e-results" +$resultDir = Join-Path $resultsRoot "results-e2e-$stamp" New-Item -ItemType Directory -Force $resultDir | Out-Null +if (-not $DemoDir) { $DemoDir = Join-Path $resultDir "work" } +if (-not $BinaryDir) { + $targetRoot = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $repoRoot "target" } + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + $target = if ($arch -eq "Arm64") { "aarch64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" } + $BinaryDir = Join-Path $targetRoot "$target/release" +} +$savedEnv = @{} +foreach ($name in @("OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY", "OPENSHELL_MXC_MOCK_WXC", "OPENSHELL_WXC_EXEC_PATH", "OPENSHELL_COMPUTE_DRIVER", "OPENSHELL_MXC_SHARE_DIR", "XDG_CONFIG_HOME")) { + $savedEnv[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} +$secretDir = Join-Path $resultsRoot "keys-$stamp-$([guid]::NewGuid().ToString('N'))" $transcriptStarted = $false function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } @@ -139,13 +152,15 @@ function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { # --- Path variables ----------------------------------------------------------- -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" -$toml = Join-Path $here "mxc-gateway.toml" +$gateway = Join-Path $BinaryDir "openshell-gateway.exe" +$cli = Join-Path $BinaryDir "openshell.exe" +$tomlTemplate = Join-Path $here "mxc-gateway.toml" +$toml = Join-Path $resultDir "mxc-gateway.toml" $policyDir = Join-Path $here "e2e-policies" $cmdExe = "C:\Windows\System32\cmd.exe" $demoDirFwd = $DemoDir.Replace('\', '/') +$defaultDemoDir = "C:\work\openshell-mxc-e2e" $roSrc = "$DemoDir-ro-src" # matches e2e-policies/fs-readonly.yaml read_only path $denyProbe = "$DemoDir-deny-probe" # ungranted, NOT the share: used to prove default-deny @@ -165,13 +180,26 @@ $gwErrLog = $null function Render-Toml { $t = $tomlBase $t = [regex]::Replace($t, '(?m)^\s*#?\s*backend\s*=.*$', "backend = `"$Backend`"") - if (-not $Mock) { - $wxcLine = "wxc_exec_path = `"$(Esc $WxcExecPath)`"" - $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) - } + $wxcLine = "wxc_exec_path = `"$(Esc $WxcExecPath)`"" + $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) + $t += @" + +[openshell.gateway.auth] +allow_unauthenticated_users = true + +[openshell.gateway.gateway_jwt] +signing_key_path = '$(Join-Path $secretDir 'signing.pem')' +public_key_path = '$(Join-Path $secretDir 'public.pem')' +kid_path = '$(Join-Path $secretDir 'kid')' +gateway_id = 'mxc-e2e' +"@ Set-Content $toml -Value $t -Encoding UTF8 } +function Test-PortListening { + return @([Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners() | Where-Object { $_.Port -eq $Port }).Count -gt 0 +} + function Start-Gw { Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue # Ephemeral in-memory DB: this is a test harness, so it must NOT write sandbox @@ -193,7 +221,7 @@ function Start-Gw { Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } throw "gateway exited early (code $($p.ExitCode)). See $gwLog." } - if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + if (Test-PortListening) { return $p } Start-Sleep -Milliseconds 400 @@ -264,10 +292,15 @@ function Launch-Succeeded([string]$gwText) { # --- Backend probe ------------------------------------------------------------ function Probe-Backend([string] $backendName, [string] $wxc) { - if ($Mock) { return @{ Live = $true; Reason = "mock mode" } } if (-not (Test-Path $wxc)) { return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } } if ($backendName -eq "process_container") { + $probeResult = Invoke-NativeCaptured $wxc @("--probe") + if ($probeResult.ExitCode -ne 0) { throw "MXC capability probe failed: $($probeResult.Output -join ' ')" } + $capabilities = ($probeResult.Output -join "`n") | ConvertFrom-Json + if (-not $capabilities.probes.baseContainerSupportsIngressHostLoopbackAllow) { + return @{ Live = $false; Reason = "native ProcessContainer host-loopback support unavailable (processmodel PSEC contract required)" } + } # Use a REAL directory + absolute cmd.exe: the canonical wxc-exec passes # cwd straight to CreateProcessW and does NOT expand %TEMP% (that yields # 0x8007010B "directory name is invalid"). @@ -368,11 +401,8 @@ try { # --- Pre-flight ----------------------------------------------------------- - if (-not $Mock) { - if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { - throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + - "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." - } + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set. E2E requires real MXC; unset this variable." } # -KeepRunning leaves the gateway up and breaks after the FIRST scenario (so the @@ -383,7 +413,7 @@ try { throw "-KeepRunning requires -Scenario: it stops after the first scenario, so a full-suite run would report PASS on partial results. Re-run with e.g. -Scenario fs-rw-positive-negative -KeepRunning." } - foreach ($f in @($gateway, $cli, $toml)) { + foreach ($f in @($gateway, $cli, $tomlTemplate, (Join-Path $BinaryDir 'openshell-supervisor.exe'), (Join-Path $BinaryDir 'openshell-windows-sandbox.exe'))) { if (-not (Test-Path $f)) { throw "Missing artifact: $f`nBuild first or run from a demo-package folder." } @@ -393,11 +423,25 @@ try { } # Capture the pristine TOML once; every scenario renders a fresh copy from this. - $tomlBase = Get-Content $toml -Raw + $tomlBase = Get-Content $tomlTemplate -Raw + $env:XDG_CONFIG_HOME = Join-Path $resultDir "cli-config" + $openssl = (Get-Command openssl -ErrorAction Stop).Source + New-Item -ItemType Directory $secretDir | Out-Null + $ownerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + & icacls.exe $secretDir /inheritance:r /grant:r "*$($ownerSid):(OI)(CI)F" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "cannot protect disposable signing-key directory" } + foreach ($argsForKey in @( + @('genpkey', '-algorithm', 'ED25519', '-out', (Join-Path $secretDir 'signing.pem')), + @('pkey', '-in', (Join-Path $secretDir 'signing.pem'), '-pubout', '-out', (Join-Path $secretDir 'public.pem')) + )) { + $keyResult = Invoke-NativeCaptured $openssl $argsForKey + if ($keyResult.ExitCode -ne 0) { throw "disposable JWT key generation failed" } + } + Set-Content (Join-Path $secretDir 'kid') -Value 'mxc-e2e' -Encoding ASCII # --- Mode setup ----------------------------------------------------------- - Step "Pre-flight (mode=$(if ($Mock) {'MOCK'} else {'REAL'}), backend=$Backend)" + Step "Pre-flight (mode=REAL, backend=$Backend)" $cliProbe = Invoke-NativeCaptured $cli @("--version") $cliProbeText = ($cliProbe.Output -join "`n") if ($cliProbe.ExitCode -ne 0) { @@ -405,29 +449,20 @@ try { } Ok "CLI executable allowed: $($cliProbe.Output -join ' ')" - if ($Mock) { - $env:OPENSHELL_MXC_MOCK_WXC = "1" - Info "OPENSHELL_MXC_MOCK_WXC=1 - mock mode: enforcement simulated" - } else { - Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue - if (-not (Test-Path $WxcExecPath)) { - throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." - } - $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath - Info "wxc-exec: $WxcExecPath" - } + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + Info "wxc-exec: $WxcExecPath" $backendProbe = Probe-Backend -backendName $Backend -wxc $WxcExecPath if ($backendProbe.Live) { Ok "Backend '$Backend' is live: $($backendProbe.Reason)" } else { Warn "Backend '$Backend' is not live: $($backendProbe.Reason)" - Warn "Enforcement scenarios will SKIP; network-reject scenario will still run." + Warn "Runtime scenarios will SKIP; this is not a real-isolation PASS." } Step "Check gateway port $Port" - $busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue - if ($busy) { throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." } + if (Test-PortListening) { throw "port $Port in use. Stop stale gateway first." } Ok "port $Port free" Step "Prepare DemoDir + read-only source + deny-probe dir" @@ -441,7 +476,7 @@ try { $env:OPENSHELL_MXC_SHARE_DIR = $DemoDir # --- Scenario definitions ------------------------------------------------- - # Kind: positive | deny | create-fail + # Kind: positive | deny # For deny: ControlTarget (granted, must be PRESENT) + DenyTarget (must be ABSENT). $allScenarios = @( @@ -468,10 +503,11 @@ try { Description = "empty policy; ungranted write denied" }, @{ - Name = "network-reject"; PolicyFile = Join-Path $policyDir "network-reject.yaml" + Name = "network-policy"; PolicyFile = Join-Path $policyDir "network-reject.yaml" SandboxId = "net" - Backends = "both"; Kind = "create-fail" - Description = "network_policies rule causes sandbox create to fail (no live backend needed)" + Backends = "both"; Kind = "positive" + PosTarget = (Join-Path $DemoDir "network-policy-result.txt") + Description = "supervisor-owned network policy is accepted and workload runs (not an egress assertion)" } ) @@ -493,15 +529,12 @@ try { Step "Scenario: $($sc.Name)" Info $sc.Description - # Backend gate (deny/positive scenarios need a live backend; create-fail does not). $skipReason = $null - if ($sc.Kind -ne "create-fail") { - $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) - if (-not $backendMatches) { - $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" - } elseif (-not $backendProbe.Live -and -not $Mock) { - $skipReason = "backend not live: $($backendProbe.Reason)" - } + $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) + if (-not $backendMatches) { + $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" + } elseif (-not $backendProbe.Live) { + $skipReason = "backend not live: $($backendProbe.Reason)" } if ($null -ne $skipReason) { Skip "$($sc.Name): $skipReason" @@ -515,6 +548,19 @@ try { continue } + # Render a disposable policy for every scenario. The source YAML + # intentionally carries the documented default paths, while + # -DemoDir is a supported override. Both the read-write path and + # the read-only sibling share the same default prefix, so one + # exact prefix substitution keeps their relative naming intact. + $policyUsed = Join-Path $resultDir "policy.$($sc.Name).yaml" + $policyText = Get-Content $sc.PolicyFile -Raw + $policyText = $policyText.Replace( + $defaultDemoDir.Replace('\', '/'), + $DemoDir.Replace('\', '/') + ) + Set-Content -Path $policyUsed -Value $policyText -Encoding UTF8 + # Per-scenario gateway logs land in the bundle under their own names. $gwLog = Join-Path $resultDir "gateway.$($sc.Name).log" $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" @@ -529,7 +575,7 @@ try { if ($sc.ControlTarget) { Remove-Item $sc.ControlTarget -Force -ErrorAction SilentlyContinue $control = $sc.ControlTarget.Replace('\', '/') - $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + $command = @($cmdExe, "/c", "echo denied 1> $denied & echo ok 1> $control") } else { $command = @($cmdExe, "/c", "echo denied 1> $denied") } @@ -547,7 +593,8 @@ try { Render-Toml # Preserve the exact rendered config + policy fixture used for this scenario. Copy-Item $toml (Join-Path $resultDir "mxc-gateway.$($sc.Name).toml") -Force -ErrorAction SilentlyContinue - Copy-Item $sc.PolicyFile (Join-Path $resultDir "policy.$($sc.Name).yaml") -Force -ErrorAction SilentlyContinue + # policyUsed already lives in the result bundle and is the exact + # rendered policy passed to OpenShell. $gw = Start-Gw Info "gateway pid $($gw.Id)" @@ -557,13 +604,12 @@ try { $sandboxName = "mxc-$($sc.SandboxId)-$runId" try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} - # Run sandbox create. Its exit status is only authoritative for the - # create-fail scenario; artifacts score workload scenarios. + # Run sandbox create; require artifact evidence for live workload tests. $createOut = $null; $createExitCode = 0 try { $createResult = Invoke-NativeCaptured $cli @( "sandbox", "create", "--name", $sandboxName, - "--policy", [string]$sc.PolicyFile, + "--policy", [string]$policyUsed, "--driver-config-json", $driverConfig, "--no-tty" ) @@ -573,40 +619,14 @@ try { $createOut = $_.Exception.Message; $createExitCode = 1 } $createOutStr = ($createOut -join "`n") - Info "create exit: $createExitCode (not used for scoring on non-create-fail scenarios)" + Info "create exit: $createExitCode" $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" # Evaluate. - if ($sc.Kind -eq "create-fail") { - # A non-zero exit alone is NOT sufficient: gateway-registration, - # transport, or malformed-fixture errors also exit non-zero and would - # false-pass this scenario. Require a genuine policy-rejection signal - # (the driver rejects the network rule with invalid_argument naming - # network_policies) AND confirm it is not an infrastructure failure. - $rejected = ($createOutStr -match '(?i)network' ` - -or $createOutStr -match '(?i)invalid[_ -]?argument' ` - -or $createOutStr -match '(?i)policy' ` - -or $gwText -match '(?i)network_policies') - $infraFail = ($createOutStr -match '(?i)connection refused' ` - -or $createOutStr -match '(?i)not registered' ` - -or $createOutStr -match '(?i)transport error' ` - -or $createOutStr -match '(?i)failed to connect') - if ($createExitCode -ne 0 -and $rejected -and -not $infraFail) { - Ok "$($sc.Name): create correctly rejected by policy (exit $createExitCode)" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "policy rejection" } - } elseif ($createExitCode -ne 0) { - Bad "$($sc.Name): create failed but not with a policy-rejection signal (possible harness/infra error)" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "non-rejection failure" } - } else { - Bad "$($sc.Name): create succeeded but should have failed" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } - } - } elseif ($sc.Kind -eq "positive") { + if ($sc.Kind -eq "positive") { $present = Wait-File $sc.PosTarget 30 - if ($present) { + if ($present -and $createExitCode -eq 0) { Ok "$($sc.Name): in-policy write produced artifact" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "artifact present" } } else { @@ -692,7 +712,7 @@ finally { $skipCount = @($results | Where-Object { $_.Result -eq "SKIP" }).Count Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount" - $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } + $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } elseif ($passCount -eq 0 -or $skipCount -gt 0) { "INCOMPLETE" } else { "PASS" } $tableText = ($results | Format-Table -AutoSize | Out-String) $summary = @" OpenShell MXC e2e scenario run @@ -700,7 +720,7 @@ OpenShell MXC e2e scenario run timestamp : $stamp machine : $env:COMPUTERNAME verdict : $verdict -mode : $(if ($Mock) { 'MOCK' } else { 'REAL' }) +mode : REAL backend : $Backend backend_live : $($backendProbe.Live) ($($backendProbe.Reason)) wxc_exec : $WxcExecPath @@ -717,9 +737,10 @@ Files in this bundle ($resultDir): mxc-gateway..toml the exact gateway config rendered per scenario policy..yaml the exact sandbox policy fixture used per scenario -What PASS means: every non-skipped scenario met its expected verdict - positive +What PASS means: every selected scenario ran against real MXC and met its expected verdict - positive writes produced their artifact, deny writes were blocked with either a control -write or driver-launch evidence, and network-reject was refused by policy. +write or driver-launch evidence. Missing coverage exits non-zero; +network-policy proves admission and execution, not network enforcement. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) @@ -728,17 +749,27 @@ write or driver-launch evidence, and network-reject was refused by policy. # Zip the bundle for easy return (defensive; never throw out of finally). try { - $zip = Join-Path $here "results-e2e-$stamp.zip" + $zip = Join-Path $resultsRoot "results-e2e-$stamp.zip" if (Test-Path $zip) { Remove-Item $zip -Force } Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } + if (-not ($KeepRunning -and $gw)) { + foreach ($keyFile in @('signing.pem', 'public.pem', 'kid')) { + Remove-Item -LiteralPath (Join-Path $secretDir $keyFile) -Force -ErrorAction SilentlyContinue + } + if (Test-Path $secretDir) { Remove-Item -LiteralPath $secretDir -ErrorAction SilentlyContinue } + } + foreach ($name in $savedEnv.Keys) { [Environment]::SetEnvironmentVariable($name, $savedEnv[$name], "Process") } } if ($harnessError -or $failCount -gt 0) { Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red exit 1 +} elseif ($passCount -eq 0 -or $skipCount -gt 0) { + Write-Host "`nE2E INCOMPLETE: PASS=$passCount SKIP=$skipCount - real runtime coverage required" -ForegroundColor Yellow + exit 1 } else { - Write-Host "`nALL SCENARIOS PASSED (or SKIPPED)" -ForegroundColor Green + Write-Host "`nALL SELECTED REAL MXC SCENARIOS PASSED: PASS=$passCount" -ForegroundColor Green exit 0 } diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 591c38bf24..374ee2c042 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -23,9 +23,6 @@ # powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 ` # -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe # -# By default the per-sandbox egress proxy is ON so the full event set (including -# SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. -# # The deliverable is the OCSF audit log (openshell-ocsf..log) inside the # results-*.zip the script produces. Pass -ShareOut '\\server\share' to also copy # the bundle to a shared location (off by default). @@ -38,8 +35,6 @@ param( [string] $ShareDir = "C:\work\openshell-mxc-demo", # How many sandboxes to create (each drives a full event burst). [int] $SandboxCount = 2, - # Disable the per-sandbox egress proxy (omits the SandboxProxyConfigured event). - [switch] $NoProxy, # Gateway bind port (matches the gateway default) + CLI registration name. [int] $Port = 17670, [string] $GatewayName = "openshell-mxc-ocsf", @@ -86,6 +81,8 @@ function Get-MxcEtwSessions { $gateway = Join-Path $here "openshell-gateway.exe" $cli = Join-Path $here "openshell.exe" +$supervisor = Join-Path $here "openshell-supervisor.exe" +$sandbox = Join-Path $here "openshell-windows-sandbox.exe" $policySrc = Join-Path $here "ocsf-audit.yaml" $policy = Join-Path $resultDir "ocsf-audit.used.yaml" # disposable policy matching -ShareDir $tomlSrc = Join-Path $here "mxc-ocsf-audit.toml" @@ -95,12 +92,11 @@ $helloPath = Join-Path $ShareDir "hello.txt" $gw = $null $gatewayEtwSessions = @() $passed = $true -$proxyOn = -not $NoProxy try { # 1. Validate artifacts + privilege. Step "Validate package artifacts" - foreach ($f in @($gateway, $cli, $policySrc, $tomlSrc)) { + foreach ($f in @($gateway, $cli, $supervisor, $sandbox, $policySrc, $tomlSrc)) { if (-not (Test-Path $f)) { throw "missing artifact: $f (run this script from inside the package folder)" } Info "found $(Split-Path $f -Leaf)" } @@ -133,12 +129,6 @@ try { } else { $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true") } - $proxyVal = if ($proxyOn) { 'true' } else { 'false' } - if ($tomlText -match '(?m)^\s*#?\s*egress_proxy\s*=') { - $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*egress_proxy\s*=.*$', "egress_proxy = $proxyVal") - } else { - $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`negress_proxy = $proxyVal") - } Set-Content $toml -Value $tomlText -Encoding UTF8 $shareDirPolicy = $ShareDir.Replace('\', '/') @@ -168,7 +158,7 @@ try { $driverConfig } - Info "backend=process_container etw_audit=true egress_proxy=$proxyVal" + Info "backend=process_container etw_audit=true runtime=supervisor+sandbox" Info "workload cwd=$shareDirPolicy policy grant=$shareDirPolicy" # 3. Port must be free. Auto-clear a stale OUR-gateway; refuse anything else. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index c7a966a32b..a149522ce9 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -1,11 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, -//! and self-reported readiness. +//! MXC compute backend using the RFC 0012 supervisor/sandbox architecture. -use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; -use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::SandboxPolicy; @@ -15,32 +21,31 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; use openshell_core::proto_struct::struct_to_json_value; +use openshell_sandbox_backend::boundary_protocol::{ + GatewayVerificationKey, SandboxTlsClientConfig, SandboxTlsServerConfig, + generate_sandbox_tls_material, +}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::pin::Pin; -use std::sync::Arc; -use tokio::sync::{Mutex, broadcast, mpsc, watch}; -use tokio::task::JoinHandle; +use tokio::io::{AsyncBufReadExt as _, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; +use crate::isolation::MxcBoundarySpec; +use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; + const DRIVER_NAME: &str = "mxc"; const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Sentinel image name — MXC has no OCI image; this string must be non-empty -/// so the gateway's `default_image` cache is satisfied, but it is not pullable. const DEFAULT_IMAGE_SENTINEL: &str = "mxc:process-container"; +const HOST_AUTH_BUNDLE_FILE: &str = "supervisor-auth.json"; +const HOST_RUNTIME_DESCRIPTOR_FILE: &str = "runtime-descriptor.json"; +const BOUNDARY_CONFIG_FILE: &str = "boundary.json"; +const BOUNDARY_TLS_CERT_FILE: &str = "sandbox.crt"; +const BOUNDARY_TLS_KEY_FILE: &str = "sandbox.key"; +const DIRECT_PROXY_USERNAME: &str = "openshell"; -// ── Config ──────────────────────────────────────────────────────────────────── - -/// Which MXC backend the driver targets. -/// -/// - `IsolationSession`: persistent, attachable session -/// (provision → start → exec → stop → deprovision). Grant-only filesystem -/// policy — it has no deny primitive and is NOT default-deny. -/// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a -/// write to any ungranted path is denied by the OS. No persistent session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum MxcBackend { @@ -49,15 +54,17 @@ pub enum MxcBackend { ProcessContainer, } -/// Configuration for the MXC compute driver. -/// -/// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from -/// environment variables / CLI flags via the standard gateway precedence chain. +impl MxcBackend { + const fn containment(self) -> &'static str { + match self { + Self::IsolationSession => "isolation_session", + Self::ProcessContainer => "processcontainer", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] -// These are independent, user-facing feature switches in the flat gateway -// configuration schema rather than one compound state machine. -#[allow(clippy::struct_excessive_bools)] pub struct MxcComputeConfig { /// Permit caller-supplied driver JSON. Does not waive resource admission. pub allow_driver_config: bool, @@ -65,54 +72,82 @@ pub struct MxcComputeConfig { pub resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig, /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, - /// Backend to target. Default: `process_container`. + pub supervisor_binary_path: String, + pub sandbox_binary_path: String, + pub state_dir: PathBuf, + pub grpc_endpoint: String, pub backend: MxcBackend, - /// `processContainer` only: request a Less-Privileged `AppContainer`. + /// Legacy isolation-session setting, accepted for existing + /// `ProcessContainer` configurations but unused by that backend. + #[serde(skip_serializing)] + pub default_configuration_id: Option, pub pc_least_privilege: bool, - /// `processContainer` only: `AppContainer` capabilities to grant. pub pc_capabilities: Vec, - /// MXC `configurationId` for isolation session. Default: `"composable"`. - /// Never use `"small"` (known OS bug). - pub default_configuration_id: String, - /// Enable Pattern-C governed egress. When true, MXC receives filesystem - /// grants plus a `network.proxy` redirect and the host CONNECT proxy - /// receives the trimmed network-only policy. - pub egress_proxy: bool, - /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is - /// enabled. The driver preserves the loopback IP and allocates a unique - /// ephemeral port per sandbox. - pub egress_proxy_addr: String, - - /// Enable `--debug` flag on `wxc-exec` invocations. + pub pc_allow_local_network: bool, + pub pc_minimal_env: bool, pub debug: bool, - /// Enable the in-process ETW → OCSF audit consumer (Plane A). Consumes the OS - /// Sandboxing provider MXC drives and emits OCSF into the gateway trail. - /// Requires the gateway account to be in "Performance Log Users" (or admin). pub etw_audit: bool, } impl Default for MxcComputeConfig { fn default() -> Self { + let state_dir = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("OpenShell") + .join("mxc"); + let executable_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + let sibling_binary = |name: &str| { + executable_dir.as_ref().map_or_else( + || name.to_string(), + |dir| dir.join(name).display().to_string(), + ) + }; Self { wxc_exec_path: "wxc-exec.exe".into(), allow_driver_config: false, resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig::default(), - backend: MxcBackend::default(), + supervisor_binary_path: sibling_binary("openshell-supervisor.exe"), + sandbox_binary_path: sibling_binary("openshell-windows-sandbox.exe"), + state_dir, + grpc_endpoint: String::new(), + backend: MxcBackend::ProcessContainer, + default_configuration_id: None, pc_least_privilege: false, pc_capabilities: Vec::new(), - default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), - egress_proxy: false, - egress_proxy_addr: String::new(), - + pc_allow_local_network: true, + pc_minimal_env: false, debug: false, etw_audit: false, } } } -/// Per-sandbox MXC workload settings supplied through -/// `template.driver_config.mxc` / `--driver-config-json`. +impl MxcComputeConfig { + /// Validate static MXC configuration without requiring launch credentials. + /// + /// Keep executable lookup independent of the gateway's PATH, as in the + /// Windows driver lane. Runtime file availability is checked at launch. + pub fn validate_configuration(&self) -> openshell_core::Result<()> { + if self.wxc_exec_path.trim().is_empty() || !Path::new(&self.wxc_exec_path).is_absolute() { + return Err(openshell_core::Error::config( + "[openshell.drivers.mxc] wxc_exec_path must be set to an absolute path to wxc-exec.exe", + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +struct GatewayConnection { + endpoint: String, + tls: Option, + tls_server_name: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct MxcSandboxConfig { @@ -121,8 +156,6 @@ struct MxcSandboxConfig { cwd: String, } -// ── Registry entry ──────────────────────────────────────────────────────────── - #[derive(Debug, Clone, PartialEq, Eq)] pub enum PhaseState { Starting, @@ -133,333 +166,85 @@ pub enum PhaseState { struct SandboxEntry { sandbox: DriverSandbox, - iso_sandbox_id: Option, - isolation_stopped: bool, phase_state: PhaseState, - /// Serializes stop/delete with provisioning and process launch. lifecycle_gate: Arc>, - monitor_cancel: Option>, - monitor_task: Option>, - trimmed_policy: Option, - proxy_addr: Option, - host_proxy: Option, -} - -impl std::fmt::Debug for SandboxEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SandboxEntry") - .field("sandbox_id", &self.sandbox.id) - .field("iso_sandbox_id", &self.iso_sandbox_id) - .field("isolation_stopped", &self.isolation_stopped) - .field("phase_state", &self.phase_state) - .finish_non_exhaustive() - } + shutdown_tx: Option>, + terminated_rx: Option>, + host_state_dir: PathBuf, + boundary_state_dir: PathBuf, } -// ── Watch stream helpers ────────────────────────────────────────────────────── - pub type WatchStream = Pin< Box> + Send>, >; -fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Sandbox( - WatchSandboxesSandboxEvent { - sandbox: Some(sandbox), - }, - )), - } -} - -fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::Deleted( - WatchSandboxesDeletedEvent { sandbox_id }, - )), - } -} - -fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { - WatchSandboxesEvent { - payload: Some(watch_sandboxes_event::Payload::PlatformEvent( - WatchSandboxesPlatformEvent { - sandbox_id, - event: Some(DriverPlatformEvent { - event_time: None, - source: "mxc-driver".into(), - r#type: "Warning".into(), - reason: reason.to_string(), - message, - metadata: HashMap::new(), - }), - }, - )), - } -} - -// ── Driver ──────────────────────────────────────────────────────────────────── - -/// In-process MXC compute driver. pub struct MxcComputeBackend { config: MxcComputeConfig, + gateway: GatewayConnection, invoker: WxcExecInvoker, registry: Arc>>, watch_tx: Arc>, policy_mapper: Arc, - /// In-process ETW → OCSF audit consumer (Plane A). `Some` only when - /// `config.etw_audit` is set and the session started; kept alive here so it - /// stops when the backend is dropped (held purely for its `Drop`, hence - /// never read directly). #[allow(dead_code)] etw_session: Option, - /// Shared MXC-ETW → `sandbox_id` attribution index. Seeded by the driver - /// (`pid → sandbox_id`) as it launches sandboxes and read by the ETW - /// consumer thread to map/emit OCSF. `Arc` even when audit is off so the - /// launch path is branch-free. attribution: Arc>, } impl std::fmt::Debug for MxcComputeBackend { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MxcComputeBackend") + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MxcComputeBackend") .field("wxc_exec_path", &self.config.wxc_exec_path) - .finish_non_exhaustive() - } -} - -fn sandbox_config(sandbox: &DriverSandbox) -> Result { - let config = sandbox - .spec - .as_ref() - .and_then(|spec| spec.template.as_ref()) - .and_then(|template| template.driver_config.as_ref()) - .ok_or_else(|| { - tonic::Status::invalid_argument( - "mxc requires template.driver_config.mxc with a non-empty command array", + .field( + "supervisor_binary_path", + &self.config.supervisor_binary_path, ) - })?; - let config: MxcSandboxConfig = - serde_json::from_value(struct_to_json_value(config)).map_err(|error| { - tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) - })?; - if config.command.is_empty() || config.command[0].is_empty() { - return Err(tonic::Status::invalid_argument( - "mxc driver_config.command must contain a non-empty executable", - )); - } - Ok(config) -} - -// Minimum non-secret Windows environment needed by CreateProcessW and the -// AppContainer DACL fallback before the workload runtime starts. -const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = - ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; - -fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { - // Released wxc-exec ProcessContainer builds start from the explicit - // process environment. Seed only the non-secret Windows bootstrap values; - // copying the gateway's full environment would leak unrelated host secrets - // into untrusted sandbox workloads. - let mut environment = MINIMAL_WINDOWS_BOOTSTRAP_ENV - .iter() - .filter_map(|key| { - std::env::var(key) - .ok() - .map(|value| ((*key).to_string(), value)) - }) - .collect::>(); - if let Some(spec) = sandbox.spec.as_ref() { - if let Some(template) = spec.template.as_ref() { - environment.extend(template.environment.clone()); - } - environment.extend(spec.environment.clone()); - } - let mut environment = environment - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::>(); - environment.sort_unstable(); - environment -} - -fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { - if !config.egress_proxy { - return Ok(None); - } - if config.backend == MxcBackend::IsolationSession { - return Err(tonic::Status::invalid_argument( - "mxc governed egress requires process_container; network.proxy is not supported on isolation_session until MXC M1 lands", - )); - } - let raw = config.egress_proxy_addr.trim(); - if raw.is_empty() { - return Err(tonic::Status::invalid_argument( - "mxc egress_proxy_addr is required when egress_proxy is enabled", - )); - } - let addr = raw.parse::().map_err(|error| { - tonic::Status::invalid_argument(format!( - "mxc egress_proxy_addr must be an IP:PORT socket address: {error}" - )) - })?; - if addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { - return Err(tonic::Status::invalid_argument(format!( - "mxc egress_proxy_addr must be 127.0.0.1:PORT because MXC 0.6.0-alpha can encode only a localhost proxy port (got {})", - addr.ip() - ))); - } - Ok(Some(addr)) -} - -fn allocate_sandbox_proxy_addr( - configured: SocketAddr, -) -> std::io::Result<(SocketAddr, std::net::TcpListener)> { - let reservation = std::net::TcpListener::bind(SocketAddr::new(configured.ip(), 0))?; - let addr = reservation.local_addr()?; - Ok((addr, reservation)) -} - -fn encode_windows_command_line(args: &[String]) -> String { - args.iter() - .map(|arg| quote_windows_argument(arg)) - .collect::>() - .join(" ") -} - -fn quote_windows_argument(arg: &str) -> String { - if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') { - return arg.to_string(); - } - - let mut quoted = String::from("\""); - let mut backslashes = 0; - for ch in arg.chars() { - match ch { - '\\' => backslashes += 1, - '"' => { - quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); - quoted.push('"'); - backslashes = 0; - } - _ => { - quoted.push_str(&"\\".repeat(backslashes)); - backslashes = 0; - quoted.push(ch); - } - } + .field("sandbox_binary_path", &self.config.sandbox_binary_path) + .finish_non_exhaustive() } - quoted.push_str(&"\\".repeat(backslashes * 2)); - quoted.push('"'); - quoted -} -fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { - config - .command - .first() - .filter(|command| !command.trim().is_empty()) - .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) -} - -const TLS_ENV_KEYS: [&str; 6] = [ - "NODE_EXTRA_CA_CERTS", - "DENO_CERT", - "SSL_CERT_FILE", - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", - "GIT_SSL_CAINFO", -]; - -fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>) { - let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { - return; - }; - - env.retain(|entry| { - let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - !TLS_ENV_KEYS - .iter() - .any(|candidate| key.eq_ignore_ascii_case(candidate)) - }); - - let ca_cert_path = ca_cert_path.display().to_string(); - let combined_bundle_path = combined_bundle_path.display().to_string(); - env.extend([ - format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"), - format!("DENO_CERT={ca_cert_path}"), - format!("SSL_CERT_FILE={combined_bundle_path}"), - format!("REQUESTS_CA_BUNDLE={combined_bundle_path}"), - format!("CURL_CA_BUNDLE={combined_bundle_path}"), - format!("GIT_SSL_CAINFO={combined_bundle_path}"), - ]); } -fn append_tls_readwrite_grant( - readwrite_paths: &mut Vec, - ca_paths: Option<&(PathBuf, PathBuf)>, -) { - let Some((ca_cert_path, _)) = ca_paths else { - return; - }; - let Some(dir) = ca_cert_path.parent() else { - return; - }; - let dir = dir.display().to_string(); - if !readwrite_paths - .iter() - .any(|existing| existing.eq_ignore_ascii_case(&dir)) - { - readwrite_paths.push(dir); +impl MxcComputeBackend { + pub fn new(gateway_name: &str, config: MxcComputeConfig) -> Self { + let endpoint = config.grpc_endpoint.clone(); + Self::new_with_gateway(gateway_name, config, endpoint, None, None) } -} -impl MxcComputeBackend { - /// Create an MXC backend whose audit records identify the configured gateway. - pub fn new(gateway_name: impl Into, config: MxcComputeConfig) -> Self { + pub fn new_with_gateway( + gateway_name: &str, + config: MxcComputeConfig, + endpoint: String, + tls: Option, + tls_server_name: Option, + ) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); let (watch_tx, _) = broadcast::channel(256); - let gateway_name = gateway_name.into(); - - // Start the Plane-A ETW → OCSF consumer if enabled. The consumer thread - // attributes each event to a `sandbox_id` via `attribution` (seeded by - // the launch path) and emits OCSF for the mapped classes. - // Failure is non-fatal — the driver still runs, just without ETW audit. let attribution = Arc::new(std::sync::Mutex::new( crate::etw_consumer::AttributionIndex::new(), )); let etw_session = if config.etw_audit { - match crate::etw_consumer::start_session(attribution.clone(), gateway_name) { - Ok(session) => Some(session), - Err(e) => { - warn!(error = %e, "MXC ETW audit consumer failed to start; continuing without it"); - None - } - } + crate::etw_consumer::start_session(attribution.clone(), gateway_name.to_string()) + .inspect_err(|error| warn!(%error, "MXC ETW audit consumer failed to start")) + .ok() } else { None }; - Self { - invoker, config, + gateway: GatewayConnection { + endpoint, + tls, + tls_server_name, + }, + invoker, registry: Arc::new(Mutex::new(HashMap::new())), watch_tx: Arc::new(watch_tx), - // Production policy translation is always handled by the embedded - // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), etw_session, attribution, } } - /// Test-only constructor wiring the in-process mock `wxc-exec` shim. - #[cfg(test)] - pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { - let mut backend = Self::new(openshell_core::config::DEFAULT_GATEWAY_NAME, config); - backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); - backend - } - pub fn capabilities(&self) -> GetCapabilitiesResponse { GetCapabilitiesResponse { resource_admission_policy: openshell_core::resource_admission::DriverAdmissionConfig { @@ -467,45 +252,95 @@ impl MxcComputeBackend { resource_admission: self.config.resource_admission.clone(), } .acknowledgement(), + extension: Some(openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/mxc", + openshell_core::VERSION, + [], + )), driver_name: DRIVER_NAME.to_string(), driver_version: DRIVER_VERSION.to_string(), default_image: DEFAULT_IMAGE_SENTINEL.to_string(), gateway_manages_lifecycle: false, supports_sandbox_authentication: false, - driver_reports_runtime_readiness: true, + driver_reports_runtime_readiness: false, resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, - extension: Some(openshell_core::extension_protocol::extension_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, - "openshell/mxc", - openshell_core::VERSION, - [], - )), + supports_ui_policy: true, } } - fn validate_sandbox_fields(sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - if let Some(spec) = &sandbox.spec { - if effective_driver_gpu_count(driver_gpu_requirements( + 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() + .map_err(|error| tonic::Status::failed_precondition(error.to_string()))?; + } + if self.config.backend != MxcBackend::ProcessContainer { + return Err(tonic::Status::failed_precondition( + "the RFC 0012 MXC runtime currently requires process_container", + )); + } + if self.gateway.endpoint.trim().is_empty() { + return Err(tonic::Status::failed_precondition( + "mxc grpc_endpoint is required for the host supervisor", + )); + } + if !self.invoker.is_mock() + && (!Path::new(&self.config.supervisor_binary_path).is_file() + || !Path::new(&self.config.sandbox_binary_path).is_file()) + { + return Err(tonic::Status::failed_precondition(format!( + "MXC requires supervisor and sandbox binaries at '{}' and '{}'", + self.config.supervisor_binary_path, self.config.sandbox_binary_path + ))); + } + if let Some(spec) = &sandbox.spec + && effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), )) .map_err(tonic::Status::invalid_argument)? .is_some() - { - return Err(tonic::Status::invalid_argument( - "mxc driver does not support GPU sandboxes", - )); - } - if let Some(tmpl) = &spec.template - && !tmpl.agent_socket_path.is_empty() - { - return Err(tonic::Status::invalid_argument( - "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", - )); - } + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support GPU sandboxes", + )); + } + let config = sandbox_config(sandbox)?; + if config.cwd.trim().is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.cwd is required for boundary staging", + )); + } + if !Path::new(&config.cwd).is_absolute() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.cwd must be an absolute Windows path", + )); + } + if !self.config.state_dir.is_absolute() { + return Err(tonic::Status::failed_precondition( + "mxc state_dir must be an absolute Windows path", + )); } - sandbox_config(sandbox)?; + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + self.map_sandbox_policy( + &sandbox.id, + policy, + "127.0.0.1:3128".parse().expect("fixed proxy address"), + )?; Ok(()) } @@ -513,157 +348,109 @@ impl MxcComputeBackend { &self, sandbox_id: &str, policy: Option<&SandboxPolicy>, - egress: Option, + proxy_addr: SocketAddr, ) -> Result { self.policy_mapper .map( policy, &MapCtx { sandbox_id: sandbox_id.to_string(), - egress, + egress: Some(proxy_addr), + containment: self.config.backend.containment().into(), }, ) .map_err(|error| tonic::Status::invalid_argument(error.to_string())) } - 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, - )?; - // MXC grants access to existing host filesystem objects, including its - // executable/workdir. There is no authoritative label resolver yet. - self.config - .resource_admission - .reject_unlabelable("MXC host filesystem grants")?; - Self::validate_sandbox_fields(sandbox)?; - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let egress_addr = configured_egress_addr(&self.config)?; - self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; - Ok(()) - } pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { - let registry = self.registry.lock().await; - registry + self.registry + .lock() + .await .values() - .find(|e| e.sandbox.name == sandbox_name) - .map(|e| e.sandbox.clone()) + .find(|entry| entry.sandbox.name == sandbox_name) + .map(|entry| entry.sandbox.clone()) } pub async fn list_sandboxes(&self) -> Vec { - let registry = self.registry.lock().await; - registry.values().map(|e| e.sandbox.clone()).collect() + self.registry + .lock() + .await + .values() + .map(|entry| entry.sandbox.clone()) + .collect() } pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { self.validate_sandbox_create(sandbox)?; + // Gateway preflight runs before it mints generation-scoped credentials. + // Require authentication at the provisioning boundary, before any state + // directories, registry entries, or runtime processes are created. + let launch = launch_authentication(sandbox)?; + launch + .validate() + .map_err(|error| tonic::Status::failed_precondition(error.to_string()))?; let sandbox_id = sandbox.id.clone(); - - Self::validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; - let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { - Some(configured_addr) => { - let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( - |error| { - tonic::Status::internal(format!( - "failed to allocate sandbox-unique MXC host egress proxy address from {configured_addr}: {error}" - )) - }, - )?; - (Some(addr), Some(reservation)) - } - None => (None, None), - }; - - // Policy translation is deterministic and side-effect free. Do it before - // inserting the registry entry or launching MXC so invalid requests fail - // synchronously at the CreateSandbox boundary. - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; - - if sandbox - .spec - .as_ref() - .is_none_or(|spec| spec.sandbox_token.is_empty()) - { - tracing::debug!( - sandbox = %sandbox.name, - "no sandbox_token minted (no supervisor consumer on MXC)" - ); - } - - let sandbox_name = sandbox.name.clone(); - let lifecycle_gate = Arc::new(Mutex::new(())); - // Take the gate before publishing the entry. stop/delete can discover the - // sandbox immediately, but cannot pass this guard until startup has either - // installed a cancellable child monitor or failed. - let startup_guard = lifecycle_gate.clone().lock_owned().await; + // The gateway owns the authenticated generation. A driver-local UUID + // would make the boundary descriptor disagree with the signed bundle. + let generation = safe_component(launch.supervisor.runtime_generation.as_str())?.to_string(); + let host_state_dir = self + .config + .state_dir + .join(safe_component(&sandbox_id)?) + .join(&generation); + let boundary_state_dir = PathBuf::from(&sandbox_config.cwd) + .join(".openshell-runtime") + .join(&generation); + let gate = Arc::new(Mutex::new(())); + let startup_guard = gate.clone().lock_owned().await; + let starting = make_sandbox_with_condition( + sandbox, + &condition("Ready", "False", "Starting", "MXC runtime starting"), + false, + ); { let mut registry = self.registry.lock().await; if registry.contains_key(&sandbox_id) { return Err(tonic::Status::already_exists(format!( - "sandbox {sandbox_name} already exists" + "sandbox {} already exists", + sandbox.name ))); } - let initial = make_sandbox_with_condition( - sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "Starting".into(), - message: "MXC lifecycle starting".into(), - transition_time: None, - }, - false, - ); - let _ = self.watch_tx.send(sandbox_event(initial.clone())); registry.insert( sandbox_id.clone(), SandboxEntry { - sandbox: initial, - iso_sandbox_id: None, - isolation_stopped: false, + sandbox: starting.clone(), phase_state: PhaseState::Starting, - lifecycle_gate, - monitor_cancel: None, - monitor_task: None, - trimmed_policy: None, - proxy_addr: None, - host_proxy: None, + lifecycle_gate: gate, + shutdown_tx: None, + terminated_rx: None, + host_state_dir: host_state_dir.clone(), + boundary_state_dir: boundary_state_dir.clone(), }, ); } + let _ = self.watch_tx.send(sandbox_event(starting)); + let context = LifecycleContext { + invoker: self.invoker.clone(), + config: self.config.clone(), + gateway: self.gateway.clone(), + registry: self.registry.clone(), + watch_tx: self.watch_tx.clone(), + attribution: self.attribution.clone(), + sandbox: sandbox.clone(), + sandbox_config, + generation, + host_state_dir, + boundary_state_dir, + policy_mapper: self.policy_mapper.clone(), + }; + tokio::spawn(async move { run_lifecycle(context, startup_guard).await }); + Ok(()) + } - let invoker = self.invoker.clone(); - let config = self.config.clone(); - let registry = self.registry.clone(); - let watch_tx = self.watch_tx.clone(); - let attribution = self.attribution.clone(); - let sandbox = sandbox.clone(); - tokio::spawn(async move { - run_lifecycle( - invoker, - config, - registry, - watch_tx, - attribution, - sandbox, - sandbox_config, - mapped, - reserved_proxy_listener, - startup_guard, - ) - .await; - }); - - Ok(()) - } pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), tonic::Status> { - let (sandbox_id, lifecycle_gate) = { + let (sandbox_id, gate) = { let registry = self.registry.lock().await; let entry = registry .values() @@ -673,51 +460,24 @@ impl MxcComputeBackend { })?; (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) }; - - let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let _guard = gate.lock().await; + let (shutdown, terminated) = { let mut registry = self.registry.lock().await; let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) })?; - ( - entry.iso_sandbox_id.clone(), - entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), - ) + (entry.shutdown_tx.take(), entry.terminated_rx.clone()) }; - if let Some(cancel) = cancel { - let _ = cancel.send(true); - } - if let Some(task) = monitor_task { - task.await.map_err(|error| { - tonic::Status::internal(format!("mxc process monitor failed: {error}")) - })?; + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); } - if let Some(ref iso_id) = iso_id - && !isolation_stopped - { - self.invoker.stop(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec stop failed: {error}")) - })?; - isolation_stopped = true; - } - + wait_for_termination(terminated, sandbox_name).await?; let mut registry = self.registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.isolation_stopped = isolation_stopped; - entry.host_proxy = None; entry.phase_state = PhaseState::Stopped; entry.sandbox = make_sandbox_with_condition( &entry.sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "Stopped".into(), - message: "MXC sandbox stopped".into(), - transition_time: None, - }, + &condition("Ready", "False", "Stopped", "MXC sandbox stopped"), false, ); let snapshot = entry.sandbox.clone(); @@ -726,12 +486,13 @@ impl MxcComputeBackend { } Ok(()) } + pub async fn delete_sandbox( &self, sandbox_id: &str, sandbox_name: &str, ) -> Result { - let lifecycle_gate = { + let gate = { let registry = self.registry.lock().await; let Some(entry) = registry.get(sandbox_id) else { return Ok(false); @@ -743,482 +504,652 @@ impl MxcComputeBackend { } entry.lifecycle_gate.clone() }; - - let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, isolation_stopped, cancel, monitor_task) = { + let _guard = gate.lock().await; + let (shutdown, terminated, host_state, boundary_state) = { let mut registry = self.registry.lock().await; - let Some(entry) = registry.get_mut(sandbox_id) else { - return Ok(false); - }; + let entry = registry.get_mut(sandbox_id).expect("entry checked above"); ( - entry.iso_sandbox_id.clone(), - entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), + entry.shutdown_tx.take(), + entry.terminated_rx.clone(), + entry.host_state_dir.clone(), + entry.boundary_state_dir.clone(), ) }; - if let Some(cancel) = cancel { - let _ = cancel.send(true); - } - if let Some(task) = monitor_task { - task.await.map_err(|error| { - tonic::Status::internal(format!("mxc process monitor failed: {error}")) - })?; + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); } - if let Some(ref iso_id) = iso_id { - if !isolation_stopped { - self.invoker.stop(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec stop failed: {error}")) - })?; - // Persist phase progress before deprovision. If deprovision - // fails, a retry resumes here instead of stopping twice. - let mut registry = self.registry.lock().await; - if let Some(entry) = registry.get_mut(sandbox_id) { - entry.isolation_stopped = true; - } - } - self.invoker.deprovision(iso_id).await.map_err(|error| { - tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) - })?; - } - - let mut registry = self.registry.lock().await; - if registry.remove(sandbox_id).is_some() { - if let Ok(mut idx) = self.attribution.lock() { - idx.forget(sandbox_id); + wait_for_termination(terminated, sandbox_name).await?; + cleanup_runtime_directory(&host_state); + cleanup_runtime_directory(&boundary_state); + let removed = self.registry.lock().await.remove(sandbox_id).is_some(); + if removed { + if let Ok(mut attribution) = self.attribution.lock() { + attribution.forget(sandbox_id); } let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); - return Ok(true); } - Ok(false) + Ok(removed) } - /// Returns a stream of watch events. - /// - /// First emits a snapshot of all current sandboxes, then forwards live - /// events from the broadcast channel. - pub async fn watch_sandboxes(&self) -> WatchStream { - let (tx, rx) = - mpsc::channel::>(256); - // Subscribe while holding the registry lock. Every transition is then - // represented by either this snapshot or the live receiver. - let (snapshots, mut broadcast_rx): (Vec, _) = { + pub async fn watch_sandboxes(&self) -> WatchStream { + let (tx, rx) = mpsc::channel(256); + let (snapshots, mut updates) = { let registry = self.registry.lock().await; - let broadcast_rx = self.watch_tx.subscribe(); - let snapshots = registry - .values() - .map(|entry| entry.sandbox.clone()) - .collect(); - (snapshots, broadcast_rx) + ( + registry + .values() + .map(|entry| entry.sandbox.clone()) + .collect::>(), + self.watch_tx.subscribe(), + ) }; - - let tx_clone = tx.clone(); tokio::spawn(async move { - // Deliver initial snapshots. - for sb in snapshots { - if tx_clone.send(Ok(sandbox_event(sb))).await.is_err() { + for sandbox in snapshots { + if tx.send(Ok(sandbox_event(sandbox))).await.is_err() { return; } } - // Forward live events. loop { - match broadcast_rx.recv().await { + match updates.recv().await { Ok(event) => { - if tx_clone.send(Ok(event)).await.is_err() { - break; + if tx.send(Ok(event)).await.is_err() { + return; } } - Err(broadcast::error::RecvError::Lagged(_)) => { - // Drop lagged events — the gateway re-syncs via Get/List. - } - Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return, } } }); - Box::pin(ReceiverStream::new(rx)) } } -// ── Lifecycle task ──────────────────────────────────────────────────────────── - -#[allow(clippy::too_many_arguments)] -async fn run_lifecycle( +#[derive(Clone)] +struct LifecycleContext { invoker: WxcExecInvoker, config: MxcComputeConfig, + gateway: GatewayConnection, registry: Arc>>, watch_tx: Arc>, attribution: Arc>, sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, - mapped: MappedConfig, - mut reserved_proxy_listener: Option, - _startup_guard: tokio::sync::OwnedMutexGuard<()>, -) { - let sandbox_id = sandbox.id.clone(); - let sandbox_name = sandbox.name.clone(); - let trimmed_policy = mapped.trimmed_policy.clone(); - let proxy_addr = mapped.proxy_addr; - let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) - { - drop(reserved_proxy_listener.take()); - match openshell_supervisor_network::host::start_host_proxy( - openshell_supervisor_network::host::HostProxyConfig { - bind_addr: addr, - policy: proxy_policy, - binary_path: host_proxy_binary_path(&sandbox_config), - sandbox_id: Some(sandbox_id.clone()), - sandbox_name: Some(sandbox_name.clone()), - openshell_endpoint: None, - provider_credentials: None, - agent_proposals: openshell_core::proposals::AgentProposals::default(), - denial_tx: None, - activity_tx: None, + generation: String, + host_state_dir: PathBuf, + boundary_state_dir: PathBuf, + policy_mapper: Arc, +} + +async fn run_lifecycle(context: LifecycleContext, startup_guard: tokio::sync::OwnedMutexGuard<()>) { + if let Err(error) = run_lifecycle_inner(&context, startup_guard).await { + set_failed(&context, &error).await; + } +} + +async fn run_lifecycle_inner( + context: &LifecycleContext, + startup_guard: tokio::sync::OwnedMutexGuard<()>, +) -> Result<(), String> { + create_restricted_state_dir(&context.host_state_dir, "host")?; + // This directory briefly contains the boundary TLS private key and direct + // proxy credential. Restrict host access before writing either secret; + // MXC adds the ProcessContainer grant when it applies the read-write path. + create_restricted_state_dir(&context.boundary_state_dir, "boundary")?; + let launch = launch_authentication(&context.sandbox).map_err(|error| error.to_string())?; + let (control_addr, control_reservation) = reserve_loopback_port() + .map_err(|error| format!("reserve MXC Sandbox Protocol port: {error}"))?; + let (proxy_addr, proxy_reservation) = reserve_loopback_port() + .map_err(|error| format!("reserve MXC supervisor proxy port: {error}"))?; + let mapped = context + .policy_mapper + .map( + context + .sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()), + &MapCtx { + sandbox_id: context.sandbox.id.clone(), + egress: Some(proxy_addr), + containment: MxcBackend::ProcessContainer.containment().to_string(), }, ) - .await - { - Ok(handle) => Some(handle), - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &format!("failed to start MXC host egress proxy at {addr}: {error}"), - ) - .await; - return; - } - } - } else { - None - }; - let host_proxy_ca_paths = host_proxy - .as_ref() - .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); - drop(reserved_proxy_listener.take()); - if let Some(addr) = proxy_addr { - { - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.trimmed_policy = trimmed_policy; - entry.proxy_addr = Some(addr); - entry.host_proxy = host_proxy; - } - } - let _ = watch_tx.send(platform_event( - sandbox_id.clone(), - "EgressRedirect", - format!("MXC egress redirected to OpenShell host CONNECT proxy at {addr}"), - )); + .map_err(|error| error.to_string())?; + let tls = generate_sandbox_tls_material(launch.supervisor.session_id) + .map_err(|error| error.to_string())?; + let tls_cert_path = context.boundary_state_dir.join(BOUNDARY_TLS_CERT_FILE); + let tls_key_path = context.boundary_state_dir.join(BOUNDARY_TLS_KEY_FILE); + std::fs::write(&tls_cert_path, tls.certificate_chain_pem.as_bytes()) + .map_err(|error| format!("write MXC boundary TLS certificate: {error}"))?; + std::fs::write(&tls_key_path, tls.private_key_pem.as_bytes()) + .map_err(|error| format!("write MXC boundary TLS private key: {error}"))?; + let proxy_password = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); + let authorization = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{DIRECT_PROXY_USERNAME}:{proxy_password}")) + ); + let proxy_url = format!("http://{DIRECT_PROXY_USERNAME}:{proxy_password}@{proxy_addr}"); + let verification_keys = launch + .verification_keys + .iter() + .map(|key| { + String::from_utf8(key.public_key_pem.clone()) + .map(|public_key_pem| GatewayVerificationKey { + key_id: key.key_id.clone(), + public_key_pem, + }) + .map_err(|error| format!("MXC verification key is not UTF-8 PEM: {error}")) + }) + .collect::, _>>()?; + let provisioning = MxcBoundarySpec { + boundary_id: context.sandbox.id.clone(), + generation: context.generation.clone(), + session_id: launch.supervisor.session_id, + session_rotation: launch.supervisor.session_rotation, + auth_epoch: launch.supervisor.auth_epoch, + gateway_id: launch.gateway_id.clone(), + verification_keys, + control_addr, + supervisor_tls: SandboxTlsClientConfig { + server_name: tls.server_name, + trust_anchor_pem: tls.trust_anchor_pem, + }, + sandbox_tls: SandboxTlsServerConfig { + certificate_chain_path: tls_cert_path, + private_key_path: tls_key_path, + }, + proxy_addr, + proxy_authorization: authorization, + proxy_url, + workload_binary: resolve_workload_binary(&context.sandbox_config.command[0])?, + child_env: sandbox_environment(&context.sandbox), } - - // Released wxc-exec BaseContainer builds cannot provision the generated - // TLS directory as a read-only share because that path fails its WRITE_DAC - // setup. Grant the sandbox-unique directory read-write instead so the - // AppContainer can actually read the injected trust paths. The directory - // contains only public CA certificates; the CA private key remains in the - // host proxy's in-memory TLS state. - let mut readwrite_paths = mapped.readwrite_paths; - append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); - let readonly_paths = mapped.readonly_paths; - let filesystem = MxcFilesystem { - readwrite_paths, - readonly_paths, - // OpenShell's policy model has no explicit deny field; default-deny is - // implicit and enforced by processContainer at the OS boundary. + .provision() + .map_err(|error| error.to_string())?; + let boundary_config_path = context.boundary_state_dir.join(BOUNDARY_CONFIG_FILE); + std::fs::write( + &boundary_config_path, + provisioning + .boundary_config + .encode() + .map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("write MXC boundary configuration: {error}"))?; + let auth_bundle_path = context.host_state_dir.join(HOST_AUTH_BUNDLE_FILE); + std::fs::write( + &auth_bundle_path, + serde_json::to_vec(&launch.supervisor) + .map_err(|error| format!("encode MXC supervisor auth bundle: {error}"))?, + ) + .map_err(|error| format!("write MXC supervisor auth bundle: {error}"))?; + 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, + ) + .map_err(|error| format!("write MXC runtime descriptor: {error}"))?; + // The supervisor owns the proxy listener. Release only that reservation + // before spawning it; keep the Sandbox Protocol port reserved until the + // ProcessContainer launch so unrelated local processes cannot squat it + // during the more expensive host-side setup. + drop(proxy_reservation); + let mut supervisor = spawn_supervisor(context, &descriptor_path, &auth_bundle_path)?; + let sandbox_command = encode_windows_command_line(&[ + context.config.sandbox_binary_path.clone(), + "--bootstrap".to_string(), + boundary_config_path.display().to_string(), + "--log-level".to_string(), + openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "warn"), + ]); + let mut filesystem = MxcFilesystem { + readwrite_paths: mapped.readwrite_paths, + readonly_paths: mapped.readonly_paths, denied_paths: Vec::new(), }; - let command_line = encode_windows_command_line(&sandbox_config.command); - let mut environment = sandbox_environment(&sandbox); - append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); - info!(sandbox = %sandbox_name, count = environment.len(), "MXC process env vars"); + push_unique_path( + &mut filesystem.readwrite_paths, + context.boundary_state_dir.display().to_string(), + ); + push_unique_path( + &mut filesystem.readonly_paths, + context.config.sandbox_binary_path.clone(), + ); let process = MxcProcess { - command_line: command_line.clone(), - cwd: sandbox_config.cwd, - env: environment, + command_line: sandbox_command, + cwd: context.sandbox_config.cwd.clone(), + env: trusted_sandbox_environment(context.config.pc_minimal_env), timeout: 0, }; - let network = proxy_addr.map(|addr| MxcNetwork { - default_policy: "block".into(), - proxy: Some(addr), - }); - - let child = match config.backend { - MxcBackend::IsolationSession => { - let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem, network) - .await - { - Ok(id) => id, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - }; - info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); - { - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - // Publish cleanup identity before any later lifecycle await. - entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); - entry.isolation_stopped = false; - } - } - if let Err(error) = invoker.start(&iso_sandbox_id).await { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - info!(sandbox = %sandbox_name, "MXC started"); - match invoker.spawn_exec(&iso_sandbox_id, process).await { - Ok(child) => child, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - } + let network = MxcNetwork { + default_policy: "block".to_string(), + proxy: Some(proxy_addr), + allow_local_network: context.config.pc_allow_local_network, + }; + drop(control_reservation); + let mut boundary = context + .invoker + .run_oneshot( + &context.sandbox.id, + filesystem, + MxcProcessContainer { + least_privilege: context.config.pc_least_privilege, + capabilities: context.config.pc_capabilities.clone(), + }, + process, + Some(network), + mapped.ui, + ) + .await + .map_err(|error| format!("start MXC ProcessContainer: {error}"))?; + attach_child_logs(&context.sandbox.name, "sandbox", &mut boundary); + attach_child_logs(&context.sandbox.name, "supervisor", &mut supervisor); + if context.config.etw_audit + && let Some(pid) = boundary.id() + && let Ok(process_start_key) = crate::etw_consumer::child_process_start_key(&boundary) + && let Ok(mut attribution) = context.attribution.lock() + { + attribution.register_launch( + &context.sandbox.id, + &context.sandbox.name, + pid, + process_start_key, + ); + } + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let (terminated_tx, terminated_rx) = watch::channel(false); + { + let mut registry = context.registry.lock().await; + let entry = registry + .get_mut(&context.sandbox.id) + .ok_or_else(|| "MXC sandbox was deleted during startup".to_string())?; + entry.shutdown_tx = Some(shutdown_tx); + entry.terminated_rx = Some(terminated_rx); + entry.phase_state = PhaseState::Running; + } + drop(startup_guard); + let result = monitor_runtime_pair(boundary, supervisor, shutdown_rx).await; + let _ = terminated_tx.send(true); + if let Ok(mut attribution) = context.attribution.lock() { + attribution.forget(&context.sandbox.id); + } + match result { + RuntimePairExit::Shutdown => Ok(()), + RuntimePairExit::Boundary(status) => Err(format!( + "MXC ProcessContainer exited before supervisor shutdown: {status}" + )), + RuntimePairExit::Supervisor(status) => Err(format!( + "MXC supervisor exited while ProcessContainer was active: {status}" + )), + RuntimePairExit::Wait(error) => Err(error), + } +} + +fn spawn_supervisor( + context: &LifecycleContext, + descriptor_path: &Path, + auth_bundle_path: &Path, +) -> Result { + let main_process_spec = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec( + context.sandbox.spec.as_ref(), + ) + .map_err(|error| format!("encode MXC main process spec: {error}"))?; + let mut command = Command::new(&context.config.supervisor_binary_path); + command + .kill_on_drop(true) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .arg("--role") + .arg("isolation-backend") + .arg("--backend-descriptor-file") + .arg(descriptor_path) + .arg("--auth-bundle-file") + .arg(auth_bundle_path) + .env( + openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND, + openshell_mxc_boundary::BACKEND_NAME, + ) + .env( + openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + main_process_spec, + ) + .env( + openshell_core::sandbox_env::ENDPOINT, + &context.gateway.endpoint, + ) + .env(openshell_core::sandbox_env::SANDBOX_ID, &context.sandbox.id) + .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"), + ); + if let Some(ca) = &context.gateway.tls { + command.env(openshell_core::sandbox_env::TLS_CA, ca); + } + if let Some(server_name) = &context.gateway.tls_server_name { + command.env( + openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME, + server_name, + ); + } + command + .spawn() + .map_err(|error| format!("start host openshell-supervisor: {error}")) +} + +enum RuntimePairExit { + Shutdown, + Boundary(std::process::ExitStatus), + Supervisor(std::process::ExitStatus), + Wait(String), +} + +async fn monitor_runtime_pair( + mut boundary: Child, + mut supervisor: Child, + mut shutdown: oneshot::Receiver<()>, +) -> RuntimePairExit { + tokio::select! { + _ = &mut shutdown => { + let _ = supervisor.kill().await; + let _ = boundary.kill().await; + let _ = supervisor.wait().await; + let _ = boundary.wait().await; + RuntimePairExit::Shutdown } - MxcBackend::ProcessContainer => { - let process_container = MxcProcessContainer { - least_privilege: config.pc_least_privilege, - capabilities: config.pc_capabilities.clone(), - }; - match invoker - .run_oneshot(&sandbox_id, filesystem, process_container, process, network) - .await - { - Ok(child) => child, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &error.to_string(), - ) - .await; - return; - } - } + result = boundary.wait() => { + let _ = supervisor.kill().await; + let _ = supervisor.wait().await; + result.map_or_else( + |error| RuntimePairExit::Wait(format!("wait for MXC ProcessContainer: {error}")), + RuntimePairExit::Boundary, + ) } - }; - info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); - - let ready_sandbox = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "True".into(), - reason: "AgentRunning".into(), - message: format!("Agent exec launched: {command_line}"), - transition_time: None, - }, + result = supervisor.wait() => { + let _ = boundary.kill().await; + let _ = boundary.wait().await; + result.map_or_else( + |error| RuntimePairExit::Wait(format!("wait for MXC supervisor: {error}")), + RuntimePairExit::Supervisor, + ) + } + } +} + +async fn set_failed(context: &LifecycleContext, message: &str) { + warn!(sandbox = %context.sandbox.name, %message, "MXC lifecycle failed"); + let failed = make_sandbox_with_condition( + &context.sandbox, + &condition("Ready", "False", "RuntimeFailed", message), false, ); - let (cancel_tx, cancel_rx) = watch::channel(false); + let mut registry = context.registry.lock().await; + if let Some(entry) = registry.get_mut(&context.sandbox.id) + && !matches!(entry.phase_state, PhaseState::Stopped) { - // Publish cancellation state before the monitor can observe a fast - // process exit. Holding the registry lock while spawning prevents a - // completed child from being overwritten with AgentRunning. - let mut registry_guard = registry.lock().await; - let Some(entry) = registry_guard.get_mut(&sandbox_id) else { - // The sandbox was deleted between agent launch and readiness. Bail - // without seeding ETW attribution (a stale key would misroute later - // events to a dead sandbox), without reporting Ready, and without - // spawning the exec monitor. `delete` already tore down the process. - return; - }; + entry.phase_state = PhaseState::Failed(message.to_string()); + entry.sandbox = failed.clone(); + drop(registry); + let _ = context.watch_tx.send(sandbox_event(failed)); + let _ = context.watch_tx.send(platform_event( + context.sandbox.id.clone(), + "RuntimeFailed", + message.to_string(), + )); + } +} - // Seed ETW attribution while holding the registry lock so a concurrent - // `delete` cannot remove the sandbox after we register (which would leave - // a stale key). The `wxc-exec` pid we just spawned is the collision-proof - // anchor that ties the `Sandboxing` provider's events back to this - // `sandbox_id` while the child is alive. Command text is never an - // attribution key. No-op unless the ETW consumer is running. - if config.etw_audit - && let Some(pid) = child.id() - { - match crate::etw_consumer::child_process_start_key(&child) { - Ok(process_start_key) => { - if let Ok(mut idx) = attribution.lock() { - idx.register_launch(&sandbox_id, &sandbox_name, pid, process_start_key); - } - } - Err(error) => { - warn!( - sandbox = %sandbox_name, - pid, - error, - "failed to obtain wxc-exec process generation key; PID-based ETW attribution disabled for this launch" - ); - } - } +fn sandbox_config(sandbox: &DriverSandbox) -> Result { + let config = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.driver_config.as_ref()) + .ok_or_else(|| { + tonic::Status::invalid_argument( + "mxc requires template.driver_config.mxc with command and cwd", + ) + })?; + let config: MxcSandboxConfig = + serde_json::from_value(struct_to_json_value(config)).map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?; + if config.command.first().is_none_or(String::is_empty) { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.command must contain an executable", + )); + } + Ok(config) +} + +fn launch_authentication( + sandbox: &DriverSandbox, +) -> Result { + let encoded = sandbox + .spec + .as_ref() + .map(|spec| spec.launch_authentication.as_slice()) + .filter(|encoded| !encoded.is_empty()) + .ok_or_else(|| { + tonic::Status::failed_precondition("MXC sandbox launch authentication is required") + })?; + serde_json::from_slice(encoded).map_err(|error| { + tonic::Status::failed_precondition(format!( + "decode MXC sandbox launch authentication: {error}" + )) + }) +} + +fn sandbox_environment(sandbox: &DriverSandbox) -> HashMap { + let mut environment = HashMap::new(); + if let Some(spec) = &sandbox.spec { + if let Some(template) = &spec.template { + environment.extend(template.environment.clone()); } + environment.extend(spec.environment.clone()); + } + environment +} - entry.sandbox = ready_sandbox.clone(); - entry.phase_state = PhaseState::Running; - entry.monitor_cancel = Some(cancel_tx); - entry.monitor_task = Some(tokio::spawn(monitor_exec( - registry.clone(), - watch_tx.clone(), - attribution.clone(), - sandbox.clone(), - sandbox_id.clone(), - cancel_rx, - child, - ))); +fn trusted_sandbox_environment(minimal: bool) -> Vec { + if minimal { + return Vec::new(); } - let _ = watch_tx.send(sandbox_event(ready_sandbox)); + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"] + .into_iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| format!("{key}={value}")) + }) + .collect() } -async fn monitor_exec( - registry: Arc>>, - watch_tx: Arc>, - attribution: Arc>, - sandbox: DriverSandbox, - sandbox_id: String, - mut cancel_rx: watch::Receiver, - mut child: tokio::process::Child, -) { - let wxc_pid = child.id(); - let status = tokio::select! { - status = child.wait() => Some(status), - changed = cancel_rx.changed() => { - let should_kill = changed.is_ok() && *cancel_rx.borrow_and_update(); - if should_kill { - if let Err(error) = child.kill().await { - warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); - } - // `kill` waits on current Tokio releases, but an explicit wait is - // harmless and guarantees the OS process handle is reaped. - let _ = child.wait().await; - } - None - } - }; +fn reserve_loopback_port() -> io::Result<(SocketAddr, std::net::TcpListener)> { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + Ok((listener.local_addr()?, listener)) +} + +fn resolve_workload_binary(command: &str) -> Result { + let path = PathBuf::from(command); + if path.is_absolute() { + return Ok(path); + } + let output = std::process::Command::new("where.exe") + .arg(command) + .output() + .map_err(|error| format!("resolve MXC workload executable {command:?}: {error}"))?; + if !output.status.success() { + return Err(format!( + "MXC workload executable {command:?} is relative and was not found on PATH" + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("decode resolved MXC workload executable: {error}"))? + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .find(|candidate| candidate.is_absolute()) + .ok_or_else(|| format!("where.exe returned no absolute path for {command:?}")) +} - // A Windows PID is authoritative only while the exact driver-owned child is - // alive. Retire it on every monitor exit path, including cancellation, before - // Windows can recycle it while the sandbox remains in the registry. - if let Some(pid) = wxc_pid - && let Ok(mut idx) = attribution.lock() +fn safe_component(value: &str) -> Result<&str, tonic::Status> { + if !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) { - idx.retire_launch(&sandbox_id, pid); + Ok(value) + } else { + Err(tonic::Status::invalid_argument( + "sandbox ID is not safe for MXC state paths", + )) + } +} + +fn push_unique_path(paths: &mut Vec, path: String) { + if !paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&path)) + { + paths.push(path); + } +} + +fn cleanup_runtime_directory(path: &Path) { + if let Err(error) = std::fs::remove_dir_all(path) + && error.kind() != io::ErrorKind::NotFound + { + warn!(path = %path.display(), %error, "failed to remove MXC runtime state"); + } +} + +/// Create a state directory whose DACL grants access only to the gateway's +/// Windows identity until MXC applies any explicit ProcessContainer grant. +/// Secret-bearing state must not inherit permissive ACLs from its parent. +fn create_restricted_state_dir(path: &Path, kind: &str) -> Result<(), String> { + std::fs::create_dir_all(path) + .map_err(|error| format!("create MXC {kind} state directory: {error}"))?; + let identity = std::process::Command::new("whoami.exe") + .args(["/user", "/fo", "csv", "/nh"]) + .output() + .map_err(|error| format!("resolve gateway Windows identity: {error}"))?; + if !identity.status.success() { + return Err("whoami failed while restricting MXC host state".to_string()); + } + let identity = String::from_utf8(identity.stdout) + .map_err(|error| format!("decode gateway Windows identity: {error}"))?; + let sid = identity + .trim() + .rsplit_once(',') + .map(|(_, sid)| sid.trim().trim_matches('"')) + .filter(|sid| sid.starts_with("S-1-")) + .ok_or_else(|| "whoami returned no Windows SID".to_string())?; + let grant = format!("*{sid}:(OI)(CI)F"); + let status = std::process::Command::new("icacls.exe") + .arg(path) + .args(["/inheritance:r", "/grant:r", &grant, "/q"]) + .status() + .map_err(|error| format!("restrict MXC host state ACL: {error}"))?; + if !status.success() { + return Err(format!( + "icacls failed to restrict MXC {kind} state directory {}", + path.display() + )); } + Ok(()) +} - let Some(status) = status else { - return; +async fn wait_for_termination( + terminated: Option>, + sandbox_name: &str, +) -> Result<(), tonic::Status> { + let Some(mut terminated) = terminated else { + return Ok(()); }; + match tokio::time::timeout(Duration::from_secs(15), terminated.wait_for(|done| *done)).await { + Ok(Ok(_)) => Ok(()), + _ => Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the MXC stop timeout" + ))), + } +} - match status { - Ok(status) if status.success() => { - info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); - let done = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "True".into(), - reason: "AgentCompleted".into(), - message: "Agent exec finished successfully (exit code 0)".into(), - transition_time: None, - }, - false, - ); - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.host_proxy = None; - entry.sandbox = done.clone(); - entry.phase_state = PhaseState::Running; +fn attach_child_logs(sandbox_name: &str, component: &'static str, child: &mut Child) { + if let Some(stdout) = child.stdout.take() { + let sandbox_name = sandbox_name.to_string(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + info!(sandbox = %sandbox_name, component, "{line}"); } - drop(registry); - let _ = watch_tx.send(sandbox_event(done)); - } - Ok(status) => { - let code = status.code().unwrap_or(-1); - warn!(sandbox = %sandbox.name, exit_code = code, "MXC agent exec exited non-zero"); - let _ = watch_tx.send(platform_event( - sandbox_id.clone(), - "AgentExecFailed", - format!("agent exited with code {code}; possible out-of-policy write"), - )); - let failed = make_sandbox_with_condition( - &sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "ExecFailed".into(), - message: format!("Agent exec exited {code}"), - transition_time: None, - }, - false, - ); - let mut registry = registry.lock().await; - if let Some(entry) = registry.get_mut(&sandbox_id) { - entry.host_proxy = None; - entry.sandbox = failed.clone(); - entry.phase_state = PhaseState::Failed(format!("exit code {code}")); + }); + } + if let Some(stderr) = child.stderr.take() { + let sandbox_name = sandbox_name.to_string(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + warn!(sandbox = %sandbox_name, component, "{line}"); } - drop(registry); - let _ = watch_tx.send(sandbox_event(failed)); - } - Err(error) => { - warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); - } + }); } } -async fn set_failed( - registry: &Arc>>, - watch_tx: &Arc>, - sandbox: &DriverSandbox, - sandbox_id: &str, - message: &str, -) { - warn!(sandbox = %sandbox.name, error = %message, "MXC lifecycle failed"); - let failed = make_sandbox_with_condition( - sandbox, - &DriverCondition { - r#type: "Ready".into(), - status: "False".into(), - reason: "ProvisionFailed".into(), - message: message.to_string(), - transition_time: None, - }, - false, - ); - let mut reg = registry.lock().await; - if let Some(entry) = reg.get_mut(sandbox_id) { - entry.host_proxy = None; - entry.sandbox = failed.clone(); - entry.phase_state = PhaseState::Failed(message.to_string()); + +fn encode_windows_command_line(args: &[String]) -> String { + args.iter() + .map(|argument| quote_windows_argument(argument)) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(argument: &str) -> String { + if !argument.is_empty() + && !argument + .chars() + .any(|character| character.is_whitespace() || character == '"') + { + return argument.to_string(); + } + let mut quoted = String::from("\""); + let mut backslashes = 0; + for character in argument.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(character); + } + } } - drop(reg); - let _ = watch_tx.send(sandbox_event(failed)); + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted } -// ── Helpers ─────────────────────────────────────────────────────────────────── +fn condition(kind: &str, status: &str, reason: &str, message: &str) -> DriverCondition { + DriverCondition { + r#type: kind.to_string(), + status: status.to_string(), + reason: reason.to_string(), + message: message.to_string(), + transition_time: None, + } +} fn make_sandbox_with_condition( base: &DriverSandbox, @@ -1232,10 +1163,6 @@ fn make_sandbox_with_condition( workspace: base.workspace.clone(), spec: base.spec.clone(), status: Some(DriverSandboxStatus { - name: base.name.clone(), - instance_id: String::new(), - agent_fd: String::new(), - sandbox_fd: String::new(), conditions: vec![condition.clone()], deleting, ..Default::default() @@ -1243,22 +1170,49 @@ fn make_sandbox_with_condition( } } -// ── Lifecycle + policy-proof tests (mock wxc-exec) ───────────────────────────── -// -// These drive the full create → provision → start → exec → self-report Ready -// flow against the in-process mock shim, proving the positive (in-policy write -// succeeds, Ready reached) and negative (out-of-policy write denied + denial -// event) paths WITHOUT the demo box. Windows-only (the crate is Windows-gated), -// run by the `windows:test:x64` mise lane. +fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), + } +} + +fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id }, + )), + } +} + +fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::PlatformEvent( + WatchSandboxesPlatformEvent { + sandbox_id, + event: Some(DriverPlatformEvent { + source: "mxc-driver".into(), + r#type: "Warning".into(), + reason: reason.to_string(), + message, + metadata: HashMap::new(), + ..Default::default() + }), + }, + )), + } +} + +// The RFC 0012 replacement above owns create/stop/delete/watch and paired +// process monitoring. Resource admission remains in validate_sandbox_create; +// readiness is reported through the authenticated supervisor session, not by +// the driver. Keep lifecycle/security tests below during their migration. #[cfg(test)] -mod lifecycle_tests { +mod tests { use super::*; - use futures::StreamExt; - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; - use openshell_core::proto::{ - FilesystemPolicy, MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy, - }; - use std::time::Duration; fn host_grants_config() -> MxcComputeConfig { MxcComputeConfig { @@ -1273,110 +1227,149 @@ mod lifecycle_tests { #[tokio::test] async fn admission_rejects_host_grants_even_with_driver_config_enabled() { - let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { - allow_driver_config: true, - ..Default::default() - }); - let sandbox = driver_sandbox("sb-admission"); + let (mut backend, sandbox, _dir) = preflight_fixture(); + backend.config.resource_admission.enabled = true; let error = backend.validate_sandbox_create(&sandbox).unwrap_err(); assert_eq!(error.code(), tonic::Code::FailedPrecondition); assert!(backend.create_sandbox(&sandbox).await.is_err()); - assert!(backend.get_sandbox("sb-admission").await.is_none()); + assert!(backend.get_sandbox(&sandbox.id).await.is_none()); + assert!(!backend.config.state_dir.exists()); } - fn driver_sandbox(id: &str) -> DriverSandbox { - driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + #[test] + fn static_configuration_requires_an_absolute_wxc_path() { + for path in ["", "wxc-exec.exe", "relative\\wxc-exec.exe"] { + let config = MxcComputeConfig { + wxc_exec_path: path.to_string(), + ..Default::default() + }; + assert!(config.validate_configuration().is_err()); + } + let config = MxcComputeConfig { + wxc_exec_path: "C:\\mxc-kit\\bin\\wxc-exec.exe".to_string(), + ..Default::default() + }; + assert!(config.validate_configuration().is_ok()); } - fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { - let serde_json::Value::Object(driver_config) = serde_json::json!({ - "command": command, - "cwd": cwd, + fn preflight_fixture() -> (MxcComputeBackend, DriverSandbox, tempfile::TempDir) { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + let dir = tempfile::tempdir().expect("test directory"); + let binary = std::env::current_exe().expect("test executable"); + let backend = MxcComputeBackend::new( + openshell_core::config::DEFAULT_GATEWAY_NAME, + MxcComputeConfig { + wxc_exec_path: binary.display().to_string(), + grpc_endpoint: "http://127.0.0.1:1".into(), + supervisor_binary_path: binary.display().to_string(), + sandbox_binary_path: binary.display().to_string(), + state_dir: dir.path().join("not-created"), + ..host_grants_config() + }, + ); + let serde_json::Value::Object(config) = serde_json::json!({ + "command": ["C:\\Windows\\System32\\cmd.exe", "/c", "exit 0"], + "cwd": dir.path(), }) else { - unreachable!(); + unreachable!() }; - DriverSandbox { - id: id.to_string(), - name: id.to_string(), - namespace: String::new(), - workspace: String::new(), + let sandbox = DriverSandbox { + id: "preflight".into(), + name: "preflight".into(), spec: Some(DriverSandboxSpec { - sandbox_token: "test-token".into(), + policy: Some(SandboxPolicy { + version: 1, + ..Default::default() + }), template: Some(DriverSandboxTemplate { driver_config: Some( - openshell_core::proto_struct::json_object_to_struct(driver_config).unwrap(), + openshell_core::proto_struct::json_object_to_struct(config) + .expect("config"), ), ..Default::default() }), ..Default::default() }), - status: None, - } - } - fn fs_policy(read_write: &[&str]) -> SandboxPolicy { - SandboxPolicy { - filesystem: Some(FilesystemPolicy { - include_workdir: false, - read_only: Vec::new(), - read_write: read_write.iter().map(ToString::to_string).collect(), - }), ..Default::default() - } + }; + (backend, sandbox, dir) } - fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { - sandbox.spec.as_mut().unwrap().policy = Some(policy); - sandbox + #[test] + fn preflight_does_not_require_not_yet_minted_authentication() { + let (backend, sandbox, _dir) = preflight_fixture(); + backend + .validate_sandbox_create(&sandbox) + .expect("static preflight"); + assert!(!backend.config.state_dir.exists()); } - fn ready_condition(sb: &DriverSandbox) -> Option { - sb.status - .as_ref()? - .conditions - .iter() - .find(|c| c.r#type == "Ready") - .cloned() + #[tokio::test] + async fn provisioning_rejects_missing_authentication_before_side_effects() { + let (backend, sandbox, _dir) = preflight_fixture(); + let error = backend + .create_sandbox(&sandbox) + .await + .expect_err("authentication required"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("launch authentication")); + assert!(!backend.config.state_dir.exists()); + assert!(backend.get_sandbox(&sandbox.id).await.is_none()); } - /// Poll the backend registry until the predicate matches or the deadline hits. - async fn wait_for( - backend: &MxcComputeBackend, - name: &str, - mut pred: F, - ) -> Option - where - F: FnMut(&DriverSandbox) -> bool, - { - for _ in 0..100 { - if let Some(sandbox) = backend.get_sandbox(name).await - && pred(&sandbox) - { - return Some(sandbox); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - None + #[test] + fn capabilities_delegate_readiness_to_supervisor() { + let backend = MxcComputeBackend::new( + openshell_core::config::DEFAULT_GATEWAY_NAME, + MxcComputeConfig::default(), + ); + assert!(!backend.capabilities().driver_reports_runtime_readiness); } #[test] - fn mxc_config_defaults_to_default_deny_process_container() { - let config = MxcComputeConfig::default(); + fn legacy_isolation_setting_does_not_change_processcontainer_contract() { + let config: MxcComputeConfig = serde_json::from_value(serde_json::json!({ + "backend": "process_container", + "default_configuration_id": "composable", + })) + .expect("legacy ProcessContainer config"); assert_eq!(config.backend, MxcBackend::ProcessContainer); - assert!(!config.egress_proxy); - assert!(config.egress_proxy_addr.is_empty()); + assert_eq!( + config.default_configuration_id.as_deref(), + Some("composable") + ); + assert!( + serde_json::to_value(&config) + .expect("config") + .get("default_configuration_id") + .is_none() + ); + let (mut backend, sandbox, _dir) = preflight_fixture(); + backend.config.backend = MxcBackend::IsolationSession; + backend.config.default_configuration_id = config.default_configuration_id; + assert!( + backend + .validate_sandbox_create(&sandbox) + .expect_err("IsolationSession still unsupported") + .message() + .contains("requires process_container") + ); } #[test] - fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { - let configured = "127.0.0.1:18080".parse().unwrap(); - let (addr, _reservation) = allocate_sandbox_proxy_addr(configured).unwrap(); - assert_eq!(addr.ip(), configured.ip()); - assert_ne!(addr.port(), 0); + fn command_line_quotes_spaces() { + assert_eq!( + encode_windows_command_line(&[ + "C:\\Program Files\\OpenShell\\openshell-windows-sandbox.exe".to_string(), + "--bootstrap".to_string(), + ]), + "\"C:\\Program Files\\OpenShell\\openshell-windows-sandbox.exe\" --bootstrap" + ); } #[test] - fn sandbox_environment_inherits_host_with_spec_precedence() { - let mut sandbox = driver_sandbox("sb-env"); + fn sandbox_environment_preserves_spec_precedence_without_host_secrets() { + let (_backend, mut sandbox, _dir) = preflight_fixture(); let spec = sandbox.spec.as_mut().unwrap(); spec.template .as_mut() @@ -1384,64 +1377,23 @@ mod lifecycle_tests { .environment .insert("SHARED".into(), "template".into()); spec.environment.insert("SHARED".into(), "spec".into()); - spec.environment.insert("TOKEN".into(), "value".into()); - let environment = sandbox_environment(&sandbox); - assert!(environment.contains(&"SHARED=spec".to_string())); - assert!(environment.contains(&"TOKEN=value".to_string())); - for key in MINIMAL_WINDOWS_BOOTSTRAP_ENV { - if let Ok(value) = std::env::var(key) { - assert!(environment.contains(&format!("{key}={value}"))); - } - } - assert!(environment.iter().all(|entry| { - let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); - key == "SHARED" || key == "TOKEN" || MINIMAL_WINDOWS_BOOTSTRAP_ENV.contains(&key) - })); - } - - #[test] - fn tls_env_vars_replace_user_trust_overrides() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let ca_cert_path = ca_cert.display().to_string(); - let bundle_path = bundle.display().to_string(); - let mut env = vec![ - "FOO=bar".to_string(), - "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), - "node_extra_ca_certs=C:\\old\\ca.pem".to_string(), - ]; - - append_tls_env_vars(&mut env, Some(&(ca_cert, bundle))); - - assert!(env.contains(&"FOO=bar".to_string())); - assert!( - !env.iter() - .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") - ); - assert!( - !env.iter() - .any(|entry| entry == "node_extra_ca_certs=C:\\old\\ca.pem") + spec.environment.insert("TOKEN".into(), "explicit".into()); + assert_eq!( + sandbox_environment(&sandbox), + HashMap::from([ + ("SHARED".into(), "spec".into()), + ("TOKEN".into(), "explicit".into()), + ]) ); - assert!(env.contains(&format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"))); - assert!(env.contains(&format!("DENO_CERT={ca_cert_path}"))); - assert!(env.contains(&format!("SSL_CERT_FILE={bundle_path}"))); - assert!(env.contains(&format!("REQUESTS_CA_BUNDLE={bundle_path}"))); - assert!(env.contains(&format!("CURL_CA_BUNDLE={bundle_path}"))); - assert!(env.contains(&format!("GIT_SSL_CAINFO={bundle_path}"))); } #[test] - fn tls_readwrite_grant_adds_ca_directory_once() { - let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); - let ca_cert = tls_dir.join("openshell-ca.pem"); - let bundle = tls_dir.join("ca-bundle.pem"); - let existing = tls_dir.display().to_string().to_ascii_lowercase(); - let mut readwrite = vec![existing.clone()]; - - append_tls_readwrite_grant(&mut readwrite, Some(&(ca_cert, bundle))); - - assert_eq!(readwrite, vec![existing]); + fn proxy_port_is_reserved_on_loopback() { + let (address, reservation) = reserve_loopback_port().unwrap(); + assert!(address.ip().is_loopback()); + assert_ne!(address.port(), 0); + assert!(std::net::TcpListener::bind(address).is_err()); + drop(reservation); } #[test] @@ -1458,384 +1410,32 @@ mod lifecycle_tests { quote_windows_argument(r#"say "hello""#), r#""say \"hello\"""# ); - assert_eq!( - quote_windows_argument("trailing slash\\ "), - r#""trailing slash\ ""# - ); - } - #[tokio::test] - async fn positive_in_policy_write_reaches_ready_and_materializes_file() { - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - - let policy = fs_policy(&[&share]); - let sb = with_policy(driver_sandbox_with_command("sb-pos", &share, cmd), policy); - backend.create_sandbox(&sb).await.expect("create accepted"); - - // Self-reported Ready=True (no supervisor) once the agent exec launches. - let ready = wait_for(&backend, "sb-pos", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!(ready.is_some(), "sandbox should self-report Ready=True"); - - // Positive proof: the in-policy write materializes the host artifact. - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); - let mut found = false; - for _ in 0..100 { - if host_path.exists() { - found = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert!(found, "hello.txt should appear in the granted share folder"); - - // A successful one-shot agent (exit 0) must STAY Ready, not demote to - // Error. Assert the terminal condition is Ready=True/AgentCompleted so the - // positive demo shows a green Ready phase, not a red Error. - let completed = wait_for(&backend, "sb-pos", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentCompleted") - }) - .await; - assert!( - completed.is_some(), - "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" - ); - assert!( - !backend - .attribution - .lock() - .unwrap() - .has_live_pid_for_sandbox("sb-pos"), - "the process monitor must retire the wxc-exec PID before publishing completion" - ); } #[tokio::test] - async fn processcontainer_one_shot_in_policy_write_reaches_ready() { - // The processContainer backend skips provision/start and runs a single - // one-shot. The mock routes through `run_oneshot`, deriving grants from - // the filesystem (not a provision step), so the in-policy write should - // materialize and the sandbox should reach Ready=True. - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - - let policy = fs_policy(&[&share]); - let sb = with_policy(driver_sandbox_with_command("sb-pc", &share, cmd), policy); - backend.create_sandbox(&sb).await.expect("create accepted"); - - let ready = wait_for(&backend, "sb-pc", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!( - ready.is_some(), - "processContainer sandbox should self-report Ready=True" - ); - let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); - assert!( - recorded.get("network").is_none(), - "coarse path must not emit an MXC network block" - ); - - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); - let mut found = false; - for _ in 0..100 { - if host_path.exists() { - found = true; - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - assert!( - found, - "in-policy write should materialize under processContainer" - ); - } - - #[tokio::test] - async fn split_path_provisions_with_proxy_redirect() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; - - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let config = MxcComputeConfig { - backend: MxcBackend::ProcessContainer, - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".into(), - ..host_grants_config() + async fn stop_terminates_and_reaps_both_runtime_processes() { + let spawn = || { + Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 60", + ]) + .kill_on_drop(true) + .spawn() + .expect("real test process") }; - let backend = MxcComputeBackend::new_mocked(config); - let mut stream = backend.watch_sandboxes().await; - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ports: vec![443], - protocol: "rest".into(), - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".into(), - }], - }, - ); - let sandbox = with_policy( - driver_sandbox_with_command("sb-egress", &share, cmd), - policy.clone(), - ); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - - let ready = wait_for(&backend, "sb-egress", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") - }) - .await; - assert!( - ready.is_some(), - "egress split sandbox should reach Ready=True" - ); - - let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["network"]["defaultPolicy"], "block"); - assert!(recorded["network"].get("allowedHosts").is_none()); - assert!(recorded["network"].get("blockedHosts").is_none()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - let proxy_port = recorded["network"]["proxy"]["localhost"] - .as_u64() - .expect("proxy localhost port"); - assert!(proxy_port > 0); - assert!(u16::try_from(proxy_port).is_ok()); - assert!( - recorded["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - recorded["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" - ); - - let reg = backend.registry.lock().await; - let entry = reg.get("sb-egress").expect("registry entry"); - let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); - assert_eq!( - entry_proxy_addr.ip(), - std::net::IpAddr::from([127, 0, 0, 1]) - ); - assert_eq!(u64::from(entry_proxy_addr.port()), proxy_port); - assert_eq!( - entry.trimmed_policy.as_ref().unwrap().network_policies, - policy.network_policies - ); - drop(reg); - - let mut saw_redirect = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload - && pe - .event - .as_ref() - .is_some_and(|e| e.reason == "EgressRedirect") - { - saw_redirect = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!(saw_redirect, "expected EgressRedirect platform event"); - } - - #[tokio::test] - async fn negative_out_of_policy_write_is_denied_with_event() { - let share_tmp = tempfile::tempdir().unwrap(); - let out_tmp = tempfile::tempdir().unwrap(); - let share = share_tmp.path().to_string_lossy().replace('\\', "/"); - let out_path = format!( - "{}/hello.txt", - out_tmp.path().to_string_lossy().replace('\\', "/") - ); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {out_path} -Value hi"), - ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - - // Subscribe to the watch stream BEFORE create so we catch the denial event. - let mut stream = backend.watch_sandboxes().await; - - let policy = fs_policy(&[&share]); - let sandbox = with_policy(driver_sandbox_with_command("sb-neg", &share, cmd), policy); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - - // Collect events until we observe the AgentExecFailed platform event. - let mut saw_denial = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(event)) = ev.payload - && event - .event - .as_ref() - .is_some_and(|event| event.reason == "AgentExecFailed") - { - saw_denial = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!( - saw_denial, - "expected an AgentExecFailed denial platform event" - ); - - // The out-of-policy artifact must NOT have been written by the mock. - let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); - assert!(!out_fs.exists(), "out-of-policy write must be denied"); - - // And the sandbox surfaces a terminal ExecFailed Ready=False condition. - let failed = wait_for(&backend, "sb-neg", |s| { - ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ExecFailed") - }) - .await; - assert!(failed.is_some(), "sandbox should report ExecFailed"); - } - - #[tokio::test] - async fn stop_terminates_and_reaps_a_running_process_container() { - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - let command = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("$null = '{share}'; Start-Sleep -Seconds 60"), - ]; - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - let policy = fs_policy(&[&share]); - let sandbox = with_policy(driver_sandbox_with_command("sb-stop", "", command), policy); - backend - .create_sandbox(&sandbox) - .await - .expect("create accepted"); - wait_for(&backend, "sb-stop", |sandbox| { - ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") - }) + let boundary = spawn(); + let supervisor = spawn(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + shutdown_tx.send(()).unwrap(); + let exit = tokio::time::timeout( + Duration::from_secs(5), + monitor_runtime_pair(boundary, supervisor, shutdown_rx), + ) .await - .expect("long-running child should start"); - tokio::time::sleep(Duration::from_millis(250)).await; - let running = backend.get_sandbox("sb-stop").await.unwrap(); - assert_eq!(ready_condition(&running).unwrap().reason, "AgentRunning"); - - tokio::time::timeout(Duration::from_secs(5), backend.stop_sandbox("sb-stop")) - .await - .expect("stop should not wait for the child sleep") - .expect("stop should terminate and reap the child"); - let stopped = backend.get_sandbox("sb-stop").await.unwrap(); - assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); - } - - #[tokio::test] - async fn unmappable_network_policy_fails_create_lifecycle() { - use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; - let tmp = tempfile::tempdir().unwrap(); - let share = tmp.path().to_string_lossy().replace('\\', "/"); - - let backend = MxcComputeBackend::new_mocked(host_grants_config()); - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ..Default::default() - }], - binaries: Vec::new(), - }, - ); - let sandbox = with_policy(driver_sandbox("sb-net"), policy); - let error = backend - .create_sandbox(&sandbox) - .await - .expect_err("unmappable policy must fail CreateSandbox synchronously"); - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(backend.get_sandbox("sb-net").await.is_none()); - } - - #[tokio::test] - async fn governed_egress_rejects_network_middleware_before_lifecycle() { - let config = MxcComputeConfig { - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".into(), - ..host_grants_config() - }; - let backend = MxcComputeBackend::new_mocked(config); - - let mut policy = fs_policy(&[]); - policy.network_middlewares.insert( - "redactor".into(), - NetworkMiddlewareConfig { - name: "redactor".into(), - middleware: "openshell/regex".into(), - on_error: "fail_closed".into(), - endpoints: Some(MiddlewareEndpointSelector { - include: vec!["api.example.com".into()], - exclude: Vec::new(), - }), - ..Default::default() - }, - ); - let sandbox = with_policy(driver_sandbox("sb-middleware"), policy); - - let error = backend.create_sandbox(&sandbox).await.unwrap_err(); - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(error.message().contains("network_middlewares")); - assert!(backend.get_sandbox("sb-middleware").await.is_none()); + .expect("stop reaps both processes promptly"); + assert!(matches!(exit, RuntimePairExit::Shutdown)); } } diff --git a/crates/openshell-driver-mxc/src/isolation.rs b/crates/openshell-driver-mxc/src/isolation.rs new file mode 100644 index 0000000000..71160605ca --- /dev/null +++ b/crates/openshell-driver-mxc/src/isolation.rs @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC provisioning for the common authenticated Sandbox Protocol. + +use std::collections::{BTreeMap, HashMap}; +use std::net::SocketAddr; +use std::path::PathBuf; + +use openshell_isolation_interface::contract::{ + BackendError, BinaryIdentity, DirectProxyConfiguration, ExecutableIdentity, + OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; +use openshell_sandbox_backend::boundary_protocol::{ + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, + SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, +}; +use serde::Serialize; + +#[derive(Serialize)] +struct MxcOuterFenceEvidence<'a> { + generation: &'a str, + containment: &'a str, + default_deny_filesystem: bool, + default_deny_egress: bool, + loopback_proxy_only: bool, + controller_loss_fails_closed: bool, +} + +pub(crate) struct MxcBoundarySpec { + pub boundary_id: String, + pub generation: String, + pub session_id: openshell_core::SandboxSessionId, + pub session_rotation: openshell_core::jwt::SessionRotation, + pub auth_epoch: openshell_core::jwt::CredentialEpoch, + pub gateway_id: String, + pub verification_keys: Vec, + pub control_addr: SocketAddr, + pub supervisor_tls: SandboxTlsClientConfig, + pub sandbox_tls: SandboxTlsServerConfig, + pub proxy_addr: SocketAddr, + pub proxy_authorization: String, + pub proxy_url: String, + pub workload_binary: PathBuf, + pub child_env: HashMap, +} + +pub(crate) struct MxcBoundaryProvisioning { + pub boundary_config: BoundaryConfig, + pub runtime_descriptor: SandboxRuntimeDescriptor, +} + +impl MxcBoundarySpec { + pub fn provision(self) -> Result { + if !self.control_addr.ip().is_loopback() + || !self.proxy_addr.ip().is_loopback() + || self.control_addr.port() == 0 + || self.proxy_addr.port() == 0 + { + return Err(BackendError::Descriptor( + "MXC control and proxy listeners must use concrete loopback ports".to_string(), + )); + } + let resource_digest = format!("mxc-processcontainer:{}", self.generation); + // The common identity envelope is numeric for Unix backends. MXC binds + // its AppContainer token through the source and resource digest while + // using reserved nonzero numeric sentinels for the common fields. + let workload_identity = ResolvedWorkloadIdentity::new( + 1, + 1, + Vec::new(), + "mxc-appcontainer".to_string(), + resource_digest, + )?; + let resource_claims = BTreeMap::from([ + ("mxc.generation".to_string(), self.generation.clone()), + ( + "mxc.appcontainer_profile".to_string(), + self.boundary_id.clone(), + ), + ]); + let evidence = serde_json::to_vec(&MxcOuterFenceEvidence { + generation: &self.generation, + containment: "process_container", + default_deny_filesystem: true, + default_deny_egress: true, + loopback_proxy_only: true, + controller_loss_fails_closed: true, + }) + .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 { + 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(), + }, + }; + 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, + }, + 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], + }, + tls: self.supervisor_tls, + host_gateway_ip: Some(self.proxy_addr.ip()), + direct_proxy: Some(direct_proxy), + resource_claims, + outer_fence, + }, + }) + } +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 4b9d328e9f..9b0850b456 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -4,10 +4,12 @@ //! `OpenShell` MXC compute driver. //! //! Implements the gateway's `ComputeDriver` gRPC contract backed by Microsoft -//! MXC (`wxc-exec`) on Windows. The driver is **in-process**, runs the agent -//! directly (exec-in-driver), and self-reports `Ready` — there is no -//! in-sandbox supervisor, no host-side surrogate, and no `ConnectSupervisor` -//! relay. +//! MXC (`wxc-exec`) on Windows. The in-process driver provisions an +//! `openshell-windows-sandbox` boundary inside the `ProcessContainer` and launches the +//! standard `openshell-supervisor --role=isolation-backend` on the host. The +//! pair communicates over the authenticated Sandbox Protocol; workload +//! lifecycle, forwarding, credentials, and governed networking therefore use +//! the same supervisor session as the other RFC 0012 isolation backends. //! //! This crate compiles to an **empty stub** on non-Windows targets so the //! Linux build stays green. All implementation code is gated on @@ -20,6 +22,8 @@ mod driver; #[cfg(target_os = "windows")] mod grpc; #[cfg(target_os = "windows")] +mod isolation; +#[cfg(target_os = "windows")] mod mxc; #[cfg(target_os = "windows")] mod policy; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 0a77565f83..b6542b748a 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -1,33 +1,23 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! `wxc-exec` invoker and MXC request/response types. -//! -//! Builds state-aware MXC config JSON, base64-encodes it, runs `wxc-exec`, -//! and parses the response envelope. The exec phase is special: its stdout is -//! live process output (not JSON) and its exit code is the agent exit code. +//! `wxc-exec` ProcessContainer launcher and request types. use base64::Engine as _; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serde::Serialize; use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; use thiserror::Error; use tokio::process::Command; -use tracing::debug; +use tracing::{debug, info}; -/// MXC config schema version. -pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; - -/// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). -pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; +/// MXC config schema version. The mapper and one-shot launcher share the +/// MXC 0.8 directional network schema. +pub const MXC_SCHEMA_VERSION: &str = "0.8.0-alpha"; /// Environment flag selecting the in-process mock `wxc-exec` shim. When set to -/// `"1"`, the invoker does NOT spawn the real `wxc-exec.exe`; instead it emits -/// canned provision/start/stop/deprovision results and simulates `AppContainer` -/// filesystem-policy enforcement for the exec phase. This is what makes the -/// full create → Ready → policy-proof round trip runnable off the demo box. +/// `"1"`, the invoker does not spawn `wxc-exec.exe`. This wiring shim has no +/// `AppContainer` token and must fail runtime audit before workload execution. pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC"; fn mock_enabled() -> bool { @@ -40,22 +30,11 @@ fn mock_normalize(s: &str) -> String { s.replace('/', "\\").to_lowercase() } -/// Per-process mock state: `iso:` sandbox id → granted read-write paths -/// (normalized). Populated by the mock provision, consumed by the mock exec to -/// decide whether the agent's write target is in-policy. -fn mock_grants() -> &'static Mutex>> { - static GRANTS: OnceLock>>> = OnceLock::new(); - GRANTS.get_or_init(|| Mutex::new(HashMap::new())) -} - // ── Request types ───────────────────────────────────────────────────────────── /// Filesystem shares for the sandbox. /// -/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no -/// deny primitive). `processContainer` additionally honors `denied_paths` -/// because the `AppContainer` backend can stamp deny ACEs; it is also genuinely -/// default-deny, so anything not granted is already inaccessible. +/// ProcessContainer honors `readwrite`/`readonly` grants and `denied_paths`. #[derive(Debug, Default)] #[allow(clippy::struct_field_names)] pub struct MxcFilesystem { @@ -69,6 +48,46 @@ pub struct MxcFilesystem { pub struct MxcNetwork { pub default_policy: String, pub proxy: Option, + /// When true, includes `"allowLocalNetwork": true` in the network JSON. + /// Required for node.js to initialize inside a processcontainer — without + /// it, node.exe DLL initialization fails with `STATUS_DLL_INIT_FAILED`. + pub allow_local_network: bool, +} + +/// Directional clipboard access in the MXC top-level `ui` policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MxcClipboardAccess { + None, + Read, + Write, + All, +} + +impl MxcClipboardAccess { + const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Read => "read", + Self::Write => "write", + Self::All => "all", + } + } +} + +/// Cross-platform MXC UI policy emitted for a process container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcUi { + pub disable: bool, + pub clipboard: MxcClipboardAccess, + pub injection: bool, +} + +fn ui_json(ui: &MxcUi) -> serde_json::Value { + serde_json::json!({ + "disable": ui.disable, + "clipboard": ui.clipboard.as_str(), + "injection": ui.injection, + }) } /// `processContainer`-specific knobs (one-shot `AppContainer` backend). @@ -92,48 +111,66 @@ pub struct MxcProcess { pub timeout: u64, } -fn network_json(network: &MxcNetwork) -> serde_json::Value { - // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. - // {"host": ..., "port": ...} and every other shape is rejected — verified - // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. - // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. - // The MxcNetwork.proxy field remains SocketAddr so callers keep full - // precision; only the port is serialized into the localhost key. - // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, - // even when they are empty. The host proxy enforces the L7 allowlist. - let mut value = serde_json::json!({ - "defaultPolicy": network.default_policy.as_str(), - }); - if let Some(proxy) = network.proxy { - value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); - } - value +/// Redacts `process.env` and `process.commandLine` from a wxc-config JSON +/// before it's ever logged or written to a sandbox-readable path (only +/// under `self.debug`, but debug output still isn't a safe place for it). +/// Both can carry host secrets verbatim -- `env` e.g. the shipped +/// `OpenClaw` example config's `OPENCLAW_GATEWAY_TOKEN`, `commandLine` +/// whenever a secret is passed as a literal CLI argument -- and both debug +/// sinks (gateway logs, and for `run_oneshot` a file inside the sandbox's +/// own readwrite path) are places an attacker or an over-broad log +/// retention policy could read from. Everything else debug tooling might +/// need to compare (filesystem grants, network policy, ...) is left intact. +fn redact_env_for_debug(config: &serde_json::Value) -> serde_json::Value { + let mut redacted = config.clone(); + if let Some(env) = redacted.get_mut("process").and_then(|p| p.get_mut("env")) { + let count = env.as_array().map_or(0, Vec::len); + *env = serde_json::json!(format!("")); + } + if let Some(command_line) = redacted + .get_mut("process") + .and_then(|p| p.get_mut("commandLine")) + { + *command_line = serde_json::json!(""); + } + redacted } -fn provision_config_json( - configuration_id: &str, - filesystem: &MxcFilesystem, - network: Option<&MxcNetwork>, -) -> serde_json::Value { - let mut config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "provision", - "containment": "isolation_session", - "filesystem": { - "readwritePaths": &filesystem.readwrite_paths, - "readonlyPaths": &filesystem.readonly_paths, - }, - "experimental": { - "isolation_session": { - "configurationId": configuration_id, - "provision": {} - } - } - }); - if let Some(network) = network { - config["network"] = network_json(network); +fn network_json(network: &MxcNetwork) -> serde_json::Value { + // MXC 0.8.0-alpha schema uses a directional egress/ingress format. + // "block" default_policy maps to egress.default "deny"; "allow" maps to "allow". + let egress_default = if network.default_policy == "block" { + "deny" + } else { + "allow" + }; + let mut value = if network.proxy.is_some() { + // Use direct loopback egress rather than runtimeConfig.networkProxy proxy + // mode. Proxy mode routes all outbound TCP through processmodel.dll's WFP + // redirect, which can block the authenticated Sandbox Protocol and + // explicit proxy connections to their host loopback listeners. Limit the + // exception to 127.0.0.1/32 rather than the broader 127.0.0.0/8 range. + // PSEC tier is still selected because requires_psec_networking() returns + // true when egress.allow is non-empty (no NetworkIsolationSetAppContainerConfig + // call needed — no elevation required). + // + // Deliberately no `ports` restriction: the authenticated Sandbox + // Protocol listener, generation-scoped supervisor proxy, and dynamic + // forwarding listeners all use independently allocated loopback ports. + serde_json::json!({ + "egress": { + "default": "deny", + "allow": [{"to": [{"cidr": "127.0.0.1/32"}]}] + }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, + }) + } else { + serde_json::json!({ "egress": { "default": egress_default } }) + }; + if network.proxy.is_none() && network.allow_local_network { + value["ingress"] = serde_json::json!({ "default": "allow", "hostLoopback": "allow" }); } - config + value } fn oneshot_config_json( @@ -142,6 +179,7 @@ fn oneshot_config_json( pc: &MxcProcessContainer, process: &MxcProcess, network: Option<&MxcNetwork>, + ui: Option<&MxcUi>, ) -> serde_json::Value { let mut filesystem_json = serde_json::Map::new(); if !filesystem.readwrite_paths.is_empty() { @@ -182,52 +220,18 @@ fn oneshot_config_json( if let Some(network) = network { config["network"] = network_json(network); } + // Root-level ui section required by mxc-fixes-env-vars build. Comes from + // the typed SandboxPolicy via `ui` -- EmbeddedPolicyMapper always + // populates this for process_container (with restrictive defaults -- + // disable=true, Win32k syscall lockdown -- when the policy has no + // explicit `ui:` section), so `None` here only happens in tests that + // bypass the mapper; omit the section entirely rather than guess. + if let Some(ui) = ui { + config["ui"] = ui_json(ui); + } config } -#[cfg(test)] -fn mock_configs() -> &'static Mutex> { - static CONFIGS: OnceLock>> = OnceLock::new(); - CONFIGS.get_or_init(|| Mutex::new(HashMap::new())) -} - -#[cfg(test)] -pub fn mock_recorded_config(id: &str) -> Option { - mock_configs().lock().unwrap().get(id).cloned() -} - -// ── Response envelope ───────────────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -pub struct ProvisionResult { - #[serde(rename = "sandboxId")] - pub sandbox_id: String, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum MxcEnvelope { - Ok { - #[allow(dead_code)] - result: serde_json::Value, - }, - Err { - error: MxcErrorBody, - }, -} - -#[derive(Debug, Deserialize)] -pub struct MxcErrorBody { - pub code: String, - pub message: String, -} - -#[derive(Debug, Deserialize)] -pub struct ProvisionEnvelope { - pub result: Option, - pub error: Option, -} - // ── Errors ──────────────────────────────────────────────────────────────────── #[derive(Debug, Error)] @@ -236,55 +240,11 @@ pub enum InvokerError { Spawn(#[from] std::io::Error), #[error("wxc-exec config serialization failed: {0}")] Serialize(#[from] serde_json::Error), - #[error("wxc-exec envelope parse failed (stdout={stdout:?}): {source}")] - Parse { - stdout: String, - source: serde_json::Error, - }, - #[error("wxc-exec process failed with no envelope (exit={exit_code}, stderr={stderr:?})")] - NoEnvelope { exit_code: i32, stderr: String }, - #[error("MXC error [{code}]: {message}")] - Mxc { code: String, message: String }, - /// Exec phase returned a non-zero exit code (the agent's own exit status). - /// Surfaced through the watch stream rather than as a gRPC error. - #[allow(dead_code)] - #[error("wxc-exec exec phase exited with code {0}")] - ExecNonZero(i32), -} - -impl InvokerError { - #[allow(dead_code)] - pub fn to_tonic_status(&self) -> tonic::Status { - match self { - Self::Mxc { code, message } => match code.as_str() { - "malformed_request" | "unsupported_phase" => { - tonic::Status::internal(format!("driver bug: {message}")) - } - "unsupported_containment" - | "not_provisioned" - | "not_started" - | "already_started" - | "already_stopped" => tonic::Status::failed_precondition(message.clone()), - "malformed_id" | "stale_id" => tonic::Status::not_found(message.clone()), - "policy_validation" => tonic::Status::invalid_argument(message.clone()), - "backend_unavailable" => tonic::Status::unavailable(message.clone()), - _ => tonic::Status::internal(message.clone()), - }, - Self::Spawn(e) => tonic::Status::internal(format!("wxc-exec spawn: {e}")), - Self::Serialize(e) => tonic::Status::internal(format!("config serialize: {e}")), - Self::Parse { .. } | Self::NoEnvelope { .. } => { - tonic::Status::internal(self.to_string()) - } - Self::ExecNonZero(code) => { - tonic::Status::internal(format!("agent exited with code {code}")) - } - } - } } // ── Invoker ─────────────────────────────────────────────────────────────────── -/// Wraps `wxc-exec` invocations for the MXC state-aware lifecycle. +/// Wraps the one-shot `wxc-exec` ProcessContainer invocation. #[derive(Debug, Clone)] pub struct WxcExecInvoker { exec_path: PathBuf, @@ -306,249 +266,36 @@ impl WxcExecInvoker { self.mock } - /// Test-only constructor that forces mock mode without touching the - /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across - /// parallel tests under edition 2024's `unsafe` `set_var`). - #[cfg(test)] - pub(crate) fn mocked(exec_path: impl Into) -> Self { - Self { - exec_path: exec_path.into(), - debug: false, - mock: true, - } - } - - /// Encode `config` as base64 and invoke wxc-exec, returning the parsed envelope. - /// Use this for all **non-exec** phases (provision/start/stop/deprovision). - pub async fn run_phase(&self, config: &serde_json::Value) -> Result<(), InvokerError> { - if self.mock { - // Mock start/stop/deprovision: canned `{"result":{}}` success. - debug!(phase = ?config.get("phase"), "mock wxc-exec phase (no-op success)"); - return Ok(()); - } - let json = serde_json::to_string(config)?; - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - - let mut cmd = Command::new(&self.exec_path); - cmd.arg("--config-base64").arg(&b64).arg("--experimental"); - if self.debug { - cmd.arg("--debug"); - } - - debug!(config = %json, "wxc-exec phase"); - let output = cmd.output().await?; - - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - - if !output.status.success() { - if let Ok(MxcEnvelope::Err { error }) = serde_json::from_str::(&stdout) { - return Err(InvokerError::Mxc { - code: error.code, - message: error.message, - }); - } - let code = output.status.code().unwrap_or(-1); - return Err(InvokerError::NoEnvelope { - exit_code: code, - stderr, - }); - } - - // Success — parse envelope to surface any embedded error field. - match serde_json::from_str::(&stdout) { - Ok(MxcEnvelope::Err { error }) => Err(InvokerError::Mxc { - code: error.code, - message: error.message, - }), - Ok(MxcEnvelope::Ok { .. }) => Ok(()), - Err(_) if stdout.trim().is_empty() => { - // Some phases return empty stdout on success. - Ok(()) - } - Err(e) => Err(InvokerError::Parse { stdout, source: e }), - } - } - - /// Run the provision phase and return the `sandboxId` from the response. - pub async fn provision( - &self, - configuration_id: &str, - filesystem: MxcFilesystem, - network: Option, - ) -> Result { - if self.mock { - // Mock provision: mint a synthetic `iso:` id and record the granted - // read-write paths so the mock exec can enforce the policy. - let id = format!("iso:mock-{}", uuid::Uuid::new_v4()); - let grants: Vec = filesystem - .readwrite_paths - .iter() - .map(|p| mock_normalize(p)) - .collect(); - mock_grants().lock().unwrap().insert(id.clone(), grants); - #[cfg(test)] - { - let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); - mock_configs().lock().unwrap().insert(id.clone(), config); - } - debug!(sandbox_id = %id, "mock wxc-exec provision"); - return Ok(id); - } - let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); - - let json = serde_json::to_string(&config)?; - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - - let mut cmd = Command::new(&self.exec_path); - cmd.arg("--config-base64").arg(&b64).arg("--experimental"); - if self.debug { - cmd.arg("--debug"); - } - - debug!(config = %json, "wxc-exec provision"); - let output = cmd.output().await?; - - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - - if !output.status.success() { - let code = output.status.code().unwrap_or(-1); - if let Ok(ProvisionEnvelope { - error: Some(error), .. - }) = serde_json::from_str::(&stdout) - { - return Err(InvokerError::Mxc { - code: error.code, - message: error.message, - }); - } - return Err(InvokerError::NoEnvelope { - exit_code: code, - stderr, - }); - } - - let env: ProvisionEnvelope = - serde_json::from_str(&stdout).map_err(|e| InvokerError::Parse { - stdout: stdout.clone(), - source: e, - })?; - - if let Some(err) = env.error { - return Err(InvokerError::Mxc { - code: err.code, - message: err.message, - }); - } - - env.result - .map(|r| r.sandbox_id) - .ok_or_else(|| InvokerError::NoEnvelope { - exit_code: 0, - stderr: "provision result missing sandboxId".to_string(), - }) - } - - /// Run the start phase for an already-provisioned sandbox. - pub async fn start(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "start", - "sandboxId": iso_sandbox_id, - "experimental": { - "isolation_session": { - "start": {} - } - } - }); - self.run_phase(&config).await - } - - /// Spawn the exec phase (agent command). Returns the child process handle. - /// **Stdout is raw agent output, not a JSON envelope. Exit code == agent exit code.** - pub async fn spawn_exec( - &self, - iso_sandbox_id: &str, - process: MxcProcess, - ) -> Result { - if self.mock { - return Self::mock_spawn_exec(iso_sandbox_id, &process); - } - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "exec", - "sandboxId": iso_sandbox_id, - "process": { - "commandLine": process.command_line, - "cwd": process.cwd, - "env": process.env, - "timeout": process.timeout, - } - }); - - let json = serde_json::to_string(&config)?; - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - - let mut cmd = Command::new(&self.exec_path); - cmd.arg("--config-base64") - .arg(&b64) - .arg("--experimental") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true); - if self.debug { - cmd.arg("--debug"); - } - - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); - let child = cmd.spawn()?; - Ok(child) - } - - /// Mock exec: simulate `AppContainer` filesystem-policy enforcement. - /// - /// The agent's write target is considered **in-policy** iff the command line - /// references one of the granted read-write paths recorded at mock provision. - fn mock_spawn_exec( - iso_sandbox_id: &str, - process: &MxcProcess, - ) -> Result { - let grants = mock_grants() - .lock() - .unwrap() - .get(iso_sandbox_id) - .cloned() - .unwrap_or_default(); - Self::mock_spawn_with_grants(process, &grants) - } - - /// Shared mock enforcement used by both the `isolation_session` exec phase - /// and the one-shot `processContainer` path. - /// - /// In-policy → run the real agent command (so the positive-proof artifact, - /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy - /// → refuse with an access-denied message on stderr and a non-zero exit, - /// mirroring how the `AppContainer` denies the write on the demo box. + /// Launch the real boundary runtime without OS isolation for wiring tests. + /// Filesystem denial must be tested with real MXC, not this mock. fn mock_spawn_with_grants( process: &MxcProcess, grants: &[String], ) -> Result { let cmd_norm = mock_normalize(&process.command_line); + // The RFC 0012 boundary is launched with its granted bootstrap directory. + // This check concerns bootstrap wiring, never workload authorization. let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); - let mut cmd = Command::new("cmd"); + // Spawn the encoded executable directly: a cmd.exe intermediary would + // survive only as a parent handle while kill_on_drop orphaned its child. + let (program, arguments) = process + .command_line + .strip_prefix('"') + .map_or_else( + || process.command_line.split_once(char::is_whitespace), + |quoted| quoted.split_once('"'), + ) + .unwrap_or((&process.command_line, "")); + let mut cmd = Command::new(if in_policy { program } else { "cmd" }); cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .kill_on_drop(true); if in_policy { debug!(command = %process.command_line, "mock exec: in-policy, running agent"); - // `command_line` is already encoded with Windows quoting rules. - // Pass it raw so this mock matches wxc-exec/CreateProcess instead - // of asking Rust to quote the entire command as one cmd.exe argv. - cmd.raw_arg(format!("/d /s /c \"{}\"", process.command_line)); + // Retain the already-encoded Windows argv without another quoting pass. + cmd.raw_arg(arguments.trim_start()); } else { debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); cmd.arg("/c").arg( @@ -561,10 +308,9 @@ impl WxcExecInvoker { /// Build a **one-shot** `processContainer` config (no `phase`) and spawn it. /// - /// Unlike the `isolation_session` lifecycle (provision → start → exec → - /// stop → deprovision), `processContainer` is a single ephemeral - /// `AppContainer`: one `wxc-exec` invocation creates the container, runs the - /// one process, and tears down when it exits. The `AppContainer` is genuinely + /// `processContainer` is a single ephemeral `AppContainer`: one `wxc-exec` + /// invocation creates the container, runs the sandbox runtime, and tears it + /// down when that runtime exits. The `AppContainer` is genuinely /// default-deny, so a write to any ungranted path is denied by the OS. /// /// **Stdout is raw agent output; the exit code is the agent's own exit code.** @@ -575,76 +321,68 @@ impl WxcExecInvoker { pc: MxcProcessContainer, process: MxcProcess, network: Option, + ui: Option, ) -> Result { - let config = - oneshot_config_json(container_id, &filesystem, &pc, &process, network.as_ref()); + let config = oneshot_config_json( + container_id, + &filesystem, + &pc, + &process, + network.as_ref(), + ui.as_ref(), + ); if self.mock { let grants: Vec = filesystem .readwrite_paths .iter() .map(|p| mock_normalize(p)) .collect(); - #[cfg(test)] - mock_configs() - .lock() - .unwrap() - .insert(container_id.to_owned(), config); return Self::mock_spawn_with_grants(&process, &grants); } let json = serde_json::to_string(&config)?; + if self.debug { + // Redacted before either sink: the readwrite path is inside the + // sandbox itself (readable by whatever untrusted code runs + // there), and gateway logs may have broader retention/access + // than the secrets in `process.env` (e.g. OPENCLAW_GATEWAY_TOKEN + // in the shipped OpenClaw example config) should get. + let redacted = redact_env_for_debug(&config); + let redacted_json = serde_json::to_string(&redacted).unwrap_or_else(|_| json.clone()); + // Dump into the first readwrite path for comparison. + if let Some(rw) = config + .get("filesystem") + .and_then(|f| f.get("readwritePaths")) + .and_then(|a| a.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + { + let _ = std::fs::write( + std::path::Path::new(rw).join("wxc-exec-config-debug.json"), + &redacted_json, + ); + } + let pretty = + serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| redacted_json.clone()); + info!(container_id = %container_id, "generated wxc-config:\n{pretty}"); + } let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let mut cmd = Command::new(&self.exec_path); cmd.arg("--config-base64") .arg(&b64) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true); + // Retain child output so the gateway can surface sandbox logs. + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); if self.debug { cmd.arg("--debug"); } - debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + info!(container_id = %container_id, "wxc-exec one-shot processContainer spawn"); let child = cmd.spawn()?; Ok(child) } - - /// Run the stop phase. - /// - /// `stop`/`deprovision` are **unit** variants in the wxc-exec schema: they - /// must serialize as `null`, not `{}`. Empirical (build 26300.8553, - /// wxc-exec 2026-06-10): `"stop": {}` is rejected with `malformed_request` - /// ("invalid type: map, expected unit"); `provision`/`start` accept maps. - pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "stop", - "sandboxId": iso_sandbox_id, - "experimental": { - "isolation_session": { - "stop": null - } - } - }); - self.run_phase(&config).await - } - - /// Run the deprovision phase (unit variant — see [`Self::stop`]). - pub async fn deprovision(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "deprovision", - "sandboxId": iso_sandbox_id, - "experimental": { - "isolation_session": { - "deprovision": null - } - } - }); - self.run_phase(&config).await - } } // ── Tests (pure serde — compile and run cross-platform) ────────────────────── @@ -653,65 +391,6 @@ impl WxcExecInvoker { mod tests { use super::*; - #[test] - fn provision_envelope_parse_success() { - let json = r#"{"result":{"sandboxId":"iso:wxc-abc123","metadata":{}}}"#; - let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); - assert_eq!(env.result.unwrap().sandbox_id, "iso:wxc-abc123"); - assert!(env.error.is_none()); - } - - #[test] - fn provision_envelope_parse_error() { - let json = - r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#; - let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); - assert!(env.result.is_none()); - let err = env.error.unwrap(); - assert_eq!(err.code, "backend_unavailable"); - } - - #[test] - fn mxc_envelope_success_variant() { - let json = r#"{"result":{}}"#; - let env: MxcEnvelope = serde_json::from_str(json).unwrap(); - assert!(matches!(env, MxcEnvelope::Ok { .. })); - } - - #[test] - fn mxc_envelope_error_variant() { - let json = r#"{"error":{"code":"not_provisioned","message":"call provision first"}}"#; - let env: MxcEnvelope = serde_json::from_str(json).unwrap(); - assert!(matches!(env, MxcEnvelope::Err { .. })); - } - - #[test] - fn provision_config_json_shape() { - // Verify the JSON we send wxc-exec has the expected shape. - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": "provision", - "containment": "isolation_session", - "filesystem": { - "readwritePaths": ["C:\\work\\demo"], - "readonlyPaths": [], - }, - "experimental": { - "isolation_session": { - "configurationId": DEFAULT_CONFIGURATION_ID, - "provision": {} - } - } - }); - assert_eq!(config["phase"], "provision"); - assert_eq!(config["containment"], "isolation_session"); - assert_eq!( - config["experimental"]["isolation_session"]["configurationId"], - "composable" - ); - assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); - } - #[test] fn oneshot_processcontainer_config_json_shape() { // Mirror the JSON `run_oneshot` builds for the one-shot processContainer @@ -745,48 +424,25 @@ mod tests { } #[test] - fn provision_config_json_includes_network_proxy_when_supplied() { - let filesystem = MxcFilesystem { - readwrite_paths: vec!["C:\\work\\demo".into()], - readonly_paths: Vec::new(), - denied_paths: Vec::new(), - }; - let network = MxcNetwork { - default_policy: "block".into(), - proxy: Some("127.0.0.1:18080".parse().unwrap()), - }; - let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); - - assert_eq!(config["network"]["defaultPolicy"], "block"); - assert!(config["network"].get("allowedHosts").is_none()); - assert!(config["network"].get("blockedHosts").is_none()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(config["network"]["proxy"]["localhost"], 18080); - assert!( - config["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - config["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" - ); - } - - #[test] - fn network_json_emits_localhost_port_shape() { - // MXC 0.6.0-alpha rejects {"host":...,"port":...} and accepts only - // {"proxy": {"localhost": N}} — verified against the real binary via - // --dry-run. This test pins the exact emitted JSON shape. + fn network_json_emits_directional_format() { + // MXC 0.8.0-alpha: egress/ingress replaces the legacy + // defaultPolicy / allowedHosts / proxy.localhost shape. let network = MxcNetwork { default_policy: "block".into(), proxy: Some("127.0.0.1:18080".parse().unwrap()), + allow_local_network: false, }; let value = network_json(&network); - assert!(value.get("allowedHosts").is_none()); - assert!(value.get("blockedHosts").is_none()); - assert_eq!(value["proxy"]["localhost"], 18080); - assert!(value["proxy"].get("host").is_none()); - assert!(value["proxy"].get("port").is_none()); + // Loopback-allow mode: egress.default="deny" with 127.0.0.1/32 allow rule. + // Allows the sandbox to reach its authenticated host-side listeners + // without enabling direct Internet access. + assert_eq!(value["egress"]["default"], "deny"); + assert_eq!(value["egress"]["allow"][0]["to"][0]["cidr"], "127.0.0.1/32"); + // ingress.hostLoopback="allow" grants networkLoopback PSEC capability. + assert_eq!(value["ingress"]["default"], "allow"); + assert_eq!(value["ingress"]["hostLoopback"], "allow"); + assert!(value.get("proxy").is_none()); + assert!(value.get("defaultPolicy").is_none()); } #[test] @@ -803,61 +459,31 @@ mod tests { env: Vec::new(), timeout: 0, }; - let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); + let config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None, None); assert!(config.get("network").is_none()); + assert!(config.get("ui").is_none()); } #[test] - fn stop_and_deprovision_serialize_as_unit_variants() { - // Pins the empirical schema contract (test box, build 26300.8553): - // stop/deprovision are unit variants and must be `null`; `{}` is - // rejected with malformed_request "invalid type: map, expected unit". - for phase in ["stop", "deprovision"] { - let config = serde_json::json!({ - "version": MXC_SCHEMA_VERSION, - "phase": phase, - "sandboxId": "iso:wxc-test", - "experimental": { - "isolation_session": { - phase: null - } - } - }); - assert!( - config["experimental"]["isolation_session"][phase].is_null(), - "{phase} must serialize as null (unit variant)" - ); - } - } - - #[test] - fn invoker_error_maps_backend_unavailable_to_unavailable() { - let err = InvokerError::Mxc { - code: "backend_unavailable".into(), - message: "missing DLL".into(), + fn oneshot_config_json_emits_typed_ui_policy() { + let filesystem = MxcFilesystem::default(); + let pc = MxcProcessContainer::default(); + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\work\\demo".into(), + env: Vec::new(), + timeout: 0, }; - let status = err.to_tonic_status(); - assert_eq!(status.code(), tonic::Code::Unavailable); - } - - #[test] - fn invoker_error_maps_policy_validation_to_invalid_argument() { - let err = InvokerError::Mxc { - code: "policy_validation".into(), - message: "path denied".into(), + let ui = MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Write, + injection: true, }; - let status = err.to_tonic_status(); - assert_eq!(status.code(), tonic::Code::InvalidArgument); - } + let config = oneshot_config_json("sb-ui", &filesystem, &pc, &process, None, Some(&ui)); - #[test] - fn invoker_error_maps_stale_id_to_not_found() { - let err = InvokerError::Mxc { - code: "stale_id".into(), - message: "session expired".into(), - }; - let status = err.to_tonic_status(); - assert_eq!(status.code(), tonic::Code::NotFound); + assert_eq!(config["ui"]["disable"], false); + assert_eq!(config["ui"]["clipboard"], "write"); + assert_eq!(config["ui"]["injection"], true); } } diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 30a0ca56da..f0376dedf5 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -19,6 +19,7 @@ use std::net::SocketAddr; +use crate::mxc::{MxcClipboardAccess, MxcUi}; use openshell_core::proto::SandboxPolicy; use thiserror::Error; @@ -38,6 +39,9 @@ pub struct MappedConfig { /// Loopback address MXC redirects sandbox egress to. `None` when governed /// egress is disabled. pub proxy_addr: Option, + /// Top-level MXC UI policy for process containers. Isolation sessions keep + /// this absent because current MXC rejects the section on presence. + pub ui: Option, } /// Context passed to the mapper alongside the policy. @@ -49,6 +53,8 @@ pub struct MapCtx { /// Pattern-C governed-egress redirect address. When set, the embedded /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. pub egress: Option, + /// MXC containment backend selected by the live driver. + pub containment: String, } /// A policy rule that the active mapper cannot enforce. @@ -109,6 +115,39 @@ fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { .unwrap_or_default() } +fn extract_ui(config: &serde_json::Value) -> Result, MapError> { + let Some(ui) = config.get("ui") else { + return Ok(None); + }; + let disable = ui["disable"] + .as_bool() + .ok_or_else(|| MapError::Internal("mapped MXC ui.disable is not a boolean".into()))?; + let clipboard = match ui["clipboard"].as_str() { + Some("none") => MxcClipboardAccess::None, + Some("read") => MxcClipboardAccess::Read, + Some("write") => MxcClipboardAccess::Write, + Some("all") => MxcClipboardAccess::All, + Some(value) => { + return Err(MapError::Internal(format!( + "mapped MXC ui.clipboard has unknown value '{value}'" + ))); + } + None => { + return Err(MapError::Internal( + "mapped MXC ui.clipboard is not a string".into(), + )); + } + }; + let injection = ui["injection"] + .as_bool() + .ok_or_else(|| MapError::Internal("mapped MXC ui.injection is not a boolean".into()))?; + Ok(Some(MxcUi { + disable, + clipboard, + injection, + })) +} + impl PolicyMapper for EmbeddedPolicyMapper { fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { let policy = policy.ok_or_else(|| { @@ -122,7 +161,7 @@ impl PolicyMapper for EmbeddedPolicyMapper { // Pattern C: MXC handles filesystem + a proxy redirect, while the // host CONNECT proxy receives the network-only trimmed policy. let opts = crate::policy_map::MxcMappingOptions { - containment: "processcontainer".to_owned(), + containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), proxy_redirect: Some(addr), ..Default::default() @@ -144,7 +183,7 @@ impl PolicyMapper for EmbeddedPolicyMapper { // yields an `error` loss for any host allowlist, which rejects // network policy below. let opts = crate::policy_map::MxcMappingOptions { - containment: "isolation_session".to_owned(), + containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), ..Default::default() }; @@ -176,12 +215,14 @@ impl PolicyMapper for EmbeddedPolicyMapper { .iter() .map(|p| normalize_path(p)) .collect(); + let ui = extract_ui(&config)?; Ok(MappedConfig { readwrite_paths: readwrite, readonly_paths: readonly, trimmed_policy, proxy_addr, + ui, }) } } @@ -197,6 +238,15 @@ mod tests { MapCtx { sandbox_id: "sb-test".into(), egress: None, + containment: "isolation_session".into(), + } + } + + fn processcontainer_ctx() -> MapCtx { + MapCtx { + sandbox_id: "sb-test".into(), + egress: None, + containment: "processcontainer".into(), } } @@ -255,6 +305,78 @@ mod tests { assert!(matches!(err, MapError::Unsupported(_))); } + #[test] + fn embedded_rejects_explicit_ui_on_isolation_session() { + use openshell_core::proto::UiPolicy; + + let mapper = EmbeddedPolicyMapper; + let policy = SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let err = mapper.map(Some(&policy), &demo_ctx()).unwrap_err(); + match err { + MapError::Unsupported(items) => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].rule_kind, "ui"); + } + MapError::Internal(message) => { + panic!("expected unsupported UI, got internal error: {message}") + } + } + } + + #[test] + fn embedded_carries_typed_ui_for_processcontainer() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let mapper = EmbeddedPolicyMapper; + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Read as i32, + allow_input_injection: true, + }), + ..Default::default() + }; + let result = mapper + .map(Some(&policy), &processcontainer_ctx()) + .expect("processContainer UI maps"); + assert_eq!( + result.ui, + Some(MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Read, + injection: true, + }) + ); + } + + #[test] + fn embedded_rejects_ui_grants_suppressed_by_disable() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + for ui in [ + UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }, + UiPolicy { + allow_input_injection: true, + ..Default::default() + }, + ] { + let policy = SandboxPolicy { + ui: Some(ui), + ..Default::default() + }; + let error = EmbeddedPolicyMapper + .map(Some(&policy), &processcontainer_ctx()) + .expect_err("MXC cannot enforce UI grants while UI is disabled"); + assert!(matches!(error, MapError::Unsupported(_))); + } + } + #[test] fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; @@ -281,6 +403,7 @@ mod tests { sandbox_id: "sb-egress".into(), egress: Some(proxy_addr), + containment: "processcontainer".into(), }; let config = mapper.map(Some(&policy), &ctx).unwrap(); @@ -294,7 +417,7 @@ mod tests { } #[test] - fn embedded_rejects_network_middleware_on_egress_proxy() { + fn embedded_preserves_network_middleware_for_supervisor() { let mapper = EmbeddedPolicyMapper; let mut policy = fs_policy(&["C:/work/demo"], &[]); policy.network_middlewares.insert( @@ -313,14 +436,11 @@ mod tests { let ctx = MapCtx { sandbox_id: "sb-egress-middleware".into(), egress: Some("127.0.0.1:18080".parse().unwrap()), + containment: "processcontainer".into(), }; - let error = mapper.map(Some(&policy), &ctx).unwrap_err(); - let MapError::Unsupported(loss) = error else { - panic!("expected unsupported middleware error"); - }; - assert_eq!(loss.len(), 1); - assert_eq!(loss[0].rule_kind, "network_middlewares"); - assert!(loss[0].detail.contains("middleware service registry")); + let mapped = mapper.map(Some(&policy), &ctx).unwrap(); + let trimmed = mapped.trimmed_policy.expect("trimmed proxy policy"); + assert_eq!(trimmed.network_middlewares, policy.network_middlewares); } } diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs index cf366b0b9d..6af3b9a32b 100644 --- a/crates/openshell-driver-mxc/src/policy_map/loss.rs +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -25,7 +25,7 @@ pub struct LossItem { /// MXC capabilities that have no `OpenShell` *policy* equivalent. Surfaced in the /// loss report so reviewers understand the mapping is not symmetric. pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ - "MXC UI policy has no OpenShell policy equivalent: ui.disable, ui.clipboard, and ui.injection.", + "MXC processContainer UI refinements have no portable OpenShell policy equivalent: isolation, desktopSystemControl, systemSettings, and ime.", "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index c1b3af94fa..14d8fc9cb5 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -11,7 +11,9 @@ use std::net::SocketAddr; -use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use openshell_core::proto::{ + NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, UiClipboardAccess, UiPolicy, +}; use serde_json::{Value, json}; use super::config::{ @@ -19,12 +21,14 @@ use super::config::{ add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, }; use super::loss::{LossItem, add_loss}; +use crate::mxc::MXC_SCHEMA_VERSION; /// Options controlling the generated MXC config. Fields not relevant to the /// coarse map (e.g. `proxy_redirect`) are reserved for the governed-egress split. #[derive(Clone, Debug)] pub struct MxcMappingOptions { - /// MXC schema version written into `version`. + /// MXC schema version written into coarse-map output. The governed-egress + /// split uses the driver's MXC 0.8 schema. pub mxc_version: String, /// MXC containment backend. pub containment: String, @@ -68,16 +72,15 @@ pub struct MxcMappingResult { pub loss: Vec, } -/// Result of the governed-egress split: the MXC config carries filesystem grants and a -/// proxy redirect; the full network policy is returned unchanged for the -/// `OpenShell` CONNECT proxy to enforce. +/// Result of the lossless split: the MXC config carries filesystem grants and +/// loopback-only network access; the full network policy is returned unchanged +/// for the `OpenShell` CONNECT proxy to enforce. #[derive(Clone, Debug)] pub struct SplitPolicyResult { - /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. + /// MXC `ContainerConfig` with filesystem grants and loopback-only egress. /// - /// Direct egress is blocked at the MXC layer. Unsupported host-list fields - /// are omitted; all outbound connections flow through the proxy, which - /// enforces the full `OpenShell` network policy. + /// Direct Internet egress is denied at the MXC layer. Proxy-aware clients + /// use the host proxy, which enforces the full `OpenShell` network policy. pub mxc_config: Value, /// Full `OpenShell` network policy preserved verbatim for the host CONNECT /// proxy. Only `network_policies` is populated; the proxy does not enforce @@ -98,13 +101,13 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin /// Governed-egress split: map filesystem + containment to MXC, delegate network to the /// `OpenShell` CONNECT proxy. /// -/// The returned [`SplitPolicyResult::mxc_config`] sets `network.proxy` to -/// `opts.proxy_redirect` and omits unsupported host-list fields. Direct egress -/// is blocked at the MXC layer and all outbound connections flow through the -/// proxy. [`SplitPolicyResult::proxy_policy`] carries the original -/// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard -/// loss items are generated for those rules. Network middleware is rejected -/// until the host proxy can receive the gateway middleware service registry. +/// The returned [`SplitPolicyResult::mxc_config`] allows only `127.0.0.1/32` +/// egress and denies direct Internet access at the MXC layer. The driver injects +/// `HTTP_PROXY`/`HTTPS_PROXY` for proxy-aware clients. +/// [`SplitPolicyResult::proxy_policy`] carries the original network policy +/// verbatim. The RFC 0012 host supervisor receives the complete policy and the +/// gateway middleware service registry through its ordinary session, so the +/// MXC outer-fence mapping does not reject middleware configuration. /// /// Returns `None` if `opts.proxy_redirect` is not set. Use [`map_to_mxc`] /// for the standalone coarse path when no proxy is in the loop. @@ -151,11 +154,11 @@ fn build_split_mxc_config( "containment", "error", &format!( - "`network.proxy` is not supported on `{}`; governed egress requires processcontainer until MXC M1 lands.", + "MXC loopback-only proxy access is not supported on `{}`; governed egress requires processcontainer.", opts.containment ), "governed egress proxy redirect", - "The generated MXC config omits network.proxy for this backend.", + "The generated MXC config cannot enable loopback-only proxy access for this backend.", ); } if !policy.network_policies.is_empty() { @@ -171,48 +174,34 @@ fn build_split_mxc_config( "The host proxy receives the trimmed policy and enforces network rules.", ); } - if !policy.network_middlewares.is_empty() { - add_loss( - items, - "network_middlewares", - "error", - &format!( - "{} network middleware config(s) cannot be enforced because the MXC host proxy is not connected to the gateway middleware service registry.", - policy.network_middlewares.len() - ), - "network egress middleware", - "The MXC sandbox is rejected before launch instead of bypassing fail-open middleware or failing unrelated allowed traffic.", - ); - } - - // Direct egress is blocked; all outbound flows through the OpenShell proxy. - // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, - // even when empty, so the proxy path omits both fields. - // - // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. - // {"host": ..., "port": ...} and every other shape is rejected — verified - // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // Direct Internet egress is denied. Proxy-aware clients can reach only the + // OpenShell proxy (and other host loopback listeners) through 127.0.0.1. if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { add_loss( items, - "network.proxy", + "proxy_redirect", "error", &format!( - "MXC schema 0.6.0-alpha can only express a localhost port \ - ({{\"localhost\": N}}); non-127.0.0.1 redirect address \ - {proxy_addr} is not representable." + "MXC governed egress requires the unpackaged OpenShell host \ + proxy to use 127.0.0.1; redirect address {proxy_addr} is not supported." ), "per-sandbox egress attribution", - "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", + "The proxy environment cannot be emitted safely; use a 127.0.0.1:PORT address.", ); } - let mut network = json!({ "defaultPolicy": "block" }); - if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { - network["proxy"] = json!({ "localhost": proxy_addr.port() }); - } + let network = json!({ + "egress": { + "default": "deny", + "allow": [{"to": [{"cidr": "127.0.0.1/32"}]}], + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow", + }, + }); let mut config = json!({ - "version": opts.mxc_version, + "version": MXC_SCHEMA_VERSION, "containerId": opts.container_id, "containment": opts.containment, "lifecycle": { @@ -222,12 +211,10 @@ fn build_split_mxc_config( "process": process, "filesystem": filesystem, "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, }); + if let Some(ui) = map_ui(policy.ui.as_ref(), &opts.containment, items) { + config["ui"] = ui; + } // No network hosts, so backend-specific network blocks (processContainer // internetClient, etc.) are not added — correct for the proxy path. @@ -275,18 +262,110 @@ fn build_mxc_config( "process": process, "filesystem": filesystem, "network": network, - "ui": { - "disable": true, - "clipboard": "none", - "injection": false, - }, }); + if let Some(ui) = map_ui(policy.ui.as_ref(), &opts.containment, items) { + config["ui"] = ui; + } add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); add_static_policy_loss(policy, opts, items); config } +fn map_ui(ui: Option<&UiPolicy>, containment: &str, items: &mut Vec) -> Option { + let restrictive = || { + json!({ + "disable": true, + "clipboard": "none", + "injection": false, + }) + }; + + match containment { + "processcontainer" | "process" => { + let Some(ui) = ui else { + // Preserve the mapper's existing deny posture for policies + // authored before the optional OpenShell UI section existed. + return Some(restrictive()); + }; + let clipboard = match UiClipboardAccess::try_from(ui.clipboard) { + Ok(UiClipboardAccess::Unspecified | UiClipboardAccess::None) => "none", + Ok(UiClipboardAccess::Read) => "read", + Ok(UiClipboardAccess::Write) => "write", + Ok(UiClipboardAccess::All) => "all", + Err(_) => { + add_loss( + items, + "ui.clipboard", + "error", + &format!( + "OpenShell UI clipboard policy has unknown enum value {}.", + ui.clipboard + ), + "directional clipboard access", + "MXC receives the restrictive clipboard=none fallback; sandbox creation is rejected.", + ); + "none" + } + }; + let graphical_ui_disabled = !ui.allow_graphical_ui; + if graphical_ui_disabled && clipboard != "none" { + add_loss( + items, + "ui.clipboard", + "error", + "MXC ignores clipboard grants when ui.disable is true.", + "directional clipboard access without graphical UI", + "MXC receives clipboard=none and sandbox creation is rejected; set allow_graphical_ui=true to request clipboard access.", + ); + } + if graphical_ui_disabled && ui.allow_input_injection { + add_loss( + items, + "ui.allow_input_injection", + "error", + "MXC ignores input-injection grants when ui.disable is true.", + "input injection without graphical UI", + "MXC receives injection=false and sandbox creation is rejected; set allow_graphical_ui=true to request input injection.", + ); + } + Some(json!({ + "disable": graphical_ui_disabled, + "clipboard": if graphical_ui_disabled { "none" } else { clipboard }, + "injection": !graphical_ui_disabled && ui.allow_input_injection, + })) + } + "isolation_session" => { + if ui.is_some() { + add_loss( + items, + "ui", + "error", + "MXC isolation_session rejects every explicitly supplied top-level UI policy, including an empty or deny-only policy.", + "OpenShell UI policy", + "The UI block is omitted and sandbox creation is rejected before wxc-exec is invoked.", + ); + } + None + } + _ => { + if ui.is_some() { + add_loss( + items, + "ui", + "error", + &format!( + "OpenShell UI policy enforcement is not supported by the MXC `{containment}` mapping target." + ), + "OpenShell UI policy", + "The generated config remains at the mapper's restrictive UI defaults and the caller must reject the mapping.", + ); + } + Some(restrictive()) + } + } +} + fn map_filesystem( policy: &SandboxPolicy, opts: &MxcMappingOptions, diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 156cd8ea7a..670e31a617 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -18,8 +18,8 @@ //! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) //! is recorded in the loss report. Use this when MXC enforces network on its //! own, with no `OpenShell` proxy in the loop. -//! - [`split_policy`] — the governed-egress split for the Windows MXC compute -//! driver: MXC handles filesystem + containment + a `network.proxy` redirect, +//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! driver: MXC handles filesystem + containment + loopback-only egress, //! while the full `OpenShell` network policy is preserved in a trimmed policy //! enforced by the host CONNECT proxy. //! diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index d8d6a37904..4b68417a18 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -210,34 +210,32 @@ fn all_example_policies_split_with_expected_invariants() { assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); } - assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!(str_list(&cfg["network"]["allowedHosts"]).is_empty()); - // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. - assert_eq!(cfg["network"]["proxy"]["localhost"], 18080); - assert!(cfg["network"]["proxy"].get("host").is_none()); - assert!(cfg["network"]["proxy"].get("port").is_none()); + assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); + assert_eq!( + cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); + assert!(cfg.get("runtimeConfig").is_none()); + assert!(cfg["network"].get("proxy").is_none()); let errors: Vec<_> = result .loss .iter() .filter(|item| item.severity == "error") .collect(); - if policy.network_middlewares.is_empty() { - assert!( - errors.is_empty(), - "processcontainer split must not emit error losses for {}: {:?}", - path.display(), - result.loss - ); - } else { - assert_eq!( - errors.len(), - 1, - "middleware policy must have one fail-closed loss for {}: {:?}", - path.display(), - result.loss - ); - assert_eq!(errors[0].path, "network_middlewares"); - } + assert!( + errors.is_empty(), + "processcontainer split must not emit error losses for {}: {:?}", + path.display(), + result.loss + ); + assert_eq!( + result.proxy_policy.network_middlewares, + policy.network_middlewares, + "supervisor handoff must preserve middleware for {}", + path.display() + ); } } @@ -295,29 +293,24 @@ fn split_policy_routes_network_to_proxy() { let result = split_policy(&policy, &opts).expect("split_policy returns Some when addr is set"); let cfg = &result.mxc_config; - // Proxy redirect is emitted. 127.0.0.2 is not the loopback 127.0.0.1 so - // the mapper records an error loss and omits the proxy block entirely. - // (MXC 0.6.0-alpha can only encode {"localhost": N}; non-127.0.0.1 is - // not representable.) + // 127.0.0.2 is not the supported 127.0.0.1 host-proxy address, so the + // mapper records an error loss and keeps the MXC config fail-closed. assert!( - cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), - "non-127.0.0.1 redirect must NOT produce a proxy block: {:?}", - cfg["network"].get("proxy") + cfg.get("runtimeConfig").is_none() || cfg["runtimeConfig"].is_null(), + "non-127.0.0.1 redirect must NOT produce runtimeConfig: {:?}", + cfg.get("runtimeConfig") ); let has_proxy_loss = result .loss .iter() - .any(|i| i.path == "network.proxy" && i.severity == "error"); + .any(|i| i.path == "proxy_redirect" && i.severity == "error"); assert!( has_proxy_loss, "non-127.0.0.1 redirect must produce an error loss item" ); - // Direct egress is blocked; unsupported host-list fields are omitted and - // the proxy enforces the full list. - assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!(cfg["network"].get("allowedHosts").is_none()); - assert!(cfg["network"].get("blockedHosts").is_none()); + // Direct egress is denied; the host proxy enforces the list. + assert_eq!(cfg["network"]["egress"]["default"], "deny"); // Filesystem grants are preserved unchanged. assert_eq!( @@ -401,7 +394,7 @@ fn split_policy_rejects_proxy_redirect_on_isolation_session() { "expected one containment error: {errors:?}" ); assert_eq!(errors[0].path, "containment"); - assert!(errors[0].message.contains("MXC M1")); + assert!(errors[0].message.contains("processcontainer")); assert!(result.mxc_config["network"].get("proxy").is_none()); } @@ -425,9 +418,7 @@ fn network_only_policy_has_empty_filesystem() { // ── New tests: proxy JSON shape and non-127.0.0.1 guard ────────────────────── #[test] -fn split_with_loopback_addr_emits_localhost_port_shape() { - // MXC 0.6.0-alpha accepts ONLY {"proxy": {"localhost": N}}. - // Verified against the real wxc-exec 0.6.0-alpha binary via --dry-run. +fn split_with_loopback_addr_emits_loopback_only_08_shape() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -440,18 +431,15 @@ fn split_with_loopback_addr_emits_localhost_port_shape() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; + assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); assert_eq!( - cfg["network"]["proxy"]["localhost"], 18080, - "proxy must use {{\"localhost\": N}} shape" - ); - assert!( - cfg["network"]["proxy"].get("host").is_none(), - "proxy must not contain 'host' key" - ); - assert!( - cfg["network"]["proxy"].get("port").is_none(), - "proxy must not contain 'port' key" + cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); + assert!(cfg.get("runtimeConfig").is_none()); + assert!(cfg["network"].get("proxy").is_none()); // No error losses — 127.0.0.1 is representable. assert!( result.loss.iter().all(|i| i.severity != "error"), @@ -465,9 +453,7 @@ fn split_with_loopback_addr_emits_localhost_port_shape() { } #[test] -fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { - // Non-127.0.0.1 redirect addresses are not representable in MXC 0.6.0-alpha. - // The mapper must record an error loss and omit the proxy block. +fn split_with_non_loopback_addr_emits_error_loss_and_no_runtime_proxy() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -480,28 +466,29 @@ fn split_with_non_loopback_addr_emits_error_loss_and_no_proxy_block() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; - // Proxy block must be absent. + // A runtime proxy block is never emitted; proxy-aware clients receive + // environment variables from the driver after this mapping step. assert!( - cfg["network"].get("proxy").is_none() || cfg["network"]["proxy"].is_null(), - "non-127.0.0.1 redirect must not produce a proxy block: {:?}", - cfg["network"].get("proxy") + cfg.get("runtimeConfig").is_none() || cfg["runtimeConfig"].is_null(), + "non-127.0.0.1 redirect must not produce runtimeConfig: {:?}", + cfg.get("runtimeConfig") ); - // An error loss for "network.proxy" must be present. + // An error loss for the unusable redirect must be present. let proxy_loss = result .loss .iter() - .find(|i| i.path == "network.proxy" && i.severity == "error"); + .find(|i| i.path == "proxy_redirect" && i.severity == "error"); assert!( proxy_loss.is_some(), - "non-127.0.0.1 redirect must produce an error loss item on network.proxy: {:?}", + "non-127.0.0.1 redirect must produce a proxy_redirect error loss item: {:?}", result.loss ); let loss = proxy_loss.unwrap(); assert_eq!(loss.openshell_feature, "per-sandbox egress attribution"); assert!( - loss.message.contains("localhost"), - "loss message should mention 'localhost': {}", + loss.message.contains("127.0.0.1"), + "loss message should mention '127.0.0.1': {}", loss.message ); } diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 6dda646bd0..28e0fdb585 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -24,7 +24,7 @@ use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, - NetworkPolicyRule, ProcessPolicy, SandboxPolicy, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy, }; use openshell_driver_mxc::{ EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, @@ -82,6 +82,12 @@ fn pc_split_opts() -> MxcMappingOptions { } } +fn pc_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "processcontainer".to_owned(), + ..Default::default() + } +} /// Build a minimal policy with one network rule whose endpoints carry a single /// endpoint set up by the caller. fn net_policy(key: &str, ep: NetworkEndpoint) -> SandboxPolicy { @@ -384,18 +390,18 @@ fn a_split_network_verbatim_and_version_preserved() { ); } -/// split path: mxc_config["network"]["proxy"]["localhost"] == port (new schema). +/// Split path emits MXC 0.8 loopback-only governed-egress fields. #[test] -fn a_split_proxy_localhost_port() { +fn a_split_proxy_uses_loopback_only_08_fields() { let policy = SandboxPolicy::default(); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert!(result.mxc_config.get("runtimeConfig").is_none()); + assert_eq!(result.mxc_config["version"], "0.8.0-alpha"); + assert_eq!(result.mxc_config["network"]["egress"]["default"], "deny"); assert_eq!( - result.mxc_config["network"]["proxy"]["localhost"], 18080, - "split must emit network.proxy.localhost == port" + result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); - // Released wxc-exec rejects host-list fields, even when empty. - assert!(result.mxc_config["network"].get("allowedHosts").is_none()); - assert!(result.mxc_config["network"].get("blockedHosts").is_none()); } // ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── @@ -931,6 +937,7 @@ fn b_seam_returns_unsupported_on_error_field() { let ctx = MapCtx { sandbox_id: "sb-test".into(), egress: None, // coarse path → isolation_session → network policy errors + containment: "isolation_session".into(), }; let err = mapper.map(Some(&policy), &ctx).unwrap_err(); assert!( @@ -1024,13 +1031,141 @@ fn c_split_empty_allowed_hosts_with_network_rules() { }, ); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); - assert!(result.mxc_config["network"].get("allowedHosts").is_none()); - assert!(result.mxc_config["network"].get("blockedHosts").is_none()); - // But proxy redirect is present. assert_eq!( - result.mxc_config["network"]["proxy"]["localhost"], 18080, - "split must emit network.proxy.localhost" + result.mxc_config["network"]["egress"]["default"], "deny", + "split path must deny direct egress even with network rules" ); + // The only MXC-level egress allowance is host loopback. + assert_eq!( + result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert!(result.mxc_config.get("runtimeConfig").is_none()); +} + +#[test] +fn a_processcontainer_maps_ui_capabilities_exactly() { + for (clipboard, expected) in [ + (UiClipboardAccess::Unspecified, "none"), + (UiClipboardAccess::None, "none"), + (UiClipboardAccess::Read, "read"), + (UiClipboardAccess::Write, "write"), + (UiClipboardAccess::All, "all"), + ] { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: clipboard as i32, + allow_input_injection: true, + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], false); + assert_eq!(result.config["ui"]["clipboard"], expected); + assert_eq!(result.config["ui"]["injection"], true); + assert_eq!(result.config["ui"].as_object().unwrap().len(), 3); + assert!(result.loss.iter().all(|item| item.path != "ui")); + } +} + +#[test] +fn c_processcontainer_absent_or_empty_ui_is_default_deny() { + for policy in [ + SandboxPolicy::default(), + SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + ] { + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["clipboard"], "none"); + assert_eq!(result.config["ui"]["injection"], false); + } +} + +#[test] +fn b_processcontainer_rejects_clipboard_without_graphical_ui() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["clipboard"], "none"); + assert_single_loss( + &result.loss, + "ui.clipboard", + "error", + "clipboard grant suppressed by ui.disable", + ); +} + +#[test] +fn b_processcontainer_rejects_input_injection_without_graphical_ui() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + allow_input_injection: true, + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc(&policy, &pc_opts()); + assert_eq!(result.config["ui"]["disable"], true); + assert_eq!(result.config["ui"]["injection"], false); + assert_single_loss( + &result.loss, + "ui.allow_input_injection", + "error", + "input-injection grant suppressed by ui.disable", + ); +} + +#[test] +fn b_isolation_session_omits_absent_ui_and_rejects_explicit_ui() { + let opts = MxcMappingOptions { + containment: "isolation_session".into(), + ..Default::default() + }; + let absent = map_to_mxc(&SandboxPolicy::default(), &opts); + assert!(absent.config.get("ui").is_none()); + + let explicit = map_to_mxc( + &SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }, + &opts, + ); + assert!(explicit.config.get("ui").is_none()); + assert_single_loss( + &explicit.loss, + "ui", + "error", + "isolation_session explicit UI", + ); +} + +#[test] +fn a_split_maps_ui_to_mxc_and_omits_it_from_proxy_policy() { + let policy = SandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Write as i32, + ..Default::default() + }), + ..Default::default() + }; + let result = split_policy(&policy, &pc_split_opts()).expect("split"); + assert_eq!(result.mxc_config["ui"]["disable"], false); + assert_eq!(result.mxc_config["ui"]["clipboard"], "write"); + assert_eq!(result.mxc_config["ui"]["injection"], false); + assert!(result.proxy_policy.ui.is_none()); } // ─── DRIFT GUARD ───────────────────────────────────────────────────────────── @@ -1047,11 +1182,14 @@ fn c_split_empty_allowed_hosts_with_network_rules() { /// "landlock" — loss item emitted in add_static_policy_loss /// "process" — loss items for run_as_user / run_as_group /// "network_policies" — mapped via map_network / delegated in split +/// "network_middlewares" — error loss in coarse map / delegated in split +/// "ui" — exact processContainer map / explicit unsupported loss const HANDLED_TOPLEVEL: &[&str] = &[ "version", "filesystem_policy", "landlock", "process", + "ui", "network_policies", "network_middlewares", ]; @@ -1122,6 +1260,7 @@ fn handled_fields_inventory() { run_as_user: "sandbox".into(), run_as_group: "sandbox".into(), }), + ui: Some(UiPolicy::default()), network_policies: { let mut m = std::collections::HashMap::new(); m.insert( diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index df4c3a5dba..7517738e67 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -37,7 +37,7 @@ use openshell_core::proto::{ NetworkPolicyRule, NetworkTlsMode, SandboxPolicy, }; use openshell_driver_mxc::{MxcComputeBackend, MxcComputeConfig}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -69,7 +69,7 @@ fn wxc_path() -> Option { /// Create a real, user-owned Windows directory for MXC filesystem grants. /// /// MXC config values are literal paths: it does not expand `%TEMP%`. A unique -/// directory also keeps AppContainer+DACL fallback mutations scoped to test +/// directory also keeps `AppContainer`+DACL fallback mutations scoped to test /// data the current user owns. fn temp_fixture() -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().expect("create MXC temp fixture"); @@ -82,10 +82,19 @@ fn temp_fixture() -> (tempfile::TempDir, String) { /// Invoke `wxc-exec --config-base64 --dry-run` synchronously. /// Returns `(exit_code, stdout, stderr)`. fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { + dry_run_with_args(wxc, config, &[]) +} + +fn dry_run_with_args( + wxc: &PathBuf, + config: &serde_json::Value, + args: &[&str], +) -> (i32, String, String) { let json = serde_json::to_string(config).expect("config serialize"); let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let out = Command::new(wxc) + .args(args) .arg("--config-base64") .arg(&b64) .arg("--dry-run") @@ -98,6 +107,32 @@ fn dry_run(wxc: &PathBuf, config: &serde_json::Value) -> (i32, String, String) { (code, stdout, stderr) } +fn wxc_version(wxc: &Path) -> Option<(u64, u64, u64, String)> { + // wxc-exec does not expose --version. Release builds carry the Cargo + // version in the standard Windows ProductVersion resource. + let path_literal = wxc.to_string_lossy().replace('\'', "''"); + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(format!( + "(Get-Item -LiteralPath '{path_literal}').VersionInfo.ProductVersion" + )) + .output() + .ok()?; + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let version = raw.split_whitespace().find_map(|token| { + let core = token + .trim_matches(|ch: char| !ch.is_ascii_digit() && ch != '.') + .split(['+', '-']) + .next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + Some((major, minor, patch)) + })?; + Some((version.0, version.1, version.2, raw)) +} + // ── (a) Dry-run contract tests ──────────────────────────────────────────────── // // These PASS on any box that has the wxc-exec binary — no enforcement backend @@ -134,6 +169,115 @@ fn dryrun_accepts_minimal_processcontainer_config() { ); } +/// Every `OpenShell` clipboard direction maps to MXC's shared top-level UI +/// contract, with graphical UI and injection carried as independent booleans. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_processcontainer_ui_policy_matrix() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let temp_path = tempdir.path().to_string_lossy().into_owned(); + for clipboard in ["none", "read", "write", "all"] { + let config = serde_json::json!({ + "version": "0.7.0-alpha", + "containerId": format!("test-ui-{clipboard}"), + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": temp_path.clone(), + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [temp_path.clone()], + }, + "ui": { + "disable": false, + "clipboard": clipboard, + "injection": true, + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "processcontainer UI policy clipboard={clipboard} rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); + } +} + +/// MXC 0.8 and the 0.9 development schema reject the shared top-level +/// UI object on `isolation_session`, while omission remains accepted. Older +/// 0.7 builds accepted and ignored the object, so `OpenShell`'s gateway-level +/// capability check is the stable enforcement boundary across versions. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_current_schema_rejects_isolation_session_ui() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + let Some((major, minor, _patch, raw_version)) = wxc_version(&wxc) else { + eprintln!("SKIP: could not determine wxc-exec version"); + return; + }; + if (major, minor) < (0, 8) { + eprintln!("SKIP: {raw_version} predates the isolation_session UI rejection contract"); + return; + } + + let mut base = serde_json::json!({ + "version": "0.8.0-alpha", + "phase": "provision", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true, + }, + }); + let args: &[&str] = if (major, minor) >= (0, 9) { + base.as_object_mut().unwrap().remove("phase"); + base["version"] = serde_json::json!("0.9.0-alpha"); + base["network"] = serde_json::json!({ + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, + }); + &["--experimental", "--operation", "provision"] + } else { + &["--experimental"] + }; + let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, args); + let output = format!("{stdout} {stderr}").to_ascii_lowercase(); + if code != 0 + && output.contains("backend_unavailable") + && output.contains("not available in this build") + { + eprintln!("SKIP: {raw_version} was built without isolation_session support"); + return; + } + assert_eq!( + code, 0, + "current isolation_session schema must accept omission of UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); + + let mut with_ui = base; + with_ui["ui"] = serde_json::json!({ "disable": true }); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &with_ui, args); + assert_ne!( + code, 0, + "current isolation_session schema unexpectedly accepted UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + format!("{stdout} {stderr}") + .to_ascii_lowercase() + .contains("ui"), + "rejection should identify UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" + ); +} + /// Network block without proxy (defaultPolicy block, empty host lists) accepted. #[test] #[ignore = "requires real wxc-exec"] @@ -273,7 +417,8 @@ fn dryrun_rejects_unknown_containment() { } /// The most important dry-run test: build a typed Windows policy, run -/// `split_policy` (`proxy_redirect` 127.0.0.1:18080, containment +/// `split_policy` (MXC 0.8 loopback-only proxy access at 127.0.0.1:18080, +/// containment /// "processcontainer"), and verify the resulting config with `--dry-run`. /// /// This proves that the mapper's emitted JSON is accepted by the real binary — @@ -322,8 +467,38 @@ fn dryrun_accepts_split_policy_output() { } let mxc_config = result.mxc_config; + assert_eq!(mxc_config["version"], "0.8.0-alpha"); + assert_eq!(mxc_config["network"]["egress"]["default"], "deny"); + assert_eq!( + mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" + ); + assert!(mxc_config.get("runtimeConfig").is_none()); + // --dry-run also resolves host capabilities; it is not schema-only. + // Verify explicit rejection on an AppContainer-only host rather than + // weakening the mapper's required loopback fence to obtain a green test. + let probe = Command::new(&wxc) + .arg("--probe") + .output() + .expect("MXC probe"); + let probe_json = serde_json::from_slice::(&probe.stdout).ok(); + let loopback_supported = probe_json + .as_ref() + .and_then(|value| value.pointer("/probes/baseContainerSupportsIngressHostLoopbackAllow")) + .and_then(serde_json::Value::as_bool); let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); + if loopback_supported == Some(false) { + assert_ne!(code, 0, "unsupported host must reject the loopback fence"); + assert!( + stderr.contains("hostLoopback"), + "unexpected rejection: {stderr}" + ); + eprintln!( + "SKIP: positive split-policy admission requires native host-loopback support; unsupported-host rejection verified" + ); + return; + } assert_eq!( code, 0, @@ -763,8 +938,6 @@ async fn pc_https_egress_reads_injected_ca_bundle() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let config = MxcComputeConfig { wxc_exec_path: wxc.to_string_lossy().into_owned(), - egress_proxy: true, - egress_proxy_addr: "127.0.0.1:18080".to_string(), ..Default::default() }; let backend = MxcComputeBackend::new(openshell_core::config::DEFAULT_GATEWAY_NAME, config); diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index a660ad98c7..c89f9284dd 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -133,6 +133,7 @@ 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 ad6d20a9a0..8c3e1b44e3 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -161,7 +161,16 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { &self, context: openshell_server::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { - let _: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + let mut config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + config.validate_configuration()?; + if config.grpc_endpoint.trim().is_empty() { + let scheme = if context.gateway_tls_enabled() { + "https" + } else { + "http" + }; + config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); + } Ok(()) } @@ -169,8 +178,28 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { &self, context: openshell_server::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { - let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; - let backend = openshell_driver_mxc::MxcComputeBackend::new(context.gateway_name(), config); + let mut config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + require_guest_tls_for_local_driver(&context, "mxc")?; + let use_internal_tls_server_name = + config.grpc_endpoint.trim().is_empty() && context.gateway_tls_enabled(); + if config.grpc_endpoint.trim().is_empty() { + let scheme = if context.gateway_tls_enabled() { + "https" + } else { + "http" + }; + config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); + } + let tls = context.guest_tls_ca().map(std::path::Path::to_path_buf); + let endpoint = config.grpc_endpoint.clone(); + let tls_server_name = use_internal_tls_server_name.then(|| "localhost".to_string()); + let backend = openshell_driver_mxc::MxcComputeBackend::new_with_gateway( + context.gateway_name(), + config, + endpoint, + tls, + tls_server_name, + ); let driver = openshell_driver_mxc::ComputeDriverService::new(backend); Ok(openshell_server::ComputeDriverInstance::InProcess( std::sync::Arc::new(driver), @@ -498,13 +527,16 @@ fn vm_config( Ok(config) } -#[cfg(all( - not(target_os = "windows"), - any( - feature = "compute-driver-docker", - feature = "compute-driver-podman", - feature = "compute-driver-vm" - ) +#[cfg(any( + all( + not(target_os = "windows"), + any( + feature = "compute-driver-docker", + feature = "compute-driver-podman", + feature = "compute-driver-vm" + ) + ), + all(target_os = "windows", feature = "compute-driver-mxc") ))] fn require_guest_tls_for_local_driver( context: &openshell_server::ComputeDriverBuildContext<'_>, @@ -517,13 +549,16 @@ fn require_guest_tls_for_local_driver( ) } -#[cfg(all( - not(target_os = "windows"), - any( - feature = "compute-driver-docker", - feature = "compute-driver-podman", - feature = "compute-driver-vm" - ) +#[cfg(any( + all( + not(target_os = "windows"), + any( + feature = "compute-driver-docker", + feature = "compute-driver-podman", + feature = "compute-driver-vm" + ) + ), + all(target_os = "windows", feature = "compute-driver-mxc") ))] fn validate_local_driver_guest_tls( gateway_tls_enabled: bool, diff --git a/crates/openshell-gateway/tests/mxc_config_preflight.rs b/crates/openshell-gateway/tests/mxc_config_preflight.rs new file mode 100644 index 0000000000..c4314f3079 --- /dev/null +++ b/crates/openshell-gateway/tests/mxc_config_preflight.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration test for the actual enforcement boundary of the +//! `wxc_exec_path` absolute-path requirement: the real `openshell-gateway` +//! binary's `config preflight` subcommand, selecting the `mxc` compute +//! driver. +//! +//! `openshell_driver_mxc::MxcComputeConfig::validate_configuration` already +//! has unit coverage, but that only proves the validation function itself is +//! correct -- it says nothing about whether `MxcFactory::validate_config` (in +//! `src/lib.rs`) still calls it. Before this fix, that factory method was a +//! no-op that discarded the parsed config entirely, so a regression back to +//! that shape would leave every unit test passing while a relative +//! `wxc_exec_path` again reached gateway startup. Spawning the real compiled +//! binary through its actual CLI entry point exercises the whole chain: CLI +//! parsing, TOML config loading, driver selection, `MxcFactory::validate_config`, +//! and `MxcComputeConfig::validate_configuration`. +//! +//! These tests assert only pass/fail, not the diagnostic's exact wording: +//! `openshell_server::cli::run_effective_config_preflight` replaces any +//! validation failure with a generic "malformed" message whenever a config +//! file path is in play (`ConfigPreflightError::invalid_current`), so the +//! specific `wxc_exec_path` wording from `validate_configuration` is not +//! observable through this boundary today. That message-masking behavior is +//! pre-existing and unrelated to this fix. + +#![cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] + +use std::io::Write; +use std::process::Command; + +/// Run `openshell-gateway config preflight` against a disposable TOML config +/// with the `mxc` driver selected. Returns whether the process exited +/// successfully and its captured stderr. +fn run_preflight(toml_body: &str) -> (bool, String) { + let mut config_file = tempfile::NamedTempFile::new().expect("create temp config file"); + write!(config_file, "{toml_body}").expect("write temp config file"); + + let output = Command::new(env!("CARGO_BIN_EXE_openshell-gateway")) + .arg("config") + .arg("preflight") + .env("OPENSHELL_GATEWAY_CONFIG", config_file.path()) + .env("OPENSHELL_COMPUTE_DRIVER", "mxc") + .env("OPENSHELL_DISABLE_TLS", "true") + .env_remove("OPENSHELL_DRIVERS") + .output() + .expect("spawn openshell-gateway config preflight"); + + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + (output.status.success(), stderr) +} + +#[test] +fn config_preflight_rejects_omitted_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r" +[openshell] +version = 2 +", + ); + assert!( + !ok, + "omitted wxc_exec_path must fail preflight, stderr: {stderr}" + ); +} + +#[test] +fn config_preflight_rejects_relative_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r#" +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +"#, + ); + assert!( + !ok, + "a relative wxc_exec_path must fail preflight, stderr: {stderr}" + ); +} + +#[test] +fn config_preflight_accepts_absolute_wxc_exec_path() { + let (ok, stderr) = run_preflight( + r#" +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +"#, + ); + assert!( + ok, + "an absolute wxc_exec_path must pass preflight, stderr: {stderr}" + ); +} diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 246394d28e..9f619c0919 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -370,6 +370,15 @@ 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 @@ -386,6 +395,31 @@ 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. /// @@ -478,7 +512,6 @@ 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 2fb1f3fdba..c1873db08a 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -383,7 +383,6 @@ 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 new file mode 100644 index 0000000000..c11a197434 --- /dev/null +++ b/crates/openshell-mxc-boundary/Cargo.toml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-mxc-boundary" +description = "Windows MXC implementation of the authenticated OpenShell sandbox boundary" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-windows-sandbox" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +async-trait = "0.1" +openshell-core = { path = "../openshell-core", default-features = false } +openshell-isolation-interface = { path = "../openshell-isolation-interface" } +openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } +openshell-ocsf = { path = "../openshell-ocsf" } +tracing-subscriber = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tonic = { workspace = true } +rustls = { workspace = true } +rustls-pemfile = { workspace = true } +tokio-rustls = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +windows = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-mxc-boundary/README.md b/crates/openshell-mxc-boundary/README.md new file mode 100644 index 0000000000..b768fec8ca --- /dev/null +++ b/crates/openshell-mxc-boundary/README.md @@ -0,0 +1,54 @@ +# openshell-mxc-boundary + +Windows-only library implementing the authenticated Sandbox Protocol inside an +MXC ProcessContainer, with a thin `openshell-windows-sandbox.exe` entrypoint. +The generic `openshell-sandbox` does not link this implementation. There is no +separate supervisor-relay executable. + +The Windows release lane builds this executable alongside the gateway, CLI, and +host supervisor. It accepts `--bootstrap ` and `--log-level `; +bootstrap configuration remains one-use and protected by the driver. On other +platforms, runtime execution returns a clear unsupported error. + +The compute driver owns provisioning, filesystem/network policy mapping, ETW, +protected bootstrap material, and monitoring the host/boundary process pair. +This library owns Windows process launch and I/O, AppContainer confirmation, +authenticated lifecycle operations, retained output, and loopback forwarding. +The separate host supervisor owns policy evaluation, provider credentials, and +upstream networking. This library does not depend on the compute driver or the +supervisor implementation. + +The library also owns the host-side `MxcRuntimeBackend`, registered under the +exact admission name `openshell-mxc`, and the MXC audit schema and its validator. +Windows supervisor composition selects this backend; it delegates transport +and common lifecycle validation to the shared Sandbox Protocol implementation. +The backend keeps evidence opaque and compares validated properties with the +confirmation; its default Linux validator rejects Windows evidence. + +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 +credentials, independently of opaque provider revisions. Provider file delivery +is unsupported and rejected before process launch or environment replacement. +Structured shell exec intent and runtime helpers are also rejected explicitly; +they must not be silently treated as raw executable launches. + +Dynamic service forwarding uses the shared protocol's `LoopbackConnect` request +and bidirectional stream. The boundary connects to the requested loopback port; +it does not launch a relay or connect back to a new gateway relay listener. +Non-loopback targets and port zero are rejected. Authentication and lifecycle +authorization precede dispatch to the forwarding operation. + +This extraction preserves the current explicit-proxy network model. It does +not add transparent socket interception or a non-PSEC transport. The outer fence +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. + +Unit tests exercise real Windows process launch, output retention, and a real +ephemeral TCP forwarding target. These tests do not qualify MXC enforcement; +that requires the real-MXC integration and E2E lanes on a supported host. diff --git a/crates/openshell-mxc-boundary/src/audit.rs b/crates/openshell-mxc-boundary/src/audit.rs new file mode 100644 index 0000000000..a795eb0a51 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/audit.rs @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC-owned containment evidence and its host-side interpretation. + +use openshell_isolation_interface::contract::{BackendError, BoundaryProperties, EnforcedProperty}; +use openshell_sandbox_backend::audit::BoundaryAuditValidator; +use serde::{Deserialize, Serialize}; + +/// Windows `ProcessContainer` evidence measured by the MXC boundary. +/// +/// MXC supplies the outer filesystem and network fence; the in-container +/// sandbox supplies authenticated lifecycle and process I/O. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow( + clippy::struct_excessive_bools, + reason = "audit evidence preserves independently measured security results" +)] +pub struct MxcSandboxAuditEvidence { + pub process_container: bool, + pub appcontainer_profile: String, + pub default_deny_filesystem: bool, + pub default_deny_egress: bool, + pub loopback_proxy_only: bool, + pub authenticated_control: bool, + pub generation_scoped_attribution: bool, +} + +impl MxcSandboxAuditEvidence { + /// Validate all containment and authentication measurements. + /// + /// # Errors + /// + /// Returns an error when any required measurement is missing or failed. + pub fn validate(&self) -> Result<(), BackendError> { + if self.process_container + && !self.appcontainer_profile.trim().is_empty() + && self.default_deny_filesystem + && self.default_deny_egress + && self.loopback_proxy_only + && self.authenticated_control + && self.generation_scoped_attribution + { + Ok(()) + } else { + Err(BackendError::Confirm( + "MXC sandbox audit evidence is incomplete".to_string(), + )) + } + } + + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + BoundaryProperties { + filesystem_confinement: EnforcedProperty::new( + self.default_deny_filesystem, + "mxc-processcontainer-appcontainer", + ), + egress_interception: EnforcedProperty::new( + self.default_deny_egress && self.loopback_proxy_only, + "mxc-wfp-loopback-proxy-fence", + ), + request_attribution: EnforcedProperty::new( + self.generation_scoped_attribution, + "mxc-generation-authenticated-proxy", + ), + privilege_floor: EnforcedProperty::new( + self.process_container, + "windows-appcontainer-token", + ), + } + } +} + +/// MXC wire format; the generic transport does not interpret platform evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "platform", content = "evidence", rename_all = "snake_case")] +pub enum MxcBoundaryAuditEvidence { + WindowsMxc(MxcSandboxAuditEvidence), +} + +/// Host-side MXC evidence interpreter selected by Windows backend composition. +#[derive(Debug)] +pub struct MxcBoundaryAuditValidator; + +impl BoundaryAuditValidator for MxcBoundaryAuditValidator { + fn validate(&self, evidence: &serde_json::Value) -> Result { + let MxcBoundaryAuditEvidence::WindowsMxc(audit) = serde_json::from_value(evidence.clone()) + .map_err(|error| { + BackendError::Confirm(format!("decode MXC sandbox audit evidence: {error}")) + })?; + audit.validate()?; + Ok(audit.properties()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_evidence() -> serde_json::Value { + serde_json::to_value(MxcBoundaryAuditEvidence::WindowsMxc( + MxcSandboxAuditEvidence { + process_container: true, + appcontainer_profile: "measured-profile".to_string(), + default_deny_filesystem: true, + default_deny_egress: true, + loopback_proxy_only: true, + authenticated_control: true, + generation_scoped_attribution: true, + }, + )) + .unwrap() + } + + #[test] + fn accepts_complete_evidence_and_preserves_wire_format() { + let evidence = valid_evidence(); + assert_eq!(evidence["platform"], "windows_mxc"); + assert!(MxcBoundaryAuditValidator.validate(&evidence).is_ok()); + assert!( + openshell_sandbox_backend::audit::LinuxBoundaryAuditValidator + .validate(&evidence) + .is_err() + ); + } + + #[test] + fn rejects_each_missing_or_failed_measurement() { + for field in [ + "process_container", + "default_deny_filesystem", + "default_deny_egress", + "loopback_proxy_only", + "authenticated_control", + "generation_scoped_attribution", + ] { + let mut evidence = valid_evidence(); + evidence["evidence"][field] = false.into(); + assert!( + MxcBoundaryAuditValidator.validate(&evidence).is_err(), + "{field}" + ); + evidence["evidence"].as_object_mut().unwrap().remove(field); + assert!( + MxcBoundaryAuditValidator.validate(&evidence).is_err(), + "missing {field}" + ); + } + for profile in ["", " "] { + let mut evidence = valid_evidence(); + evidence["evidence"]["appcontainer_profile"] = profile.into(); + assert!(MxcBoundaryAuditValidator.validate(&evidence).is_err()); + } + } + + #[test] + fn rejects_other_platforms_and_unknown_measurements() { + for platform in ["linux", "unknown"] { + let mut evidence = valid_evidence(); + evidence["platform"] = platform.into(); + assert!(MxcBoundaryAuditValidator.validate(&evidence).is_err()); + } + let mut evidence = valid_evidence(); + evidence["evidence"]["unmeasured_guarantee"] = true.into(); + assert!(MxcBoundaryAuditValidator.validate(&evidence).is_err()); + } +} diff --git a/crates/openshell-mxc-boundary/src/backend.rs b/crates/openshell-mxc-boundary/src/backend.rs new file mode 100644 index 0000000000..be89d65b33 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/backend.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC registration and audit interpretation over the shared Sandbox Protocol. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use openshell_isolation_interface::contract::{ + BackendError, BoundBoundary, IsolationBackend, SandboxContext, VerifiedBackendDescriptor, +}; + +/// The host-side MXC isolation backend. The compute driver owns provisioning; +/// this backend owns attachment and interpretation of Windows confirmation. +#[derive(Debug)] +pub struct MxcRuntimeBackend { + transport: openshell_sandbox_backend::OpenShellRuntimeBackend, +} + +impl MxcRuntimeBackend { + /// Compose authenticated transport with the MXC-specific evidence validator. + #[must_use] + pub fn new( + ca_file_paths: Arc>>, + provider_credentials: openshell_core::provider_credentials::ProviderCredentialState, + sandbox_bearer: openshell_core::jwt::SessionBearerTokenSlot, + ) -> Self { + Self { + transport: openshell_sandbox_backend::OpenShellRuntimeBackend::new( + ca_file_paths, + provider_credentials, + sandbox_bearer, + ) + .with_audit_validator(Arc::new(crate::audit::MxcBoundaryAuditValidator)), + } + } +} + +#[async_trait] +impl IsolationBackend for MxcRuntimeBackend { + fn backend_name(&self) -> &str { + crate::BACKEND_NAME + } + + async fn attach( + &self, + descriptor: VerifiedBackendDescriptor, + sandbox: SandboxContext, + ) -> Result, BackendError> { + if descriptor.backend_name() != crate::BACKEND_NAME { + return Err(BackendError::Descriptor( + "MXC backend requires MXC admission".into(), + )); + } + // Keep shared validation of session, generation, identity, resource + // claims, TLS and outer-fence guarantees; only native audit differs. + self.transport.attach(descriptor, sandbox).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_isolation_interface::contract::{BackendDescriptor, BackendRegistry}; + + fn backend() -> Arc { + Arc::new(MxcRuntimeBackend::new( + Arc::new(Mutex::new(None)), + openshell_core::provider_credentials::ProviderCredentialState::from_environment( + 0, + Default::default(), + Default::default(), + Default::default(), + ), + openshell_core::jwt::SessionBearerTokenSlot::empty(), + )) + } + + #[test] + fn registry_requires_exact_mxc_admission_and_never_falls_back() { + let mut registry = BackendRegistry::new(); + registry.register(backend()).unwrap(); + let descriptor = || BackendDescriptor { + backend_name: crate::BACKEND_NAME.into(), + payload: Vec::new(), + }; + let (selected, verified) = registry.resolve(descriptor(), crate::BACKEND_NAME).unwrap(); + assert_eq!(selected.backend_name(), "openshell-mxc"); + assert_eq!(verified.backend_name(), selected.backend_name()); + assert!(matches!( + registry.resolve(descriptor(), "openshell-sandbox"), + Err(BackendError::Descriptor(_)) + )); + assert!(matches!( + registry.resolve( + BackendDescriptor { + backend_name: "openshell-sandbox".into(), + payload: Vec::new(), + }, + "openshell-sandbox" + ), + Err(BackendError::NotRegistered(_)) + )); + } + + #[test] + fn duplicate_mxc_backend_registration_is_rejected() { + let mut registry = BackendRegistry::new(); + registry.register(backend()).unwrap(); + assert!(matches!( + registry.register(backend()), + Err(BackendError::Descriptor(_)) + )); + } +} diff --git a/crates/openshell-mxc-boundary/src/lib.rs b/crates/openshell-mxc-boundary/src/lib.rs new file mode 100644 index 0000000000..68e0692fd3 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/lib.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows implementation of the in-MXC `OpenShell` sandbox boundary. +//! +//! The dedicated MXC sandbox executable calls this library. It implements Windows +//! process operations, containment confirmation, and loopback forwarding over +//! the shared authenticated Sandbox Protocol. It does not provision MXC, link +//! the compute driver, evaluate network policy, or embed the host supervisor. + +#[cfg(target_os = "windows")] +pub mod audit; +#[cfg(target_os = "windows")] +pub mod backend; + +/// Exact admission name for the MXC isolation backend; never a Linux fallback. +pub const BACKEND_NAME: &str = "openshell-mxc"; +#[cfg(target_os = "windows")] +mod runtime; + +/// Run the Windows boundary using driver-protected bootstrap material. +/// +/// # Errors +/// +/// Returns an error if bootstrap validation, authentication setup, or listener +/// initialization fails. Untrusted workloads start only after confirmation. +#[cfg(target_os = "windows")] +pub fn run(config_path: &std::path::Path) -> Result<(), String> { + runtime::run_boundary(config_path) +} diff --git a/crates/openshell-mxc-boundary/src/main.rs b/crates/openshell-mxc-boundary/src/main.rs new file mode 100644 index 0000000000..71e53a6874 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/main.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Dedicated in-MXC sandbox executable; the supervisor remains on the host. + +use clap::Parser; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Debug, Parser)] +#[command(name = "openshell-windows-sandbox", version)] +#[command(about = "OpenShell Windows MXC isolation boundary")] +struct Args { + /// Protected one-use bootstrap configuration staged by the MXC driver. + #[arg(long)] + bootstrap: PathBuf, + + /// Log level (trace, debug, info, warn, error). + #[arg(long, default_value = "warn", env = "OPENSHELL_LOG_LEVEL")] + log_level: String, +} + +#[cfg(target_os = "windows")] +fn run(args: &Args) -> Result<(), String> { + use openshell_ocsf::OcsfShorthandLayer; + use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt}; + + let console_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)); + let _ = tracing_subscriber::registry() + .with( + OcsfShorthandLayer::new(std::io::stderr()) + .with_non_ocsf(true) + .with_filter(console_filter), + ) + .try_init(); + openshell_mxc_boundary::run(&args.bootstrap) +} + +#[cfg(not(target_os = "windows"))] +fn run(_args: &Args) -> Result<(), String> { + Err("openshell-windows-sandbox requires Windows MXC".to_string()) +} + +fn main() -> ExitCode { + match run(&Args::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/openshell-mxc-boundary/src/runtime.rs b/crates/openshell-mxc-boundary/src/runtime.rs new file mode 100644 index 0000000000..bdb16c81e8 --- /dev/null +++ b/crates/openshell-mxc-boundary/src/runtime.rs @@ -0,0 +1,1668 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows `ProcessContainer` implementation of the authenticated Sandbox Protocol. +//! +//! MXC owns the outer filesystem and network fence. This process owns the +//! authenticated lifecycle channel, launches the admitted workload only after +//! confirmation, retains process output, and provides exec and loopback access. + +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}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use crate::audit::MxcSandboxAuditEvidence; +use openshell_core::jwt::{ + SandboxId, SessionJwtVerifier, SessionTokenProfile, SessionVerificationKey, SystemJwtClock, +}; +use openshell_isolation_interface::contract::BoundaryConfirmation; +use openshell_sandbox_backend::boundary_protocol::{ + AgentSpecWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener, ExecSpecWire, + ExitStatusWire, OutputWindowWire, ProcessKindWire, ProcessSnapshotWire, Request, + RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, STREAM_STDERR, STREAM_STDIN, + STREAM_STDIN_CLOSED, STREAM_STDOUT, SandboxPolicyWire, SessionSnapshotWire, SignalWire, + encode_frame, read_frame_async, read_stream_frame, write_stream_frame, +}; +use openshell_sandbox_backend::proto::{ + BoundaryChunk, + isolation_boundary_server::{IsolationBoundary, IsolationBoundaryServer}, +}; +use openshell_sandbox_backend::sandbox_auth::{ + SandboxConnectionId, SandboxConnectionRegistry, SandboxProtocolAuthenticator, + SandboxProtocolPrincipal, +}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::process::{Child, Command}; +use tokio_stream::wrappers::ReceiverStream; + +const CONTROL_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); +const CONTROL_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10); +const AUTHENTICATED_RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024; +const MAX_REPLAY_ENTRIES: usize = 4096; + +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) + .map_err(|error| format!("decode boundary config {}: {error}", config_path.display()))?; + validate_config(&config)?; + std::fs::remove_file(config_path) + .map_err(|error| format!("consume boundary config {}: {error}", config_path.display()))?; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("create Windows boundary runtime: {error}"))?; + runtime.block_on(async move { + let (address, tls) = match &config.listener { + BoundaryListener::TlsTcp { address, tls } => (*address, tls.clone()), + BoundaryListener::Unix { .. } | BoundaryListener::Vsock { .. } => { + return Err("MXC requires a TLS TCP boundary listener".to_string()); + } + }; + let tls = Arc::new(load_tls_server_config(&tls)?); + let listener = tokio::net::TcpListener::bind(address) + .await + .map_err(|error| format!("bind MXC boundary listener at {address}: {error}"))?; + let boundary = Arc::new(BoundaryRuntime::new(config)?); + tracing::info!(%address, "MXC Sandbox Protocol listener 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); + 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)) + .await + .map_err(|_| "MXC boundary TLS handshake timed out".to_string())? + .map_err(|error| { + format!("MXC boundary TLS handshake failed: {error}") + })?; + serve_grpc(Box::new(stream), boundary, SandboxConnectionId::new()).await + } + .await; + if let Err(error) = result { + tracing::debug!(%error, "MXC boundary connection ended"); + } + }); + } + }) +} + +fn validate_config(config: &BoundaryConfig) -> Result<(), String> { + if config.boundary_id.trim().is_empty() + || config.generation.trim().is_empty() + || config.gateway_id.trim().is_empty() + || config.verification_keys.is_empty() + { + return Err("MXC boundary identity and verification keys are required".to_string()); + } + config + .outer_fence + .validate(&config.generation) + .map_err(|error| error.to_string())?; + match &config.listener { + BoundaryListener::TlsTcp { address, tls } + if address.port() != 0 + && tls.certificate_chain_path.is_absolute() + && tls.private_key_path.is_absolute() => {} + BoundaryListener::TlsTcp { .. } => { + return Err("MXC boundary TLS listener configuration is invalid".to_string()); + } + BoundaryListener::Unix { .. } | BoundaryListener::Vsock { .. } => { + 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 + .parse::() + .map_err(|error| format!("validate MXC direct proxy URL: {error}"))?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.port().is_none() + || url.username().is_empty() + || url.password().is_none() + { + return Err("MXC direct proxy must be an authenticated 127.0.0.1 HTTP URL".to_string()); + } + Ok(()) +} + +fn load_tls_server_config( + tls: &openshell_sandbox_backend::boundary_protocol::SandboxTlsServerConfig, +) -> Result { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let certificate_bytes = std::fs::read(&tls.certificate_chain_path) + .map_err(|error| format!("read MXC boundary TLS certificate: {error}"))?; + let certificates = rustls_pemfile::certs(&mut certificate_bytes.as_slice()) + .collect::, _>>() + .map_err(|error| format!("parse MXC boundary TLS certificate: {error}"))?; + let private_key_bytes = std::fs::read(&tls.private_key_path) + .map_err(|error| format!("read MXC boundary TLS private key: {error}"))?; + let private_key = rustls_pemfile::private_key(&mut private_key_bytes.as_slice()) + .map_err(|error| format!("parse MXC boundary TLS private key: {error}"))? + .ok_or_else(|| "MXC boundary TLS private key is empty".to_string())?; + let mut server = + rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) + .with_no_client_auth() + .with_single_cert(certificates, private_key) + .map_err(|error| format!("configure MXC boundary TLS: {error}"))?; + server.alpn_protocols = vec![b"h2".to_vec()]; + for path in [&tls.certificate_chain_path, &tls.private_key_path] { + std::fs::remove_file(path).map_err(|error| { + format!("consume MXC boundary TLS file {}: {error}", path.display()) + })?; + } + Ok(server) +} + +async fn serve_grpc( + stream: openshell_isolation_interface::contract::BoundaryDuplexStream, + runtime: Arc, + connection_id: SandboxConnectionId, +) -> Result<(), String> { + let (connection_shutdown, mut connection_closed) = tokio::sync::watch::channel(()); + runtime.register_connection(connection_id, connection_shutdown.clone()); + let incoming = tokio_stream::StreamExt::chain( + tokio_stream::iter([Ok::<_, io::Error>(GrpcServerIo { + stream, + _connection_alive: connection_shutdown, + _disconnect: DisconnectGuard { + runtime: Arc::downgrade(&runtime), + connection_id, + }, + })]), + tokio_stream::pending(), + ); + let result = tonic::transport::Server::builder() + .http2_keepalive_interval(Some(CONTROL_KEEPALIVE_INTERVAL)) + .http2_keepalive_timeout(Some(CONTROL_KEEPALIVE_TIMEOUT)) + .add_service(IsolationBoundaryServer::new(GrpcBoundaryService { + runtime: runtime.clone(), + connection_id, + })) + .serve_with_incoming_shutdown(incoming, async move { + let _ = connection_closed.changed().await; + }) + .await; + runtime.transport_disconnected(connection_id); + result.map_err(|error| format!("serve MXC boundary gRPC: {error}")) +} + +struct GrpcServerIo { + stream: openshell_isolation_interface::contract::BoundaryDuplexStream, + _connection_alive: tokio::sync::watch::Sender<()>, + _disconnect: DisconnectGuard, +} + +struct DisconnectGuard { + runtime: std::sync::Weak, + connection_id: SandboxConnectionId, +} + +impl Drop for DisconnectGuard { + fn drop(&mut self) { + if let Some(runtime) = self.runtime.upgrade() { + runtime.transport_disconnected(self.connection_id); + } + } +} + +impl tokio::io::AsyncRead for GrpcServerIo { + fn poll_read( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_read(context, buffer) + } +} + +impl tokio::io::AsyncWrite for GrpcServerIo { + fn poll_write( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(context, buffer) + } + + fn poll_flush(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(context) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(context) + } +} + +impl tonic::transport::server::Connected for GrpcServerIo { + type ConnectInfo = (); + fn connect_info(&self) -> Self::ConnectInfo {} +} + +#[derive(Clone)] +struct GrpcBoundaryService { + runtime: Arc, + connection_id: SandboxConnectionId, +} + +type GrpcResponseStream = ReceiverStream>; + +#[tonic::async_trait] +impl IsolationBoundary for GrpcBoundaryService { + type ExchangeStream = GrpcResponseStream; + type MediateStream = GrpcResponseStream; + + async fn exchange( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let principal = self + .runtime + .authenticate_request(self.connection_id, request.metadata())?; + let (stream, response) = bridge_grpc_stream(request.into_inner()); + let runtime = self.runtime.clone(); + tokio::spawn(async move { + if let Err(error) = serve_one(stream, runtime, principal).await { + tracing::warn!(%error, "MXC Sandbox Protocol exchange failed"); + } + }); + Ok(tonic::Response::new(response)) + } + + async fn mediate( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let _ = self + .runtime + .authenticate_request(self.connection_id, request.metadata())?; + Err(tonic::Status::failed_precondition( + "MXC uses the supervisor-owned authenticated explicit proxy", + )) + } +} + +fn bridge_grpc_stream( + mut inbound: tonic::Streaming, +) -> (tokio::io::DuplexStream, GrpcResponseStream) { + let (application, bridge) = tokio::io::duplex(256 * 1024); + let (mut reader, mut writer) = tokio::io::split(bridge); + let (outbound, outbound_rx) = tokio::sync::mpsc::channel(64); + tokio::spawn(async move { + loop { + match inbound.message().await { + Ok(Some(chunk)) if writer.write_all(&chunk.data).await.is_ok() => {} + Ok(Some(_)) | Err(_) => return, + Ok(None) => { + let _ = writer.shutdown().await; + return; + } + } + } + }); + tokio::spawn(async move { + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + let Ok(read) = reader.read(&mut buffer).await else { + return; + }; + if read == 0 + || outbound + .send(Ok(BoundaryChunk { + data: buffer[..read].to_vec(), + })) + .await + .is_err() + { + return; + } + } + }); + (application, ReceiverStream::new(outbound_rx)) +} + +#[derive(Clone)] +struct ReplayRecord { + digest: String, + response: Response, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Lifecycle { + AwaitingAttach, + Bound, + Ready, + Running, + Terminal, +} + +struct BoundaryRuntime { + config: BoundaryConfig, + authenticator: SandboxProtocolAuthenticator, + connections: SandboxConnectionRegistry, + connection_shutdowns: Mutex>>, + active_connection: Mutex>, + lifecycle: Mutex, + attached_policy: Mutex>, + processes: Mutex>>, + main_process: Mutex>, + provider_environment: Mutex<(u64, HashMap, u64)>, + replay: Mutex>, + replay_order: Mutex>, + exec_requests: Mutex>, + next_exec: AtomicU64, +} + +impl BoundaryRuntime { + fn new(config: BoundaryConfig) -> Result { + let sandbox_id = SandboxId::parse(config.boundary_id.clone()) + .map_err(|error| format!("validate MXC sandbox ID: {error}"))?; + let generation = openshell_core::sandbox_generation::SandboxGenerationId::parse( + config.generation.clone(), + ) + .map_err(|error| format!("validate MXC runtime generation: {error}"))?; + let verifier = SessionJwtVerifier::new( + &config.gateway_id, + SessionTokenProfile::Sandbox, + config + .verification_keys + .iter() + .map(|key| SessionVerificationKey { + key_id: key.key_id.clone(), + public_key_pem: key.public_key_pem.as_bytes().to_vec(), + }), + Arc::new(SystemJwtClock), + ) + .map_err(|error| format!("configure MXC Sandbox Protocol verifier: {error}"))?; + Ok(Self { + authenticator: SandboxProtocolAuthenticator::new( + verifier, + sandbox_id, + generation, + config.auth_epoch, + ), + connections: SandboxConnectionRegistry::new(config.session_id, config.session_rotation), + config, + connection_shutdowns: Mutex::new(HashMap::new()), + active_connection: Mutex::new(None), + lifecycle: Mutex::new(Lifecycle::AwaitingAttach), + attached_policy: Mutex::new(None), + processes: Mutex::new(HashMap::new()), + main_process: Mutex::new(None), + provider_environment: Mutex::new((0, HashMap::new(), 0)), + replay: Mutex::new(HashMap::new()), + replay_order: Mutex::new(VecDeque::new()), + exec_requests: Mutex::new(HashSet::new()), + next_exec: AtomicU64::new(1), + }) + } + + fn authenticate_request( + &self, + connection_id: SandboxConnectionId, + metadata: &tonic::metadata::MetadataMap, + ) -> Result { + self.authenticator + .authenticate(connection_id, metadata) + .map_err(|error| tonic::Status::unauthenticated(error.to_string())) + } + + fn authorize( + &self, + principal: &SandboxProtocolPrincipal, + request: &Request, + ) -> Result<(), String> { + if matches!(request, Request::Attach { .. } | Request::DiscoverPolicy) { + return Ok(()); + } + if matches!(request, Request::Confirm) { + self.connections + .require_attached(principal) + .map_err(|error| error.to_string()) + } else { + self.connections + .require_active(principal) + .map_err(|error| error.to_string()) + } + } + + fn register_connection( + &self, + id: SandboxConnectionId, + shutdown: tokio::sync::watch::Sender<()>, + ) { + lock(&self.connection_shutdowns).insert(id, shutdown); + } + + fn close_connection(&self, id: SandboxConnectionId) { + let shutdown = lock(&self.connection_shutdowns).remove(&id); + if let Some(shutdown) = shutdown { + let _ = shutdown.send(()); + } + } + + fn transport_disconnected(self: &Arc, id: SandboxConnectionId) { + lock(&self.connection_shutdowns).remove(&id); + if !self.connections.disconnect(id) { + return; + } + *lock(&self.active_connection) = None; + let weak = Arc::downgrade(self); + tokio::spawn(async move { + tokio::time::sleep(AUTHENTICATED_RECONNECT_TIMEOUT).await; + let Some(runtime) = weak.upgrade() else { + return; + }; + if lock(&runtime.active_connection).is_none() { + tracing::error!("MXC supervisor recovery expired; terminating workload"); + runtime.terminate_all().await; + } + }); + } + + fn commit_attach( + &self, + principal: &SandboxProtocolPrincipal, + instance: openshell_sandbox_backend::boundary_protocol::SupervisorInstanceId, + ) -> Result<(), String> { + if let Some(replaced) = self + .connections + .attach(principal, instance) + .map_err(|error| error.to_string())? + { + self.close_connection(replaced); + } + Ok(()) + } + + fn commit_confirm(&self, principal: &SandboxProtocolPrincipal) -> Result<(), String> { + if let Some(replaced) = self + .connections + .confirm(principal) + .map_err(|error| error.to_string())? + { + self.close_connection(replaced); + } + *lock(&self.active_connection) = Some(principal.connection_id()); + Ok(()) + } + + fn snapshot(&self) -> SessionSnapshotWire { + let mut processes = lock(&self.processes) + .values() + .map(|process| ProcessSnapshotWire { + process_id: process.id.clone(), + kind: process.kind, + terminal: false, + status: process.exit_status(), + retained_output: process.output.window(), + }) + .collect::>(); + processes.sort_by(|left, right| left.process_id.cmp(&right.process_id)); + SessionSnapshotWire { + generation: self.config.generation.clone(), + processes, + } + } + + fn confirmation(&self) -> Result { + 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()), + default_deny_filesystem: true, + default_deny_egress: true, + loopback_proxy_only: self.config.direct_proxy_url.is_some(), + authenticated_control: true, + generation_scoped_attribution: true, + }; + evidence.validate().map_err(|error| error.to_string())?; + let properties = evidence.properties(); + let backend_audit = + serde_json::to_value(crate::audit::MxcBoundaryAuditEvidence::WindowsMxc(evidence)) + .map_err(|error| format!("encode MXC audit evidence: {error}"))?; + Ok(BoundaryConfirmation { + generation: self.config.generation.clone(), + identity: self.config.workload_identity.clone(), + properties, + authenticated_supervisor: true, + session_id: self.config.session_id, + outer_fence: self.config.outer_fence.clone(), + runtime_exit_terminates_workload: true, + resource_claims: self.config.resource_claims.clone(), + backend_audit, + }) + } + + fn dispatch(&self, envelope: &RequestEnvelope) -> Response { + if envelope.validate_payload_digest().is_err() { + return guest_error(BoundaryErrorKind::Denied, "control payload digest mismatch"); + } + if envelope.request.is_replayable_mutation() + && let Some(record) = lock(&self.replay).get(&envelope.request_id) + { + return if record.digest == envelope.payload_digest { + record.response.clone() + } else { + guest_error( + BoundaryErrorKind::Denied, + "control request ID reused with a different payload", + ) + }; + } + let response = match &envelope.request { + // MXC has no rootfs image policy; discovery is authenticated above. + Request::DiscoverPolicy => Response::ImagePolicy { + yaml: None, + invalid: false, + }, + Request::ProbeProviderFiles => guest_error( + BoundaryErrorKind::Invalid, + "MXC provider file delivery is not supported", + ), + Request::Attach { + policy, + resource_claims, + .. + } => { + if resource_claims == &self.config.resource_claims { + let mut lifecycle = lock(&self.lifecycle); + let mut attached_policy = lock(&self.attached_policy); + if *lifecycle == Lifecycle::AwaitingAttach { + *attached_policy = Some((**policy).clone()); + *lifecycle = Lifecycle::Bound; + } + if attached_policy.as_ref() == Some(policy) { + Response::Attached { + snapshot: self.snapshot(), + } + } else { + guest_error(BoundaryErrorKind::Denied, "MXC attach policy changed") + } + } else { + guest_error(BoundaryErrorKind::Denied, "MXC resource claims mismatch") + } + } + Request::Confirm => { + let mut lifecycle = lock(&self.lifecycle); + match *lifecycle { + Lifecycle::Bound | Lifecycle::Ready | Lifecycle::Running => { + match self.confirmation() { + Ok(confirmation) => { + if *lifecycle == Lifecycle::Bound { + *lifecycle = Lifecycle::Ready; + } + Response::Confirmed { + confirmation: Box::new(confirmation), + } + } + Err(error) => guest_error(BoundaryErrorKind::Process, error), + } + } + Lifecycle::AwaitingAttach | Lifecycle::Terminal => guest_error( + BoundaryErrorKind::Invalid, + "MXC boundary must be attached before confirmation", + ), + } + } + Request::UpdateProviderEnvironment { + generation, + revision, + provider_env, + provider_files, + } => { + if !provider_files.is_empty() { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC provider file delivery is not supported", + ); + } + if *lock(&self.lifecycle) != Lifecycle::Running { + return guest_error( + BoundaryErrorKind::Invalid, + "agent must be running before provider updates", + ); + } + let mut current = lock(&self.provider_environment); + publish_provider_environment(&mut current, *generation, *revision, provider_env) + } + Request::Resize { .. } => guest_error( + BoundaryErrorKind::Invalid, + "Windows ConPTY is not enabled for the MXC boundary", + ), + Request::OpenMediation | Request::AcceptNetwork => guest_error( + BoundaryErrorKind::Invalid, + "MXC uses the supervisor-owned authenticated explicit proxy", + ), + Request::StartAgent { .. } + | Request::AttachProcess { .. } + | Request::Wait { .. } + | Request::Signal { .. } + | Request::Terminate { .. } + | Request::TerminateBoundary + | Request::Exec { .. } + | Request::ExecSignal { .. } + | Request::LoopbackConnect { .. } => guest_error( + BoundaryErrorKind::Invalid, + "streaming request used on the non-streaming path", + ), + }; + if envelope.request.is_replayable_mutation() { + self.remember_replay(envelope, &response); + } + response + } + + fn remember_replay(&self, envelope: &RequestEnvelope, response: &Response) { + let mut replay = lock(&self.replay); + let mut order = lock(&self.replay_order); + if !replay.contains_key(&envelope.request_id) { + while replay.len() >= MAX_REPLAY_ENTRIES { + if let Some(oldest) = order.pop_front() { + replay.remove(&oldest); + } + } + order.push_back(envelope.request_id.clone()); + } + replay.insert( + envelope.request_id.clone(), + ReplayRecord { + digest: envelope.payload_digest.clone(), + response: response.clone(), + }, + ); + } + + async fn start_agent(&self, envelope: &RequestEnvelope) -> Response { + let Request::StartAgent { + spec, + policy, + ca_cert, + ca_bundle, + provider_env_revision, + provider_env, + provider_files, + .. + } = &envelope.request + else { + return guest_error(BoundaryErrorKind::Invalid, "expected StartAgent"); + }; + if !provider_files.is_empty() { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC provider file delivery is not supported", + ); + } + { + let lifecycle = *lock(&self.lifecycle); + let main_process = lock(&self.main_process).clone(); + if lifecycle == Lifecycle::Running + && let Some(id) = main_process + { + let installed = lock(&self.provider_environment); + return Response::Started { + process_id: id, + provider_env_revision: installed.0, + provider_env_generation: installed.2, + }; + } + if lifecycle != Lifecycle::Ready { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC boundary must be confirmed before agent start", + ); + } + if lock(&self.attached_policy).as_ref() != Some(policy) { + return guest_error(BoundaryErrorKind::Denied, "MXC start policy changed"); + } + } + let mut environment = self.config.child_env.clone(); + environment.extend(provider_env.clone()); + if let Some(proxy_url) = &self.config.direct_proxy_url { + for key in ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"] { + environment.insert(key.to_string(), proxy_url.clone()); + } + environment.insert("NO_PROXY".to_string(), String::new()); + environment.insert("no_proxy".to_string(), String::new()); + } + match install_ca_material( + &self.config.generation, + ca_cert.as_deref().map(str::as_bytes), + ca_bundle.as_deref().map(str::as_bytes), + ) { + Ok(Some((certificate, bundle))) => { + install_ca_environment(&mut environment, &certificate, &bundle); + } + Ok(None) => {} + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + } + let process_id = format!("{}:main:0", self.config.generation); + let (program, args) = agent_command(spec.clone()); + let process = match ManagedProcess::spawn( + process_id.clone(), + ProcessKindWire::Main, + program, + args, + spec.workdir.clone(), + environment, + ) + .await + { + Ok(process) => Arc::new(process), + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + }; + lock(&self.processes).insert(process_id.clone(), process); + *lock(&self.main_process) = Some(process_id.clone()); + *lock(&self.provider_environment) = (*provider_env_revision, provider_env.clone(), 0); + *lock(&self.lifecycle) = Lifecycle::Running; + let response = Response::Started { + process_id, + provider_env_revision: *provider_env_revision, + provider_env_generation: 0, + }; + self.remember_replay(envelope, &response); + response + } + + async fn start_exec(&self, envelope: &RequestEnvelope, spec: ExecSpecWire) -> Response { + if spec.shell.is_some() || spec.runtime_helper.is_some() { + return guest_error( + BoundaryErrorKind::Invalid, + "MXC does not support shell exec intent or runtime helpers", + ); + } + if spec.pty { + return guest_error( + BoundaryErrorKind::Invalid, + "Windows ConPTY is not enabled for MXC exec", + ); + } + { + let mut requests = lock(&self.exec_requests); + if !requests.insert(envelope.request_id.clone()) { + return guest_error( + BoundaryErrorKind::Denied, + "exec request was already consumed", + ); + } + } + let id = format!( + "{}:exec:{}", + self.config.generation, + self.next_exec.fetch_add(1, Ordering::Relaxed) + ); + let mut environment = self.config.child_env.clone(); + environment.extend(lock(&self.provider_environment).1.clone()); + environment.extend(spec.env.iter().cloned()); + let process = match ManagedProcess::spawn( + id.clone(), + ProcessKindWire::Exec, + spec.program, + spec.args, + spec.workdir, + environment, + ) + .await + { + Ok(process) => Arc::new(process), + Err(error) => return guest_error(BoundaryErrorKind::Process, error), + }; + lock(&self.processes).insert(id.clone(), process); + Response::ExecStarted { + process_id: id, + pty: false, + } + } + + #[allow( + clippy::result_large_err, + reason = "errors are shared Sandbox Protocol responses" + )] + fn process(&self, id: &str) -> Result, Response> { + lock(&self.processes) + .get(id) + .cloned() + .ok_or_else(|| guest_error(BoundaryErrorKind::Invalid, "unknown MXC process ID")) + } + + async fn terminate_all(&self) { + self.connections.mark_terminal(); + let processes = lock(&self.processes).values().cloned().collect::>(); + for process in processes { + let _ = process.terminate().await; + } + *lock(&self.lifecycle) = Lifecycle::Terminal; + } +} + +#[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, + principal: SandboxProtocolPrincipal, +) -> Result<(), String> { + let envelope: RequestEnvelope = read_frame_async(&mut stream) + .await + .map_err(|error| format!("read MXC control request: {error}"))?; + runtime.authorize(&principal, &envelope.request)?; + if envelope.validate_payload_digest().is_err() { + return write_response( + &mut stream, + &envelope.request_id, + guest_error(BoundaryErrorKind::Denied, "control payload digest mismatch"), + ) + .await; + } + + match envelope.request.clone() { + Request::Attach { + supervisor_instance_id, + .. + } => { + let response = runtime.dispatch(&envelope); + if matches!(response, Response::Attached { .. }) { + runtime.commit_attach(&principal, supervisor_instance_id)?; + } + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Confirm => { + let response = runtime.dispatch(&envelope); + if matches!(response, Response::Confirmed { .. }) { + runtime.commit_confirm(&principal)?; + } + write_response(&mut stream, &envelope.request_id, response).await + } + Request::StartAgent { .. } => { + let response = runtime.start_agent(&envelope).await; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Exec { spec } => { + let response = runtime.start_exec(&envelope, spec).await; + let process_id = match &response { + Response::ExecStarted { process_id, .. } => Some(process_id.clone()), + _ => None, + }; + write_response(&mut stream, &envelope.request_id, response).await?; + if let Some(process_id) = process_id { + let process = runtime.process(&process_id).map_err(response_error)?; + bridge_process(stream, process).await?; + } + Ok(()) + } + Request::AttachProcess { process_id } => { + let process = runtime.process(&process_id).map_err(response_error)?; + write_response( + &mut stream, + &envelope.request_id, + Response::ProcessAttached { terminal: false }, + ) + .await?; + bridge_process(stream, process).await + } + Request::Wait { process_id } => { + let response = match runtime.process(&process_id) { + Ok(process) => Response::Exited { + status: process.wait().await, + }, + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Signal { process_id, signal } | Request::ExecSignal { process_id, signal } => { + let response = match runtime.process(&process_id) { + Ok(process) => process.signal(signal).await.map_or_else( + |error| guest_error(BoundaryErrorKind::Process, error), + |()| Response::Signaled, + ), + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::Terminate { process_id } => { + let response = match runtime.process(&process_id) { + Ok(process) => process.terminate().await.map_or_else( + |error| guest_error(BoundaryErrorKind::Process, error), + |()| Response::Terminated, + ), + Err(response) => response, + }; + write_response(&mut stream, &envelope.request_id, response).await + } + Request::TerminateBoundary => { + runtime.terminate_all().await; + write_response( + &mut stream, + &envelope.request_id, + Response::BoundaryTerminated, + ) + .await + } + Request::LoopbackConnect { host, port } => { + forward_loopback(stream, &envelope.request_id, host, port).await + } + _ => { + let response = runtime.dispatch(&envelope); + write_response(&mut stream, &envelope.request_id, response).await + } + } +} + +// Only the authenticated request dispatcher calls this operation in production. +// Forwarding stays on the shared protocol stream; no reverse host listener or +// separate relay process is needed for an independently allocated target port. +async fn forward_loopback( + mut stream: tokio::io::DuplexStream, + request_id: &str, + host: std::net::IpAddr, + port: u16, +) -> Result<(), String> { + if !host.is_loopback() || port == 0 { + return write_response( + &mut stream, + request_id, + guest_error(BoundaryErrorKind::Denied, "forward target is not loopback"), + ) + .await; + } + match tokio::net::TcpStream::connect((host, port)).await { + Ok(mut target) => { + openshell_core::net::set_tcp_nodelay_best_effort(&target); + write_response(&mut stream, request_id, Response::PortConnected).await?; + tokio::io::copy_bidirectional(&mut stream, &mut target) + .await + .map(|_| ()) + .map_err(|error| format!("bridge MXC loopback connection: {error}")) + } + Err(error) => { + write_response( + &mut stream, + request_id, + guest_error(BoundaryErrorKind::Process, error.to_string()), + ) + .await + } + } +} + +async fn write_response( + stream: &mut tokio::io::DuplexStream, + request_id: &str, + response: Response, +) -> Result<(), String> { + let frame = encode_frame(&ResponseEnvelope { + request_id: request_id.to_string(), + response, + }) + .map_err(|error| format!("encode MXC control response: {error}"))?; + stream + .write_all(&frame) + .await + .map_err(|error| format!("write MXC control response: {error}"))?; + stream + .flush() + .await + .map_err(|error| format!("flush MXC control response: {error}")) +} + +fn response_error(response: Response) -> String { + match response { + Response::Error { message, .. } => message, + other => format!("unexpected MXC process response: {other:?}"), + } +} + +fn agent_command(spec: AgentSpecWire) -> (String, Vec) { + if spec.program.trim().is_empty() { + ( + std::env::var("COMSPEC") + .unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".to_string()), + vec!["/D".to_string(), "/Q".to_string()], + ) + } else { + (spec.program, spec.args) + } +} + +fn install_ca_environment( + environment: &mut HashMap, + certificate: &Path, + bundle: &Path, +) { + for (key, path) in [ + ("NODE_EXTRA_CA_CERTS", certificate), + ("DENO_CERT", certificate), + ("SSL_CERT_FILE", bundle), + ("REQUESTS_CA_BUNDLE", bundle), + ("CURL_CA_BUNDLE", bundle), + ("GIT_SSL_CAINFO", bundle), + ] { + // Windows environment names are case-insensitive. Remove aliases so + // HashMap iteration cannot reinstall a caller's trust override. + environment.retain(|existing, _| !existing.eq_ignore_ascii_case(key)); + environment.insert(key.to_string(), path.display().to_string()); + } +} + +fn install_ca_material( + generation: &str, + certificate: Option<&[u8]>, + bundle: Option<&[u8]>, +) -> Result, String> { + let (Some(certificate), Some(bundle)) = (certificate, bundle) else { + return Ok(None); + }; + let directory = std::env::temp_dir().join(format!("openshell-ca-{generation}")); + std::fs::create_dir_all(&directory) + .map_err(|error| format!("create MXC CA directory: {error}"))?; + let certificate_path = directory.join("openshell-ca.pem"); + let bundle_path = directory.join("ca-bundle.pem"); + std::fs::write(&certificate_path, certificate) + .map_err(|error| format!("write MXC proxy CA: {error}"))?; + std::fs::write(&bundle_path, bundle) + .map_err(|error| format!("write MXC proxy CA bundle: {error}"))?; + Ok(Some((certificate_path, bundle_path))) +} + +struct ManagedProcess { + id: String, + kind: ProcessKindWire, + child: Arc>, + stdin: tokio::sync::Mutex>, + output: Arc, + exit: tokio::sync::watch::Receiver>, + attached: Arc, +} + +impl ManagedProcess { + async fn spawn( + id: String, + kind: ProcessKindWire, + program: String, + args: Vec, + workdir: Option, + environment: HashMap, + ) -> Result { + if program.trim().is_empty() { + return Err("MXC workload program is empty".to_string()); + } + let mut command = Command::new(&program); + command + .args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .envs(environment); + if let Some(workdir) = workdir.filter(|path| !path.trim().is_empty()) { + command.current_dir(workdir); + } + let mut child = command + .spawn() + .map_err(|error| format!("spawn MXC workload executable {program:?}: {error}"))?; + let stdin = child.stdin.take(); + let stdout = child + .stdout + .take() + .ok_or_else(|| "MXC workload stdout pipe is unavailable".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "MXC workload stderr pipe is unavailable".to_string())?; + let output = OutputLog::new(); + output.spawn_reader(stdout, STREAM_STDOUT); + output.spawn_reader(stderr, STREAM_STDERR); + let (exit_tx, exit) = tokio::sync::watch::channel(None); + let process = Self { + id, + kind, + child: Arc::new(tokio::sync::Mutex::new(child)), + stdin: tokio::sync::Mutex::new(stdin), + output, + exit, + attached: Arc::new(AtomicBool::new(false)), + }; + process.start_monitor(exit_tx); + Ok(process) + } + + fn start_monitor(&self, exit_tx: tokio::sync::watch::Sender>) { + let child = self.child.clone(); + let output = self.output.clone(); + tokio::spawn(async move { + loop { + let result = { + let mut child = child.lock().await; + child.try_wait() + }; + match result { + Ok(Some(status)) => { + let status = ExitStatusWire::Exited(status.code().unwrap_or(1)); + output.publish_exit(status); + exit_tx.send_replace(Some(status)); + return; + } + Ok(None) => tokio::time::sleep(Duration::from_millis(25)).await, + Err(_) => { + let status = ExitStatusWire::Exited(1); + output.publish_exit(status); + exit_tx.send_replace(Some(status)); + return; + } + } + } + }); + } + + fn acquire_attachment(&self) -> Result { + self.attached + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| "MXC process already has an active attachment".to_string())?; + Ok(AttachmentGuard(self.attached.clone())) + } + + async fn wait(&self) -> ExitStatusWire { + let mut exit = self.exit.clone(); + loop { + let status = *exit.borrow_and_update(); + if let Some(status) = status { + return status; + } + if exit.changed().await.is_err() { + return ExitStatusWire::Exited(1); + } + } + } + + fn exit_status(&self) -> Option { + *self.exit.borrow() + } + + async fn signal(&self, signal: SignalWire) -> Result<(), String> { + match signal { + SignalWire::Term | SignalWire::Kill => self.terminate().await, + SignalWire::Int | SignalWire::Hup => Err( + "MXC ProcessContainer does not provide POSIX interrupt or hangup signals" + .to_string(), + ), + } + } + + async fn terminate(&self) -> Result<(), String> { + if self.exit_status().is_some() { + return Ok(()); + } + self.child + .lock() + .await + .start_kill() + .map_err(|error| format!("terminate MXC process: {error}")) + } +} + +struct AttachmentGuard(Arc); + +impl Drop for AttachmentGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[derive(Clone)] +struct OutputEvent { + sequence: u64, + channel: u8, + payload: Vec, +} + +struct OutputState { + events: VecDeque, + retained_bytes: usize, + next_sequence: u64, +} + +struct OutputLog { + state: Mutex, + version: tokio::sync::watch::Sender, +} + +impl OutputLog { + fn new() -> Arc { + let (version, _) = tokio::sync::watch::channel(0); + Arc::new(Self { + state: Mutex::new(OutputState { + events: VecDeque::new(), + retained_bytes: 0, + next_sequence: 0, + }), + version, + }) + } + + fn spawn_reader( + self: &Arc, + mut reader: impl tokio::io::AsyncRead + Send + Unpin + 'static, + channel: u8, + ) { + let output = self.clone(); + tokio::spawn(async move { + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + match reader.read(&mut buffer).await { + Ok(0) | Err(_) => return, + Ok(read) => output.publish(channel, buffer[..read].to_vec()), + } + } + }); + } + + fn publish_exit(&self, status: ExitStatusWire) { + if let Ok(payload) = serde_json::to_vec(&status) { + self.publish(STREAM_EXIT, payload); + } + } + + fn publish(&self, channel: u8, payload: Vec) { + let version = { + let mut state = lock(&self.state); + let sequence = state.next_sequence; + state.next_sequence = state.next_sequence.saturating_add(1); + state.retained_bytes = state.retained_bytes.saturating_add(payload.len()); + state.events.push_back(OutputEvent { + sequence, + channel, + payload, + }); + while state.retained_bytes > OUTPUT_BUFFER_BYTES { + let Some(removed) = state.events.pop_front() else { + break; + }; + state.retained_bytes = state.retained_bytes.saturating_sub(removed.payload.len()); + } + state.next_sequence + }; + self.version.send_replace(version); + } + + fn cursor(self: &Arc) -> OutputCursor { + let state = lock(&self.state); + let next_sequence = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + drop(state); + OutputCursor { + output: self.clone(), + next_sequence, + version: self.version.subscribe(), + } + } + + fn window(&self) -> OutputWindowWire { + let state = lock(&self.state); + let first_sequence = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + OutputWindowWire { + first_sequence, + next_sequence: state.next_sequence, + truncated: first_sequence != 0, + } + } +} + +struct OutputCursor { + output: Arc, + next_sequence: u64, + version: tokio::sync::watch::Receiver, +} + +impl OutputCursor { + async fn recv(&mut self) -> Option { + loop { + let event = { + let state = lock(&self.output.state); + let oldest = state + .events + .front() + .map_or(state.next_sequence, |event| event.sequence); + if self.next_sequence < oldest { + self.next_sequence = oldest; + } + state + .events + .get(usize::try_from(self.next_sequence.saturating_sub(oldest)).ok()?) + .cloned() + }; + if let Some(event) = event { + self.next_sequence = event.sequence.saturating_add(1); + return Some(event); + } + if self.version.changed().await.is_err() { + return None; + } + } + } +} + +async fn bridge_process( + stream: tokio::io::DuplexStream, + process: Arc, +) -> Result<(), String> { + let _guard = process.acquire_attachment()?; + let (mut reader, mut writer) = tokio::io::split(stream); + let input_process = process.clone(); + let mut input = tokio::spawn(async move { + while let Some((channel, payload)) = read_stream_frame(&mut reader).await? { + match channel { + STREAM_STDIN => { + let mut stdin = input_process.stdin.lock().await; + let Some(stdin) = stdin.as_mut() else { + return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin closed")); + }; + stdin.write_all(&payload).await?; + stdin.flush().await?; + } + STREAM_STDIN_CLOSED => { + input_process.stdin.lock().await.take(); + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected MXC input stream channel", + )); + } + } + } + Ok::<(), io::Error>(()) + }); + let mut cursor = process.output.cursor(); + loop { + tokio::select! { + result = &mut input => { + return result + .map_err(|error| format!("join MXC process input: {error}"))? + .map_err(|error| format!("read MXC process input: {error}")); + } + event = cursor.recv() => { + let Some(event) = event else { return Ok(()) }; + write_stream_frame(&mut writer, event.channel, &event.payload) + .await + .map_err(|error| format!("write MXC process output: {error}"))?; + if event.channel == STREAM_EXIT { + return Ok(()); + } + } + } + } +} + +fn guest_error(kind: BoundaryErrorKind, message: impl Into) -> Response { + Response::Error { + kind, + message: message.into(), + } +} + +fn publish_provider_environment( + installed: &mut (u64, HashMap, u64), + generation: u64, + revision: u64, + environment: &HashMap, +) -> Response { + // The revision is an opaque fingerprint, not publication order. A newer + // generation can repair an identical revision or install a smaller value. + let applied = generation > installed.2; + if applied { + *installed = (revision, environment.clone(), generation); + } + Response::ProviderEnvironmentUpdated { + revision: installed.0, + generation: installed.2, + applied, + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installed_ca_replaces_case_insensitive_trust_overrides() { + let certificate = Path::new(r"C:\trusted\ca.pem"); + let bundle = Path::new(r"C:\trusted\bundle.pem"); + let mut environment = HashMap::from([ + ("SSL_CERT_FILE".into(), "old-bundle".into()), + ("node_extra_ca_certs".into(), "old-cert".into()), + ("FOO".into(), "bar".into()), + ]); + install_ca_environment(&mut environment, certificate, bundle); + assert_eq!(environment["FOO"], "bar"); + assert!(!environment.contains_key("node_extra_ca_certs")); + for key in ["NODE_EXTRA_CA_CERTS", "DENO_CERT"] { + assert_eq!(environment[key], certificate.display().to_string()); + } + for key in [ + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + ] { + assert_eq!(environment[key], bundle.display().to_string()); + } + } + + #[test] + fn provider_publications_are_ordered_independently_of_opaque_revisions() { + let mut installed = (99, HashMap::new(), 0); + for (generation, revision, value) in [(1, 99, "first"), (2, 99, "repair"), (3, 1, "new")] { + let environment = HashMap::from([("KEY".into(), value.into())]); + assert!(matches!( + publish_provider_environment(&mut installed, generation, revision, &environment), + Response::ProviderEnvironmentUpdated { applied: true, .. } + )); + assert_eq!(installed, (revision, environment, generation)); + } + let retained = installed.clone(); + for generation in [0, 1, 2, 3] { + assert!(matches!( + publish_provider_environment(&mut installed, generation, 100, &HashMap::new()), + Response::ProviderEnvironmentUpdated { + revision: 1, + generation: 3, + applied: false + } + )); + assert_eq!(installed, retained); + } + } + + #[tokio::test] + async fn forwarding_bridges_a_dynamic_target_over_the_shared_stream() { + tokio::time::timeout(Duration::from_secs(10), async { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind real forwarding target"); + let address = listener.local_addr().expect("target address"); + let echo = tokio::spawn(async move { + let (mut socket, _) = listener + .accept() + .await + .expect("accept forwarded connection"); + let mut request = [0_u8; 4]; + socket + .read_exact(&mut request) + .await + .expect("read target data"); + assert_eq!(&request, b"ping"); + socket.write_all(b"pong").await.expect("write target data"); + socket.shutdown().await.expect("close target output"); + }); + let (mut client, server) = tokio::io::duplex(4096); + let bridge = tokio::spawn(forward_loopback( + server, + "forward-1", + address.ip(), + address.port(), + )); + let response: ResponseEnvelope = read_frame_async(&mut client) + .await + .expect("protocol response"); + assert_eq!(response.request_id, "forward-1"); + assert!(matches!(response.response, Response::PortConnected)); + client + .write_all(b"ping") + .await + .expect("send forwarded data"); + client.shutdown().await.expect("close client output"); + let mut reply = Vec::new(); + client + .read_to_end(&mut reply) + .await + .expect("receive forwarded data"); + assert_eq!(reply, b"pong"); + bridge + .await + .expect("join bridge") + .expect("bridge succeeded"); + echo.await.expect("join real target"); + }) + .await + .expect("forwarding completes without stalling"); + } + + #[tokio::test] + async fn forwarding_rejects_non_loopback_and_zero_ports() { + for (host, port) in [("192.0.2.1", 80), ("127.0.0.1", 0), ("::1", 0)] { + let (mut client, server) = tokio::io::duplex(4096); + forward_loopback(server, "denied", host.parse().expect("IP address"), port) + .await + .expect("write denial"); + let response: ResponseEnvelope = read_frame_async(&mut client) + .await + .expect("denial response"); + assert!(matches!( + response.response, + Response::Error { + kind: BoundaryErrorKind::Denied, + .. + } + )); + } + } + + #[tokio::test] + async fn managed_launch_preserves_arguments_and_retains_real_process_output() { + let process = ManagedProcess::spawn( + "launch-test".to_string(), + ProcessKindWire::Main, + std::env::var("COMSPEC").expect("Windows command interpreter"), + vec![ + "/D".to_string(), + "/C".to_string(), + "echo relay-free launch".to_string(), + ], + None, + HashMap::new(), + ) + .await + .expect("launch real Windows process"); + let guard = process.acquire_attachment().expect("first attachment"); + assert!(process.acquire_attachment().is_err()); + drop(guard); + let _guard = process + .acquire_attachment() + .expect("attachment can reconnect"); + let status = tokio::time::timeout(Duration::from_secs(10), process.wait()) + .await + .expect("process exits"); + assert!(matches!(status, ExitStatusWire::Exited(0))); + // Drain stdout independently: exit notification can precede the reader. + let mut cursor = process.output.cursor(); + let output = tokio::time::timeout(Duration::from_secs(10), async { + let mut output = Vec::new(); + loop { + let event = cursor.recv().await.expect("retained output event"); + if event.channel == STREAM_STDOUT { + output.extend(event.payload); + if String::from_utf8_lossy(&output).contains("relay-free launch") { + return output; + } + } + } + }) + .await + .expect("stdout is retained"); + assert!(String::from_utf8_lossy(&output).contains("relay-free launch")); + } + + #[test] + fn output_retention_is_bounded_and_reports_truncation() { + let output = OutputLog::new(); + output.publish(STREAM_STDOUT, vec![1; OUTPUT_BUFFER_BYTES]); + output.publish(STREAM_STDOUT, vec![2; 1]); + let window = output.window(); + assert!(window.truncated); + assert_eq!(window.first_sequence, 1); + assert_eq!(window.next_sequence, 2); + } +} diff --git a/crates/openshell-mxc-boundary/tests/cli.rs b/crates/openshell-mxc-boundary/tests/cli.rs new file mode 100644 index 0000000000..54326142e2 --- /dev/null +++ b/crates/openshell-mxc-boundary/tests/cli.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real executable checks, without starting a mock MXC runtime. + +use std::process::Command; + +#[test] +fn help_and_version_identify_dedicated_boundary() { + for argument in ["--help", "--version"] { + let output = Command::new(env!("CARGO_BIN_EXE_openshell-windows-sandbox")) + .arg(argument) + .output() + .expect("run dedicated boundary executable"); + assert!(output.status.success()); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains("openshell-windows-sandbox")); + } +} + +#[test] +fn bootstrap_is_required_and_missing_bootstrap_fails_closed() { + let binary = env!("CARGO_BIN_EXE_openshell-windows-sandbox"); + let output = Command::new(binary) + .output() + .expect("run without arguments"); + assert!(!output.status.success()); + assert!( + output.stdout.is_empty(), + "startup errors must stay on stderr" + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("--bootstrap")); + + // Unique absent path: never create bootstrap material or launch a workload. + let absent = std::env::temp_dir().join(format!( + "openshell-mxc-absent-bootstrap-{}.json", + std::process::id() + )); + assert!(!absent.exists()); + let output = Command::new(binary) + .arg("--bootstrap") + .arg(absent) + .env("OPENSHELL_LOG_LEVEL", "info") + .env_remove("RUST_LOG") + .output() + .expect("run with absent bootstrap"); + assert!(!output.status.success()); + assert!(output.stdout.is_empty(), "startup logs must stay on stderr"); + #[cfg(target_os = "windows")] + assert!(String::from_utf8_lossy(&output.stderr).contains("read boundary config")); + #[cfg(not(target_os = "windows"))] + assert!(String::from_utf8_lossy(&output.stderr).contains("requires Windows MXC")); +} diff --git a/crates/openshell-mxc-boundary/tests/composition.rs b/crates/openshell-mxc-boundary/tests/composition.rs new file mode 100644 index 0000000000..a58ffbe47b --- /dev/null +++ b/crates/openshell-mxc-boundary/tests/composition.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Guard the relay-free library boundary without starting a mock runtime. + +#[test] +fn dedicated_boundary_does_not_embed_driver_or_supervisor() { + let manifest = include_str!("../Cargo.toml"); + assert!(manifest.contains("name = \"openshell-windows-sandbox\"")); + assert!(!manifest.contains("openshell-driver-mxc")); + assert!(!manifest.contains("openshell-supervisor")); + let runtime = include_str!("../src/runtime.rs"); + assert!(!runtime.contains("openshell-supervisor-relay")); +} + +#[test] +fn shared_runtime_keeps_platform_audit_interpretation_in_backend_extension() { + let protocol = include_str!("../../openshell-sandbox-backend/src/boundary_protocol.rs"); + assert!(!protocol.contains("MxcSandboxAuditEvidence")); + assert!(!protocol.contains("WindowsMxc")); + 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"); + assert!(composition.contains("MxcRuntimeBackend::new")); + 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 only_dedicated_sandbox_delegates_to_the_windows_boundary_library() { + let manifest = include_str!("../../openshell-sandbox/Cargo.toml"); + assert!(!manifest.contains("\nwindows = ")); + assert!(!manifest.contains("openshell-mxc-boundary")); + let sandbox = include_str!("../../openshell-sandbox/src/boundary_server.rs"); + assert!(!sandbox.contains("openshell_mxc_boundary::run")); + assert!(!sandbox.contains("mod windows;")); + let main = include_str!("../src/main.rs"); + assert!(main.contains("openshell_mxc_boundary::run(&args.bootstrap)")); + let driver = include_str!("../../openshell-driver-mxc/src/driver.rs"); + assert!(driver.contains("sibling_binary(\"openshell-windows-sandbox.exe\")")); +} diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 656433b794..ea0f2b0d06 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -195,6 +195,12 @@ pub struct PolicyDocument { skip_serializing_if = "Option::is_none" )] pub process: Option, + #[serde( + default, + deserialize_with = "deserialize_non_null_optional_field", + skip_serializing_if = "Option::is_none" + )] + pub ui: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub network_policies: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -236,6 +242,34 @@ pub struct ProcessPolicy { pub run_as_group: String, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UiPolicy { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_graphical_ui: bool, + #[serde(default, skip_serializing_if = "UiClipboardAccess::is_none")] + pub clipboard: UiClipboardAccess, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_input_injection: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiClipboardAccess { + #[default] + None, + Read, + Write, + All, +} + +impl UiClipboardAccess { + #[must_use] + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct NetworkPolicyRule { @@ -740,6 +774,7 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { "filesystem_policy", "landlock", "process", + "ui", "network_policies", "network_middlewares", ], @@ -759,6 +794,11 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { "process", &["run_as_user", "run_as_group"], )?; + inspect_named( + root.get("ui"), + "ui", + &["allow_graphical_ui", "clipboard", "allow_input_injection"], + )?; for (name, rule) in open_map(root.get("network_policies")) { let path = join("network_policies", name); @@ -1222,6 +1262,7 @@ mod tests { for source in [ "version: 1\nfilesystem_policy: null\n", "version: 1\nprocess: null\n", + "version: 1\nui: null\n", "version: 1\nmetadata: null\n", "version: 1\nnetwork_policies:\n x:\n endpoints:\n - host: x\n port: 443\n mcp: null\n", ] { @@ -1307,6 +1348,7 @@ mod tests { "landlock.future", ), ("version: 1\nprocess: { future: true }\n", "process.future"), + ("version: 1\nui: { future: true }\n", "ui.future"), ( "version: 1\nnetwork_policies: { api: { future: true } }\n", "network_policies.api.future", diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index bad8532cdf..a2ddd4c45b 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -31,9 +31,27 @@ use openshell_core::mcp::{ use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, LandlockPolicy, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, - SandboxPolicy, + SandboxPolicy, UiClipboardAccess, UiPolicy, }; +const fn ui_clipboard_to_proto(value: UiClipboardAccessDef) -> UiClipboardAccess { + match value { + UiClipboardAccessDef::None => UiClipboardAccess::None, + UiClipboardAccessDef::Read => UiClipboardAccess::Read, + UiClipboardAccessDef::Write => UiClipboardAccess::Write, + UiClipboardAccessDef::All => UiClipboardAccess::All, + } +} + +fn ui_clipboard_from_proto(value: i32) -> UiClipboardAccessDef { + match UiClipboardAccess::try_from(value).unwrap_or(UiClipboardAccess::Unspecified) { + UiClipboardAccess::Unspecified | UiClipboardAccess::None => UiClipboardAccessDef::None, + UiClipboardAccess::Read => UiClipboardAccessDef::Read, + UiClipboardAccess::Write => UiClipboardAccessDef::Write, + UiClipboardAccess::All => UiClipboardAccessDef::All, + } +} + pub use compose::{ PROVIDER_RULE_NAME_PREFIX, ProviderPolicyLayer, compose_effective_policy, is_provider_rule_name, provider_rule_name, strip_provider_rule_names, @@ -66,6 +84,7 @@ use openshell_policy_schema::{ NetworkCredentialBinding as NetworkCredentialBindingDef, NetworkEndpoint as NetworkEndpointDef, NetworkPolicyRule as NetworkPolicyRuleDef, ParameterMatcher as ParamMatcherDef, PolicyDocument as PolicyFile, ProcessPolicy as ProcessDef, QueryMatcher as QueryMatcherDef, + UiClipboardAccess as UiClipboardAccessDef, UiPolicy as UiDef, }; fn json_rpc_config_from_proto(max_body_bytes: u32) -> Option { @@ -609,6 +628,11 @@ fn to_proto(raw: PolicyFile) -> Result { run_as_user: p.run_as_user, run_as_group: p.run_as_group, }), + ui: raw.ui.map(|ui| UiPolicy { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: ui_clipboard_to_proto(ui.clipboard) as i32, + allow_input_injection: ui.allow_input_injection, + }), network_policies, network_middlewares, }) @@ -652,6 +676,12 @@ fn from_proto(policy: &SandboxPolicy) -> Result { } }); + let ui = policy.ui.as_ref().map(|ui| UiDef { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: ui_clipboard_from_proto(ui.clipboard), + allow_input_injection: ui.allow_input_injection, + }); + let network_policies = policy .network_policies .iter() @@ -796,6 +826,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { filesystem_policy, landlock, process, + ui, network_policies, network_middlewares, }) @@ -989,6 +1020,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { compatibility: "best_effort".into(), }), process: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), } @@ -1024,6 +1056,8 @@ const MAX_PATH_LENGTH: usize = 4096; pub enum PolicyViolation { /// An explicit `run_as_user` or `run_as_group` is unsafe. InvalidProcessIdentity { field: &'static str, value: String }, + /// The protobuf carries a clipboard enum value unknown to this version. + InvalidUiClipboardAccess { value: i32 }, /// A filesystem path contains `..` components. PathTraversal { path: String }, /// A filesystem path is not absolute (does not start with `/`). @@ -1125,6 +1159,12 @@ impl fmt::Display for PolicyViolation { "{field} must be 'sandbox' or a numeric UID/GID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}], got '{value}'" ) } + Self::InvalidUiClipboardAccess { value } => { + write!( + f, + "ui clipboard access has unknown enum value {value}; expected unspecified, none, read, write, or all" + ) + } Self::PathTraversal { path } => { write!(f, "path contains '..' traversal component: {path}") } @@ -1331,6 +1371,7 @@ impl fmt::Display for PolicyViolation { /// /// Checks performed: /// - Explicit `run_as_user` / `run_as_group` fields must be safe identities +/// - UI clipboard access must use a recognized enum value /// - Filesystem paths must be absolute (start with `/`) /// - Filesystem paths must not contain `..` components /// - Read-write paths must not be overly broad (just `/`) @@ -1445,6 +1486,14 @@ fn validate_sandbox_policy_with_mcp_presence( }); } + if let Some(ref ui) = policy.ui + && UiClipboardAccess::try_from(ui.clipboard).is_err() + { + violations.push(PolicyViolation::InvalidUiClipboardAccess { + value: ui.clipboard, + }); + } + if let Some(ref fs) = policy.filesystem && let Err(errors) = validate_filesystem_paths(&fs.read_only, &fs.read_write) { @@ -1856,6 +1905,7 @@ fn validate_and_canonicalize_mcp_policy_schema( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_ui_defaults(&mut policy); validate_mcp_policy_schema(&policy, McpVersionPresence::RequireMaterialized) .map_err(|violations| PolicyValidationError { violations })?; Ok(policy) @@ -1878,6 +1928,7 @@ pub fn validate_and_canonicalize_sandbox_policy( .map_err(|violations| PolicyValidationError { violations })?; materialize_default_mcp_versions(&mut policy); canonicalize_mcp_version_allowlists(&mut policy); + canonicalize_ui_defaults(&mut policy); debug_assert!( validate_sandbox_policy(&policy).is_ok(), "validated MCP canonicalization must preserve every policy invariant" @@ -1885,6 +1936,20 @@ pub fn validate_and_canonicalize_sandbox_policy( Ok(policy) } +/// Materialize protobuf UI defaults that have a distinct canonical enum value. +/// +/// Proto3 clients commonly leave `clipboard` at `Unspecified(0)`. `OpenShell` +/// defines that value as deny, so canonical policy state stores the equivalent +/// explicit `None(1)`. This keeps protobuf, YAML, hashes, and static-field +/// comparisons stable across a serialize/parse round trip. +fn canonicalize_ui_defaults(policy: &mut SandboxPolicy) { + if let Some(ui) = policy.ui.as_mut() + && ui.clipboard == UiClipboardAccess::Unspecified as i32 + { + ui.clipboard = UiClipboardAccess::None as i32; + } +} + /// Replace absent protobuf MCP options and empty revision lists with the /// single pinned policy default while preserving every explicit MCP option. pub(crate) fn materialize_default_mcp_versions(policy: &mut SandboxPolicy) { @@ -2004,6 +2069,99 @@ network_policies: assert!(json.get("network_policies").is_some()); } + #[test] + fn ui_absence_and_explicit_empty_remain_distinct() { + let absent = parse_sandbox_policy("version: 1\n").expect("absent UI parses"); + assert!(absent.ui.is_none()); + let absent_yaml = serialize_sandbox_policy(&absent).expect("absent UI serializes"); + assert!(!absent_yaml.contains("\nui:")); + + let explicit = parse_sandbox_policy("version: 1\nui: {}\n").expect("empty UI parses"); + let ui = explicit.ui.as_ref().expect("UI presence preserved"); + assert!(!ui.allow_graphical_ui); + assert_eq!(ui.clipboard, UiClipboardAccess::None as i32); + assert!(!ui.allow_input_injection); + + let explicit_yaml = serialize_sandbox_policy(&explicit).expect("empty UI serializes"); + assert!(explicit_yaml.contains("ui: {}"), "got:\n{explicit_yaml}"); + let reparsed = parse_sandbox_policy(&explicit_yaml).expect("empty UI reparses"); + assert!(reparsed.ui.is_some()); + } + + #[test] + fn ui_policy_round_trips_all_clipboard_directions() { + for (wire, expected) in [ + ("none", UiClipboardAccess::None), + ("read", UiClipboardAccess::Read), + ("write", UiClipboardAccess::Write), + ("all", UiClipboardAccess::All), + ] { + let yaml = format!( + "version: 1\nui:\n allow_graphical_ui: true\n clipboard: {wire}\n allow_input_injection: true\n" + ); + let policy = parse_sandbox_policy(&yaml).expect("UI policy parses"); + let ui = policy.ui.as_ref().expect("UI policy present"); + assert!(ui.allow_graphical_ui); + assert_eq!(ui.clipboard, expected as i32); + assert!(ui.allow_input_injection); + + let serialized = serialize_sandbox_policy(&policy).expect("UI policy serializes"); + let reparsed = parse_sandbox_policy(&serialized).expect("UI policy reparses"); + assert_eq!(reparsed, policy); + } + } + + #[test] + fn ui_unspecified_clipboard_canonicalizes_to_none_across_yaml_round_trip() { + let raw = SandboxPolicy { + version: 1, + ui: Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Unspecified as i32, + ..Default::default() + }), + ..Default::default() + }; + + let canonical = validate_and_canonicalize_sandbox_policy(raw) + .expect("unspecified clipboard must be a valid deny default"); + assert_eq!( + canonical.ui.as_ref().expect("UI remains present").clipboard, + UiClipboardAccess::None as i32 + ); + + let yaml = serialize_sandbox_policy(&canonical).expect("canonical UI serializes"); + let reparsed = parse_sandbox_policy(&yaml).expect("canonical UI reparses"); + assert_eq!(reparsed, canonical); + } + + #[test] + fn ui_policy_rejects_unknown_yaml_clipboard_value() { + let error = parse_sandbox_policy("version: 1\nui:\n clipboard: execute\n") + .expect_err("unknown clipboard value must fail"); + assert!( + error + .to_string() + .contains("failed to decode sandbox policy fields") + ); + } + + #[test] + fn ui_policy_validation_rejects_unknown_proto_clipboard_value() { + let policy = SandboxPolicy { + ui: Some(UiPolicy { + clipboard: 99, + ..Default::default() + }), + ..Default::default() + }; + let violations = validate_sandbox_policy(&policy).expect_err("unknown enum must fail"); + assert_eq!( + violations, + vec![PolicyViolation::InvalidUiClipboardAccess { value: 99 }] + ); + } + /// Verify that `allowed_ips` survives the round-trip. #[test] fn round_trip_preserves_allowed_ips() { @@ -3703,6 +3861,7 @@ network_policies: process: None, filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4161,6 +4320,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4177,6 +4337,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4249,6 +4410,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; diff --git a/crates/openshell-prover/src/containment.rs b/crates/openshell-prover/src/containment.rs index f7a7a43c3c..73d96b67a8 100644 --- a/crates/openshell-prover/src/containment.rs +++ b/crates/openshell-prover/src/containment.rs @@ -83,6 +83,11 @@ impl ContainmentEndpoint for Endpoint { pub fn parse_policy_str(source: &str) -> Result { let document = openshell_policy_schema::parse_policy(source) .map_err(|error| ParsePolicyError(format!("invalid policy: {error:#}")))?; + if document.ui.is_some() { + return Err(ParsePolicyError( + "UI policy containment is not supported".to_string(), + )); + } let mut filesystem_policy = document.effective_filesystem_policy(); normalize_filesystem_paths(&mut filesystem_policy)?; let PolicyDocument { @@ -92,6 +97,7 @@ pub fn parse_policy_str(source: &str) -> Result CheckOptions { diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 70d491d318..a30f83b8e7 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -59,7 +59,6 @@ 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)] @@ -440,6 +439,12 @@ 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)] @@ -458,6 +463,7 @@ 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() @@ -512,8 +518,13 @@ 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 network fence. + /// Backend-neutral projection of the validated outer 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, @@ -542,6 +553,10 @@ 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() } @@ -1522,7 +1537,6 @@ 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 b20ca44f3b..6818b2d4f3 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -159,6 +159,7 @@ 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,6 +190,7 @@ 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, @@ -258,6 +260,18 @@ 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(()) } @@ -335,6 +349,7 @@ 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, @@ -354,6 +369,12 @@ 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 { @@ -2807,6 +2828,7 @@ mod tests { }, tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), } @@ -3070,6 +3092,7 @@ 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(), }; @@ -3090,6 +3113,7 @@ 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(), }; @@ -3112,6 +3136,7 @@ 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(), }; @@ -3134,6 +3159,7 @@ 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(), }; @@ -3442,6 +3468,7 @@ mod tests { }, tls: certificate.client_tls, host_gateway_ip: None, + direct_proxy: None, resource_claims: std::collections::BTreeMap::new(), outer_fence: test_outer_fence(), }, @@ -3635,6 +3662,7 @@ 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 88bce43fab..392840d07a 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -61,6 +61,7 @@ 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-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 16299730c9..cb965fe6da 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -6059,5 +6059,8 @@ pub fn run_boundary( _config_path: &Path, _qualification: crate::RuntimeQualification, ) -> Result<(), String> { - Err("boundary mode is supported only on Linux".to_string()) + Err( + "openshell-sandbox boundary requires Linux; use openshell-windows-sandbox on Windows" + .to_string(), + ) } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index da99518833..8cff53a0ee 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -53,11 +53,10 @@ pub struct RuntimeQualification { pub socket_loopback_confinement: bool, } -/// Placeholder used when compiling the package on a non-Linux host. +/// Linux runtime qualification is not used on other platforms. /// -/// The sandbox binary rejects execution on those hosts before constructing a -/// qualification, but retaining the type keeps the library API portable for -/// workspace-wide checks. +/// This executable's boundary mode requires Linux. Windows MXC uses the separate +/// `openshell-windows-sandbox` executable and its platform-specific confirmation. #[cfg(not(target_os = "linux"))] #[derive(Clone, Copy, Debug)] pub struct RuntimeQualification; diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 86ef2bb4f5..352642b986 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -1776,7 +1776,9 @@ fn run_boundary(bootstrap: &Path, log_level: &str) -> Result<()> { #[cfg(not(target_os = "linux"))] fn run_boundary(_bootstrap: &Path, _log_level: &str) -> Result<()> { - Err(miette::miette!("openshell-sandbox requires Linux")) + Err(miette::miette!( + "openshell-sandbox requires Linux; use openshell-windows-sandbox on Windows" + )) } fn main() -> Result<()> { diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 3449bd6d76..b82f2e60a0 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -369,6 +369,9 @@ 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 @@ -792,6 +795,7 @@ 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( @@ -1079,6 +1083,7 @@ impl ComputeRuntime { .as_ref() .and_then(|spec| spec.template.as_ref()), )?; + self.validate_policy_capabilities(sandbox, runtime_inputs.effective_policy.as_ref())?; let mut driver_sandbox = driver_sandbox_from_public(sandbox, &self.driver_info.name) .map_err(|status| *status)?; if let Some(spec) = driver_sandbox.spec.as_mut() { @@ -1111,6 +1116,24 @@ impl ComputeRuntime { .map(|_| ()) } + fn validate_policy_capabilities( + &self, + sandbox: &Sandbox, + effective_policy: Option<&ProtoSandboxPolicy>, + ) -> Result<(), Status> { + let has_explicit_ui = effective_policy + .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!( + "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 + ))); + } + Ok(()) + } + pub async fn create_sandbox( &self, sandbox: Sandbox, @@ -1245,6 +1268,7 @@ impl ComputeRuntime { .as_ref() .and_then(|spec| spec.template.as_ref()), )?; + self.validate_policy_capabilities(&sandbox, runtime_inputs.effective_policy.as_ref())?; let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; @@ -7406,6 +7430,7 @@ impl ComputeDriver for NoopTestDriver { "test", [], )), + supports_ui_policy: false, }, )) } @@ -7583,6 +7608,7 @@ 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, @@ -7615,6 +7641,7 @@ mod tests { GetSandboxResponse, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, ValidateSandboxCreateResponse, WatchSandboxesDeletedEvent, WatchSandboxesSandboxEvent, }; + use openshell_core::proto::{SandboxPolicy as PublicSandboxPolicy, UiPolicy}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as TestMutex}; @@ -8026,6 +8053,7 @@ mod tests { } metadata }), + supports_ui_policy: false, })) } @@ -8433,6 +8461,7 @@ mod tests { "test", [], )), + supports_ui_policy: false, })) } @@ -8694,6 +8723,7 @@ 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, @@ -8714,6 +8744,105 @@ mod tests { } } + fn sandbox_with_explicit_ui(id: &str) -> Sandbox { + let mut sandbox = sandbox_record(id, "ui-policy", SandboxPhase::Provisioning); + sandbox.spec = Some(SandboxSpec { + policy: Some(PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }), + ..Default::default() + }); + sandbox + } + + #[tokio::test] + async fn explicit_ui_policy_rejects_before_unsupported_driver_validation() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let error = runtime + .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-validate")) + .await + .expect_err("an unsupported driver must reject explicit UI policy"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("complete UI policy contract")); + assert_eq!( + driver.validate_create_calls.load(Ordering::Relaxed), + 0, + "gateway capability validation must run before the driver RPC" + ); + } + + #[tokio::test] + async fn explicit_ui_policy_rejects_before_unsupported_driver_create() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + + let error = runtime + .create_sandbox(sandbox_with_explicit_ui("sb-ui-create"), None, false) + .await + .expect_err("an internal caller must not bypass UI capability validation"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!( + driver.create_calls.load(Ordering::Relaxed), + 0, + "unsupported UI policy must fail before provisioning" + ); + } + + #[tokio::test] + async fn effective_ui_policy_rejects_before_unsupported_driver_validation() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record( + "sb-effective-ui", + "effective-ui-policy", + SandboxPhase::Provisioning, + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new(PublicSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }); + + let error = runtime + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) + .await + .expect_err("an unsupported driver must reject the effective UI policy"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + 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 + .validate_sandbox_create(&sandbox_with_explicit_ui("sb-ui-supported")) + .await + .expect("a driver advertising complete UI support accepts validation"); + + assert_eq!(driver.validate_create_calls.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn absent_ui_policy_preserves_unsupported_driver_behavior() { + let driver = Arc::new(TestDriver::default()); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-no-ui", "no-ui-policy", SandboxPhase::Provisioning); + + runtime + .validate_sandbox_create(&sandbox) + .await + .expect("an absent UI section must preserve existing behavior"); + + 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 b366dc4a58..98ec894704 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -299,6 +299,7 @@ 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 99fb1dde79..0dd9a0d214 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1936,6 +1936,20 @@ async fn auto_approve_chunk( Ok(()) } +/// Preserve UI as the sandbox's startup contract when applying a global policy. +/// +/// UI controls are enforced by the compute runtime before the workload starts, +/// so a later global override cannot safely add, remove, or change them. Global +/// policy writes reject their own UI section; this overlay also prevents a +/// global dynamic policy from hiding the UI state that the runtime enforced. +fn preserve_sandbox_startup_ui(policy: &mut ProtoSandboxPolicy, sandbox: &Sandbox) { + policy.ui = sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .and_then(|policy| policy.ui); +} + // TODO: share effective-policy lookup with `load_sandbox_policy` / // `GetSandboxConfig`. They re-implement very similar global-settings and // profile-composition logic; consolidating them is out of scope for the @@ -1971,10 +1985,11 @@ async fn current_effective_policy_from_records( records: &[super::provider::ProviderEnvironmentRecord], ) -> Result<(ProtoSandboxPolicy, PolicySource), Status> { let global_settings = load_global_settings(state.store.as_ref()).await?; - if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? { + if let Some(mut global_policy) = decode_policy_from_global_settings(&global_settings)? { // A global policy is the complete effective policy. Dormant sandbox // history and specs may predate the current schema, but they must not // prevent the valid global policy from being served. + preserve_sandbox_startup_ui(&mut global_policy, sandbox); return apply_captured_policy_context( provider_policy_context_from_records(catalog, records), global_policy, @@ -2838,9 +2853,10 @@ pub(super) async fn load_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?; - let (mut policy, version, mut policy_hash, policy_source) = if let Some(global_policy) = + let (mut policy, version, mut policy_hash, policy_source) = if let Some(mut global_policy) = global_policy { + preserve_sandbox_startup_ui(&mut global_policy, sandbox); let version = latest .as_ref() .map(|record| u32::try_from(record.version).unwrap_or(0)) @@ -3961,6 +3977,11 @@ async fn handle_update_config_inner( clear_provider_credentialed_markers(&mut new_policy); validate_no_reserved_provider_policy_keys(&new_policy)?; new_policy = validate_and_canonicalize_policy(new_policy)?; + if new_policy.ui.is_some() { + return Err(Status::invalid_argument( + "UI policy cannot be set globally because it is applied at sandbox startup; configure ui in each sandbox policy", + )); + } validate_policy_safety(&new_policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) .await?; @@ -8741,6 +8762,32 @@ 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; + let mut global_policy = install_test_global_policy(&state).await; + let mut policy = openshell_policy::restrictive_default_policy(); + policy.ui = Some(openshell_core::proto::UiPolicy::default()); + global_policy.ui = policy.ui; + let sandbox = test_sandbox("create-global", "create-global", policy, Vec::new()); + let inputs = resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox) + .await + .expect("global effective policy"); + assert_eq!(inputs.effective_policy, Some(global_policy)); + } + #[tokio::test] async fn list_sandbox_policies_traverses_multiple_pages_exactly_once() { let state = test_server_state().await; @@ -9336,6 +9383,67 @@ mod tests { ); } + #[tokio::test] + async fn global_policy_preserves_each_sandbox_startup_ui_contract() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let state = test_server_state().await; + let global_policy = install_test_global_policy(&state).await; + assert!(global_policy.ui.is_none()); + + let sandbox_id = "global-preserves-startup-ui"; + let startup_ui = UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::Read as i32, + allow_input_injection: false, + }; + let mut sandbox_policy = openshell_policy::restrictive_default_policy(); + sandbox_policy.ui = Some(startup_ui); + let sandbox = test_sandbox(sandbox_id, sandbox_id, sandbox_policy, Vec::new()); + state + .store + .put_message(&sandbox) + .await + .expect("store sandbox"); + + let response = handle_get_sandbox_config( + &state, + with_sandbox( + Request::new(GetSandboxConfigRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + sandbox_id, + ), + ) + .await + .expect("global policy read must preserve startup UI") + .into_inner(); + assert_eq!( + response + .policy + .as_ref() + .and_then(|policy| policy.ui.as_ref()), + Some(&startup_ui) + ); + + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .expect("provider profile catalog"); + let effective = current_effective_policy_for_sandbox( + state.as_ref(), + &catalog, + "default", + &sandbox, + sandbox_id, + ) + .await + .expect("effective policy lookup must preserve startup UI"); + assert_eq!(effective.ui.as_ref(), Some(&startup_ui)); + } + #[tokio::test] async fn canonical_mcp_version_order_produces_identical_policy_bytes_and_hashes() { let state = test_server_state().await; @@ -9433,6 +9541,93 @@ mod tests { } } + #[tokio::test] + async fn global_policy_ingress_rejects_startup_only_ui_before_persistence() { + use openshell_core::proto::UiPolicy; + + let state = test_server_state().await; + let mut policy = openshell_policy::restrictive_default_policy(); + policy.ui = Some(UiPolicy::default()); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(policy), + ..Default::default() + })), + ) + .await + .expect_err("global UI must be rejected before persistence"); + + assert_eq!(error.code(), Code::InvalidArgument); + assert!(error.message().contains("sandbox startup")); + assert!( + state + .store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + .expect("global policy lookup") + .is_none() + ); + let settings = load_global_settings(state.store.as_ref()) + .await + .expect("global settings lookup"); + assert!(!settings.settings.contains_key(POLICY_SETTING_KEY)); + } + + #[tokio::test] + async fn sandbox_policy_update_accepts_semantically_unchanged_ui_default() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let state = test_server_state().await; + let sandbox_id = "ui-default-roundtrip"; + let mut canonical = openshell_policy::restrictive_default_policy(); + canonical.ui = Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::None as i32, + ..Default::default() + }); + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + canonical.clone(), + Vec::new(), + )) + .await + .expect("store sandbox"); + + let mut protobuf_default = canonical; + protobuf_default.ui.as_mut().expect("UI policy").clipboard = + UiClipboardAccess::Unspecified as i32; + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + sandbox: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(protobuf_default), + ..Default::default() + })), + ) + .await + .expect("unspecified and none are the same static UI policy"); + + let stored = state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .expect("policy revision"); + let persisted = ProtoSandboxPolicy::decode(stored.policy_payload.as_slice()) + .expect("decode persisted policy"); + assert_eq!( + persisted.ui.expect("persisted UI").clipboard, + UiClipboardAccess::None as i32 + ); + } + #[tokio::test] async fn policy_record_identity_global_deduplicates_defaulted_mcp_history() { for (case, legacy_policy) in defaulted_mcp_policy_cases() { @@ -22051,6 +22246,35 @@ mod tests { ); } + #[test] + fn policy_hash_distinguishes_ui_absence_presence_and_values() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let absent = ProtoSandboxPolicy::default(); + let explicit_deny = ProtoSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let clipboard_read = ProtoSandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + + assert_ne!( + deterministic_policy_hash(&absent), + deterministic_policy_hash(&explicit_deny), + "an explicitly present deny-only UI block remains hash-significant" + ); + assert_ne!( + deterministic_policy_hash(&explicit_deny), + deterministic_policy_hash(&clipboard_read), + "UI capability changes must produce a new policy hash" + ); + } + #[test] fn policy_hash_is_stable_across_middleware_config_field_insertion_order() { use prost_types::{Struct, Value, value::Kind}; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 463176fb65..77a479896d 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -5827,6 +5827,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), + ..Default::default() }), ) .await diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index abdfccda10..4d47b95105 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -10,7 +10,7 @@ use openshell_core::proto::{ CredentialHandle, ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy, - SandboxRestartPolicy, SandboxSpec, SandboxTemplate, + SandboxRestartPolicy, SandboxSpec, SandboxTemplate, UiClipboardAccess, }; use openshell_core::rpc_error::invalid_argument; use prost::Message; @@ -1030,7 +1030,7 @@ pub(super) fn validate_no_reserved_provider_policy_keys( Ok(()) } -/// Validate that static policy fields (filesystem, landlock, process) haven't changed +/// Validate that static policy fields (filesystem, landlock, process, UI) haven't changed /// from the baseline (version 1) policy. pub(super) fn validate_static_fields_unchanged( baseline: &ProtoSandboxPolicy, @@ -1054,6 +1054,18 @@ pub(super) fn validate_static_fields_unchanged( "process policy cannot be changed on a live sandbox (applied at startup)", )); } + let mut baseline_ui = baseline.ui; + let mut new_ui = new.ui; + for ui in [&mut baseline_ui, &mut new_ui].into_iter().flatten() { + if ui.clipboard == UiClipboardAccess::Unspecified as i32 { + ui.clipboard = UiClipboardAccess::None as i32; + } + } + if baseline_ui != new_ui { + return Err(Status::invalid_argument( + "UI policy cannot be changed on a live sandbox (applied at startup)", + )); + } Ok(()) } @@ -2401,6 +2413,33 @@ mod tests { assert!(result.unwrap_err().message().contains("include_workdir")); } + #[test] + fn validate_static_fields_rejects_ui_presence_or_value_change() { + use openshell_core::proto::{UiClipboardAccess, UiPolicy}; + + let absent = ProtoSandboxPolicy::default(); + let deny = ProtoSandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let allow_clipboard = ProtoSandboxPolicy { + ui: Some(UiPolicy { + clipboard: UiClipboardAccess::Read as i32, + ..Default::default() + }), + ..Default::default() + }; + + let presence_error = validate_static_fields_unchanged(&absent, &deny) + .expect_err("adding explicit UI policy must be static"); + assert!(presence_error.message().contains("UI policy")); + + let value_error = validate_static_fields_unchanged(&deny, &allow_clipboard) + .expect_err("changing UI policy must be static"); + assert!(value_error.message().contains("UI policy")); + assert!(validate_static_fields_unchanged(&deny, &deny).is_ok()); + } + // ---- Exec validation ---- #[test] diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 3e0bad0afe..f4a26745a9 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -621,9 +621,9 @@ mod tests { overlap_hash.as_str(), ), ( - (311, 27), - (93, 21), - (81, 21), + (312, 28), + (94, 22), + (82, 22), PUBLIC_RPC_SCHEMA_SHA256, DURABLE_SCHEMA_SHA256, PUBLIC_DURABLE_OVERLAP_SHA256 @@ -769,6 +769,7 @@ mod tests { PolicyRevisionPayload::decode(legacy_bytes(V0_0_116_POLICY_PAYLOAD).as_slice()) .expect("legacy policy payload must decode"); assert!(policy_payload.policy.is_some()); + assert!(policy_payload.policy.as_ref().unwrap().ui.is_none()); assert_eq!(policy_payload.hash, "sha256"); assert_eq!(policy_payload.load_error, "none"); assert_eq!(policy_payload.loaded_at_ms, 300); diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 0cd4abf2d5..9980c14a81 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -161,6 +161,7 @@ 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-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs deleted file mode 100644 index 5ae44dfff9..0000000000 --- a/crates/openshell-supervisor-network/src/host.rs +++ /dev/null @@ -1,379 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Host-side proxy startup for compute drivers that do not run the Linux -//! in-sandbox supervisor. -//! -//! MXC uses this on Windows: MXC's `network.proxy` redirects sandbox egress to a -//! per-sandbox loopback listener in the gateway process, and this module starts -//! the existing `OpenShell` CONNECT proxy against the trimmed network-only -//! `SandboxPolicy`. - -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicU32; - -use miette::Result; -use openshell_core::activity::ActivitySender; -use openshell_core::denial::DenialEvent; -use openshell_core::policy::ProxyPolicy; -use openshell_core::proposals::AgentProposals; -use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; -use openshell_core::provider_credentials::ProviderCredentialState; -use openshell_isolation_interface::contract::{BinaryIdentity, ExecutableIdentity, Sha256Digest}; -use openshell_ocsf::{ - ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, -}; -use tokio::sync::mpsc::UnboundedSender; - -use crate::identity::BinaryIdentityCache; -use crate::l7::tls::{ - CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, - write_ca_files, -}; -use crate::opa::OpaEngine; -use crate::policy_local::PolicyLocalContext; -use crate::proxy::ProxyHandle; - -/// Configuration for a host-side `OpenShell` CONNECT proxy. -pub struct HostProxyConfig { - /// Exact socket the compute driver will redirect sandbox egress to. - pub bind_addr: SocketAddr, - /// Network-only policy produced by the compute driver's policy split. - pub policy: ProtoSandboxPolicy, - /// Static process identity used when the platform cannot recover the - /// socket-owning sandbox process. Policy binaries must match this path for - /// L4/L7 allow rules to pass. - pub binary_path: PathBuf, - pub sandbox_id: Option, - pub sandbox_name: Option, - pub openshell_endpoint: Option, - pub provider_credentials: Option, - /// Shared feature state for the policy.local agent proposal surface. - pub agent_proposals: AgentProposals, - pub denial_tx: Option>, - pub activity_tx: Option, -} - -/// RAII handle for a host-side proxy. Dropping it aborts the proxy accept loop. -pub struct HostProxyHandle { - proxy: ProxyHandle, - ca_file_paths: Option<(PathBuf, PathBuf)>, - #[allow(dead_code)] - tls_dir: Option, - pub policy_local_ctx: Arc, -} - -impl HostProxyHandle { - #[must_use] - pub const fn http_addr(&self) -> Option { - self.proxy.http_addr() - } - - #[must_use] - pub fn ca_file_paths(&self) -> Option<(PathBuf, PathBuf)> { - self.ca_file_paths.clone() - } -} - -/// Start a host-side proxy for one sandbox. -/// -/// Linux supervisor mode should continue to use `run::run_networking`; this API -/// is for host-side compute-driver integrations such as Windows MXC. -pub async fn start_host_proxy(config: HostProxyConfig) -> Result { - if !config.bind_addr.ip().is_loopback() { - return Err(miette::miette!( - "host proxy bind address must be loopback-only: {}", - config.bind_addr - )); - } - if !config.policy.network_middlewares.is_empty() { - return Err(miette::miette!( - "host proxy cannot enforce network_middlewares without a middleware service registry; refusing to start with {} configured middleware(s)", - config.policy.network_middlewares.len() - )); - } - - let engine = Arc::new(OpaEngine::from_proto(&config.policy)?); - let (_workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); - let policy_local_ctx = Arc::new(PolicyLocalContext::new( - Some(config.policy.clone()), - config.openshell_endpoint.clone(), - config - .sandbox_name - .clone() - .or_else(|| config.sandbox_id.clone()), - config.agent_proposals, - workspace_rx, - )); - let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true); - let proxy_policy = ProxyPolicy { - http_addr: Some(config.bind_addr), - }; - let upstream_proxy_args = crate::upstream_proxy::UpstreamProxyArgs::default(); - let (tls_state, ca_file_paths, tls_dir) = match tempfile::Builder::new() - .prefix("openshell-mxc-tls-") - .tempdir() - { - Ok(tls_dir) => match SandboxCa::generate() { - Ok(ca) => { - let system_ca_bundle = read_system_ca_bundle(); - match write_ca_files(&ca, tls_dir.path(), &system_ca_bundle) { - Ok(paths) => match build_upstream_client_config(&system_ca_bundle) { - Ok(upstream_config) => { - let cert_cache = CertCache::new(ca); - let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config)); - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "enabled") - .message( - "Host proxy TLS termination enabled: ephemeral CA generated" - ) - .build() - ); - (Some(state), Some(paths), Some(tls_dir)) - } - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "disabled") - .message(format!( - "Failed to build host proxy upstream TLS config, TLS termination disabled: {e}" - )) - .build() - ); - (None, None, Some(tls_dir)) - } - }, - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "disabled") - .message(format!( - "Failed to write host proxy CA files, TLS termination disabled: {e}" - )) - .build() - ); - (None, None, Some(tls_dir)) - } - } - } - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "disabled") - .message(format!( - "Failed to generate host proxy ephemeral CA, TLS termination disabled: {e}" - )) - .build() - ); - (None, None, Some(tls_dir)) - } - }, - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(StateId::Disabled, "disabled") - .message(format!( - "Failed to create host proxy TLS trust directory, TLS termination disabled: {e}" - )) - .build() - ); - (None, None, None) - } - }; - let binary_digest: Sha256Digest = crate::procfs::file_sha256(&config.binary_path)? - .parse() - .map_err(|error| miette::miette!("hash host proxy binary identity: {error}"))?; - let direct_listener_identity = BinaryIdentity { - executable: ExecutableIdentity { - path: config.binary_path, - digest: Some(binary_digest), - }, - ancestors: Vec::new(), - cmdline_paths: Vec::new(), - }; - let proxy = ProxyHandle::start_with_bind_addr( - &proxy_policy, - Some(config.bind_addr), - engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(0)), - tls_state, - config.provider_credentials, - Some(policy_local_ctx.clone()), - config.denial_tx, - config.activity_tx, - None, - ready_rx, - &upstream_proxy_args, - None, - None, - None, - Some(direct_listener_identity), - None, - ) - .await?; - - Ok(HostProxyHandle { - proxy, - ca_file_paths, - tls_dir, - policy_local_ctx, - }) -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use openshell_core::proposals::AgentProposals; - use openshell_core::proto::{ - MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy as ProtoSandboxPolicy, - }; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpStream; - - use super::*; - - fn test_config(bind_addr: SocketAddr, binary_path: PathBuf) -> HostProxyConfig { - HostProxyConfig { - bind_addr, - policy: ProtoSandboxPolicy { - version: 1, - ..Default::default() - }, - binary_path, - sandbox_id: Some("sandbox-123".to_string()), - sandbox_name: Some("agent-box".to_string()), - openshell_endpoint: None, - provider_credentials: None, - agent_proposals: AgentProposals::new(true), - denial_tx: None, - activity_tx: None, - } - } - - #[tokio::test] - async fn rejects_non_loopback_bind_addr() { - let result = start_host_proxy(test_config( - ([192, 0, 2, 1], 0).into(), - PathBuf::from("missing-agent.exe"), - )) - .await; - - let Err(err) = result else { - panic!("host proxy should reject non-loopback bind addresses"); - }; - assert!( - err.to_string().contains("loopback-only"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn rejects_middleware_policy_without_registry() { - let mut config = test_config( - ([127, 0, 0, 1], 0).into(), - PathBuf::from("missing-agent.exe"), - ); - config.policy.network_middlewares.insert( - "redactor".into(), - NetworkMiddlewareConfig { - name: "redactor".into(), - middleware: "openshell/regex".into(), - on_error: "fail_closed".into(), - endpoints: Some(MiddlewareEndpointSelector { - include: vec!["api.example.com".into()], - exclude: Vec::new(), - }), - ..Default::default() - }, - ); - - let Err(error) = start_host_proxy(config).await else { - panic!("host proxy must reject middleware without a registry"); - }; - assert!( - error - .to_string() - .contains("cannot enforce network_middlewares"), - "unexpected error: {error}" - ); - } - - #[tokio::test] - async fn starts_loopback_proxy_and_serves_policy_local() { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let binary = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(binary.path(), b"agent").unwrap(); - - let handle = start_host_proxy(test_config( - ([127, 0, 0, 1], 0).into(), - binary.path().to_path_buf(), - )) - .await - .unwrap(); - - let addr = handle.http_addr().expect("proxy should report bound addr"); - assert!(addr.ip().is_loopback()); - assert_ne!(addr.port(), 0); - let (ca_cert, bundle) = handle.ca_file_paths().expect("CA paths"); - assert!(ca_cert.exists(), "standalone CA cert should exist"); - assert!(bundle.exists(), "combined CA bundle should exist"); - assert!( - std::fs::read_to_string(ca_cert) - .unwrap() - .contains("BEGIN CERTIFICATE") - ); - assert!( - std::fs::read_to_string(bundle) - .unwrap() - .contains("BEGIN CERTIFICATE") - ); - - let mut client = TcpStream::connect(addr).await.unwrap(); - client - .write_all( - b"GET http://policy.local/v1/policy/current HTTP/1.1\r\n\ - Host: policy.local\r\n\ - Connection: close\r\n\ - \r\n", - ) - .await - .unwrap(); - - let mut response = Vec::new(); - tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) - .await - .unwrap() - .unwrap(); - - let response = String::from_utf8(response).unwrap(); - assert!( - response.starts_with("HTTP/1.1 200 OK"), - "unexpected response: {response}" - ); - let (_, body) = response.split_once("\r\n\r\n").expect("response body"); - let body: serde_json::Value = serde_json::from_str(body).unwrap(); - assert_eq!(body["format"], "yaml"); - assert!( - body["policy_yaml"] - .as_str() - .unwrap_or_default() - .contains("version: 1"), - "unexpected policy payload: {body}" - ); - } -} diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index d9fcd004b6..c01da3551d 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -11,8 +11,6 @@ //! aggregate them. mod google_cloud_metadata; -#[cfg(target_os = "windows")] -pub mod host; pub mod identity; pub mod identity_source; pub mod l7; diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 62cf0582e5..6c28c3e11d 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1559,6 +1559,7 @@ fn validate_opa_static_settings(data: &mut serde_json::Value) -> Result<()> { fn redacted_policy_violation_category(violation: &PolicyViolation) -> &'static str { match violation { PolicyViolation::InvalidProcessIdentity { .. } => "invalid process identity", + PolicyViolation::InvalidUiClipboardAccess { .. } => "invalid UI clipboard access", PolicyViolation::InvalidLandlockCompatibility { .. } => "invalid Landlock compatibility", PolicyViolation::PathTraversal { .. } | PolicyViolation::RelativePath { .. } @@ -3370,6 +3371,7 @@ mod tests { run_as_user: "sandbox".to_string(), run_as_group: "sandbox".to_string(), }), + ui: None, network_policies, network_middlewares: std::collections::HashMap::default(), } @@ -5729,6 +5731,7 @@ process: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto_with_pid_and_binary_identity_required(&proto, 0, false) .expect("engine from relaxed proto"); @@ -6352,6 +6355,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6423,6 +6427,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6499,6 +6504,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8242,6 +8248,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8299,6 +8306,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8357,6 +8365,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8417,6 +8426,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -8476,6 +8486,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -9987,6 +9998,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { @@ -10057,6 +10069,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); @@ -10287,6 +10300,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).unwrap(); // Port 443 @@ -11252,6 +11266,7 @@ network_policies: process: None, network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let pid = std::process::id(); // accessible root, leaf paths absent @@ -11935,6 +11950,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Build engine with our PID (symlink resolution will work via /proc/self/root/) @@ -12098,6 +12114,7 @@ process: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Initial load at pid=0 — no symlink expansion diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs index 80759131d1..c7de3fe0e9 100644 --- a/crates/openshell-supervisor-process/src/main_session.rs +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -758,6 +758,7 @@ mod tests { )); } + #[cfg(unix)] #[tokio::test] async fn signaling_without_boundary_is_rejected() { let session = MainSession::inert(); diff --git a/crates/openshell-supervisor/Cargo.toml b/crates/openshell-supervisor/Cargo.toml index b44b963952..33d3bcf4a8 100644 --- a/crates/openshell-supervisor/Cargo.toml +++ b/crates/openshell-supervisor/Cargo.toml @@ -54,6 +54,9 @@ defaults-without-telemetry = ["bundled-ca-roots"] telemetry = ["openshell-core/telemetry"] bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"] +[target.'cfg(target_os = "windows")'.dependencies] +openshell-mxc-boundary = { path = "../openshell-mxc-boundary" } + [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } futures = { workspace = true } diff --git a/crates/openshell-supervisor/src/isolation_backends.rs b/crates/openshell-supervisor/src/isolation_backends.rs new file mode 100644 index 0000000000..7538066396 --- /dev/null +++ b/crates/openshell-supervisor/src/isolation_backends.rs @@ -0,0 +1,29 @@ +// 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 b09c91842f..03f5a25175 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -21,6 +21,7 @@ mod activity_aggregator; mod backend_setup; mod denial_aggregator; mod endpoint_status; +mod isolation_backends; mod mechanistic_mapper; mod provider_readiness; @@ -120,12 +121,14 @@ enum ReadinessEndpoint { Tcp(u16), } +#[cfg(unix)] enum ReadinessListener { #[cfg(unix)] Unix(tokio::net::UnixListener), Tcp(tokio::net::TcpListener), } +#[cfg(unix)] impl ReadinessEndpoint { fn prepare(&self) -> Result<()> { match self { @@ -174,6 +177,7 @@ impl ReadinessEndpoint { } /// Falls back to IPv4 when the network namespace has IPv6 disabled. +#[cfg(unix)] fn bind_readiness_tcp(port: u16) -> std::io::Result { use socket2::{Domain, Socket, Type}; @@ -192,6 +196,7 @@ fn bind_readiness_tcp(port: u16) -> std::io::Result { .or_else(|_| bind(Domain::IPV4, (std::net::Ipv4Addr::UNSPECIFIED, port).into())) } +#[cfg(unix)] impl ReadinessListener { async fn accept(&self) -> std::io::Result<()> { match self { @@ -202,11 +207,13 @@ impl ReadinessListener { } } +#[cfg(unix)] struct ControlReadiness { task: tokio::task::JoinHandle<()>, endpoint: ReadinessEndpoint, } +#[cfg(unix)] impl ControlReadiness { fn start( endpoint: ReadinessEndpoint, @@ -328,7 +335,6 @@ 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(); @@ -336,6 +342,21 @@ impl Drop for ControlReadiness { } } +#[cfg(not(unix))] +struct ControlReadiness; + +#[cfg(not(unix))] +impl ControlReadiness { + fn start( + _endpoint: ReadinessEndpoint, + _session_readiness: Option>, + ) -> Result { + Err(miette::miette!( + "supervisor readiness sockets require a Unix host" + )) + } +} + /// Check whether the live supervisor owns its private readiness socket. #[cfg(unix)] pub fn check_control_readiness(path: &std::path::Path) -> Result<()> { @@ -5326,6 +5347,7 @@ mod tests { .expect("replacement session restores readiness socket"); } + #[cfg(unix)] #[test] fn tcp_readiness_listener_accepts_ipv4_regardless_of_bindv6only() { let port = std::net::TcpListener::bind("127.0.0.1:0") @@ -5346,6 +5368,7 @@ mod tests { } #[tokio::test] + #[cfg(unix)] async fn tcp_control_readiness_tracks_supervisor_session() { let port = std::net::TcpListener::bind("127.0.0.1:0") .and_then(|reserved| reserved.local_addr()) diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 30a75f7f6a..ee80d02ac5 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -952,7 +952,7 @@ The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so this validation path does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. -### MXC +### MXC audit configuration The MXC driver runs Windows workloads through `wxc-exec`. Enable ETW auditing to map Windows Sandboxing provider events into the gateway's OCSF stream. @@ -967,13 +967,14 @@ log_level = "info" compute_driver = "mxc" [openshell.drivers.mxc] -wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" -backend = "process_container" -default_configuration_id = "composable" -pc_least_privilege = false -pc_capabilities = [] -debug = false -etw_audit = true +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe" +sandbox_binary_path = "C:\\OpenShell\\openshell-windows-sandbox.exe" +backend = "process_container" +pc_least_privilege = false +pc_capabilities = [] +debug = false +etw_audit = true ``` `etw_audit` defaults to `false`. When enabled, the gateway account must be an @@ -1232,6 +1233,54 @@ OpenShell sends no override and Podman applies its runtime-selected profile. The setting applies to the workload container; the supervisor retains Podman's runtime-selected profile. +### MXC + +The MXC driver is Windows-only and opt-in. It links into the gateway and invokes Microsoft MXC through `wxc-exec.exe`. The driver starts `openshell-supervisor --role=isolation-backend` on the host and `openshell-windows-sandbox.exe` inside each ProcessContainer. The standard authenticated Sandbox Protocol and supervisor session provide lifecycle, exec, forwarding, provider refresh, and network policy. + +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. + +```toml +[openshell] +version = 2 + +[openshell.gateway] +bind_address = "127.0.0.1:17670" +log_level = "info" +compute_driver = "mxc" +# Required when gateway TLS is enabled. The gateway injects this bundle into +# the host supervisor. +guest_tls_ca = "C:\\OpenShell\\certs\\ca.pem" +guest_tls_cert = "C:\\OpenShell\\certs\\client.pem" +guest_tls_key = "C:\\OpenShell\\certs\\client-key.pem" + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe" +sandbox_binary_path = "C:\\OpenShell\\openshell-windows-sandbox.exe" +# Defaults to %LOCALAPPDATA%\OpenShell\mxc. +state_dir = "C:\\Users\\operator\\AppData\\Local\\OpenShell\\mxc" +# Empty derives the gateway loopback URL and its TLS mode. +grpc_endpoint = "" +# The RFC 0012 MXC path requires process_container. +backend = "process_container" +pc_least_privilege = false +pc_capabilities = [] +pc_allow_local_network = true +pc_minimal_env = false +debug = false +etw_audit = false +``` + +The packaged supervisor and sandbox binaries default to siblings of `openshell-gateway.exe`; explicit paths are useful for development layouts. The driver protects host supervisor tokens and descriptors with an owner-only Windows DACL. + +Existing ProcessContainer configurations may retain `default_configuration_id`. This legacy IsolationSession-only field is accepted for configuration compatibility but has no effect on ProcessContainer. It does not enable the unsupported `isolation_session` backend; omit it in new configurations. + +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. + +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. + ### MicroVM Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index f10cccb561..02553add7d 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -267,6 +267,11 @@ 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: @@ -1000,6 +1005,20 @@ 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 60bda56914..255309320a 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -108,6 +108,10 @@ 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 a4c32bcd42..1522ea7739 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -971,6 +971,10 @@ 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/proto/sandbox.proto b/proto/sandbox.proto index 7f7f407c3c..3dd8dc0656 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -33,6 +33,10 @@ message SandboxPolicy { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. map network_middlewares = 6; + // Static, platform-neutral user-interface access policy. Within an explicit + // section, omitted capabilities deny. Omitting the section preserves the + // compute platform's existing behavior. + UiPolicy ui = 7; } // Filesystem access policy. @@ -59,6 +63,32 @@ message ProcessPolicy { string run_as_group = 2; } +// Directional clipboard access for a sandboxed workload. +enum UiClipboardAccess { + // Unspecified resolves to no clipboard access. + UI_CLIPBOARD_ACCESS_UNSPECIFIED = 0; + // No clipboard reads or writes. + UI_CLIPBOARD_ACCESS_NONE = 1; + // The sandbox may read host clipboard contents. + UI_CLIPBOARD_ACCESS_READ = 2; + // The sandbox may write host clipboard contents. + UI_CLIPBOARD_ACCESS_WRITE = 3; + // The sandbox may read and write host clipboard contents. + UI_CLIPBOARD_ACCESS_ALL = 4; +} + +// Platform-neutral user-interface capabilities. Every omitted field in an +// explicit policy defaults to deny. Compute platforms without complete support +// reject the entire explicit policy before provisioning. +message UiPolicy { + // Allow the sandbox to display graphical windows. + bool allow_graphical_ui = 1; + // Directional host clipboard access. + UiClipboardAccess clipboard = 2; + // Allow the sandbox to synthesize keyboard or pointer input. + bool allow_input_injection = 3; +} + // A named network access policy rule. message NetworkPolicyRule { // Human-readable name for this policy rule. diff --git a/rfc/0013-native-windows-mxc/README.md b/rfc/0013-native-windows-mxc/README.md index 955bb1e9dc..a9229e9468 100644 --- a/rfc/0013-native-windows-mxc/README.md +++ b/rfc/0013-native-windows-mxc/README.md @@ -5,461 +5,197 @@ state: review links: - https://github.com/NVIDIA/OpenShell/issues/2050 - https://github.com/NVIDIA/OpenShell/pull/2071 + - https://github.com/NVIDIA/OpenShell/pull/3370 --- # RFC 0013 - Native Windows Support via the MXC Compute Driver - - ## Summary -This RFC proposes extending OpenShell to run natively on Windows 11 (x64 and -ARM64) without a Linux VM, Docker Desktop, or WSL. It will produce a new -compute driver, `openshell-driver-mxc`, that will use Microsoft -Execution Containers (MXC, via `wxc-exec.exe`) as the sandbox primitive. - -The central architectural conclusion is that OpenShell rejects porting its Linux -in-sandbox supervisor to Windows as part of this design. The value layers the -supervisor delivers on Linux — egress policy enforcement (OPA), L7 HTTP -inspection — are relocated to the host by running OpenShell's existing CONNECT -proxy inside the driver process and pointing MXC's built-in `network.proxy` -redirect at it. The integration therefore collapses to three moving parts: the -native Windows OpenShell gateway, a Windows-only MXC compute driver crate, and an -unmodified `wxc-exec` binary, with no OpenShell binary running inside the -sandbox. +OpenShell runs natively on Windows 11 by using Microsoft Execution Containers +(MXC, through `wxc-exec.exe`) as an RFC 0012 isolation backend. The gateway's +in-process MXC compute driver provisions a host +`openshell-supervisor --role=isolation-backend` and an `openshell-sandbox` +boundary inside each ProcessContainer. + +The authenticated Sandbox Protocol is the only runtime control and forwarding +transport. MXC supplies the Windows outer fence; the existing supervisor owns +policy evaluation, credentials, network proxying, and the gateway session. ## Motivation -OpenShell sandboxes autonomous AI agents. On Linux it does so through the -Docker, Podman, Kubernetes, and libkrun-VM compute drivers, each pairing a -compute backend with an in-sandbox `openshell-sandbox` supervisor that enforces -policy and runs the agent. None of those drivers give a first-class experience on -Windows: Docker Desktop uses a WSL2-backed VM for Linux containers and adds -licensing and resource overhead, WSL2 adds install and networking complexity, and -Hyper-V/Windows Sandbox are heavy and require elevation. Today a Windows -developer or enterprise host cannot run an OpenShell sandbox without standing up -a Linux runtime underneath it. - -Windows is a primary environment for the agents OpenShell targets, particularly -for GeForce and enterprise Windows users. We want native, OS-level isolation -that runs unelevated, with the same policy, inference, and audit guarantees users -get on Linux. Windows 11-Preview Builds now supports MXC (`processcontainer`, backed by -AppContainer + a Low Integrity token) as an OS-native sandbox primitive that -already honors a `network.proxy` egress redirect. That makes a supervisor-free, -host-enforced design feasible without any changes to Microsoft's runtime. - -This is worth an RFC rather than a single issue because it is a cross-cutting -architectural decision: it adds a new compute-driver model (in-process, -supervisor-free), a new platform target with its own build/CI lane, a new -policy-translation seam between OpenShell policy and MXC config, and a new -host-side enforcement model for native Windows sandboxes. It also commits -OpenShell to a set of dependencies on the Microsoft MXC team. These decisions -deserve broad review and a durable record. - -If we leave the current design unchanged, OpenShell remains Linux-only in -practice, Windows users are pushed toward heavyweight VM-based workarounds, and -the Windows work continues to live outside the public project. +Docker Desktop and WSL2 add a Linux VM to Windows workflows. MXC provides a +native AppContainer and ProcessContainer boundary, but the earlier +supervisor-free prototype duplicated lifecycle, credential, forwarding, and +proxy behavior in the driver and a workload relay. That duplicated security +protocols and diverged from sandbox authentication introduced in the common +runtime. + +Reusing RFC 0012 keeps Windows backend-specific code at the isolation edge and +preserves one supervisor session model across Docker, Podman, Kubernetes, VM, +and MXC. It also lets forwarding and provider credential refresh use existing +authenticated paths instead of MXC-only side channels. ## Non-goals -- Porting `openshell-sandbox` (the Linux supervisor) to Windows, or shipping any - in-sandbox OpenShell binary. This RFC rejects that path for native Windows. -- Making Windows a Docker, Podman, Kubernetes, or VM runtime host. Those drivers - remain compile-only configuration stubs that return an unsupported error. -- Starting MXC sandboxes from OCI images or Dockerfiles in the MVP. MXC runs - against the host Windows OS with policy/configuration, not a separate Linux - container image. -- Named-pipe driver IPC, a cross-process MXC driver binary, or a tonic - `ComputeDriverService` adapter for MXC. The driver is in-process. -- Full L7/port/binary-scoped policy enforcement inside MXC itself. MXC network - filtering is host/IP/CIDR-level; rich policy stays on the host proxy. -- MSI/WinGet packaging, installer UX, auto-start, and background gateway - management. -- GPU passthrough into MXC sandboxes. -- Changing Linux or macOS build, runtime, or driver behavior. All Windows code is - gated behind `cfg(target_os = "windows")`. +- Supporting Docker, Kubernetes, Podman, VM, WSL, or Hyper-V compute drivers on + Windows. +- Supporting MXC `isolation_session`; the initial runtime requires + `process_container`. +- Starting Windows sandboxes from OCI images. +- MSI, WinGet, Windows service, or background gateway installation. +- GPU passthrough. +- Full terminal resize before a Windows ConPTY implementation is available. +- Durable recovery of live MXC generations after gateway restart. ## Proposal -### Layered architecture - -OpenShell on Windows is a four-layer stack with a single hard trust boundary at -the MXC sandbox. For the current scope, the gateway runs as a user-launched -native Windows process. The MXC compute driver and per-sandbox host CONNECT proxy -tasks live inside that process. The agent runs inside an MXC AppContainer with -all egress redirected to its assigned host proxy listener. +### Runtime composition ```mermaid -flowchart LR - clients["Clients
CLI · TUI · SDK"] - upstreams["Internet / configured upstreams"] - - subgraph host["Windows host"] - direction LR - - subgraph gateway["openshell-gateway.exe - one native Windows process"] - direction TB - - control["Control plane
auth · sandbox state · policy · audit"] - driver["openshell-driver-mxc (in-process)
one backend · N sandbox entries"] +flowchart TD + Gateway[Gateway / in-process MXC driver] + Supervisor[openshell-supervisor
role=isolation-backend] + Sandbox[openshell-sandbox
inside MXC ProcessContainer] + Workload[Workload process tree] + + Gateway -->|policy + launch authentication| Supervisor + Gateway -->|MXC config + one-use bootstrap| Sandbox + Supervisor <-->|generation-scoped TLS + sandbox JWT| Sandbox + Sandbox --> Workload +``` - subgraph proxies["Per-sandbox host proxy tasks and listeners (N)"] - direction TB - proxy_a["Proxy A
127.0.0.1:port_A
policy A · agent identity A"] - proxy_n["Proxy N
127.0.0.1:port_N
policy N · agent identity N"] - end +The driver owns provisioning and pairwise lifecycle monitoring. If either the +host supervisor or ProcessContainer exits unexpectedly, the driver terminates +the other. Stop and delete wait for pair termination before publishing success. +The gateway treats the standard supervisor session, not a driver-specific port +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 +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 +the backend-neutral isolation contract. + +The boundary terminates owned workload processes if no authenticated supervisor +recovers within the bounded reconnect deadline. Host auth bundles and runtime +descriptors are stored beneath an owner-only Windows DACL. + +### Networking and credentials + +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 +children. The supervisor retains private CA keys and provider secrets, applies +network policy, and refreshes provider state through the ordinary session. + +The listener rejects missing, duplicate, malformed, and cross-generation proxy +authorization before policy evaluation. The initial implementation assigns +requests to the admitted main workload binary because Windows socket-owner +identity is not yet carried by the explicit-proxy transport. Policies that rely +on different network rights for descendant executables are therefore outside +the initial enforcement contract. The loopback exception also does not isolate +unrelated services bound to `127.0.0.1`; the gateway host remains trusted. + +### Process lifecycle and forwarding + +The Windows boundary implements authenticated start, exec, attach, wait, +signal, terminate, retained stdout/stderr, provider environment refresh, and +loopback connect operations. Standard gateway dynamic forwarding reaches the +target through `BoundaryLoopbackConnector`; there is no reverse WebSocket, +stdin/stdout JSON protocol, or MXC-specific relay binary. + +ProcessContainer teardown remains the outer kill boundary. ConPTY terminal +resize is deferred; non-terminal exec and byte-stream I/O are supported first. + +### Configuration and packaging + +The Windows release contains `openshell-gateway.exe`, `openshell.exe`, +`openshell-supervisor.exe`, and `openshell-sandbox.exe`. The runtime binaries +default to siblings of the gateway and may be overridden for development. +Gateway TLS uses the gateway-owned guest certificate bundle. + +The MXC driver configuration contains only host/runtime settings. Workload +command, working directory, environment, and policy stay sandbox-scoped. +Relay paths, relay target ports, and driver-owned proxy enable/seed settings are +removed. - control --> driver - driver -->|"owns HostProxyHandle A"| proxy_a - driver -->|"owns HostProxyHandle N"| proxy_n - end +## Implementation plan - wxc["wxc-exec.exe invocation(s)"] +1. Make the sandbox, supervisor, and supervisor-process crates compile on + Windows without enabling Linux-only controls. +2. Add the Windows Sandbox Protocol boundary and MXC confirmation evidence. +3. Provision the host supervisor and in-ProcessContainer sandbox as one + generation from the MXC driver. +4. Reuse the supervisor network and process sessions for forwarding, + credentials, exec, output, and controller-loss handling. +5. Remove the relay crate and MXC-only forwarding/credential side channels. +6. Build all four Windows binaries on x64 and ARM64, then validate on a native + MXC host. - subgraph sandboxes["MXC AppContainers (N)"] - direction TB - sandbox_a["Sandbox A
agent workload A only
no OpenShell supervisor"] - sandbox_n["Sandbox N
agent workload N only
no OpenShell supervisor"] - end +## Risks - driver -->|"launch and configure"| wxc - wxc -->|"creates and runs"| sandbox_a - wxc -->|"creates and runs"| sandbox_n +- MXC and AppContainer networking can differ across Windows preview builds. + Native-host qualification remains required in addition to cross-compilation. +- The explicit proxy cannot yet distinguish descendant executable identities. + This limitation is documented and must fail review for policies that require + per-child network separation until socket-owner attribution is added. +- Loopback transport exposes unrelated host listeners to the AppContainer if + those listeners lack their own authentication. OpenShell listeners always + require generation-scoped credentials, but operators must treat the host as + trusted. +- Gateway restart recovery is not durable. Orphan discovery and persisted + generation reconciliation are follow-up work. +- Windows process-tree and terminal semantics differ from Unix. The + ProcessContainer remains the final teardown boundary while ConPTY support is + incomplete. - sandbox_a -.->|"MXC network.proxy
localhost:port_A"| proxy_a - sandbox_n -.->|"MXC network.proxy
localhost:port_N"| proxy_n - end +## Alternatives - clients -->|"gRPC + mTLS"| control - proxy_a -->|"policy-filtered egress"| upstreams - proxy_n -->|"policy-filtered egress"| upstreams -``` +### Driver-owned relay and proxy -The gateway-to-sandbox relationship is 1:N for control and lifecycle, but the -proxy-listener-to-sandbox relationship is 1:1. Each sandbox registry entry owns -one `HostProxyHandle`, one sandbox-specific policy, and one unique ephemeral -loopback listener. The listener identifies the sandbox without attributing -connections arriving on a shared proxy port. - -The defining property is that OpenShell network enforcement lives on the host -inside the gateway process, not inside the sandbox. The current host-mode path -provides L4 policy and plaintext/forward-proxy L7 handling. HTTPS MITM trust -bootstrap, inference/privacy routing, and gateway event-bus wiring remain -follow-up work. - -This still allows the existing supervisor networking code to be reused as a -host-side proxy component. The boundary is that Windows does not run -`ConnectSupervisor`, a sandbox relay, or any OpenShell process inside the MXC -sandbox. - -### Part 1 - Native Windows build - -This effort compiles the gateway and CLI for -`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc` while keeping Linux and -macOS unchanged. The dominant change is a consistent cfg-gating pattern: each -crate whose implementation is Unix-specific moves its Unix body into a -`*_unix.rs` module and adds a small `windows.rs` (or stub), preserving public -library entry points and configuration structs on both platforms. - -- Unsupported drivers (Docker, Podman, Kubernetes, VM) become compile-only - configuration stubs. The gateway still parses existing config files for every - driver name and returns a clear unsupported error at gateway construction - (Docker/Kubernetes/Podman) or at spawn (VM), never a parse failure or silent - no-op. -- `openshell-core/build.rs` selects a vendored `protoc` per platform - (`protoc-bin-vendored` on Windows, `protobuf-src` elsewhere) so proto - compilation succeeds under MSVC. -- Windows path defaults resolve configuration under `%APPDATA%` and state/data - under `%LOCALAPPDATA%`. -- Validation runs through a dedicated `mise` lane (`tasks/windows.toml` → - `tasks/scripts/windows-msvc.ps1`) invoked as `mise run --skip-tools windows:*`, - separate from the default Linux `ci` task. The wrapper discovers Visual - Studio's `VsDevCmd.bat`, adds rustup MSVC targets, and clears inherited - `RUSTC_WRAPPER`. -- A `windows-msvc` GitHub Actions job runs x64 check/build/test plus the - unsupported-driver contract tests on `windows-2025`; ARM64 is scaffolded and - disabled until an ARM64 runner is available. - -The Windows build target intentionally includes both `openshell.exe` and -`openshell-gateway.exe`. Running the gateway only as a Linux container while a -remote Windows MXC driver manages native sandboxes would keep the main repo's -Windows target smaller, but it would reintroduce a Linux container/VM dependency -and does not meet the native, low-overhead client-system goal of this RFC. - -### Part 2 - The MXC compute driver - -`openshell-driver-mxc` is a library crate entirely behind -`cfg(target_os = "windows")` (an empty shell elsewhere). It is linked -in-process into `openshell-server` and implements a plain Rust `ComputeBackend` -trait — there is no separate binary, no surrogate, and no tonic adapter. - -| Module | Responsibility | -|---|---| -| `driver.rs` (`MxcComputeBackend`) | Orchestrator: owns the registry, validates specs, runs the lifecycle, drives `wxc-exec` phases, runs the agent, resolves provider credentials (Windows Credential Manager) and injects them, self-reports readiness, emits watch events. | -| registry | In-memory `Arc>` mapping OpenShell sandbox id/name ⇄ MXC session id + phase state + exec/PTY handles. Source of truth for Get/List/Watch (MXC has no list-sessions API). | -| `mxc.rs` | Builds MXC config JSON, base64-encodes it, runs `wxc-exec`, parses envelopes; encapsulates exec-vs-non-exec stdout semantics and error-code mapping. | -| `policy.rs` | Translates the `SandboxPolicy` proto → MXC config and rejects unenforceable rules. Delegates to the embedded policy mapper (see Part 3). | -| `openshell-supervisor-network::host` | Starts one host CONNECT proxy task per sandbox on a unique `127.0.0.1:` listener, applies that sandbox's trimmed network policy and static agent identity, and retains its `HostProxyHandle` in the driver registry. | - -#### Workload and software availability - -MXC does not consume the OCI image model used by Linux container runtimes. For current support, the sandbox runs Windows -software already present on the host or made available through explicit MXC -filesystem grants, with the driver supplying the agent command, working -directory, environment, credentials, and policy-derived MXC configuration. - -That is different from Linux, where a sandbox image can carry a separate userland -and dependency set. The MXC `processcontainer` and AppContainer paths share the -host Windows OS; the policy/configuration creates the isolation boundary. If MXC -later grows a Windows VM-backed image model, OpenShell can add a separate -bootstrap/image workflow for that backend. - -#### The `wxc-exec` interface contract - -The `mxc.rs` invoker is the boundary to MXC. Invocation is always -`wxc-exec.exe --config-base64 --experimental [--debug]`; -`configurationId` defaults to `composable` (never `small` — a known OS bug). The -invoker must branch on phase for I/O semantics: - -| Phase(s) | stdout | Exit code | Parse as | -|---|---|---|---| -| `provision` / `start` / `stop` / `deprovision` | single JSON envelope `{"result":…}` or `{"error":…}` | 0 success / 1 error | JSON envelope | -| `exec` | live process output (not JSON) | the script's exit code | raw bytes / stream | - -`provision` returns the session id to capture. MXC `error.code` values -(`not_provisioned`, `already_started`, `policy_validation`, -`backend_unavailable`, …) map to typed errors. A non-zero `exec` exit is the -script's result, not a driver error. - -#### State model and lifecycle - -MXC has no remote inventory API, so the in-memory registry is the single source -of truth. +The prototype launched a relay inside MXC and implemented forwarding, +credentials, readiness, and proxy lifecycle in the driver. It reduced the +initial Windows porting work but created a second security protocol and repeated +existing supervisor patterns. This proposal removes it. -```mermaid -stateDiagram-v2 - [*] --> Pending: CreateSandbox (validated, reserved) - Pending --> Provisioned: wxc-exec provision (capture session id) - Provisioned --> Started: wxc-exec start - Started --> Ready: wxc-exec exec (agent running) — driver self-reports - Ready --> Stopped: StopSandbox (wxc-exec stop) - Stopped --> Deleted: DeleteSandbox (wxc-exec deprovision) - Pending --> Failed: provision/start/exec error - Failed --> Deleted: cleanup - Deleted --> [*] -``` +### Supervisor-only host proxy without `openshell-sandbox` -`Ready` is self-reported once the agent launches; it does not depend on any -supervisor connection. Every transition emits a `WatchSandboxes` event. -`CreateSandbox` translates policy → MXC config, resolves and injects credentials, -then runs `provision → start → exec`. Live `connect`/`exec` spawns a fresh -`wxc-exec phase=exec` in a ConPTY and bridges the gateway's bidi stream to its -stdin/stdout — no `ConnectSupervisor`, no in-sandbox SSH server, no relay socket. - -There is currently no reconciliation loop in the MVP. If an operator deletes an -OpenShell-managed MXC/AppContainer resource outside OpenShell, `get`, `list`, and -`watch` will continue to reflect the driver's registry until a later operation -touches the missing MXC resource and can mark the sandbox failed or not found. -Durable reconciliation is follow-up work: persist the OpenShell sandbox id ⇄ MXC -session id mapping in SQLite, probe or deprovision known sessions on startup, and -add a periodic reconcile loop when MXC exposes a list/inspect API. - -#### Governed egress - -Governed egress is the core value layer. When it is enabled, -`egress_proxy_addr` serves as a loopback address seed. For each sandbox, the MXC -driver preserves the configured IP and binds port `0` to allocate a fresh -ephemeral port. It starts the existing OpenShell host CONNECT proxy with that -sandbox's trimmed network-only policy, then writes the allocated port to MXC's -`network.proxy = { localhost: N }` redirect. Loopback inside an AppContainer is -host loopback, so sandbox egress reaches the proxy running in the in-process MXC -driver inside the gateway process. - -The host-listener-to-sandbox topology is **1:1**, not many-to-one. Multiple -sandboxes share `127.0.0.1`, but each active sandbox owns a unique ephemeral port -and host proxy handle in the driver registry. The resulting -`127.0.0.1:` tuple scopes every inbound proxy connection to exactly one -sandbox, eliminating the need to infer sandbox identity from shared loopback -traffic. Dropping the handle when the sandbox stops, exits, or fails terminates -that sandbox's proxy accept loop. - -Because MXC does not expose Linux procfs socket ownership, the proxy evaluates -each connection against the listener's sandbox policy and a static sandbox-agent -identity derived from the configured `agent_command`. The current host-mode path -evaluates L4 host:port allow/deny via OPA, handles plaintext and forward-proxy L7 -traffic, and emits OCSF events. HTTPS MITM trust bootstrap and gateway -denial/activity bus wiring remain follow-up work. The default `processcontainer` -backend already honors `network.proxy`, so this design requires no MXC changes. - -### Part 3 - Policy translation between OpenShell and MXC - -OpenShell policy is authored as YAML and parsed to the `SandboxPolicy` proto by -the shared cross-platform `openshell-policy` crate. The MXC driver does not -re-parse YAML; a dedicated Rust policy mapper (embedded in the driver and called -automatically) maps the proto IR to MXC `ContainerConfig` and **rejects rather -than silently drops** anything MXC cannot enforce. MXC imposes a provision-time -vs exec-time split. - -| OpenShell policy | Where enforced | MXC mapping | When | -|---|---|---|---| -| filesystem read/write paths | MXC | `filesystem.readwritePaths` | provision | -| filesystem read-only paths | MXC | `filesystem.readonlyPaths` | provision | -| filesystem denied paths | MXC | limited / unsupported | provision | -| process (uid/gid/seccomp) | — (no analog) | reject (default) | — | -| network (OPA / L7 / inference / privacy) | host CONNECT proxy | `network.proxy = { localhost: N }` redirect | provision | - -The primary governed-egress design does **not** try to map the full OpenShell -network policy into MXC network policy. MXC receives a fail-closed redirect -layer: `network.defaultPolicy = "block"`, empty direct allowlists, and -`network.proxy = { localhost: N }`. The original OpenShell `network_policies` -are preserved and handed to the host CONNECT proxy, which remains responsible -for ports, binaries, L7 rules, privacy routing, and audit. - -The coarse MXC-only mapper is a separate fallback and analysis path for cases -where no proxy is in the loop. In that mode, MXC can roughly express literal -host/IP/CIDR allowlists, but it cannot encode ports, protocols, per-binary scope, -TLS inspection behavior, credential rewrite, inference routing, or REST, -WebSocket, and GraphQL rules. The mapper emits a structured loss report and -rejects error-severity losses rather than silently broadening access. Critically, -MXC defaults to `defaultPolicy: "allow"` when the network block is omitted, so -both paths must explicitly emit `network.defaultPolicy: "block"`. - -Across the five OpenShell example policies, the MXC-only coarse mapping is -schema-valid but lossy: an aggregate of 64 access-broadening errors, 32 -warnings, and 4 info items. The dominant gaps are binary-scoped network policy, -port-scoped outbound policy, protocol-aware (REST/WebSocket/GraphQL) policy, and -access presets. Those losses do not apply to the governed-egress split because -the host CONNECT proxy receives and enforces the original OpenShell network -policy. - -To consume OpenShell policy more faithfully over time, MXC would need -kernel-enforceable additions such as port-scoped network endpoints, a filesystem -`defaultPolicy`, per-process/binary network scoping, and DNS/wildcard handling. A -proposed two-surface direction for Microsoft keeps `ContainerConfig` as the -execution manifest (add portable kernel-enforceable fields such as ports and -filesystem `defaultPolicy`) and adds a separate `policyProxy` surface for L7 and -dynamic policy so HTTP/WebSocket/GraphQL parsing, credential rewrite, audit, and -hot-reload stay out of every backend runner. None of these are required for the -host-enforced design proposed here; they are enhancements that would deepen -kernel-level defense-in-depth. - -### Design decisions (D1–D4) - -- **D1 — MXC as the Windows sandbox primitive**, over Docker Desktop, WSL2, and - Windows Sandbox/Hyper-V. MXC is OS-native, needs no VM, and runs unelevated. - Default backend `processcontainer`; `isolation_session` opt-in. Requires - Windows 11 build ≥ 26100 and `wxc-exec.exe` present. -- **D2 — Reject porting the supervisor for native Windows.** Use a host proxy + - MXC `network.proxy` redirect for governed egress, plus driver-owned host-side - behavior for credentials and exec. No in-sandbox OpenShell binary is part of - this RFC. Consequence: governed egress on the opt-in `isolation_session` - backend depends on Microsoft extending `network.proxy` to that backend; until - then the design defaults to `processcontainer`, where it works today. -- **D3 — User-launched native Windows gateway for the current scope.** Run - `openshell-gateway.exe` as a regular user process. Clients connect over gRPC - (loopback or remote mTLS), and existing per-user configuration and state paths - remain in effect. Installation, auto-start, background process management, - and Windows Event Log integration are outside this RFC. -- **D4 — Reduce the gRPC footprint to the client-facing API only.** Supervisor - removal deletes the supervisor and sandbox-relay boundaries; in-process MXC - removes the wire protocol on the gateway↔driver boundary. Only client↔gateway - gRPC survives on Windows. +Running only the host supervisor cannot provide authenticated in-boundary +process lifecycle, retained I/O, controller-loss handling, or loopback target +connection. It also leaves the driver responsible for these behaviors. -## Implementation plan +### Windows VM or WSL2 -All Windows code is gated behind `cfg(target_os = "windows")`, so Linux and macOS -are never affected and the changes can land additively. - -- **Compile.** Land the MSVC cfg-gating, per-platform `protoc` selection, - Windows path defaults, the `mise` Windows lane, and the `windows-msvc` CI job. - Unsupported drivers become contract stubs with tests asserting they return - unsupported. -- **MXC driver and host proxy.** Add `openshell-driver-mxc` driving the default - `processcontainer` backend: lifecycle, policy translation, one host CONNECT - proxy listener per sandbox, credential injection, and interactive exec via - the driver's ConPTY bridge. Unenforceable policy is rejected in - `ValidateSandboxCreate` with `invalid_argument` naming the rule. HTTPS MITM, - inference/privacy routing, and gateway event-bus wiring follow after host-mode - trust bootstrap is available. -- **Gateway runtime.** Run `openshell-gateway.exe` directly as a regular user - process with the existing per-user configuration, SQLite, TLS, and logging - paths. Installation, auto-start, and background process management are - follow-up work. -- **Hardening.** Validate collision-free per-sandbox ephemeral port allocation - and `processcontainer` concurrency. Persist the sandbox-id ⇄ session-id - mapping so a gateway restart can reconcile or clean up orphaned sessions, and - add a periodic reconcile loop once MXC exposes a list/inspect API. -- **Opt-in `isolation_session` egress.** Becomes available if and when Microsoft - extends `network.proxy` to that backend; the same host proxy then governs its - egress. - -Validation follows a layered pyramid: pure-Rust unit tests for the JSON -builders/parsers and policy mapper (on the Windows MSVC test lane), a mock -`wxc-exec` shim (`OPENSHELL_MXC_MOCK_WXC=1`) for lifecycle logic, egress-proxy -component tests for redirect → listener-scoped policy → OPA decision, gated -integration tests against a real `wxc-exec` (`#[ignore]` unless present), and -manual E2E on a real Windows 11 host. User-facing configuration is documented in -the gateway config reference and the architecture docs. +The existing Linux runtime can run inside a VM, but that does not deliver the +native, low-overhead Windows isolation workflow this RFC targets. -## Risks +### Do nothing -| Risk | Mitigation | -|---|---| -| MXC `allowedHosts`/`blockedHosts` not enforced on Windows yet, so there is no kernel-level defense-in-depth beneath the host proxy. | Rely on the host proxy for host-level allow/deny | -| Per-sandbox proxy routing must remain collision-free when multiple sandboxes run concurrently. | Bind a fresh ephemeral port on `127.0.0.1` for each sandbox and retain its proxy handle in the driver registry, making the listener-to-sandbox mapping 1:1. | -| `--config-base64` carries credentials in argv (briefly visible in process listings). | Zero-fill after invocation; prefer passing config on stdin. | -| Concurrency: `isolation_session` is single-session; `processcontainer` limits are unverified. | Validate `processcontainer` concurrency and document any cap. | -| OCSF fidelity: with no in-sandbox supervisor, arbitrary in-process events are not visible (only network + lifecycle). | Accept reduced fidelity; an ETW/callback hook from the Microsoft MXC team will help restore in-process visibility later. | -| Restart and external deletion: the registry is in-memory, so a gateway restart or out-of-band MXC deletion is not immediately reflected in OpenShell state. | Persist the sandbox-id ⇄ session-id mapping in SQLite, reconcile or deprovision orphans on startup, and add periodic reconcile when MXC exposes list/inspect. | -| Policy fidelity: MXC cannot enforce port/binary/L7 policy, so the MXC-only tier is a coarse approximation. | Fail-safe mapper (always `block`, never silently broaden) + host proxy as the real enforcer + a published loss report. | -| Microsoft dependency: several deepening improvements are outside OpenShell's control. | Ship the host-enforced design with no MXC changes required; treat MXC enhancements as optional, not blockers. | - -## Alternatives Considered - -- **Run OpenShell on Windows via Docker Desktop, WSL2, or Hyper-V/Windows - Sandbox.** Reuses the existing Linux drivers unchanged, but reintroduces a - Linux VM, heavier install/network complexity, licensing constraints, and (for - Hyper-V/Windows Sandbox) elevation. It defeats the goal of OS-native, - unelevated Windows isolation. -- **Port `openshell-sandbox` to Windows (in-sandbox supervisor).** Maximizes - Linux parity and defense-in-depth, but requires Windows analogs of - Landlock/seccomp/netns, a Windows relay protocol, and an in-sandbox binary — - far more surface for the same user-visible feature set, which the host-proxy - design already delivers. This RFC rejects that path; any future - defense-in-depth revisit should be a new design rather than assumed follow-up - work. -- **Out-of-tree remote MXC driver with a containerized Linux gateway.** This - would reduce the main repo's Windows build surface to the CLI if the gateway - could stay containerized. It does not meet this RFC's native Windows goal: - the MXC driver needs Windows-host access to `wxc-exec`, AppContainer/MXC - state, loopback proxy routing, and Windows credentials, while a Linux - containerized gateway would reintroduce the VM/container dependency this RFC - is removing. It would also require extending the remote driver protocol to - carry policy and proxy state that the current in-process design can share - directly. -- **Cross-compile the Windows binaries from Linux only.** Cheaper CI, but cannot - validate runtime correctness on real Windows hardware, which is essential for - MXC integration. -- **A cross-process MXC driver binary (like the VM driver) with a tonic - adapter.** Matches the existing VM driver shape, but adds a wire protocol and a - second process for no benefit when the driver can be linked in-process and the - supervisor is gone. -- **Do nothing.** OpenShell stays Linux-only in practice and Windows users rely - on VM-based workarounds. +Keeping the supervisor-free prototype would preserve protocol duplication and +make sandbox authentication, forwarding, credential rotation, and policy fixes +diverge by platform. ## Prior art -- **OpenShell's existing compute drivers** (Docker, Podman, Kubernetes, VM) - establish the `ComputeDriver`/`ComputeBackend` contract and the - driver-selection model this RFC extends with an in-process, supervisor-free - variant. -- **RFC 0001 (core architecture)** and `architecture/sandbox.md` define the - supervisor/relay model that the Windows design deliberately removes. -- **Microsoft MXC (`wxc-exec`)** provides the Windows AppContainer-based - sandbox primitive and the `network.proxy` redirect that makes host-side - enforcement possible without an in-sandbox agent. -- **Open Policy Agent and OpenShell's CONNECT proxy / L7 / inference / privacy - stack** are reused unchanged on the host, demonstrating that the value layers - are already cross-platform Rust. +- RFC 0012 defines the backend-neutral isolation contract and authenticated + supervisor/boundary pairing used here. +- The VM backend already runs the supervisor on the host while a capability-free + boundary runs inside a stronger isolation primitive. +- Docker, Podman, and Kubernetes use the same supervisor session for network + policy, credentials, lifecycle, and forwarding. +- Windows AppContainer and ProcessContainer provide the native outer fence but + not OpenShell's application-layer policy semantics. + +## Open questions + +- Which Windows API should provide race-resistant socket-owner executable + identity for per-descendant binary network policy? +- Should MXC restart recovery persist enough generation metadata to reconnect, + or should startup always terminate and recreate orphaned ProcessContainers? +- What ConPTY surface is required before Windows interactive exec is considered + complete? diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 2932457c25..965e11b0dc 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -79,6 +79,10 @@ func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { pr := *p.Process cp.Process = &pr } + if p.UI != nil { + ui := *p.UI + cp.UI = &ui + } if p.NetworkPolicies != nil { np := make(map[string]types.NetworkPolicyRule, len(p.NetworkPolicies)) for k, rule := range p.NetworkPolicies { diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go index 4329652c13..526cf4319a 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -843,6 +843,11 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { RunAsUser: "sandbox", RunAsGroup: "sandbox-group", }, + UI: &types.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: types.UIClipboardAccessRead, + AllowInputInjection: true, + }, NetworkPolicies: map[string]types.NetworkPolicyRule{ "web": { Name: "web", @@ -880,6 +885,10 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { require.NotNil(t, p.Process) assert.Equal(t, "sandbox", p.Process.RunAsUser) assert.Equal(t, "sandbox-group", p.Process.RunAsGroup) + require.NotNil(t, p.UI) + assert.True(t, p.UI.AllowGraphicalUI) + assert.Equal(t, types.UIClipboardAccessRead, p.UI.Clipboard) + assert.True(t, p.UI.AllowInputInjection) require.Len(t, p.NetworkPolicies, 1) webRule, ok := p.NetworkPolicies["web"] @@ -892,19 +901,23 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { // Deep-copy isolation: mutate input spec, verify stored copy unchanged spec.Policy.Version = 99 spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.UI.Clipboard = types.UIClipboardAccessAll spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} got2, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, uint32(3), got2.Spec.Policy.Version) assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, types.UIClipboardAccessRead, got2.Spec.Policy.UI.Clipboard) assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) // Deep-copy isolation: mutate returned object, verify store unchanged got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got.Spec.Policy.UI.AllowInputInjection = false got3, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) + assert.True(t, got3.Spec.Policy.UI.AllowInputInjection) } func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 1c58c400b6..67b9a85eb2 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -171,6 +171,7 @@ func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { "network_policies": true, "process": true, "landlock": true, + "ui": true, "network_middlewares": true, } diff --git a/sdk/go/openshell/v1/internal/converter/health.go b/sdk/go/openshell/v1/internal/converter/health.go index 8e1c2b98f6..c1cd07fea4 100644 --- a/sdk/go/openshell/v1/internal/converter/health.go +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -83,6 +83,7 @@ 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 ab8e0c2415..3fc3a09646 100644 --- a/sdk/go/openshell/v1/internal/converter/health_test.go +++ b/sdk/go/openshell/v1/internal/converter/health_test.go @@ -20,8 +20,9 @@ func TestGatewayInfoFromProto(t *testing.T) { { Name: "k8s", Capabilities: &pb.ComputeDriverCapabilities{ - DriverName: "kubernetes", - DriverVersion: "2.1.0", + DriverName: "kubernetes", + DriverVersion: "2.1.0", + SupportsUiPolicy: true, }, }, { @@ -54,12 +55,14 @@ 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) { @@ -130,6 +133,7 @@ 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/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 1a473654e9..7be4ecbb86 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -117,6 +117,7 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { Filesystem: filesystemPolicyFromProto(p.GetFilesystem()), Landlock: landlockPolicyFromProto(p.GetLandlock()), Process: processPolicyFromProto(p.GetProcess()), + UI: uiPolicyFromProto(p.GetUi()), } if np := p.GetNetworkPolicies(); np != nil { result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np)) @@ -148,6 +149,7 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { Filesystem: filesystemPolicyToProto(p.Filesystem), Landlock: landlockPolicyToProto(p.Landlock), Process: processPolicyToProto(p.Process), + Ui: uiPolicyToProto(p.UI), } if p.NetworkPolicies != nil { result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies)) @@ -184,6 +186,58 @@ func SandboxPolicyToProtoChecked(p *types.SandboxPolicy) (*sbv1.SandboxPolicy, e return result, nil } +func uiPolicyFromProto(p *sbv1.UiPolicy) *types.UIPolicy { + if p == nil { + return nil + } + return &types.UIPolicy{ + AllowGraphicalUI: p.GetAllowGraphicalUi(), + Clipboard: uiClipboardAccessFromProto(p.GetClipboard()), + AllowInputInjection: p.GetAllowInputInjection(), + } +} + +func uiPolicyToProto(p *types.UIPolicy) *sbv1.UiPolicy { + if p == nil { + return nil + } + return &sbv1.UiPolicy{ + AllowGraphicalUi: p.AllowGraphicalUI, + Clipboard: uiClipboardAccessToProto(p.Clipboard), + AllowInputInjection: p.AllowInputInjection, + } +} + +func uiClipboardAccessFromProto(v sbv1.UiClipboardAccess) types.UIClipboardAccess { + switch v { + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE: + return types.UIClipboardAccessNone + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ: + return types.UIClipboardAccessRead + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE: + return types.UIClipboardAccessWrite + case sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL: + return types.UIClipboardAccessAll + default: + return types.UIClipboardAccessUnspecified + } +} + +func uiClipboardAccessToProto(v types.UIClipboardAccess) sbv1.UiClipboardAccess { + switch v { + case types.UIClipboardAccessNone: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE + case types.UIClipboardAccessRead: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ + case types.UIClipboardAccessWrite: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE + case types.UIClipboardAccessAll: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL + default: + return sbv1.UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED + } +} + func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMiddlewareConfig { result := types.NetworkMiddlewareConfig{ Name: m.GetName(), diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index dde2205773..c6cf740d7d 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -200,6 +200,11 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { RunAsUser: "sandbox-user", RunAsGroup: "sandbox-group", }, + UI: &v1.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: v1.UIClipboardAccessRead, + AllowInputInjection: true, + }, NetworkPolicies: map[string]v1.NetworkPolicyRule{ "web-api": { Name: "web-api", @@ -239,6 +244,10 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { assert.Equal(t, original.Process.RunAsUser, roundTrip.Process.RunAsUser) assert.Equal(t, original.Process.RunAsGroup, roundTrip.Process.RunAsGroup) + // UI + require.NotNil(t, roundTrip.UI) + assert.Equal(t, original.UI, roundTrip.UI) + // NetworkPolicies require.Len(t, roundTrip.NetworkPolicies, 2) webAPI, ok := roundTrip.NetworkPolicies["web-api"] @@ -315,6 +324,39 @@ func TestSandboxPolicyPartialSubPolicies(t *testing.T) { assert.Nil(t, roundTrip.NetworkPolicies) }) + t.Run("only UI", func(t *testing.T) { + for _, clipboard := range []v1.UIClipboardAccess{ + v1.UIClipboardAccessUnspecified, + v1.UIClipboardAccessNone, + v1.UIClipboardAccessRead, + v1.UIClipboardAccessWrite, + v1.UIClipboardAccessAll, + } { + original := &v1.SandboxPolicy{ + UI: &v1.UIPolicy{ + AllowGraphicalUI: true, + Clipboard: clipboard, + AllowInputInjection: true, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Equal(t, original.UI, roundTrip.UI) + } + }) + + t.Run("explicit empty UI remains present", func(t *testing.T) { + original := &v1.SandboxPolicy{UI: &v1.UIPolicy{}} + protoPolicy := SandboxPolicyToProto(original) + require.NotNil(t, protoPolicy) + require.NotNil(t, protoPolicy.Ui) + + roundTrip := SandboxPolicyFromProto(protoPolicy) + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.UI) + assert.Equal(t, &v1.UIPolicy{}, roundTrip.UI) + }) + t.Run("only landlock", func(t *testing.T) { original := &v1.SandboxPolicy{ Version: 2, diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go index 3039f1c80d..1b112ace3c 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -54,9 +54,10 @@ type ExtensionInfo struct { // ComputeDriverInfo describes a compute backend available on the gateway. type ComputeDriverInfo struct { - Name string - DriverName string - DriverVersion string + Name string + DriverName string + DriverVersion string + SupportsUIPolicy bool } // CurrentUser holds the authenticated caller's identity. diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 8fb0712b60..ab01014522 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -112,7 +112,7 @@ type DraftPolicy struct { // SandboxPolicy is the top-level security policy configuration for a sandbox. // It contains filesystem access rules, Landlock LSM configuration, process -// identity rules, and named network access policies. +// identity rules, portable UI capabilities, and named network access policies. type SandboxPolicy struct { // Version is the policy version number. The server may override this on write. Version uint32 @@ -125,6 +125,9 @@ type SandboxPolicy struct { // Process controls the user and group identity for sandboxed processes. // Nil means no process policy is specified. Process *ProcessPolicy + // UI controls portable graphical UI, clipboard, and input-injection capabilities. + // Nil means no UI policy is specified. + UI *UIPolicy // NetworkPolicies contains named network access rules. // Nil means no network policies are specified; an empty map is distinct from nil. NetworkPolicies map[string]NetworkPolicyRule @@ -178,6 +181,33 @@ type ProcessPolicy struct { RunAsGroup string } +// UIClipboardAccess controls host clipboard direction from the sandbox's perspective. +type UIClipboardAccess int + +const ( + // UIClipboardAccessUnspecified resolves to no clipboard access. + UIClipboardAccessUnspecified UIClipboardAccess = iota + // UIClipboardAccessNone denies clipboard reads and writes. + UIClipboardAccessNone + // UIClipboardAccessRead permits reading host clipboard contents. + UIClipboardAccessRead + // UIClipboardAccessWrite permits writing host clipboard contents. + UIClipboardAccessWrite + // UIClipboardAccessAll permits reading and writing host clipboard contents. + UIClipboardAccessAll +) + +// UIPolicy declares platform-neutral user-interface capabilities. +// Every zero value denies access. +type UIPolicy struct { + // AllowGraphicalUI permits the workload to display graphical windows. + AllowGraphicalUI bool + // Clipboard controls host clipboard direction. + Clipboard UIClipboardAccess + // AllowInputInjection permits synthetic keyboard or pointer input. + AllowInputInjection bool +} + // SandboxPolicyRevision represents a versioned policy revision for a sandbox. type SandboxPolicyRevision struct { // Version is the policy version (monotonically increasing per sandbox). diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 8a3f7a78d1..54ebe97f29 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1993,8 +1993,11 @@ 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 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 } func (x *ComputeDriverCapabilities) Reset() { @@ -2048,6 +2051,13 @@ 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 { @@ -11702,8 +11712,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). @@ -18283,12 +18293,13 @@ 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\"\xbc\x01\n" + + "\fcapabilities\x18\x02 \x01(\v2'.openshell.v1.ComputeDriverCapabilitiesR\fcapabilities\"\xea\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\"\xca\x01\n" + + "\x15resource_capabilities\x18\x03 \x01(\v2\".openshell.v1.ResourceCapabilitiesR\x14resourceCapabilities\x12,\n" + + "\x12supports_ui_policy\x18\x04 \x01(\bR\x10supportsUiPolicy\"\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/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 237c2c94ba..83d1143877 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -27,6 +27,67 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Directional clipboard access for a sandboxed workload. +type UiClipboardAccess int32 + +const ( + // Unspecified resolves to no clipboard access. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED UiClipboardAccess = 0 + // No clipboard reads or writes. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_NONE UiClipboardAccess = 1 + // The sandbox may read host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_READ UiClipboardAccess = 2 + // The sandbox may write host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_WRITE UiClipboardAccess = 3 + // The sandbox may read and write host clipboard contents. + UiClipboardAccess_UI_CLIPBOARD_ACCESS_ALL UiClipboardAccess = 4 +) + +// Enum value maps for UiClipboardAccess. +var ( + UiClipboardAccess_name = map[int32]string{ + 0: "UI_CLIPBOARD_ACCESS_UNSPECIFIED", + 1: "UI_CLIPBOARD_ACCESS_NONE", + 2: "UI_CLIPBOARD_ACCESS_READ", + 3: "UI_CLIPBOARD_ACCESS_WRITE", + 4: "UI_CLIPBOARD_ACCESS_ALL", + } + UiClipboardAccess_value = map[string]int32{ + "UI_CLIPBOARD_ACCESS_UNSPECIFIED": 0, + "UI_CLIPBOARD_ACCESS_NONE": 1, + "UI_CLIPBOARD_ACCESS_READ": 2, + "UI_CLIPBOARD_ACCESS_WRITE": 3, + "UI_CLIPBOARD_ACCESS_ALL": 4, + } +) + +func (x UiClipboardAccess) Enum() *UiClipboardAccess { + p := new(UiClipboardAccess) + *p = x + return p +} + +func (x UiClipboardAccess) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (UiClipboardAccess) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[0].Descriptor() +} + +func (UiClipboardAccess) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[0] +} + +func (x UiClipboardAccess) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use UiClipboardAccess.Descriptor instead. +func (UiClipboardAccess) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{0} +} + // TLS handling for a network endpoint. type NetworkTlsMode int32 @@ -72,11 +133,11 @@ func (x NetworkTlsMode) String() string { } func (NetworkTlsMode) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[0].Descriptor() + return file_sandbox_proto_enumTypes[1].Descriptor() } func (NetworkTlsMode) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[0] + return &file_sandbox_proto_enumTypes[1] } func (x NetworkTlsMode) Number() protoreflect.EnumNumber { @@ -85,7 +146,7 @@ func (x NetworkTlsMode) Number() protoreflect.EnumNumber { // Deprecated: Use NetworkTlsMode.Descriptor instead. func (NetworkTlsMode) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{0} + return file_sandbox_proto_rawDescGZIP(), []int{1} } // Enforcement behavior for inspected network traffic. @@ -125,11 +186,11 @@ func (x NetworkEnforcementMode) String() string { } func (NetworkEnforcementMode) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[1].Descriptor() + return file_sandbox_proto_enumTypes[2].Descriptor() } func (NetworkEnforcementMode) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[1] + return &file_sandbox_proto_enumTypes[2] } func (x NetworkEnforcementMode) Number() protoreflect.EnumNumber { @@ -138,7 +199,7 @@ func (x NetworkEnforcementMode) Number() protoreflect.EnumNumber { // Deprecated: Use NetworkEnforcementMode.Descriptor instead. func (NetworkEnforcementMode) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{1} + return file_sandbox_proto_rawDescGZIP(), []int{2} } // Shorthand access policy for an inspected network endpoint. @@ -182,11 +243,11 @@ func (x NetworkAccessPreset) String() string { } func (NetworkAccessPreset) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[2].Descriptor() + return file_sandbox_proto_enumTypes[3].Descriptor() } func (NetworkAccessPreset) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[2] + return &file_sandbox_proto_enumTypes[3] } func (x NetworkAccessPreset) Number() protoreflect.EnumNumber { @@ -195,7 +256,7 @@ func (x NetworkAccessPreset) Number() protoreflect.EnumNumber { // Deprecated: Use NetworkAccessPreset.Descriptor instead. func (NetworkAccessPreset) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{2} + return file_sandbox_proto_rawDescGZIP(), []int{3} } // Scope that currently controls a setting. @@ -232,11 +293,11 @@ func (x SettingScope) String() string { } func (SettingScope) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[3].Descriptor() + return file_sandbox_proto_enumTypes[4].Descriptor() } func (SettingScope) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[3] + return &file_sandbox_proto_enumTypes[4] } func (x SettingScope) Number() protoreflect.EnumNumber { @@ -245,7 +306,7 @@ func (x SettingScope) Number() protoreflect.EnumNumber { // Deprecated: Use SettingScope.Descriptor instead. func (SettingScope) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{3} + return file_sandbox_proto_rawDescGZIP(), []int{4} } // Source used for the policy payload in GetSandboxConfigResponse. @@ -282,11 +343,11 @@ func (x PolicySource) String() string { } func (PolicySource) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[4].Descriptor() + return file_sandbox_proto_enumTypes[5].Descriptor() } func (PolicySource) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[4] + return &file_sandbox_proto_enumTypes[5] } func (x PolicySource) Number() protoreflect.EnumNumber { @@ -295,7 +356,7 @@ func (x PolicySource) Number() protoreflect.EnumNumber { // Deprecated: Use PolicySource.Descriptor instead. func (PolicySource) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{4} + return file_sandbox_proto_rawDescGZIP(), []int{5} } // Sandbox security policy configuration. @@ -315,8 +376,12 @@ type SandboxPolicy struct { // policy-local names. At most 10 configs are accepted, and at most 10 stages // can be selected per request. NetworkMiddlewares map[string]*NetworkMiddlewareConfig `protobuf:"bytes,6,rep,name=network_middlewares,json=networkMiddlewares,proto3" json:"network_middlewares,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Static, platform-neutral user-interface access policy. Within an explicit + // section, omitted capabilities deny. Omitting the section preserves the + // compute platform's existing behavior. + Ui *UiPolicy `protobuf:"bytes,7,opt,name=ui,proto3" json:"ui,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxPolicy) Reset() { @@ -391,6 +456,13 @@ func (x *SandboxPolicy) GetNetworkMiddlewares() map[string]*NetworkMiddlewareCon return nil } +func (x *SandboxPolicy) GetUi() *UiPolicy { + if x != nil { + return x.Ui + } + return nil +} + // Filesystem access policy. type FilesystemPolicy struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -556,6 +628,72 @@ func (x *ProcessPolicy) GetRunAsGroup() string { return "" } +// Platform-neutral user-interface capabilities. Every omitted field in an +// explicit policy defaults to deny. Compute platforms without complete support +// reject the entire explicit policy before provisioning. +type UiPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Allow the sandbox to display graphical windows. + AllowGraphicalUi bool `protobuf:"varint,1,opt,name=allow_graphical_ui,json=allowGraphicalUi,proto3" json:"allow_graphical_ui,omitempty"` + // Directional host clipboard access. + Clipboard UiClipboardAccess `protobuf:"varint,2,opt,name=clipboard,proto3,enum=openshell.sandbox.v1.UiClipboardAccess" json:"clipboard,omitempty"` + // Allow the sandbox to synthesize keyboard or pointer input. + AllowInputInjection bool `protobuf:"varint,3,opt,name=allow_input_injection,json=allowInputInjection,proto3" json:"allow_input_injection,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UiPolicy) Reset() { + *x = UiPolicy{} + mi := &file_sandbox_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UiPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UiPolicy) ProtoMessage() {} + +func (x *UiPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UiPolicy.ProtoReflect.Descriptor instead. +func (*UiPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{4} +} + +func (x *UiPolicy) GetAllowGraphicalUi() bool { + if x != nil { + return x.AllowGraphicalUi + } + return false +} + +func (x *UiPolicy) GetClipboard() UiClipboardAccess { + if x != nil { + return x.Clipboard + } + return UiClipboardAccess_UI_CLIPBOARD_ACCESS_UNSPECIFIED +} + +func (x *UiPolicy) GetAllowInputInjection() bool { + if x != nil { + return x.AllowInputInjection + } + return false +} + // A named network access policy rule. type NetworkPolicyRule struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -571,7 +709,7 @@ type NetworkPolicyRule struct { func (x *NetworkPolicyRule) Reset() { *x = NetworkPolicyRule{} - mi := &file_sandbox_proto_msgTypes[4] + mi := &file_sandbox_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -583,7 +721,7 @@ func (x *NetworkPolicyRule) String() string { func (*NetworkPolicyRule) ProtoMessage() {} func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[4] + mi := &file_sandbox_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -596,7 +734,7 @@ func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkPolicyRule.ProtoReflect.Descriptor instead. func (*NetworkPolicyRule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{4} + return file_sandbox_proto_rawDescGZIP(), []int{5} } func (x *NetworkPolicyRule) GetName() string { @@ -641,7 +779,7 @@ type NetworkMiddlewareConfig struct { func (x *NetworkMiddlewareConfig) Reset() { *x = NetworkMiddlewareConfig{} - mi := &file_sandbox_proto_msgTypes[5] + mi := &file_sandbox_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -653,7 +791,7 @@ func (x *NetworkMiddlewareConfig) String() string { func (*NetworkMiddlewareConfig) ProtoMessage() {} func (x *NetworkMiddlewareConfig) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[5] + mi := &file_sandbox_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -666,7 +804,7 @@ func (x *NetworkMiddlewareConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkMiddlewareConfig.ProtoReflect.Descriptor instead. func (*NetworkMiddlewareConfig) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{5} + return file_sandbox_proto_rawDescGZIP(), []int{6} } func (x *NetworkMiddlewareConfig) GetName() string { @@ -726,7 +864,7 @@ type MiddlewareEndpointSelector struct { func (x *MiddlewareEndpointSelector) Reset() { *x = MiddlewareEndpointSelector{} - mi := &file_sandbox_proto_msgTypes[6] + mi := &file_sandbox_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -738,7 +876,7 @@ func (x *MiddlewareEndpointSelector) String() string { func (*MiddlewareEndpointSelector) ProtoMessage() {} func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[6] + mi := &file_sandbox_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -751,7 +889,7 @@ func (x *MiddlewareEndpointSelector) ProtoReflect() protoreflect.Message { // Deprecated: Use MiddlewareEndpointSelector.ProtoReflect.Descriptor instead. func (*MiddlewareEndpointSelector) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{6} + return file_sandbox_proto_rawDescGZIP(), []int{7} } func (x *MiddlewareEndpointSelector) GetInclude() []string { @@ -780,7 +918,7 @@ type NetworkCredentialBinding struct { func (x *NetworkCredentialBinding) Reset() { *x = NetworkCredentialBinding{} - mi := &file_sandbox_proto_msgTypes[7] + mi := &file_sandbox_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -792,7 +930,7 @@ func (x *NetworkCredentialBinding) String() string { func (*NetworkCredentialBinding) ProtoMessage() {} func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[7] + mi := &file_sandbox_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -805,7 +943,7 @@ func (x *NetworkCredentialBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkCredentialBinding.ProtoReflect.Descriptor instead. func (*NetworkCredentialBinding) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{7} + return file_sandbox_proto_rawDescGZIP(), []int{8} } func (x *NetworkCredentialBinding) GetProvider() string { @@ -841,7 +979,6 @@ type NetworkEndpoint struct { // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: // - If host is also set: domain must resolve to an IP in this list. // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. - // // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked // regardless of this field. @@ -921,7 +1058,7 @@ type NetworkEndpoint struct { func (x *NetworkEndpoint) Reset() { *x = NetworkEndpoint{} - mi := &file_sandbox_proto_msgTypes[8] + mi := &file_sandbox_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -933,7 +1070,7 @@ func (x *NetworkEndpoint) String() string { func (*NetworkEndpoint) ProtoMessage() {} func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[8] + mi := &file_sandbox_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -946,7 +1083,7 @@ func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkEndpoint.ProtoReflect.Descriptor instead. func (*NetworkEndpoint) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{8} + return file_sandbox_proto_rawDescGZIP(), []int{9} } func (x *NetworkEndpoint) GetHost() string { @@ -1179,7 +1316,7 @@ type McpOptions struct { func (x *McpOptions) Reset() { *x = McpOptions{} - mi := &file_sandbox_proto_msgTypes[9] + mi := &file_sandbox_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1191,7 +1328,7 @@ func (x *McpOptions) String() string { func (*McpOptions) ProtoMessage() {} func (x *McpOptions) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[9] + mi := &file_sandbox_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1204,7 +1341,7 @@ func (x *McpOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use McpOptions.ProtoReflect.Descriptor instead. func (*McpOptions) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{9} + return file_sandbox_proto_rawDescGZIP(), []int{10} } func (x *McpOptions) GetStrictToolNames() bool { @@ -1243,7 +1380,7 @@ type GraphqlOperation struct { func (x *GraphqlOperation) Reset() { *x = GraphqlOperation{} - mi := &file_sandbox_proto_msgTypes[10] + mi := &file_sandbox_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1255,7 +1392,7 @@ func (x *GraphqlOperation) String() string { func (*GraphqlOperation) ProtoMessage() {} func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[10] + mi := &file_sandbox_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1268,7 +1405,7 @@ func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use GraphqlOperation.ProtoReflect.Descriptor instead. func (*GraphqlOperation) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{10} + return file_sandbox_proto_rawDescGZIP(), []int{11} } func (x *GraphqlOperation) GetOperationType() string { @@ -1323,7 +1460,7 @@ type L7DenyRule struct { func (x *L7DenyRule) Reset() { *x = L7DenyRule{} - mi := &file_sandbox_proto_msgTypes[11] + mi := &file_sandbox_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1335,7 +1472,7 @@ func (x *L7DenyRule) String() string { func (*L7DenyRule) ProtoMessage() {} func (x *L7DenyRule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[11] + mi := &file_sandbox_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1348,7 +1485,7 @@ func (x *L7DenyRule) ProtoReflect() protoreflect.Message { // Deprecated: Use L7DenyRule.ProtoReflect.Descriptor instead. func (*L7DenyRule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{11} + return file_sandbox_proto_rawDescGZIP(), []int{12} } func (x *L7DenyRule) GetMethod() string { @@ -1417,7 +1554,7 @@ type L7Rule struct { func (x *L7Rule) Reset() { *x = L7Rule{} - mi := &file_sandbox_proto_msgTypes[12] + mi := &file_sandbox_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1429,7 +1566,7 @@ func (x *L7Rule) String() string { func (*L7Rule) ProtoMessage() {} func (x *L7Rule) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[12] + mi := &file_sandbox_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1442,7 +1579,7 @@ func (x *L7Rule) ProtoReflect() protoreflect.Message { // Deprecated: Use L7Rule.ProtoReflect.Descriptor instead. func (*L7Rule) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{12} + return file_sandbox_proto_rawDescGZIP(), []int{13} } func (x *L7Rule) GetAllow() *L7Allow { @@ -1482,7 +1619,7 @@ type L7Allow struct { func (x *L7Allow) Reset() { *x = L7Allow{} - mi := &file_sandbox_proto_msgTypes[13] + mi := &file_sandbox_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1494,7 +1631,7 @@ func (x *L7Allow) String() string { func (*L7Allow) ProtoMessage() {} func (x *L7Allow) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[13] + mi := &file_sandbox_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1507,7 +1644,7 @@ func (x *L7Allow) ProtoReflect() protoreflect.Message { // Deprecated: Use L7Allow.ProtoReflect.Descriptor instead. func (*L7Allow) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{13} + return file_sandbox_proto_rawDescGZIP(), []int{14} } func (x *L7Allow) GetMethod() string { @@ -1579,7 +1716,7 @@ type L7QueryMatcher struct { func (x *L7QueryMatcher) Reset() { *x = L7QueryMatcher{} - mi := &file_sandbox_proto_msgTypes[14] + mi := &file_sandbox_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1591,7 +1728,7 @@ func (x *L7QueryMatcher) String() string { func (*L7QueryMatcher) ProtoMessage() {} func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[14] + mi := &file_sandbox_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1604,7 +1741,7 @@ func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { // Deprecated: Use L7QueryMatcher.ProtoReflect.Descriptor instead. func (*L7QueryMatcher) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{14} + return file_sandbox_proto_rawDescGZIP(), []int{15} } func (x *L7QueryMatcher) GetGlob() string { @@ -1631,7 +1768,7 @@ type NetworkBinary struct { func (x *NetworkBinary) Reset() { *x = NetworkBinary{} - mi := &file_sandbox_proto_msgTypes[15] + mi := &file_sandbox_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1643,7 +1780,7 @@ func (x *NetworkBinary) String() string { func (*NetworkBinary) ProtoMessage() {} func (x *NetworkBinary) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[15] + mi := &file_sandbox_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1656,7 +1793,7 @@ func (x *NetworkBinary) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkBinary.ProtoReflect.Descriptor instead. func (*NetworkBinary) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{15} + return file_sandbox_proto_rawDescGZIP(), []int{16} } func (x *NetworkBinary) GetPath() string { @@ -1678,7 +1815,7 @@ type GetSandboxConfigRequest struct { func (x *GetSandboxConfigRequest) Reset() { *x = GetSandboxConfigRequest{} - mi := &file_sandbox_proto_msgTypes[16] + mi := &file_sandbox_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1690,7 +1827,7 @@ func (x *GetSandboxConfigRequest) String() string { func (*GetSandboxConfigRequest) ProtoMessage() {} func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[16] + mi := &file_sandbox_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1703,7 +1840,7 @@ func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigRequest.ProtoReflect.Descriptor instead. func (*GetSandboxConfigRequest) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{16} + return file_sandbox_proto_rawDescGZIP(), []int{17} } func (x *GetSandboxConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -1729,7 +1866,7 @@ type GetGatewayConfigRequest struct { func (x *GetGatewayConfigRequest) Reset() { *x = GetGatewayConfigRequest{} - mi := &file_sandbox_proto_msgTypes[17] + mi := &file_sandbox_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1741,7 +1878,7 @@ func (x *GetGatewayConfigRequest) String() string { func (*GetGatewayConfigRequest) ProtoMessage() {} func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[17] + mi := &file_sandbox_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1754,7 +1891,7 @@ func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayConfigRequest.ProtoReflect.Descriptor instead. func (*GetGatewayConfigRequest) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{17} + return file_sandbox_proto_rawDescGZIP(), []int{18} } // Response containing gateway-global settings. @@ -1771,7 +1908,7 @@ type GetGatewayConfigResponse struct { func (x *GetGatewayConfigResponse) Reset() { *x = GetGatewayConfigResponse{} - mi := &file_sandbox_proto_msgTypes[18] + mi := &file_sandbox_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1783,7 +1920,7 @@ func (x *GetGatewayConfigResponse) String() string { func (*GetGatewayConfigResponse) ProtoMessage() {} func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[18] + mi := &file_sandbox_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1796,7 +1933,7 @@ func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGatewayConfigResponse.ProtoReflect.Descriptor instead. func (*GetGatewayConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{18} + return file_sandbox_proto_rawDescGZIP(), []int{19} } func (x *GetGatewayConfigResponse) GetSettings() map[string]*SettingValue { @@ -1829,7 +1966,7 @@ type SettingValue struct { func (x *SettingValue) Reset() { *x = SettingValue{} - mi := &file_sandbox_proto_msgTypes[19] + mi := &file_sandbox_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1841,7 +1978,7 @@ func (x *SettingValue) String() string { func (*SettingValue) ProtoMessage() {} func (x *SettingValue) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[19] + mi := &file_sandbox_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1854,7 +1991,7 @@ func (x *SettingValue) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingValue.ProtoReflect.Descriptor instead. func (*SettingValue) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{19} + return file_sandbox_proto_rawDescGZIP(), []int{20} } func (x *SettingValue) GetValue() isSettingValue_Value { @@ -1939,7 +2076,7 @@ type EffectiveSetting struct { func (x *EffectiveSetting) Reset() { *x = EffectiveSetting{} - mi := &file_sandbox_proto_msgTypes[20] + mi := &file_sandbox_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1951,7 +2088,7 @@ func (x *EffectiveSetting) String() string { func (*EffectiveSetting) ProtoMessage() {} func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[20] + mi := &file_sandbox_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1964,7 +2101,7 @@ func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { // Deprecated: Use EffectiveSetting.ProtoReflect.Descriptor instead. func (*EffectiveSetting) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{20} + return file_sandbox_proto_rawDescGZIP(), []int{21} } func (x *EffectiveSetting) GetValue() *SettingValue { @@ -2033,7 +2170,7 @@ type GetSandboxConfigResponse struct { func (x *GetSandboxConfigResponse) Reset() { *x = GetSandboxConfigResponse{} - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2045,7 +2182,7 @@ func (x *GetSandboxConfigResponse) String() string { func (*GetSandboxConfigResponse) ProtoMessage() {} func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[21] + mi := &file_sandbox_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2058,7 +2195,7 @@ func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSandboxConfigResponse.ProtoReflect.Descriptor instead. func (*GetSandboxConfigResponse) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{21} + return file_sandbox_proto_rawDescGZIP(), []int{22} } func (x *GetSandboxConfigResponse) GetPolicy() *SandboxPolicy { @@ -2205,7 +2342,7 @@ type SupervisorMiddlewareService struct { func (x *SupervisorMiddlewareService) Reset() { *x = SupervisorMiddlewareService{} - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2217,7 +2354,7 @@ func (x *SupervisorMiddlewareService) String() string { func (*SupervisorMiddlewareService) ProtoMessage() {} func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { - mi := &file_sandbox_proto_msgTypes[22] + mi := &file_sandbox_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2230,7 +2367,7 @@ func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message { // Deprecated: Use SupervisorMiddlewareService.ProtoReflect.Descriptor instead. func (*SupervisorMiddlewareService) Descriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{22} + return file_sandbox_proto_rawDescGZIP(), []int{23} } func (x *SupervisorMiddlewareService) GetName() string { @@ -2286,7 +2423,7 @@ var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + "\n" + - "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x0fdatamodel.proto\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x0fdatamodel.proto\"\xd8\x05\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2295,7 +2432,8 @@ const file_sandbox_proto_rawDesc = "" + "\blandlock\x18\x03 \x01(\v2$.openshell.sandbox.v1.LandlockPolicyR\blandlock\x12=\n" + "\aprocess\x18\x04 \x01(\v2#.openshell.sandbox.v1.ProcessPolicyR\aprocess\x12c\n" + "\x10network_policies\x18\x05 \x03(\v28.openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntryR\x0fnetworkPolicies\x12l\n" + - "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x1ak\n" + + "\x13network_middlewares\x18\x06 \x03(\v2;.openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntryR\x12networkMiddlewares\x12.\n" + + "\x02ui\x18\a \x01(\v2\x1e.openshell.sandbox.v1.UiPolicyR\x02ui\x1ak\n" + "\x14NetworkPoliciesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x05value:\x028\x01\x1at\n" + @@ -2312,7 +2450,11 @@ const file_sandbox_proto_rawDesc = "" + "\rProcessPolicy\x12\x1e\n" + "\vrun_as_user\x18\x01 \x01(\tR\trunAsUser\x12 \n" + "\frun_as_group\x18\x02 \x01(\tR\n" + - "runAsGroup\"\xad\x01\n" + + "runAsGroup\"\xb3\x01\n" + + "\bUiPolicy\x12,\n" + + "\x12allow_graphical_ui\x18\x01 \x01(\bR\x10allowGraphicalUi\x12E\n" + + "\tclipboard\x18\x02 \x01(\x0e2'.openshell.sandbox.v1.UiClipboardAccessR\tclipboard\x122\n" + + "\x15allow_input_injection\x18\x03 \x01(\bR\x13allowInputInjection\"\xad\x01\n" + "\x11NetworkPolicyRule\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12C\n" + "\tendpoints\x18\x02 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + @@ -2466,7 +2608,13 @@ const file_sandbox_proto_rawDesc = "" + "\x0frequest_timeout\x18h \x01(\v2\x19.google.protobuf.DurationR\x0erequestTimeout\x12%\n" + "\x0ftls_ca_cert_pem\x18\x05 \x01(\fR\ftlsCaCertPem\x12\x1a\n" + "\baudience\x18\x06 \x01(\tR\baudience\x128\n" + - "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransportJ\x04\b\x04\x10\x05R\atimeout*\x97\x01\n" + + "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransportJ\x04\b\x04\x10\x05R\atimeout*\xb0\x01\n" + + "\x11UiClipboardAccess\x12#\n" + + "\x1fUI_CLIPBOARD_ACCESS_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18UI_CLIPBOARD_ACCESS_NONE\x10\x01\x12\x1c\n" + + "\x18UI_CLIPBOARD_ACCESS_READ\x10\x02\x12\x1d\n" + + "\x19UI_CLIPBOARD_ACCESS_WRITE\x10\x03\x12\x1b\n" + + "\x17UI_CLIPBOARD_ACCESS_ALL\x10\x04*\x97\x01\n" + "\x0eNetworkTlsMode\x12 \n" + "\x1cNETWORK_TLS_MODE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15NETWORK_TLS_MODE_SKIP\x10\x01\x12\"\n" + @@ -2502,96 +2650,100 @@ func file_sandbox_proto_rawDescGZIP() []byte { return file_sandbox_proto_rawDescData } -var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 6) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_sandbox_proto_goTypes = []any{ - (NetworkTlsMode)(0), // 0: openshell.sandbox.v1.NetworkTlsMode - (NetworkEnforcementMode)(0), // 1: openshell.sandbox.v1.NetworkEnforcementMode - (NetworkAccessPreset)(0), // 2: openshell.sandbox.v1.NetworkAccessPreset - (SettingScope)(0), // 3: openshell.sandbox.v1.SettingScope - (PolicySource)(0), // 4: openshell.sandbox.v1.PolicySource - (*SandboxPolicy)(nil), // 5: openshell.sandbox.v1.SandboxPolicy - (*FilesystemPolicy)(nil), // 6: openshell.sandbox.v1.FilesystemPolicy - (*LandlockPolicy)(nil), // 7: openshell.sandbox.v1.LandlockPolicy - (*ProcessPolicy)(nil), // 8: openshell.sandbox.v1.ProcessPolicy - (*NetworkPolicyRule)(nil), // 9: openshell.sandbox.v1.NetworkPolicyRule - (*NetworkMiddlewareConfig)(nil), // 10: openshell.sandbox.v1.NetworkMiddlewareConfig - (*MiddlewareEndpointSelector)(nil), // 11: openshell.sandbox.v1.MiddlewareEndpointSelector - (*NetworkCredentialBinding)(nil), // 12: openshell.sandbox.v1.NetworkCredentialBinding - (*NetworkEndpoint)(nil), // 13: openshell.sandbox.v1.NetworkEndpoint - (*McpOptions)(nil), // 14: openshell.sandbox.v1.McpOptions - (*GraphqlOperation)(nil), // 15: openshell.sandbox.v1.GraphqlOperation - (*L7DenyRule)(nil), // 16: openshell.sandbox.v1.L7DenyRule - (*L7Rule)(nil), // 17: openshell.sandbox.v1.L7Rule - (*L7Allow)(nil), // 18: openshell.sandbox.v1.L7Allow - (*L7QueryMatcher)(nil), // 19: openshell.sandbox.v1.L7QueryMatcher - (*NetworkBinary)(nil), // 20: openshell.sandbox.v1.NetworkBinary - (*GetSandboxConfigRequest)(nil), // 21: openshell.sandbox.v1.GetSandboxConfigRequest - (*GetGatewayConfigRequest)(nil), // 22: openshell.sandbox.v1.GetGatewayConfigRequest - (*GetGatewayConfigResponse)(nil), // 23: openshell.sandbox.v1.GetGatewayConfigResponse - (*SettingValue)(nil), // 24: openshell.sandbox.v1.SettingValue - (*EffectiveSetting)(nil), // 25: openshell.sandbox.v1.EffectiveSetting - (*GetSandboxConfigResponse)(nil), // 26: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 27: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 30: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 31: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 32: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 33: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 34: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 35: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 36: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 37: google.protobuf.Struct - (*datamodelv1.WorkspaceSelector)(nil), // 38: openshell.datamodel.v1.WorkspaceSelector - (*durationpb.Duration)(nil), // 39: google.protobuf.Duration + (UiClipboardAccess)(0), // 0: openshell.sandbox.v1.UiClipboardAccess + (NetworkTlsMode)(0), // 1: openshell.sandbox.v1.NetworkTlsMode + (NetworkEnforcementMode)(0), // 2: openshell.sandbox.v1.NetworkEnforcementMode + (NetworkAccessPreset)(0), // 3: openshell.sandbox.v1.NetworkAccessPreset + (SettingScope)(0), // 4: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 5: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 6: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 7: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 8: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 9: openshell.sandbox.v1.ProcessPolicy + (*UiPolicy)(nil), // 10: openshell.sandbox.v1.UiPolicy + (*NetworkPolicyRule)(nil), // 11: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkMiddlewareConfig)(nil), // 12: openshell.sandbox.v1.NetworkMiddlewareConfig + (*MiddlewareEndpointSelector)(nil), // 13: openshell.sandbox.v1.MiddlewareEndpointSelector + (*NetworkCredentialBinding)(nil), // 14: openshell.sandbox.v1.NetworkCredentialBinding + (*NetworkEndpoint)(nil), // 15: openshell.sandbox.v1.NetworkEndpoint + (*McpOptions)(nil), // 16: openshell.sandbox.v1.McpOptions + (*GraphqlOperation)(nil), // 17: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 18: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 19: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 20: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 21: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 22: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 24: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 25: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 26: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 27: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 28: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 29: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 30: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 31: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 32: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 33: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 34: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 35: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 36: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 37: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 38: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 39: google.protobuf.Struct + (*datamodelv1.WorkspaceSelector)(nil), // 40: openshell.datamodel.v1.WorkspaceSelector + (*durationpb.Duration)(nil), // 41: google.protobuf.Duration } var file_sandbox_proto_depIdxs = []int32{ - 6, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy - 7, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy - 8, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy - 28, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - 29, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - 13, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 20, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 37, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct - 11, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector - 0, // 9: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode - 1, // 10: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode - 2, // 11: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset - 17, // 12: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 16, // 13: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 30, // 14: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 14, // 15: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 12, // 16: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 31, // 17: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 32, // 18: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry - 18, // 19: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 33, // 20: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 34, // 21: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 38, // 22: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 35, // 23: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 24, // 24: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue - 3, // 25: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 5, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 36, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 4, // 28: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 27, // 29: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 39, // 30: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration - 9, // 31: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 10, // 32: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 15, // 33: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 19, // 34: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 35: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 36: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 19, // 37: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 24, // 38: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 25, // 39: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 40, // [40:40] is the sub-list for method output_type - 40, // [40:40] is the sub-list for method input_type - 40, // [40:40] is the sub-list for extension type_name - 40, // [40:40] is the sub-list for extension extendee - 0, // [0:40] is the sub-list for field type_name + 7, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 8, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 9, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 30, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 31, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + 10, // 5: openshell.sandbox.v1.SandboxPolicy.ui:type_name -> openshell.sandbox.v1.UiPolicy + 0, // 6: openshell.sandbox.v1.UiPolicy.clipboard:type_name -> openshell.sandbox.v1.UiClipboardAccess + 15, // 7: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 22, // 8: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 39, // 9: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 13, // 10: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector + 1, // 11: openshell.sandbox.v1.NetworkEndpoint.tls:type_name -> openshell.sandbox.v1.NetworkTlsMode + 2, // 12: openshell.sandbox.v1.NetworkEndpoint.enforcement:type_name -> openshell.sandbox.v1.NetworkEnforcementMode + 3, // 13: openshell.sandbox.v1.NetworkEndpoint.access:type_name -> openshell.sandbox.v1.NetworkAccessPreset + 19, // 14: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 18, // 15: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 32, // 16: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 16, // 17: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 14, // 18: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 33, // 19: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 34, // 20: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 20, // 21: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 35, // 22: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 36, // 23: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 40, // 24: openshell.sandbox.v1.GetSandboxConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 37, // 25: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 26, // 26: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 4, // 27: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 6, // 28: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 38, // 29: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 5, // 30: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 29, // 31: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 41, // 32: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration + 11, // 33: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 12, // 34: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 17, // 35: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 21, // 36: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 37: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 38: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 39: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 26, // 40: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 27, // 41: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 42, // [42:42] is the sub-list for method output_type + 42, // [42:42] is the sub-list for method input_type + 42, // [42:42] is the sub-list for extension type_name + 42, // [42:42] is the sub-list for extension extendee + 0, // [0:42] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2599,8 +2751,8 @@ func file_sandbox_proto_init() { if File_sandbox_proto != nil { return } - file_sandbox_proto_msgTypes[9].OneofWrappers = []any{} - file_sandbox_proto_msgTypes[19].OneofWrappers = []any{ + file_sandbox_proto_msgTypes[10].OneofWrappers = []any{} + file_sandbox_proto_msgTypes[20].OneofWrappers = []any{ (*SettingValue_StringValue)(nil), (*SettingValue_BoolValue)(nil), (*SettingValue_IntValue)(nil), @@ -2611,8 +2763,8 @@ func file_sandbox_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), - NumEnums: 5, - NumMessages: 32, + NumEnums: 6, + NumMessages: 33, NumExtensions: 0, NumServices: 0, }, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index db349e0068..b606959946 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -11,7 +11,7 @@ Generate YAML sandbox network policies and network middleware configuration from This skill translates a user's plain-language policy intent into a valid sandbox policy. The amount of detail the user provides determines the granularity of the generated policy — from broad L4 or preset-based policies (just a host:port) up to fine-grained per-endpoint L7 rules (full API docs). -The output is a `network_policies` YAML block, an optional `network_middlewares` block, and optionally a full policy file that conforms to the sandbox policy schema. +The output is a `network_policies` YAML block, an optional `network_middlewares` block, an optional static `ui` block when explicitly requested, and optionally a full policy file that conforms to the sandbox policy schema. ## Step 1: Gather Inputs @@ -177,6 +177,7 @@ Key sections to reference: - **Private IP Access via `allowed_ips`** — CIDR allowlist for private IP space - **Network Middleware** - top-level middleware configs, ordering, host selection, and failure behavior - **Validation Rules** — what combinations are valid/invalid +- **UI** — static, portable capabilities and runtime support boundaries When middleware is requested, also read the published [supervisor middleware guide](https://docs.nvidia.com/openshell/latest/extensibility/supervisor-middleware.md). @@ -273,6 +274,28 @@ Use the most specific pattern that covers the intent. Prefer narrow globs over ` ## Step 5: Generate the Policy +### UI Policy + +Emit `ui` only when the user explicitly requests a graphical surface, +clipboard access, or synthetic input. Choose the narrowest capability and keep +unrequested fields omitted so they remain deny by default: + +```yaml +ui: + allow_graphical_ui: true + clipboard: read # none | read | write | all + allow_input_injection: false +``` + +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 +section rather than emitting deny-only UI for those drivers; omission preserves +their existing behavior. + ### Output Format Generate a complete `network_policies` entry. Use this template: @@ -404,6 +427,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages - [ ] Any required response control advertises `HTTP_RESPONSE/PRE_RETURN` - [ ] Endpoints contributed by a credentialed provider are not L4-only or `tls: skip` unless `allow_uninspected_credentials: true` explicitly records the exception +- [ ] An explicit `ui` section targets a configured driver/backend that advertises complete UI-policy support ### Schema Warnings (log-only, but should be fixed) @@ -466,7 +490,7 @@ The policy needs to go somewhere. Determine which mode applies: 1. **Read the existing file** to understand current state: - What policies already exist under `network_policies` - - What the `filesystem_policy`, `landlock`, and `process` sections look like + - What the `filesystem_policy`, `landlock`, `process`, and `ui` sections look like - Whether the file uses compact (`{ host: ..., port: ... }`) or expanded YAML style 2. **Check for conflicts**: @@ -485,7 +509,7 @@ The policy needs to go somewhere. Determine which mode applies: - **Modifying an existing policy**: Edit the specific policy in place — add/remove endpoints, change access presets, update rules, add binaries, etc. A rule authorizes every binary it lists to reach every endpoint and port it lists, so adding one binary grants it all of that rule's endpoints, and adding one endpoint grants it to all of that rule's binaries. State the resulting pairs to the user before writing them. When the user wants a binary to reach only part of a rule's endpoints, put that binary and those endpoints in a separate rule instead of extending the existing one. An empty `binaries` list means any binary, so leaving it off widens the rule to every process. - **Removing a policy**: Delete the policy block if the user asks. -4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, or other policies unless the user explicitly asks. +4. **Preserve everything else**: Do not modify `filesystem_policy`, `landlock`, `process`, `ui`, or other policies unless the user explicitly asks. ### Mode B: Create a New Policy File diff --git a/skills/generate-sandbox-policy/examples.md b/skills/generate-sandbox-policy/examples.md index 17b7ca4646..96a703afea 100644 --- a/skills/generate-sandbox-policy/examples.md +++ b/skills/generate-sandbox-policy/examples.md @@ -747,7 +747,7 @@ An exact IP is treated as `/32` — only that specific address is permitted. - { path: /usr/bin/curl } ``` -The agent inserts the new entry after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`) are untouched. +The agent inserts the new entry after the last existing policy in the `network_policies` block. All other sections (`filesystem_policy`, `landlock`, `process`, `ui`) are untouched. --- diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 75d2de5057..13464f5f44 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -502,6 +502,11 @@ 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 @@ -509,6 +514,10 @@ 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 @@ -542,7 +551,7 @@ For an infrastructure `Error` on a backend that supports restarting stopped sand This is the most important multi-step workflow. It enables a tight feedback cycle where sandbox policy is refined based on observed activity. -**Key concept**: Policies have static fields (immutable after activation: `filesystem_policy`, `landlock`, `process`) 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. +**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. If startup reports `ConfigurationInvalid`, inspect `openshell sandbox get` and repair the complete policy or provider set through the gateway. The workload @@ -558,6 +567,11 @@ 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 @@ -629,7 +643,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. 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. +`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. 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/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 0bc373af0f..5b0480edf6 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -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-sandbox --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-vfio" $WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes $WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async" $PrebuiltZ3WorkspaceFeatures = "--features openshell-prover/prebuilt-z3" @@ -512,7 +512,7 @@ function Invoke-Lint([string] $RustTarget) { function Invoke-Build([string] $RustTarget) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell $Z3WorkspaceFeatures" ` + -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor --bin openshell-windows-sandbox $Z3WorkspaceFeatures" ` -LogName "build-$RustTarget-release.log" } @@ -542,7 +542,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { foreach ($test in $tests) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3ServerFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3WorkspaceFeatures" ` -LogName "test-$RustTarget-unsupported-$test.log" } @@ -556,6 +556,14 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { } } +function Invoke-MxcRealTests([string] $RustTarget) { + Assert-NativeTestTarget $RustTarget + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo test -p openshell-driver-mxc --test wxc_exec_real --target $RustTarget -- --ignored --test-threads=1 --nocapture" ` + -LogName "test-$RustTarget-mxc-real.log" +} + function Get-Sha256([string] $Path) { $stream = [System.IO.File]::OpenRead($Path) try { @@ -573,7 +581,7 @@ function Get-Sha256([string] $Path) { function Show-Artifacts([string[]] $RustTargets) { $rows = @() foreach ($rustTarget in $RustTargets) { - foreach ($binary in @("openshell-gateway.exe", "openshell.exe")) { + foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor.exe", "openshell-windows-sandbox.exe")) { $path = Join-Path $TargetDir "$rustTarget\release\$binary" if (-not (Test-Path $path)) { continue @@ -600,13 +608,13 @@ if ($Action -eq "ci" -and (Get-HostArch) -ne "amd64") { } $targets = Get-SelectedTargets $Target -if ($Action -in @("test", "test-precommit", "test-unsupported")) { +if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real")) { foreach ($rustTarget in $targets) { Assert-NativeTestTarget $rustTarget } } -if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "ci")) { +if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -648,6 +656,11 @@ switch ($Action) { Invoke-UnsupportedContractTests $rustTarget } } + "test-mxc-real" { + foreach ($rustTarget in $targets) { + Invoke-MxcRealTests $rustTarget + } + } "artifacts" { Show-Artifacts $targets } diff --git a/tasks/windows.toml b/tasks/windows.toml index 708ba2c248..6fe15c5f4b 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -76,16 +76,16 @@ run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 ci all" ["windows:test:mxc-real:x64"] -description = "Run real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" +description = "Run native x64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" -run_windows = "cargo test -p openshell-driver-mxc --test wxc_exec_real --target x86_64-pc-windows-msvc -- --ignored --test-threads=1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real x86_64-pc-windows-msvc" -["windows:e2e:mxc"] -description = "Run MXC Tier-3 e2e scenario runner against real wxc-exec (probe-gated; skip-safe on hosts without the binary)" +["windows:test:mxc-real:arm64"] +description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip-safe: tests print SKIP when binary/backend absent)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" -run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-real aarch64-pc-windows-msvc" -["windows:e2e:mxc:mock"] -description = "Run MXC Tier-3 e2e scenario runner in mock/wiring-only mode (no real wxc-exec required)" +["windows:e2e:mxc"] +description = "Run real MXC Tier-3 e2e; missing runtime coverage exits non-zero (no mocks)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" -run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 -Mock" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1" From 5ff18baad6df02c9d60d790d4406ccfecdb58d57 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 1 Oct 2026 21:43:06 -0700 Subject: [PATCH 02/22] refactor(mxc): isolate platform setup and portable supervisor access Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 9 ++- crates/openshell-driver-mxc/README.md | 5 ++ crates/openshell-driver-mxc/src/driver.rs | 60 ++++++++++++++++++- crates/openshell-driver-mxc/src/isolation.rs | 5 +- crates/openshell-driver-mxc/src/mxc.rs | 6 +- crates/openshell-driver-mxc/src/policy.rs | 4 +- .../tests/policy_mapper_examples.rs | 7 +-- crates/openshell-gateway/src/lib.rs | 36 +++-------- crates/openshell-mxc-boundary/src/backend.rs | 6 +- crates/openshell-server/src/grpc/sandbox.rs | 1 - crates/openshell-supervisor/src/lib.rs | 23 ------- 11 files changed, 88 insertions(+), 74 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 88eba4e108..e6e4c34fdd 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -47,8 +47,13 @@ Register its `MxcRuntimeBackend` under `openshell-mxc` at supervisor composition 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 -or gateway create-time credential snapshots. Windows gateway JSONL audit output -is an explicit operator opt-in, independent of the selected driver. +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 +in the MXC driver. Gateway composition supplies generic connection inputs; the +driver resolves its endpoint defaults and TLS server name. Supervisor gateway +sessions and main attachment must work without SSH on any host. Gate only the +Unix socket adapter, not TCP readiness or authenticated session retries. Adopt the shared protocol's authenticated policy discovery and monotonic provider-publication generations. Do not order opaque credential revisions diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 4d9d178bdc..d777fc7c13 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -32,6 +32,11 @@ The resolved create-time `SandboxPolicy` is carried by launches the Windows boundary and separate host supervisor, and monitors both. The boundary launches the workload only after authenticated confirmation. +The driver resolves its gateway endpoint and TLS server-name defaults from +generic gateway inputs. The host supervisor authenticates its gateway session +and retains main-process attachment independently of the optional Unix SSH +adapter. TCP readiness remains gated by authenticated session acceptance. + Windows boundary execution is implemented by the `openshell-mxc-boundary` library behind the dedicated `openshell-windows-sandbox.exe`. The compute driver does not embed the supervisor, and the sandbox does not link the compute driver. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index a149522ce9..4ec9613eb5 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -65,6 +65,7 @@ impl MxcBackend { #[derive(Debug, Clone, Serialize, Deserialize)] #[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. pub allow_driver_config: bool, @@ -92,8 +93,7 @@ pub struct MxcComputeConfig { impl Default for MxcComputeConfig { fn default() -> Self { let state_dir = std::env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir) + .map_or_else(std::env::temp_dir, PathBuf::from) .join("OpenShell") .join("mxc"); let executable_dir = std::env::current_exe() @@ -205,6 +205,26 @@ impl std::fmt::Debug for MxcComputeBackend { } impl MxcComputeBackend { + /// Compose the driver's host connection from generic gateway inputs. + /// MXC endpoint defaults and TLS server-name selection stay in the driver. + pub fn for_gateway( + gateway_name: &str, + mut config: MxcComputeConfig, + gateway_port: u16, + gateway_tls_enabled: bool, + tls_ca: Option, + ) -> Self { + let use_internal_endpoint = config.grpc_endpoint.trim().is_empty(); + if use_internal_endpoint { + let scheme = if gateway_tls_enabled { "https" } else { "http" }; + config.grpc_endpoint = format!("{scheme}://127.0.0.1:{gateway_port}"); + } + let endpoint = config.grpc_endpoint.clone(); + let tls_server_name = + (use_internal_endpoint && gateway_tls_enabled).then(|| "localhost".to_string()); + Self::new_with_gateway(gateway_name, config, endpoint, tls_ca, tls_server_name) + } + pub fn new(gateway_name: &str, config: MxcComputeConfig) -> Self { let endpoint = config.grpc_endpoint.clone(); Self::new_with_gateway(gateway_name, config, endpoint, None, None) @@ -1033,7 +1053,7 @@ fn cleanup_runtime_directory(path: &Path) { } /// Create a state directory whose DACL grants access only to the gateway's -/// Windows identity until MXC applies any explicit ProcessContainer grant. +/// Windows identity until MXC applies any explicit `ProcessContainer` grant. /// Secret-bearing state must not inherit permissive ACLs from its parent. fn create_restricted_state_dir(path: &Path, kind: &str) -> Result<(), String> { std::fs::create_dir_all(path) @@ -1214,6 +1234,40 @@ fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSan mod tests { use super::*; + #[test] + fn gateway_connection_defaults_are_driver_owned() { + for (tls_enabled, scheme, server_name) in + [(false, "http", None), (true, "https", Some("localhost"))] + { + let ca = PathBuf::from("C:\\gateway\\ca.pem"); + let backend = MxcComputeBackend::for_gateway( + "test", + MxcComputeConfig::default(), + 9000, + tls_enabled, + Some(ca.clone()), + ); + assert_eq!( + backend.gateway.endpoint, + format!("{scheme}://127.0.0.1:9000") + ); + assert_eq!(backend.gateway.tls_server_name.as_deref(), server_name); + assert_eq!(backend.gateway.tls, Some(ca)); + assert_eq!(backend.config.grpc_endpoint, backend.gateway.endpoint); + } + } + + #[test] + fn gateway_connection_preserves_explicit_endpoint() { + let config = MxcComputeConfig { + grpc_endpoint: "https://gateway.example:9443".to_string(), + ..Default::default() + }; + let backend = MxcComputeBackend::for_gateway("test", config, 9000, true, None); + assert_eq!(backend.gateway.endpoint, "https://gateway.example:9443"); + assert_eq!(backend.gateway.tls_server_name, None); + } + fn host_grants_config() -> MxcComputeConfig { MxcComputeConfig { allow_driver_config: true, diff --git a/crates/openshell-driver-mxc/src/isolation.rs b/crates/openshell-driver-mxc/src/isolation.rs index 71160605ca..f18a4077e1 100644 --- a/crates/openshell-driver-mxc/src/isolation.rs +++ b/crates/openshell-driver-mxc/src/isolation.rs @@ -18,6 +18,7 @@ use openshell_sandbox_backend::boundary_protocol::{ use serde::Serialize; #[derive(Serialize)] +#[allow(clippy::struct_excessive_bools)] // separately serialized enforcement properties, not state flags struct MxcOuterFenceEvidence<'a> { generation: &'a str, containment: &'a str, @@ -27,7 +28,7 @@ struct MxcOuterFenceEvidence<'a> { controller_loss_fails_closed: bool, } -pub(crate) struct MxcBoundarySpec { +pub struct MxcBoundarySpec { pub boundary_id: String, pub generation: String, pub session_id: openshell_core::SandboxSessionId, @@ -45,7 +46,7 @@ pub(crate) struct MxcBoundarySpec { pub child_env: HashMap, } -pub(crate) struct MxcBoundaryProvisioning { +pub struct MxcBoundaryProvisioning { pub boundary_config: BoundaryConfig, pub runtime_descriptor: SandboxRuntimeDescriptor, } diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index b6542b748a..99a3e6fcef 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! `wxc-exec` ProcessContainer launcher and request types. +//! `wxc-exec` `ProcessContainer` launcher and request types. use base64::Engine as _; use serde::Serialize; @@ -34,7 +34,7 @@ fn mock_normalize(s: &str) -> String { /// Filesystem shares for the sandbox. /// -/// ProcessContainer honors `readwrite`/`readonly` grants and `denied_paths`. +/// `ProcessContainer` honors `readwrite`/`readonly` grants and `denied_paths`. #[derive(Debug, Default)] #[allow(clippy::struct_field_names)] pub struct MxcFilesystem { @@ -244,7 +244,7 @@ pub enum InvokerError { // ── Invoker ─────────────────────────────────────────────────────────────────── -/// Wraps the one-shot `wxc-exec` ProcessContainer invocation. +/// Wraps the one-shot `wxc-exec` `ProcessContainer` invocation. #[derive(Debug, Clone)] pub struct WxcExecInvoker { exec_path: PathBuf, diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index f0376dedf5..753785e3d2 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -439,8 +439,8 @@ mod tests { containment: "processcontainer".into(), }; - let mapped = mapper.map(Some(&policy), &ctx).unwrap(); - let trimmed = mapped.trimmed_policy.expect("trimmed proxy policy"); + let result = mapper.map(Some(&policy), &ctx).unwrap(); + let trimmed = result.trimmed_policy.expect("trimmed proxy policy"); assert_eq!(trimmed.network_middlewares, policy.network_middlewares); } } diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index 4b68417a18..2c4bf4f0a9 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -219,13 +219,8 @@ fn all_example_policies_split_with_expected_invariants() { assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "allow"); assert!(cfg.get("runtimeConfig").is_none()); assert!(cfg["network"].get("proxy").is_none()); - let errors: Vec<_> = result - .loss - .iter() - .filter(|item| item.severity == "error") - .collect(); assert!( - errors.is_empty(), + !result.loss.iter().any(|item| item.severity == "error"), "processcontainer split must not emit error losses for {}: {:?}", path.display(), result.loss diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index 8c3e1b44e3..b7f80b5f67 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -161,44 +161,22 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { &self, context: openshell_server::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { - let mut config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; - config.validate_configuration()?; - if config.grpc_endpoint.trim().is_empty() { - let scheme = if context.gateway_tls_enabled() { - "https" - } else { - "http" - }; - config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); - } - Ok(()) + let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + config.validate_configuration() } async fn build( &self, context: openshell_server::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { - let mut config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; require_guest_tls_for_local_driver(&context, "mxc")?; - let use_internal_tls_server_name = - config.grpc_endpoint.trim().is_empty() && context.gateway_tls_enabled(); - if config.grpc_endpoint.trim().is_empty() { - let scheme = if context.gateway_tls_enabled() { - "https" - } else { - "http" - }; - config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); - } - let tls = context.guest_tls_ca().map(std::path::Path::to_path_buf); - let endpoint = config.grpc_endpoint.clone(); - let tls_server_name = use_internal_tls_server_name.then(|| "localhost".to_string()); - let backend = openshell_driver_mxc::MxcComputeBackend::new_with_gateway( + let backend = openshell_driver_mxc::MxcComputeBackend::for_gateway( context.gateway_name(), config, - endpoint, - tls, - tls_server_name, + context.gateway_port(), + context.gateway_tls_enabled(), + context.guest_tls_ca().map(std::path::Path::to_path_buf), ); let driver = openshell_driver_mxc::ComputeDriverService::new(backend); Ok(openshell_server::ComputeDriverInstance::InProcess( diff --git a/crates/openshell-mxc-boundary/src/backend.rs b/crates/openshell-mxc-boundary/src/backend.rs index be89d65b33..daf3ab3bf9 100644 --- a/crates/openshell-mxc-boundary/src/backend.rs +++ b/crates/openshell-mxc-boundary/src/backend.rs @@ -69,9 +69,9 @@ mod tests { Arc::new(Mutex::new(None)), openshell_core::provider_credentials::ProviderCredentialState::from_environment( 0, - Default::default(), - Default::default(), - Default::default(), + std::collections::HashMap::default(), + std::collections::HashMap::default(), + std::collections::HashMap::default(), ), openshell_core::jwt::SessionBearerTokenSlot::empty(), )) diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 77a479896d..463176fb65 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -5827,7 +5827,6 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), - ..Default::default() }), ) .await diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 03f5a25175..097c6714f6 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -121,14 +121,12 @@ enum ReadinessEndpoint { Tcp(u16), } -#[cfg(unix)] enum ReadinessListener { #[cfg(unix)] Unix(tokio::net::UnixListener), Tcp(tokio::net::TcpListener), } -#[cfg(unix)] impl ReadinessEndpoint { fn prepare(&self) -> Result<()> { match self { @@ -177,7 +175,6 @@ impl ReadinessEndpoint { } /// Falls back to IPv4 when the network namespace has IPv6 disabled. -#[cfg(unix)] fn bind_readiness_tcp(port: u16) -> std::io::Result { use socket2::{Domain, Socket, Type}; @@ -196,7 +193,6 @@ fn bind_readiness_tcp(port: u16) -> std::io::Result { .or_else(|_| bind(Domain::IPV4, (std::net::Ipv4Addr::UNSPECIFIED, port).into())) } -#[cfg(unix)] impl ReadinessListener { async fn accept(&self) -> std::io::Result<()> { match self { @@ -207,13 +203,11 @@ impl ReadinessListener { } } -#[cfg(unix)] struct ControlReadiness { task: tokio::task::JoinHandle<()>, endpoint: ReadinessEndpoint, } -#[cfg(unix)] impl ControlReadiness { fn start( endpoint: ReadinessEndpoint, @@ -342,21 +336,6 @@ impl Drop for ControlReadiness { } } -#[cfg(not(unix))] -struct ControlReadiness; - -#[cfg(not(unix))] -impl ControlReadiness { - fn start( - _endpoint: ReadinessEndpoint, - _session_readiness: Option>, - ) -> Result { - Err(miette::miette!( - "supervisor readiness sockets require a Unix host" - )) - } -} - /// Check whether the live supervisor owns its private readiness socket. #[cfg(unix)] pub fn check_control_readiness(path: &std::path::Path) -> Result<()> { @@ -5347,7 +5326,6 @@ mod tests { .expect("replacement session restores readiness socket"); } - #[cfg(unix)] #[test] fn tcp_readiness_listener_accepts_ipv4_regardless_of_bindv6only() { let port = std::net::TcpListener::bind("127.0.0.1:0") @@ -5368,7 +5346,6 @@ mod tests { } #[tokio::test] - #[cfg(unix)] async fn tcp_control_readiness_tracks_supervisor_session() { let port = std::net::TcpListener::bind("127.0.0.1:0") .and_then(|reserved| reserved.local_addr()) From 79f6ad29865addfdb47ad3c230a2b5ae74cf9b41 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 1 Oct 2026 22:40:42 -0700 Subject: [PATCH 03/22] refactor(runtime): decouple supervisor access and boundary audit validation Signed-off-by: Drew Newberry --- crates/openshell-sandbox-backend/README.md | 1 - crates/openshell-sandbox-backend/src/runtime.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 2186210cb4..69033f3767 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -24,7 +24,6 @@ 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/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 6818b2d4f3..396175289e 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -130,7 +130,6 @@ impl OpenShellRuntimeBackend { self.connector = Some(connector); self } - /// Select the backend implementation that validates opaque audit evidence. #[must_use] pub fn with_audit_validator( From f5a51285f193acadd3c5a1ca7a477daea2c23891 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 5 Oct 2026 22:55:56 -0700 Subject: [PATCH 04/22] fix(mxc): correct native runtime identity and lifecycle handling Signed-off-by: Drew Newberry --- crates/openshell-driver-mxc/src/driver.rs | 85 +++- crates/openshell-driver-mxc/src/mxc.rs | 58 ++- crates/openshell-mxc-boundary/README.md | 23 + crates/openshell-mxc-boundary/src/runtime.rs | 426 ++++++++++++++++--- docs/how-it-works/gateways/configuration.mdx | 6 + 5 files changed, 523 insertions(+), 75 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 4ec9613eb5..5253a3ae7f 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -611,6 +611,15 @@ async fn run_lifecycle_inner( context: &LifecycleContext, startup_guard: tokio::sync::OwnedMutexGuard<()>, ) -> Result<(), String> { + let main = mxc_main_process_config( + context.sandbox.spec.as_ref(), + &context.sandbox_config.command, + ); + let workload_binary = resolve_workload_binary( + main.command + .first() + .ok_or_else(|| "MXC workload command is empty".to_string())?, + )?; create_restricted_state_dir(&context.host_state_dir, "host")?; // This directory briefly contains the boundary TLS private key and direct // proxy credential. Restrict host access before writing either secret; @@ -684,7 +693,7 @@ async fn run_lifecycle_inner( proxy_addr, proxy_authorization: authorization, proxy_url, - workload_binary: resolve_workload_binary(&context.sandbox_config.command[0])?, + workload_binary, child_env: sandbox_environment(&context.sandbox), } .provision() @@ -720,7 +729,7 @@ async fn run_lifecycle_inner( // ProcessContainer launch so unrelated local processes cannot squat it // during the more expensive host-side setup. drop(proxy_reservation); - let mut supervisor = spawn_supervisor(context, &descriptor_path, &auth_bundle_path)?; + let mut supervisor = spawn_supervisor(context, &descriptor_path, &auth_bundle_path, &main)?; let sandbox_command = encode_windows_command_line(&[ context.config.sandbox_binary_path.clone(), "--bootstrap".to_string(), @@ -815,11 +824,9 @@ fn spawn_supervisor( context: &LifecycleContext, descriptor_path: &Path, auth_bundle_path: &Path, + main: &openshell_core::sandbox_env::MainProcessConfig, ) -> Result { - let main_process_spec = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec( - context.sandbox.spec.as_ref(), - ) - .map_err(|error| format!("encode MXC main process spec: {error}"))?; + let main_process_spec = mxc_main_process_spec(main)?; let mut command = Command::new(&context.config.supervisor_binary_path); command .kill_on_drop(true) @@ -864,6 +871,24 @@ fn spawn_supervisor( .map_err(|error| format!("start host openshell-supervisor: {error}")) } +fn mxc_main_process_spec( + main: &openshell_core::sandbox_env::MainProcessConfig, +) -> Result { + serde_json::to_string(main).map_err(|error| format!("encode MXC main process spec: {error}")) +} + +fn mxc_main_process_config( + spec: Option<&DriverSandboxSpec>, + driver_command: &[String], +) -> openshell_core::sandbox_env::MainProcessConfig { + let mut main = openshell_core::sandbox_env::MainProcessConfig::from_driver_spec(spec); + // Match windows: the MXC driver-config command remains a supported launch + // source when no canonical command was supplied by the CLI. + if main.command.is_empty() { + main.command = driver_command.to_vec(); + } + main +} enum RuntimePairExit { Shutdown, Boundary(std::process::ExitStatus), @@ -1232,6 +1257,54 @@ fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSan // the driver. Keep lifecycle/security tests below during their migration. #[cfg(test)] mod tests { + #[test] + fn mxc_driver_command_reaches_supervisor_without_generic_command() { + let command = vec![ + "cmd.exe".into(), + "/c".into(), + "echo ok 1> C:/work/result.txt".into(), + ]; + let encoded = mxc_main_process_spec(&mxc_main_process_config(None, &command)).unwrap(); + let main = openshell_core::sandbox_env::MainProcessConfig::decode(&encoded).unwrap(); + assert_eq!(main.command, command); + assert!(!main.await_main_process_attachment); + let spec = DriverSandboxSpec { + command: vec!["explicit.exe".into()], + ..Default::default() + }; + let encoded = + mxc_main_process_spec(&mxc_main_process_config(Some(&spec), &command)).unwrap(); + assert_eq!( + openshell_core::sandbox_env::MainProcessConfig::decode(&encoded) + .unwrap() + .command, + spec.command + ); + } + + #[test] + fn proxy_identity_uses_the_canonical_workload_command() { + let canonical = std::env::var("COMSPEC").unwrap(); + let spec = DriverSandboxSpec { + command: vec![canonical.clone(), "/C".into(), "exit 0".into()], + ..Default::default() + }; + let fallback = vec!["not-the-workload.exe".into()]; + let main = mxc_main_process_config(Some(&spec), &fallback); + assert_eq!(main.command, spec.command); + assert_eq!( + resolve_workload_binary(&main.command[0]).unwrap(), + resolve_workload_binary(&canonical).unwrap() + ); + assert_eq!( + openshell_core::sandbox_env::MainProcessConfig::decode( + &mxc_main_process_spec(&main).unwrap() + ) + .unwrap() + .command, + main.command + ); + } use super::*; #[test] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 99a3e6fcef..c7ed821952 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -266,6 +266,18 @@ impl WxcExecInvoker { self.mock } + fn real_command(&self) -> Command { + let mut command = Command::new(&self.exec_path); + command + // Both processes in the runtime pair must die if their monitor is + // cancelled, not only when it takes the explicit shutdown branch. + .kill_on_drop(true) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + command + } + /// Launch the real boundary runtime without OS isolation for wiring tests. /// Filesystem denial must be tested with real MXC, not this mock. fn mock_spawn_with_grants( @@ -368,13 +380,8 @@ impl WxcExecInvoker { } let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - let mut cmd = Command::new(&self.exec_path); - cmd.arg("--config-base64") - .arg(&b64) - // Retain child output so the gateway can surface sandbox logs. - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); + let mut cmd = self.real_command(); + cmd.arg("--config-base64").arg(&b64); if self.debug { cmd.arg("--debug"); } @@ -391,6 +398,43 @@ impl WxcExecInvoker { mod tests { use super::*; + #[cfg(target_os = "windows")] + #[tokio::test] + #[allow(unsafe_code)] // Native synchronization handle verifies cancellation. + async fn real_launcher_command_terminates_on_drop() { + use windows::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0, WAIT_TIMEOUT}; + use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }; + + // Exercise the real launcher's command construction with a native, + // long-lived process. This is not an MXC enforcement test or mock E2E. + let powershell = PathBuf::from(std::env::var("SystemRoot").unwrap()) + .join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let invoker = WxcExecInvoker::new(powershell, false); + let child = invoker + .real_command() + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 60", + ]) + .spawn() + .expect("launch long-lived PowerShell process"); + // SAFETY: OpenProcess supplies a synchronization-only handle for the + // child, retained through termination and closed exactly once below. + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, false, child.id().unwrap()) + .expect("open child synchronization handle"); + assert_eq!(WaitForSingleObject(handle, 0), WAIT_TIMEOUT); + drop(child); + let exited = WaitForSingleObject(handle, 5_000); + CloseHandle(handle).expect("close child handle"); + assert_eq!(exited, WAIT_OBJECT_0, "cancelled launcher must terminate"); + } + } + #[test] fn oneshot_processcontainer_config_json_shape() { // Mirror the JSON `run_oneshot` builds for the one-shot processContainer diff --git a/crates/openshell-mxc-boundary/README.md b/crates/openshell-mxc-boundary/README.md index b768fec8ca..58835da0eb 100644 --- a/crates/openshell-mxc-boundary/README.md +++ b/crates/openshell-mxc-boundary/README.md @@ -25,6 +25,23 @@ and common lifecycle validation to the shared Sandbox Protocol implementation. The backend keeps evidence opaque and compares validated properties with the confirmation; its default Linux validator rejects Windows evidence. +MXC initiates the control connection to the host backend's loopback listener, +as the Windows branch relay does. The boundary remains the TLS server on that +outbound socket; the supervisor validates its pinned certificate and supplies +the authenticated Sandbox Protocol bearer. A backend-owned raw connector lets +discovery, attachment, and reconnection retain this direction without adding +MXC interpretation to shared transport or changing host firewall policy. + +Authenticated RPCs renew the connection's JWT expiry deadline. Expiry or +connection shutdown cancels open process and forwarding exchanges, including +idle streams that have not sent a request yet. + +MXC owns separate host-launch and one-use boundary-bootstrap envelopes. Host +proxy address, authorization, and workload identity stay in the MXC launch +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 @@ -33,6 +50,12 @@ is unsupported and rejected before process launch or environment replacement. Structured shell exec intent and runtime helpers are also rejected explicitly; they must not be silently treated as raw executable launches. +Main launch and raw exec share the authenticated proxy and installed CA +environment. Exec combines these with the latest provider publication rather +than retaining startup credentials. Caller environment overrides cannot replace +the governed proxy or CA settings. Process attachments drain stdout and stderr +before emitting exit, with a bounded deadline for inherited pipes. + Dynamic service forwarding uses the shared protocol's `LoopbackConnect` request and bidirectional stream. The boundary connects to the requested loopback port; it does not launch a relay or connect back to a new gateway relay listener. diff --git a/crates/openshell-mxc-boundary/src/runtime.rs b/crates/openshell-mxc-boundary/src/runtime.rs index bdb16c81e8..19f781e7bf 100644 --- a/crates/openshell-mxc-boundary/src/runtime.rs +++ b/crates/openshell-mxc-boundary/src/runtime.rs @@ -46,6 +46,7 @@ const CONTROL_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10); const AUTHENTICATED_RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); const OUTPUT_BUFFER_BYTES: usize = 1024 * 1024; const MAX_REPLAY_ENTRIES: usize = 4096; +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) @@ -176,14 +177,15 @@ async fn serve_grpc( connection_id: SandboxConnectionId, ) -> Result<(), String> { let (connection_shutdown, mut connection_closed) = tokio::sync::watch::channel(()); + let connection_expiry = Arc::new(ConnectionExpiry::new(connection_shutdown.clone())); runtime.register_connection(connection_id, connection_shutdown.clone()); let incoming = tokio_stream::StreamExt::chain( tokio_stream::iter([Ok::<_, io::Error>(GrpcServerIo { stream, - _connection_alive: connection_shutdown, _disconnect: DisconnectGuard { runtime: Arc::downgrade(&runtime), connection_id, + connection_shutdown, }, })]), tokio_stream::pending(), @@ -194,6 +196,8 @@ async fn serve_grpc( .add_service(IsolationBoundaryServer::new(GrpcBoundaryService { runtime: runtime.clone(), connection_id, + connection_expiry, + connection_closed: connection_closed.clone(), })) .serve_with_incoming_shutdown(incoming, async move { let _ = connection_closed.changed().await; @@ -205,17 +209,21 @@ async fn serve_grpc( struct GrpcServerIo { stream: openshell_isolation_interface::contract::BoundaryDuplexStream, - _connection_alive: tokio::sync::watch::Sender<()>, _disconnect: DisconnectGuard, } struct DisconnectGuard { runtime: std::sync::Weak, connection_id: SandboxConnectionId, + connection_shutdown: tokio::sync::watch::Sender<()>, } impl Drop for DisconnectGuard { fn drop(&mut self) { + // Expiry owns a sender too, so dropping the physical socket alone no + // longer closes the watch channel. Wake the server and every exchange + // explicitly, including a discovery connection before attachment. + let _ = self.connection_shutdown.send(()); if let Some(runtime) = self.runtime.upgrade() { runtime.transport_disconnected(self.connection_id); } @@ -259,10 +267,65 @@ impl tonic::transport::server::Connected for GrpcServerIo { struct GrpcBoundaryService { runtime: Arc, connection_id: SandboxConnectionId, + connection_expiry: Arc, + connection_closed: tokio::sync::watch::Receiver<()>, } type GrpcResponseStream = ReceiverStream>; +// Match the Linux boundary: renewed RPC credentials update the physical +// connection deadline, and expiry cancels every open exchange on it. +struct ConnectionExpiry { + deadline: tokio::sync::watch::Sender>, + worker: tokio::task::AbortHandle, +} + +impl ConnectionExpiry { + fn new(connection_shutdown: tokio::sync::watch::Sender<()>) -> Self { + let (deadline, mut updates) = tokio::sync::watch::channel(None); + let worker = tokio::spawn(async move { + loop { + let deadline = *updates.borrow_and_update(); + if let Some(deadline) = deadline { + tokio::select! { + () = tokio::time::sleep_until(deadline) => { + let _ = connection_shutdown.send(()); + return; + } + result = updates.changed() => { + if result.is_err() { return; } + } + } + } else if updates.changed().await.is_err() { + return; + } + } + }) + .abort_handle(); + Self { deadline, worker } + } + + fn update(&self, expires_at: i64) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()); + self.deadline.send_replace((expires_at != 0).then(|| { + tokio::time::Instant::now() + + Duration::from_secs( + u64::try_from(expires_at) + .unwrap_or_default() + .saturating_sub(now), + ) + })); + } +} + +impl Drop for ConnectionExpiry { + fn drop(&mut self) { + self.worker.abort(); + } +} + #[tonic::async_trait] impl IsolationBoundary for GrpcBoundaryService { type ExchangeStream = GrpcResponseStream; @@ -275,11 +338,21 @@ impl IsolationBoundary for GrpcBoundaryService { let principal = self .runtime .authenticate_request(self.connection_id, request.metadata())?; - let (stream, response) = bridge_grpc_stream(request.into_inner()); + self.connection_expiry + .update(principal.session().expires_at); + let (stream, response) = + bridge_grpc_stream(request.into_inner(), self.connection_closed.clone()); let runtime = self.runtime.clone(); + let mut connection_closed = self.connection_closed.clone(); tokio::spawn(async move { - if let Err(error) = serve_one(stream, runtime, principal).await { - tracing::warn!(%error, "MXC Sandbox Protocol exchange failed"); + tokio::select! { + biased; + _ = connection_closed.changed() => {} + result = serve_one(stream, runtime, principal) => { + if let Err(error) = result { + tracing::warn!(%error, "MXC Sandbox Protocol exchange failed"); + } + } } }); Ok(tonic::Response::new(response)) @@ -299,39 +372,57 @@ impl IsolationBoundary for GrpcBoundaryService { } fn bridge_grpc_stream( - mut inbound: tonic::Streaming, + mut inbound: impl tokio_stream::Stream> + + Send + + Unpin + + 'static, + connection_closed: tokio::sync::watch::Receiver<()>, ) -> (tokio::io::DuplexStream, GrpcResponseStream) { let (application, bridge) = tokio::io::duplex(256 * 1024); let (mut reader, mut writer) = tokio::io::split(bridge); let (outbound, outbound_rx) = tokio::sync::mpsc::channel(64); + let mut inbound_closed = connection_closed.clone(); tokio::spawn(async move { - loop { - match inbound.message().await { - Ok(Some(chunk)) if writer.write_all(&chunk.data).await.is_ok() => {} - Ok(Some(_)) | Err(_) => return, - Ok(None) => { - let _ = writer.shutdown().await; - return; + tokio::select! { + biased; + _ = inbound_closed.changed() => {} + () = async { + loop { + match tokio_stream::StreamExt::next(&mut inbound).await { + Some(Ok(chunk)) if writer.write_all(&chunk.data).await.is_ok() => {} + Some(Ok(_) | Err(_)) => return, + None => { + let _ = writer.shutdown().await; + return; + } + } } - } + } => {} } }); + let mut outbound_closed = connection_closed; tokio::spawn(async move { - let mut buffer = vec![0_u8; 16 * 1024]; - loop { - let Ok(read) = reader.read(&mut buffer).await else { - return; - }; - if read == 0 - || outbound - .send(Ok(BoundaryChunk { - data: buffer[..read].to_vec(), - })) - .await - .is_err() - { - return; - } + tokio::select! { + biased; + _ = outbound_closed.changed() => {} + () = async { + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + let Ok(read) = reader.read(&mut buffer).await else { + return; + }; + if read == 0 + || outbound + .send(Ok(BoundaryChunk { + data: buffer[..read].to_vec(), + })) + .await + .is_err() + { + return; + } + } + } => {} } }); (application, ReceiverStream::new(outbound_rx)) @@ -363,6 +454,7 @@ struct BoundaryRuntime { processes: Mutex>>, main_process: Mutex>, provider_environment: Mutex<(u64, HashMap, u64)>, + ca_paths: Mutex>, replay: Mutex>, replay_order: Mutex>, exec_requests: Mutex>, @@ -406,6 +498,7 @@ impl BoundaryRuntime { processes: Mutex::new(HashMap::new()), main_process: Mutex::new(None), provider_environment: Mutex::new((0, HashMap::new(), 0)), + ca_paths: Mutex::new(None), replay: Mutex::new(HashMap::new()), replay_order: Mutex::new(VecDeque::new()), exec_requests: Mutex::new(HashSet::new()), @@ -736,26 +829,21 @@ impl BoundaryRuntime { return guest_error(BoundaryErrorKind::Denied, "MXC start policy changed"); } } - let mut environment = self.config.child_env.clone(); - environment.extend(provider_env.clone()); - if let Some(proxy_url) = &self.config.direct_proxy_url { - for key in ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"] { - environment.insert(key.to_string(), proxy_url.clone()); - } - environment.insert("NO_PROXY".to_string(), String::new()); - environment.insert("no_proxy".to_string(), String::new()); - } - match install_ca_material( + let ca_paths = match install_ca_material( &self.config.generation, ca_cert.as_deref().map(str::as_bytes), ca_bundle.as_deref().map(str::as_bytes), ) { - Ok(Some((certificate, bundle))) => { - install_ca_environment(&mut environment, &certificate, &bundle); - } - Ok(None) => {} + Ok(paths) => paths, Err(error) => return guest_error(BoundaryErrorKind::Process, error), - } + }; + let environment = workload_environment( + &self.config.child_env, + provider_env, + &[], + &self.proxy_url, + ca_paths.as_ref(), + ); let process_id = format!("{}:main:0", self.config.generation); let (program, args) = agent_command(spec.clone()); let process = match ManagedProcess::spawn( @@ -773,6 +861,7 @@ impl BoundaryRuntime { }; lock(&self.processes).insert(process_id.clone(), process); *lock(&self.main_process) = Some(process_id.clone()); + *lock(&self.ca_paths) = ca_paths; *lock(&self.provider_environment) = (*provider_env_revision, provider_env.clone(), 0); *lock(&self.lifecycle) = Lifecycle::Running; let response = Response::Started { @@ -811,9 +900,13 @@ impl BoundaryRuntime { self.config.generation, self.next_exec.fetch_add(1, Ordering::Relaxed) ); - let mut environment = self.config.child_env.clone(); - environment.extend(lock(&self.provider_environment).1.clone()); - environment.extend(spec.env.iter().cloned()); + let environment = workload_environment( + &self.config.child_env, + &lock(&self.provider_environment).1, + &spec.env, + &self.proxy_url, + lock(&self.ca_paths).as_ref(), + ); let process = match ManagedProcess::spawn( id.clone(), ProcessKindWire::Exec, @@ -1097,6 +1190,31 @@ fn install_ca_environment( } } +fn workload_environment( + child: &HashMap, + providers: &HashMap, + overrides: &[(String, String)], + proxy_url: &str, + ca_paths: Option<&(PathBuf, PathBuf)>, +) -> HashMap { + let mut environment = child.clone(); + environment.extend(providers.clone()); + environment.extend(overrides.iter().cloned()); + for (key, value) in [ + ("HTTP_PROXY", proxy_url), + ("HTTPS_PROXY", proxy_url), + ("NO_PROXY", ""), + ] { + environment.retain(|existing, _| !existing.eq_ignore_ascii_case(key)); + environment.insert(key.to_string(), value.to_string()); + environment.insert(key.to_ascii_lowercase(), value.to_string()); + } + if let Some((certificate, bundle)) = ca_paths { + install_ca_environment(&mut environment, certificate, bundle); + } + environment +} + fn install_ca_material( generation: &str, certificate: Option<&[u8]>, @@ -1127,6 +1245,27 @@ struct ManagedProcess { attached: Arc, } +async fn drain_output(readers: &mut [tokio::task::JoinHandle<()>; 2]) { + // A descendant can inherit the pipes after its parent exits. Bound the + // drain, then stop readers before publishing EXIT so no data follows it. + let drained = tokio::time::timeout(OUTPUT_DRAIN_TIMEOUT, async { + for reader in readers.iter_mut() { + let _ = reader.await; + } + }) + .await; + if drained.is_err() { + for reader in readers.iter() { + reader.abort(); + } + for reader in readers.iter_mut() { + if !reader.is_finished() { + let _ = reader.await; + } + } + } +} + impl ManagedProcess { async fn spawn( id: String, @@ -1162,8 +1301,10 @@ impl ManagedProcess { .take() .ok_or_else(|| "MXC workload stderr pipe is unavailable".to_string())?; let output = OutputLog::new(); - output.spawn_reader(stdout, STREAM_STDOUT); - output.spawn_reader(stderr, STREAM_STDERR); + let readers = [ + output.spawn_reader(stdout, STREAM_STDOUT), + output.spawn_reader(stderr, STREAM_STDERR), + ]; let (exit_tx, exit) = tokio::sync::watch::channel(None); let process = Self { id, @@ -1174,11 +1315,15 @@ impl ManagedProcess { exit, attached: Arc::new(AtomicBool::new(false)), }; - process.start_monitor(exit_tx); + process.start_monitor(exit_tx, readers); Ok(process) } - fn start_monitor(&self, exit_tx: tokio::sync::watch::Sender>) { + fn start_monitor( + &self, + exit_tx: tokio::sync::watch::Sender>, + mut readers: [tokio::task::JoinHandle<()>; 2], + ) { let child = self.child.clone(); let output = self.output.clone(); tokio::spawn(async move { @@ -1190,6 +1335,7 @@ impl ManagedProcess { match result { Ok(Some(status)) => { let status = ExitStatusWire::Exited(status.code().unwrap_or(1)); + drain_output(&mut readers).await; output.publish_exit(status); exit_tx.send_replace(Some(status)); return; @@ -1197,6 +1343,7 @@ impl ManagedProcess { Ok(None) => tokio::time::sleep(Duration::from_millis(25)).await, Err(_) => { let status = ExitStatusWire::Exited(1); + drain_output(&mut readers).await; output.publish_exit(status); exit_tx.send_replace(Some(status)); return; @@ -1295,7 +1442,7 @@ impl OutputLog { self: &Arc, mut reader: impl tokio::io::AsyncRead + Send + Unpin + 'static, channel: u8, - ) { + ) -> tokio::task::JoinHandle<()> { let output = self.clone(); tokio::spawn(async move { let mut buffer = vec![0_u8; 16 * 1024]; @@ -1305,7 +1452,7 @@ impl OutputLog { Ok(read) => output.publish(channel, buffer[..read].to_vec()), } } - }); + }) } fn publish_exit(&self, status: ExitStatusWire) { @@ -1405,7 +1552,7 @@ async fn bridge_process( let _guard = process.acquire_attachment()?; let (mut reader, mut writer) = tokio::io::split(stream); let input_process = process.clone(); - let mut input = tokio::spawn(async move { + let input = async move { while let Some((channel, payload)) = read_stream_frame(&mut reader).await? { match channel { STREAM_STDIN => { @@ -1428,14 +1575,13 @@ async fn bridge_process( } } Ok::<(), io::Error>(()) - }); + }; + tokio::pin!(input); let mut cursor = process.output.cursor(); loop { tokio::select! { result = &mut input => { - return result - .map_err(|error| format!("join MXC process input: {error}"))? - .map_err(|error| format!("read MXC process input: {error}")); + return result.map_err(|error| format!("read MXC process input: {error}")); } event = cursor.recv() => { let Some(event) = event else { return Ok(()) }; @@ -1486,6 +1632,117 @@ fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { mod tests { use super::*; + #[test] + fn exec_environment_keeps_proxy_and_ca_with_refreshed_providers() { + let ca_paths = ( + PathBuf::from(r"C:\trusted\ca.pem"), + PathBuf::from(r"C:\trusted\bundle.pem"), + ); + let child = HashMap::from([("BASE".into(), "bootstrap".into())]); + let providers = HashMap::from([("TOKEN".into(), "refreshed".into())]); + let overrides = vec![ + ("OTHER".into(), "exec".into()), + ("Https_Proxy".into(), "http://untrusted".into()), + ("ssl_cert_file".into(), "untrusted-ca".into()), + ]; + let proxy = "http://user:secret@127.0.0.1:3128"; + let environment = + workload_environment(&child, &providers, &overrides, proxy, Some(&ca_paths)); + for key in ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"] { + assert_eq!(environment[key], proxy); + } + assert_eq!(environment["NO_PROXY"], ""); + assert_eq!( + environment["SSL_CERT_FILE"], + ca_paths.1.display().to_string() + ); + assert_eq!( + environment["NODE_EXTRA_CA_CERTS"], + ca_paths.0.display().to_string() + ); + assert!(!environment.contains_key("Https_Proxy")); + assert!(!environment.contains_key("ssl_cert_file")); + assert_eq!(environment["TOKEN"], "refreshed"); + assert_eq!(environment["BASE"], "bootstrap"); + assert_eq!(environment["OTHER"], "exec"); + assert!( + !workload_environment(&child, &HashMap::new(), &[], proxy, Some(&ca_paths)) + .contains_key("TOKEN") + ); + } + + #[tokio::test] + async fn connection_expiry_closes_the_authenticated_connection() { + let (shutdown, mut closed) = tokio::sync::watch::channel(()); + let expiry = ConnectionExpiry::new(shutdown); + expiry.update(1); // Already expired: terminate immediately. + tokio::time::timeout(Duration::from_secs(2), closed.changed()) + .await + .expect("expiry fires") + .expect("shutdown notification"); + } + + #[tokio::test] + async fn expired_connection_cancels_idle_exchange_streams() { + let (shutdown, closed) = tokio::sync::watch::channel(()); + let expiry = ConnectionExpiry::new(shutdown); + let (inbound, inbound_rx) = tokio::sync::mpsc::channel(1); + let (mut application, mut response) = + bridge_grpc_stream(ReceiverStream::new(inbound_rx), closed); + expiry.update(1); + tokio::time::timeout(Duration::from_secs(2), async { + let mut byte = [0]; + assert_eq!(application.read(&mut byte).await.unwrap(), 0); + assert!(tokio_stream::StreamExt::next(&mut response).await.is_none()); + assert!( + inbound + .send(Ok(BoundaryChunk { data: vec![1] })) + .await + .is_err() + ); + }) + .await + .expect("expired exchanges must close without client activity"); + } + + #[tokio::test] + async fn physical_disconnect_closes_discovery_even_with_an_expiry_worker() { + let (shutdown, mut closed) = tokio::sync::watch::channel(()); + let _expiry = ConnectionExpiry::new(shutdown.clone()); + let guard = DisconnectGuard { + runtime: std::sync::Weak::new(), + connection_id: SandboxConnectionId::new(), + connection_shutdown: shutdown, + }; + drop(guard); + tokio::time::timeout(Duration::from_secs(2), closed.changed()) + .await + .expect("disconnected discovery must not wait for JWT expiry") + .expect("explicit disconnect notification"); + } + + #[tokio::test] + async fn renewed_credentials_replace_the_connection_deadline() { + let (shutdown, mut closed) = tokio::sync::watch::channel(()); + let expiry = ConnectionExpiry::new(shutdown); + expiry.deadline.send_replace(Some( + tokio::time::Instant::now() + Duration::from_millis(20), + )); + expiry + .deadline + .send_replace(Some(tokio::time::Instant::now() + Duration::from_mins(1))); + assert!( + tokio::time::timeout(Duration::from_millis(50), closed.changed()) + .await + .is_err() + ); + expiry.update(1); + tokio::time::timeout(Duration::from_secs(2), closed.changed()) + .await + .expect("new deadline fires") + .expect("shutdown notification"); + } + #[test] fn installed_ca_replaces_case_insensitive_trust_overrides() { let certificate = Path::new(r"C:\trusted\ca.pem"); @@ -1636,7 +1893,7 @@ mod tests { .await .expect("process exits"); assert!(matches!(status, ExitStatusWire::Exited(0))); - // Drain stdout independently: exit notification can precede the reader. + // Production attachments stop at EXIT; all output must precede it. let mut cursor = process.output.cursor(); let output = tokio::time::timeout(Duration::from_secs(10), async { let mut output = Vec::new(); @@ -1644,9 +1901,9 @@ mod tests { let event = cursor.recv().await.expect("retained output event"); if event.channel == STREAM_STDOUT { output.extend(event.payload); - if String::from_utf8_lossy(&output).contains("relay-free launch") { - return output; - } + } + if event.channel == STREAM_EXIT { + return output; } } }) @@ -1655,6 +1912,51 @@ mod tests { assert!(String::from_utf8_lossy(&output).contains("relay-free launch")); } + #[tokio::test] + async fn process_bridge_drains_stdout_and_stderr_before_exit() { + let process = Arc::new( + ManagedProcess::spawn( + "drain-test".into(), + ProcessKindWire::Exec, + std::env::var("COMSPEC").unwrap(), + vec![ + "/D".into(), + "/C".into(), + "(for /L %i in (1,1,2000) do @echo output-line) & echo last-error 1>&2".into(), + ], + None, + HashMap::new(), + ) + .await + .expect("launch real command"), + ); + let (mut client, server) = tokio::io::duplex(4096); + let bridge = tokio::spawn(bridge_process(server, process)); + let (stdout, stderr) = tokio::time::timeout(Duration::from_secs(10), async { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + while let Some((channel, payload)) = read_stream_frame(&mut client).await.unwrap() { + match channel { + STREAM_STDOUT => stdout.extend(payload), + STREAM_STDERR => stderr.extend(payload), + STREAM_EXIT => break, + _ => panic!("unexpected output channel"), + } + } + (stdout, stderr) + }) + .await + .expect("attachment finishes"); + bridge.await.unwrap().unwrap(); + assert_eq!( + String::from_utf8_lossy(&stdout) + .matches("output-line") + .count(), + 2000 + ); + assert!(String::from_utf8_lossy(&stderr).contains("last-error")); + } + #[test] fn output_retention_is_bounded_and_reports_truncation() { let output = OutputLog::new(); diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index ee80d02ac5..7d2b4bf3ec 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -1281,6 +1281,12 @@ The driver assigns distinct Sandbox Protocol and proxy listeners plus fresh cred 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. +Main launch and raw exec receive the authenticated proxy and installed CA +environment. Exec uses the latest provider environment. The proxy identity +uses the canonical workload command when supplied, falling back to the MXC +driver command. Expired control credentials close open process and forwarding +streams; renewed credentials update the connection deadline. + ### MicroVM Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. From 799659498ba67ada48892730ec37bba757db9b58 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 6 Oct 2026 15:08:42 -0700 Subject: [PATCH 05/22] test(mxc): cover real sandbox TCP forwarding Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 10 + crates/openshell-driver-mxc/README.md | 8 + .../examples/e2e-policies/forwarding.yaml | 11 + .../examples/mxc-forwarding-agent.rs | 39 ++++ .../examples/run-mxc-e2e.ps1 | 107 +++++++++- tasks/scripts/run-mxc-remote.ps1 | 91 +++++++++ tasks/scripts/test-mxc-remote.ps1 | 189 ++++++++++++++++++ tasks/scripts/windows-msvc.ps1 | 11 +- tasks/windows.toml | 16 ++ 9 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/forwarding.yaml create mode 100644 crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs create mode 100644 tasks/scripts/run-mxc-remote.ps1 create mode 100644 tasks/scripts/test-mxc-remote.ps1 diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index e6e4c34fdd..48ea6eae20 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -363,6 +363,16 @@ test-internal `SKIP` messages from Cargo's passed count in the real integration suite, and report a verified unsupported-host rejection independently from positive admission coverage. +The real `forwarding` E2E scenario uses a native test-only echo agent inside MXC and +`openshell forward service` to verify two exact request/reply exchanges through +the gateway, supervisor, and boundary. Dynamic ports and a per-workload nonce +avoid mistaking unrelated listeners for success. This is managed-ingress +coverage, not evidence that direct host ingress is fenced. Use +`-Scenario forwarding` on the existing local or remote runner for focused checks. +The E2E mise task and remote runner build the `mxc-forwarding-agent` Cargo example +using `windows:build:mxc-fixtures` (or its `:x64` / `:arm64` variant). This fixture +is not one of the four production deliverables; do not package it in releases. + When reporting `windows:ci`, distinguish these categories: - Passed tests from the full x64 workspace test log. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index d777fc7c13..ef52ba257f 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -164,3 +164,11 @@ run incomplete and exits non-zero; a passing exit requires all selected scenario 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 `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 +assigned. Its native echo agent is a test-only Cargo example, built automatically +by the E2E mise task and remote runner, not a shipped runtime binary. It verifies +managed ingress, not denial of direct host-to-workload connections. Select it with +`-Scenario forwarding` on the local harness or remote runner. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/forwarding.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/forwarding.yaml new file mode 100644 index 0000000000..59b428d704 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/forwarding.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# The listener reports its dynamically assigned port in the per-run workdir. +# No Internet egress policy is needed for this ingress-only test. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs new file mode 100644 index 0000000000..e268ceef1b --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Test-only TCP fixture for real MXC ingress E2E; not a shipped runtime binary. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::time::Duration; + +fn main() -> std::io::Result<()> { + let arguments: Vec = std::env::args().collect(); + if arguments.len() != 3 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "usage: mxc-forwarding-agent READY_FILE NONCE", + )); + } + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))?; + let ready_file = std::path::Path::new(&arguments[1]); + let staged_ready = ready_file.with_extension("tmp"); + std::fs::write(&staged_ready, listener.local_addr()?.port().to_string())?; + std::fs::rename(staged_ready, ready_file)?; + for connection in listener.incoming() { + let mut socket = connection?; + socket.set_nodelay(true)?; + socket.set_read_timeout(Some(Duration::from_secs(10)))?; + socket.set_write_timeout(Some(Duration::from_secs(10)))?; + let mut request = String::new(); + BufReader::new((&socket).take(4096)).read_line(&mut request)?; + if !request.ends_with('\n') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request must be a bounded newline-terminated string", + )); + } + write!(socket, "{}:{request}", arguments[2])?; + } + Ok(()) +} diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 57f76979f5..727fb79883 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -34,6 +34,7 @@ # fs-default-deny - ungranted write is denied after the agent launches. # processcontainer only. # network-policy - supervisor-owned network policy permits admission. +# forwarding - two TCP request/reply exchanges through OpenShell ingress. [CmdletBinding()] param( @@ -118,7 +119,7 @@ function Quote-NativeArgument([string]$value) { return $quoted.ToString() } -function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { +function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList, [int]$timeoutSeconds = 0) { $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $filePath $startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') @@ -142,6 +143,9 @@ function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { } $stdout = $process.StandardOutput.ReadToEndAsync() $stderr = $process.StandardError.ReadToEndAsync() + if ($timeoutSeconds -gt 0 -and -not $process.WaitForExit($timeoutSeconds * 1000)) { + $process.Kill() + } $process.WaitForExit() return @{ @@ -271,6 +275,66 @@ function Wait-File([string]$path, [int]$seconds) { return (Test-Path $path) } +# Exercise the public gRPC forward path, never dial the workload port directly. +# The nonce identifies this workload, not an unrelated host-loopback listener. +function Test-Forwarding([string]$sandboxName, [string]$readyFile, [string]$nonce) { + if (-not (Wait-File $readyFile 30)) { throw "MXC forwarding listener did not report readiness" } + $targetPort = [int](Get-Content -LiteralPath $readyFile -Raw).Trim() + if ($targetPort -lt 1 -or $targetPort -gt 65535) { throw "Invalid workload listener port" } + $outLog = Join-Path $resultDir "forward.stdout.log" + $errLog = Join-Path $resultDir "forward.stderr.log" + $arguments = @("forward", "service", $sandboxName, "--target-port", "$targetPort", "--local", "127.0.0.1:0") + $forwarder = $null + try { + $forwarder = Start-Process -FilePath $cli -WindowStyle Hidden -PassThru ` + -ArgumentList (($arguments | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') ` + -RedirectStandardOutput $outLog -RedirectStandardError $errLog + $deadline = (Get-Date).AddSeconds(30) + $localPort = 0 + while ((Get-Date) -lt $deadline) { + if ($forwarder.HasExited) { throw "CLI forwarder exited early (code $($forwarder.ExitCode)); see $errLog" } + $text = (Get-Content -LiteralPath $errLog -Raw -ErrorAction SilentlyContinue) + if ($text -match 'Forwarding 127\.0\.0\.1:(\d+) ->') { + $localPort = [int]$Matches[1] + break + } + Start-Sleep -Milliseconds 200 + } + if ($localPort -eq 0) { throw "CLI forwarder did not bind within 30 seconds" } + for ($exchange = 1; $exchange -le 2; $exchange++) { + $socket = New-Object System.Net.Sockets.TcpClient + $reader = $null + try { + $connect = $socket.BeginConnect('127.0.0.1', $localPort, $null, $null) + try { + if (-not $connect.AsyncWaitHandle.WaitOne(5000)) { throw "Forward client connection timed out" } + $socket.EndConnect($connect) + } finally { $connect.AsyncWaitHandle.Close() } + $socket.NoDelay = $true + $stream = $socket.GetStream() + $stream.ReadTimeout = 10000 + $stream.WriteTimeout = 10000 + $request = "request-$exchange-$([guid]::NewGuid().ToString('N'))" + $bytes = [Text.Encoding]::ASCII.GetBytes("$request`n") + $stream.Write($bytes, 0, $bytes.Length) + $socket.Client.Shutdown([Net.Sockets.SocketShutdown]::Send) + $reader = New-Object IO.StreamReader($stream, [Text.Encoding]::ASCII) + $reply = $reader.ReadLine() + if ($reply -ne "$nonce`:$request") { throw "Forwarded exchange $exchange returned an unexpected response: '$reply'" } + Info "forwarded exchange ${exchange}: exact workload response verified" + } finally { + if ($reader) { $reader.Dispose() } + $socket.Close() + } + } + } finally { + if ($forwarder) { + if (-not $forwarder.HasExited) { Stop-Process -Id $forwarder.Id -Force -ErrorAction SilentlyContinue } + $forwarder.Dispose() + } + } +} + # Detect an agent *launch* failure (binary not found / not implemented) vs a # legitimate policy denial. Used to avoid false-passing a deny scenario when the # agent never actually ran. @@ -508,6 +572,13 @@ try { Backends = "both"; Kind = "positive" PosTarget = (Join-Path $DemoDir "network-policy-result.txt") Description = "supervisor-owned network policy is accepted and workload runs (not an egress assertion)" + }, + @{ + Name = "forwarding"; PolicyFile = Join-Path $policyDir "forwarding.yaml" + SandboxId = "fwd" + Backends = "process_container"; Kind = "forwarding" + ReadyFile = (Join-Path $DemoDir "forwarding-port.txt") + Description = "real MXC listener receives two TCP exchanges through authenticated OpenShell forwarding" } ) @@ -579,6 +650,19 @@ try { } else { $command = @($cmdExe, "/c", "echo denied 1> $denied") } + } elseif ($sc.Kind -eq "forwarding") { + Remove-Item -LiteralPath $sc.ReadyFile -Force -ErrorAction SilentlyContinue + $forwardNonce = [guid]::NewGuid().ToString('N') + $fixture = Join-Path $BinaryDir 'mxc-forwarding-agent.exe' + if (-not (Test-Path -LiteralPath $fixture)) { + $fixture = Join-Path $BinaryDir 'examples/mxc-forwarding-agent.exe' + } + if (-not (Test-Path -LiteralPath $fixture)) { + throw "Missing forwarding fixture; run mise run --skip-tools windows:build:mxc-fixtures" + } + $workload = Join-Path $DemoDir 'mxc-forwarding-agent.exe' + Copy-Item -LiteralPath $fixture -Destination $workload -Force + $command = @($workload, $sc.ReadyFile, $forwardNonce) } else { $command = @($cmdExe, "/c", "exit 0") } @@ -635,6 +719,21 @@ try { if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure (not a policy result)" } $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact absent" } } + } elseif ($sc.Kind -eq "forwarding") { + try { + if ($createExitCode -ne 0) { throw "sandbox create failed: $createOutStr" } + Test-Forwarding $sandboxName $sc.ReadyFile $forwardNonce + Ok "$($sc.Name): both forwarded request/reply exchanges succeeded" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "two exact TCP replies through OpenShell" } + } catch { + Bad "$($sc.Name): $($_.Exception.Message)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = $_.Exception.Message } + try { + $diagnostic = Invoke-NativeCaptured $cli @('sandbox', 'get', $sandboxName, '-o', 'json') 5 + $diagnostic.Output | Set-Content -LiteralPath (Join-Path $resultDir 'forward.sandbox.json') -Encoding UTF8 + Info "sandbox state captured in forward.sandbox.json" + } catch { Info "sandbox diagnostics unavailable: $($_.Exception.Message)" } + } } else { # deny if ($sc.ControlTarget) { @@ -739,8 +838,10 @@ Files in this bundle ($resultDir): What PASS means: every selected scenario ran against real MXC and met its expected verdict - positive writes produced their artifact, deny writes were blocked with either a control -write or driver-launch evidence. Missing coverage exits non-zero; -network-policy proves admission and execution, not network enforcement. +write or driver-launch evidence, and forwarding verified two exact TCP replies +through OpenShell. Missing coverage exits non-zero; network-policy proves +admission and execution, not network enforcement. Forwarding proves managed +ingress works, not that direct host ingress is blocked. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) diff --git a/tasks/scripts/run-mxc-remote.ps1 b/tasks/scripts/run-mxc-remote.ps1 new file mode 100644 index 0000000000..8ead81e5c3 --- /dev/null +++ b/tasks/scripts/run-mxc-remote.ps1 @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Remote half of test-mxc-remote.ps1. No source checkout or build tools required. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$PSNativeCommandUseErrorActionPreference = $false +$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$export = Join-Path $repo 'result-export' +New-Item -ItemType Directory -Path $export | Out-Null +$manifest = Get-Content -Raw (Join-Path $repo 'manifest.json') | ConvertFrom-Json +Copy-Item -LiteralPath (Join-Path $repo 'manifest.json') -Destination $export +$code = 1 +$transcript = $false +function Get-PeMachine([string] $Path) { + $stream = [IO.File]::OpenRead($Path) + $reader = New-Object IO.BinaryReader($stream) + try { + if ($reader.ReadUInt16() -ne 0x5a4d) { throw "Not a PE executable: $Path" } + $stream.Position = 0x3c + $offset = $reader.ReadInt32() + $stream.Position = $offset + if ($reader.ReadUInt32() -ne 0x4550) { throw "Invalid PE signature: $Path" } + return $reader.ReadUInt16() + } finally { $reader.Dispose() } +} +try { + Start-Transcript -Path (Join-Path $export 'remote-transcript.txt') | Out-Null + $transcript = $true + $native = Get-ItemPropertyValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' PROCESSOR_ARCHITECTURE + $expected = if ($manifest.target -eq 'aarch64-pc-windows-msvc') { 0xaa64 } else { 0x8664 } + if (($expected -eq 0xaa64 -and $native -ne 'ARM64') -or ($expected -eq 0x8664 -and $native -ne 'AMD64')) { throw 'Native host architecture changed.' } + foreach ($name in @('openshell.exe', 'openshell-gateway.exe', 'openshell-supervisor.exe', 'openshell-windows-sandbox.exe', 'mxc-forwarding-agent.exe', 'libz3.dll')) { + if ((Get-PeMachine (Join-Path $repo "bin/$name")) -ne $expected) { throw "Wrong executable architecture: $name" } + } + $wxc = $manifest.wxcExecPath + # Check loader dependencies before the harness starts a gateway. + $startInfo = New-Object Diagnostics.ProcessStartInfo + $startInfo.FileName = Join-Path $repo 'bin/openshell-gateway.exe' + $startInfo.Arguments = '--version' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = New-Object Diagnostics.Process + $process.StartInfo = $startInfo + try { + [void]$process.Start() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(15000)) { $process.Kill(); throw 'Gateway loader check timed out.' } + @($stdout.Result, $stderr.Result) | Set-Content (Join-Path $export 'gateway-version.txt') -Encoding UTF8 + if ($process.ExitCode -ne 0) { throw "Gateway loader check failed: exit $($process.ExitCode). Check the packaged DLLs and native Visual C++ runtime." } + Write-Host "Gateway loader check passed: $($stdout.Result.Trim())" + } finally { $process.Dispose() } + if (-not $wxc) { $wxc = (Get-Command wxc-exec.exe -ErrorAction Stop).Source } + $openssl = (Get-Command openssl.exe -ErrorAction Stop).Source + $probe = @(& $wxc --probe) + $probeCode = $LASTEXITCODE + $probe | Set-Content -LiteralPath (Join-Path $export 'wxc-probe.json') -Encoding UTF8 + [ordered]@{ hostName = $env:COMPUTERNAME; nativeArchitecture = $native; shellArchitecture = $env:PROCESSOR_ARCHITECTURE; + windowsVersion = [Environment]::OSVersion.Version.ToString(); wxcExecPath = $wxc; + wxcVersion = (Get-Item -LiteralPath $wxc).VersionInfo.ProductVersion; + wxcSha256 = (Get-FileHash -LiteralPath $wxc).Hash; wxcPeMachine = (Get-PeMachine $wxc); + probeExitCode = $probeCode; openssl = $openssl } | ConvertTo-Json | Set-Content (Join-Path $export 'host.json') -Encoding UTF8 + if ($probeCode -ne 0) { throw "MXC probe failed: exit $probeCode" } + Write-Host "Native host: $native; MXC: $wxc; running unchanged REAL E2E" + $harness = Join-Path $repo 'crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1' + $arguments = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $harness, + '-BinaryDir', (Join-Path $repo 'bin'), '-WxcExecPath', $wxc, '-Backend', 'process_container', '-Port', "$($manifest.gatewayPort)") + if ($manifest.scenario) { $arguments += @('-Scenario', $manifest.scenario) } + & powershell.exe @arguments + $code = $LASTEXITCODE +} catch { + Write-Warning $_.Exception.Message + $_ | Out-String | Set-Content -LiteralPath (Join-Path $export 'error.txt') +} finally { + Set-Content -LiteralPath (Join-Path $export 'exit-code.txt') -Value $code + $resultsRoot = Join-Path $repo 'target/windows-e2e-results' + if (Test-Path $resultsRoot) { + # Only copy completed harness bundles and summaries, never sibling keys-*. + Get-ChildItem $resultsRoot -Filter 'results-e2e-*.zip' -File | Copy-Item -Destination $export + foreach ($dir in Get-ChildItem $resultsRoot -Filter 'results-e2e-*' -Directory) { + $summary = Join-Path $dir.FullName 'summary.txt' + if (Test-Path $summary) { Copy-Item -LiteralPath $summary -Destination (Join-Path $export "$($dir.Name)-summary.txt") } + } + } + if ($transcript) { Stop-Transcript | Out-Null } + Compress-Archive -Path (Join-Path $export '*') -DestinationPath (Join-Path $repo 'results.zip') +} +exit $code diff --git a/tasks/scripts/test-mxc-remote.ps1 b/tasks/scripts/test-mxc-remote.ps1 new file mode 100644 index 0000000000..6c842f6c31 --- /dev/null +++ b/tasks/scripts/test-mxc-remote.ps1 @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Build locally, upload content-addressed artifacts, run unchanged real E2E, +# and download diagnostic bundles even when tests return a nonzero exit code. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string] $HostName, + [Parameter(Mandatory = $true)] [string] $UserName, + [string] $IdentityFile = (Join-Path $env:USERPROFILE '.ssh/openshell-mxc-test'), + [ValidateSet('auto', 'arm64', 'x64')] [string] $Architecture = 'auto', + [string] $BuildDirectory, + [string] $Z3DllPath, + [string] $RemoteRoot, + [string] $WxcExecPath, + [string] $Scenario, + [ValidateRange(1, 65535)] [int] $SshPort = 22, + [ValidateRange(1, 65535)] [int] $GatewayPort = 17670, + [switch] $SkipBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +$repo = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +if ($HostName -notmatch '^[A-Za-z0-9.-]+$' -or $UserName -notmatch '^[A-Za-z0-9_.-]+$') { + throw 'Use a hostname/IP and a simple SSH username (or configure an SSH alias).' +} +if (-not (Test-Path -LiteralPath $IdentityFile -PathType Leaf)) { throw "Missing SSH key: $IdentityFile" } +$ssh = (Get-Command ssh.exe -ErrorAction Stop).Source +$scp = (Get-Command scp.exe -ErrorAction Stop).Source +$peer = "$UserName@$HostName" +$sshOptions = @('-i', $IdentityFile, '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', + '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=15', + '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', '-p', "$SshPort") +$scpOptions = @('-i', $IdentityFile, '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', + '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=15', '-P', "$SshPort") +function Quote-PS([string] $Value) { return "'" + $Value.Replace("'", "''") + "'" } +function Find-Z3Runtime([string] $BuildRoot, [string] $RustTarget, [string] $ExplicitPath) { + if ($ExplicitPath) { + if (-not (Test-Path -LiteralPath $ExplicitPath -PathType Leaf)) { throw "Missing Z3 runtime: $ExplicitPath" } + return (Resolve-Path -LiteralPath $ExplicitPath).Path + } + $release = Join-Path $BuildRoot "$RustTarget/release" + $adjacent = Join-Path $release 'libz3.dll' + if (Test-Path -LiteralPath $adjacent -PathType Leaf) { return $adjacent } + # Windows prebuilt-z3 links an import library rather than a static library. + $candidates = @(Get-ChildItem -Path (Join-Path $release 'build/z3-sys-*/out/z3-*/bin/libz3.dll') -File -ErrorAction SilentlyContinue) + if ($candidates.Count -eq 0) { throw 'libz3.dll not found in the target build cache. Supply -Z3DllPath matching the linked Z3 build.' } + $hashes = @($candidates | ForEach-Object { (Get-FileHash -LiteralPath $_.FullName).Hash } | Select-Object -Unique) + if ($hashes.Count -ne 1) { throw 'Multiple different Z3 runtimes found. Supply -Z3DllPath matching the linked Z3 build.' } + return $candidates[0].FullName +} +function Read-CacheHashes([string] $Json) { + # Windows PowerShell 5.1 emits decoded JSON arrays as one pipeline object. + # Explicitly enumerate so callers do not get a nested array with -contains. + $decoded = $Json | ConvertFrom-Json + foreach ($hash in $decoded) { + if ($hash -notmatch '^[a-fA-F0-9]{64}$') { throw 'Invalid hash in remote cache response.' } + Write-Output $hash + } +} +function Remote-Command([string] $Code) { + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes( + '$ProgressPreference="SilentlyContinue"; $ErrorActionPreference="Stop"; ' + $Code)) + & $ssh @sshOptions $peer "powershell.exe -NoProfile -NonInteractive -EncodedCommand $encoded" +} + +# Registry architecture is native even if the SSH shell runs under emulation. +$hostJson = @(Remote-Command @' +[pscustomobject]@{ + name = $env:COMPUTERNAME + architecture = (Get-ItemPropertyValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' PROCESSOR_ARCHITECTURE) + profile = $env:USERPROFILE + os = [Environment]::OSVersion.Version.ToString() +} | ConvertTo-Json -Compress +'@) +if ($LASTEXITCODE -ne 0) { throw 'Remote host inspection failed.' } +$hostInfo = ($hostJson -join "`n") | ConvertFrom-Json +$native = switch ($hostInfo.architecture) { 'ARM64' { 'arm64' } 'AMD64' { 'x64' } default { throw 'Unsupported remote architecture.' } } +if ($Architecture -eq 'auto') { $Architecture = $native } +if ($Architecture -ne $native) { throw "Requested $Architecture does not match native remote $native." } +$target = if ($Architecture -eq 'arm64') { 'aarch64-pc-windows-msvc' } else { 'x86_64-pc-windows-msvc' } +if (-not $BuildDirectory) { $BuildDirectory = Join-Path $repo 'target/remote-mxc-build' } +$BuildDirectory = [IO.Path]::GetFullPath($BuildDirectory) +if (-not $RemoteRoot) { $RemoteRoot = Join-Path $hostInfo.profile 'openshell-mxc-tests' } +if ($RemoteRoot -notmatch '^[A-Za-z]:[\\/]' -or $RemoteRoot -match '[\r\n]') { throw 'RemoteRoot must be an absolute Windows directory.' } +$RemoteRoot = $RemoteRoot.Replace('/', '\').TrimEnd('\') +$commit = (& git -C $repo rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0) { throw 'Cannot determine checkout commit.' } +$dirty = @(& git -C $repo status --porcelain) +$runId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit.Substring(0, 12) + '-' + [guid]::NewGuid().ToString('N').Substring(0, 6) +$localRun = Join-Path $repo "target/windows-remote-results/$runId" +New-Item -ItemType Directory -Force -Path $localRun | Out-Null +$transcript = $false +$exitCode = 1 +try { + Start-Transcript -Path (Join-Path $localRun 'local-transcript.txt') | Out-Null + $transcript = $true + Write-Host "Host: $($hostInfo.name), $native, Windows $($hostInfo.os); checkout: $commit" + if (-not $SkipBuild) { + $previousTarget = $env:CARGO_TARGET_DIR + Push-Location $repo + try { + $env:CARGO_TARGET_DIR = $BuildDirectory + & mise run --skip-tools "windows:build:$Architecture" + if ($LASTEXITCODE -ne 0) { throw 'Local Windows build failed; no tests launched.' } + & mise run --skip-tools "windows:build:mxc-fixtures:$Architecture" + if ($LASTEXITCODE -ne 0) { throw 'MXC E2E fixture build failed; no tests launched.' } + } finally { + $env:CARGO_TARGET_DIR = $previousTarget + Pop-Location + foreach ($logName in @("build-$target-release.log", "build-$target-mxc-fixtures.log")) { + $buildLog = Join-Path $repo $logName + if (Test-Path $buildLog) { Copy-Item -LiteralPath $buildLog -Destination $localRun } + } + } + } else { Write-Warning 'SkipBuild: using existing binaries, not claiming they were rebuilt from this checkout.' } + + $files = @() + foreach ($name in @('openshell.exe', 'openshell-gateway.exe', 'openshell-supervisor.exe', 'openshell-windows-sandbox.exe')) { + $files += @{ source = (Join-Path $BuildDirectory "$target/release/$name"); path = "bin/$name" } + } + $examples = 'crates/openshell-driver-mxc/examples' + $files += @{ source = (Join-Path $BuildDirectory "$target/release/examples/mxc-forwarding-agent.exe"); path = 'bin/mxc-forwarding-agent.exe' } + $files += @{ source = (Find-Z3Runtime $BuildDirectory $target $Z3DllPath); path = 'bin/libz3.dll' } + foreach ($path in @("$examples/run-mxc-e2e.ps1", "$examples/mxc-gateway.toml", 'tasks/scripts/run-mxc-remote.ps1')) { + $files += @{ source = (Join-Path $repo $path); path = $path } + } + foreach ($policy in Get-ChildItem (Join-Path $repo "$examples/e2e-policies") -File) { + $files += @{ source = $policy.FullName; path = "$examples/e2e-policies/$($policy.Name)" } + } + $entries = @($files | ForEach-Object { + if (-not (Test-Path -LiteralPath $_.source -PathType Leaf)) { throw "Missing artifact: $($_.source)" } + [pscustomobject]@{ source = $_.source; path = $_.path; sha256 = (Get-FileHash -LiteralPath $_.source -Algorithm SHA256).Hash.ToLowerInvariant(); bytes = (Get-Item -LiteralPath $_.source).Length } + }) + $manifest = [ordered]@{ runId = $runId; commit = $commit; dirty = $dirty; buildSkipped = [bool]$SkipBuild; + target = $target; hostInfo = $hostInfo; wxcExecPath = $WxcExecPath; scenario = $Scenario; gatewayPort = $GatewayPort; + files = @($entries | Select-Object path, sha256, bytes) } + $manifestPath = Join-Path $localRun 'manifest.json' + $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 + $remoteRun = "$RemoteRoot\runs\$runId" + $cache = "$RemoteRoot\cache" + Remote-Command "New-Item -ItemType Directory -Force -Path $(Quote-PS $remoteRun),$(Quote-PS $cache) | Out-Null" + if ($LASTEXITCODE -ne 0) { throw 'Could not create remote test directories.' } + & $scp @scpOptions $manifestPath "${peer}:$($remoteRun.Replace('\', '/'))/manifest.json" + if ($LASTEXITCODE -ne 0) { throw 'Manifest transfer failed.' } + $missingJson = @(Remote-Command @" +`$m = Get-Content -Raw $(Quote-PS "$remoteRun\manifest.json") | ConvertFrom-Json +`$missing = @(`$m.files | Where-Object { `$p = Join-Path $(Quote-PS $cache) `$_.sha256; !(Test-Path -LiteralPath `$p) -or (Get-FileHash -LiteralPath `$p).Hash -ne `$_.sha256 } | ForEach-Object { `$_.sha256 }) +ConvertTo-Json -InputObject `$missing -Compress +"@) + if ($LASTEXITCODE -ne 0) { throw 'Remote artifact cache inspection failed.' } + $missing = @(Read-CacheHashes ($missingJson -join "`n")) + foreach ($entry in $entries) { + if ($missing -contains $entry.sha256) { + Write-Host "Uploading $($entry.path) ($($entry.bytes) bytes)" + & $scp @scpOptions $entry.source "${peer}:$($cache.Replace('\', '/'))/$($entry.sha256)" + if ($LASTEXITCODE -ne 0) { throw "Transfer failed: $($entry.path)" } + } else { Write-Host "Cached: $($entry.path)" } + } + Remote-Command @" +`$m = Get-Content -Raw $(Quote-PS "$remoteRun\manifest.json") | ConvertFrom-Json +foreach (`$f in `$m.files) { + `$src = Join-Path $(Quote-PS $cache) `$f.sha256 + if ((Get-FileHash -LiteralPath `$src).Hash -ne `$f.sha256) { throw 'Remote artifact hash mismatch' } + `$dst = Join-Path $(Quote-PS $remoteRun) `$f.path + New-Item -ItemType Directory -Force -Path (Split-Path `$dst) | Out-Null + Copy-Item -LiteralPath `$src -Destination `$dst +} +"@ + if ($LASTEXITCODE -ne 0) { throw 'Remote payload verification failed.' } + $command = "& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $(Quote-PS "$remoteRun\tasks\scripts\run-mxc-remote.ps1"); exit `$LASTEXITCODE" + Remote-Command $command | Tee-Object -FilePath (Join-Path $localRun 'ssh-console.log') + $exitCode = $LASTEXITCODE + # Download independently of test status. Never include the harness key directory. + & $scp @scpOptions "${peer}:$($remoteRun.Replace('\', '/'))/results.zip" (Join-Path $localRun 'results.zip') + if ($LASTEXITCODE -ne 0) { throw "Results download failed (remote test exit $exitCode). Artifacts remain in $remoteRun" } + Expand-Archive -LiteralPath (Join-Path $localRun 'results.zip') -DestinationPath (Join-Path $localRun 'results') + Write-Host "Remote test exit: $exitCode; results: $localRun" +} catch { + Write-Warning $_.Exception.Message + $_ | Out-String | Set-Content -LiteralPath (Join-Path $localRun 'error.txt') + $exitCode = 1 +} finally { + if ($transcript) { Stop-Transcript | Out-Null } + Write-Host "Local logs: $localRun" +} +exit $exitCode diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 5b0480edf6..a88f6744f3 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -614,7 +614,7 @@ if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real") } } -if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { +if ($Action -in @("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -641,6 +641,13 @@ switch ($Action) { } Show-Artifacts $targets } + "build-mxc-fixtures" { + foreach ($rustTarget in $targets) { + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo build --release -p openshell-driver-mxc --example mxc-forwarding-agent --target $rustTarget" ` + -LogName "build-$rustTarget-mxc-fixtures.log" + } + } "test" { foreach ($rustTarget in $targets) { Invoke-Test $rustTarget diff --git a/tasks/windows.toml b/tasks/windows.toml index 6fe15c5f4b..8c753edf33 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -87,5 +87,21 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts ["windows:e2e:mxc"] description = "Run real MXC Tier-3 e2e; missing runtime coverage exits non-zero (no mocks)" +depends = ["windows:build:mxc-fixtures"] run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1" + +["windows:build:mxc-fixtures"] +description = "Build test-only MXC forwarding fixture for the native host" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 build-mxc-fixtures native" + +["windows:build:mxc-fixtures:x64"] +description = "Build test-only MXC forwarding fixture for x64" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 build-mxc-fixtures x86_64-pc-windows-msvc" + +["windows:build:mxc-fixtures:arm64"] +description = "Build test-only MXC forwarding fixture for ARM64" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 build-mxc-fixtures aarch64-pc-windows-msvc" From 5826365ffd9aa923ba9cc6dcf70f416532286342 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 6 Oct 2026 15:18:37 -0700 Subject: [PATCH 06/22] test(mxc): cover named HTTP service forwarding Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 4 + crates/openshell-driver-mxc/README.md | 6 ++ .../examples/mxc-forwarding-agent.rs | 43 ++++++++- .../examples/run-mxc-e2e.ps1 | 92 +++++++++++++++++-- 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 48ea6eae20..bbda299333 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -372,6 +372,10 @@ coverage, not evidence that direct host ingress is fenced. Use The E2E mise task and remote runner build the `mxc-forwarding-agent` Cargo example using `windows:build:mxc-fixtures` (or its `:x64` / `:arm64` variant). This fixture is not one of the four production deliverables; do not package it in releases. +The `service-forwarding` scenario uses its HTTP mode to test named endpoint +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. When reporting `windows:ci`, distinguish these categories: diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index ef52ba257f..2755131b9b 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -172,3 +172,9 @@ assigned. Its native echo agent is a test-only Cargo example, built automaticall by the E2E mise task and remote runner, not a shipped runtime binary. It verifies managed ingress, not denial of direct host-to-workload connections. Select it with `-Scenario forwarding` on the local harness or remote runner. + +The separate `service-forwarding` scenario runs the same native fixture in HTTP +mode, exposes a named endpoint with `openshell service expose`, and verifies two +unique GET responses through gateway host-based routing. It deletes the endpoint +and requires HTTP 404 afterward. Requests connect only to the gateway with the +exposed URL's Host header, so no wildcard DNS setup is required. diff --git a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs index e268ceef1b..9afa9f8c13 100644 --- a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs +++ b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs @@ -9,12 +9,13 @@ use std::time::Duration; fn main() -> std::io::Result<()> { let arguments: Vec = std::env::args().collect(); - if arguments.len() != 3 { + if !(arguments.len() == 3 || (arguments.len() == 4 && arguments[3] == "http")) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "usage: mxc-forwarding-agent READY_FILE NONCE", + "usage: mxc-forwarding-agent READY_FILE NONCE [http]", )); } + let http_mode = arguments.len() == 4; let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))?; let ready_file = std::path::Path::new(&arguments[1]); let staged_ready = ready_file.with_extension("tmp"); @@ -26,14 +27,48 @@ fn main() -> std::io::Result<()> { socket.set_read_timeout(Some(Duration::from_secs(10)))?; socket.set_write_timeout(Some(Duration::from_secs(10)))?; let mut request = String::new(); - BufReader::new((&socket).take(4096)).read_line(&mut request)?; + let mut reader = BufReader::new((&socket).take(8192)); + reader.read_line(&mut request)?; if !request.ends_with('\n') { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "request must be a bounded newline-terminated string", )); } - write!(socket, "{}:{request}", arguments[2])?; + if http_mode { + loop { + let mut header = String::new(); + if reader.read_line(&mut header)? == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP headers must be bounded and complete", + )); + } + if header == "\r\n" { + break; + } + } + drop(reader); + let mut fields = request.split_whitespace(); + if fields.next() != Some("GET") { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP fixture accepts GET only", + )); + } + let target = fields.next().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "missing HTTP target") + })?; + let body = format!("{}:{target}", arguments[2]); + write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + } else { + drop(reader); + write!(socket, "{}:{request}", arguments[2])?; + } } Ok(()) } diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 727fb79883..e8fe06dced 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -35,6 +35,7 @@ # processcontainer only. # 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. [CmdletBinding()] param( @@ -335,6 +336,70 @@ function Test-Forwarding([string]$sandboxName, [string]$readyFile, [string]$nonc } } +# Connect to the gateway only, setting the exposed service URL's Host header. +# This avoids DNS requirements and cannot accidentally dial the workload directly. +function Invoke-ServiceRequest([uri]$serviceUrl, [string]$target) { + if ($serviceUrl.Scheme -ne 'http' -or $serviceUrl.Port -ne $Port) { + throw "Unexpected E2E service URL: $serviceUrl" + } + $request = [Net.HttpWebRequest]::Create("http://127.0.0.1:$Port$target") + $request.Host = $serviceUrl.Authority + $request.Proxy = $null + $request.AllowAutoRedirect = $false + $request.KeepAlive = $false + $request.Timeout = 10000 + $request.ReadWriteTimeout = 10000 + $response = $null + $reader = $null + try { + try { $response = $request.GetResponse() } + catch [Net.WebException] { + if (-not $_.Exception.Response) { throw } + $response = $_.Exception.Response + } + $reader = New-Object IO.StreamReader($response.GetResponseStream()) + return @{ Status = [int]$response.StatusCode; Body = $reader.ReadToEnd() } + } finally { + if ($reader) { $reader.Dispose() } + if ($response) { $response.Dispose() } + } +} + +function Test-ServiceForwarding([string]$sandboxName, [string]$readyFile, [string]$nonce) { + if (-not (Wait-File $readyFile 30)) { throw "MXC HTTP listener did not report readiness" } + $targetPort = [int](Get-Content -LiteralPath $readyFile -Raw).Trim() + if ($targetPort -lt 1 -or $targetPort -gt 65535) { throw "Invalid workload HTTP port" } + $exposed = $false + try { + $result = Invoke-NativeCaptured $cli @('service', 'expose', $sandboxName, "$targetPort", 'web') 15 + $text = $result.Output -join "`n" + $text | Set-Content -LiteralPath (Join-Path $resultDir 'service.expose.log') -Encoding UTF8 + if ($result.ExitCode -ne 0) { throw "service expose failed: $text" } + $exposed = $true + if ($text -notmatch 'URL:\s+(http://[^\s\x1b]+)') { throw "service expose returned no HTTP URL: $text" } + $serviceUrl = [uri]$Matches[1] + for ($exchange = 1; $exchange -le 2; $exchange++) { + $target = "/probe-$exchange?request=$([guid]::NewGuid().ToString('N'))" + $response = Invoke-ServiceRequest $serviceUrl $target + if ($response.Status -ne 200 -or $response.Body -ne "$nonce`:$target") { + throw "HTTP exchange $exchange failed: status=$($response.Status); body=$($response.Body)" + } + Info "service HTTP exchange ${exchange}: exact workload response verified" + } + $deleted = Invoke-NativeCaptured $cli @('service', 'delete', $sandboxName, 'web') 15 + $deleted.Output | Set-Content -LiteralPath (Join-Path $resultDir 'service.delete.log') -Encoding UTF8 + if ($deleted.ExitCode -ne 0) { throw "service delete failed: $($deleted.Output -join "`n")" } + $exposed = $false + $response = Invoke-ServiceRequest $serviceUrl '/after-delete' + if ($response.Status -ne 404) { throw "Deleted service still routes: status=$($response.Status)" } + Info 'deleted service returns HTTP 404' + } finally { + if ($exposed) { + try { Invoke-NativeCaptured $cli @('service', 'delete', $sandboxName, 'web') 15 | Out-Null } catch {} + } + } +} + # Detect an agent *launch* failure (binary not found / not implemented) vs a # legitimate policy denial. Used to avoid false-passing a deny scenario when the # agent never actually ran. @@ -579,6 +644,13 @@ try { Backends = "process_container"; Kind = "forwarding" ReadyFile = (Join-Path $DemoDir "forwarding-port.txt") Description = "real MXC listener receives two TCP exchanges through authenticated OpenShell forwarding" + }, + @{ + Name = "service-forwarding"; PolicyFile = Join-Path $policyDir "forwarding.yaml" + SandboxId = "svc" + Backends = "process_container"; Kind = "service-forwarding" + ReadyFile = (Join-Path $DemoDir "service-forwarding-port.txt") + Description = "named HTTP service routes two requests into MXC and stops routing after deletion" } ) @@ -650,7 +722,7 @@ try { } else { $command = @($cmdExe, "/c", "echo denied 1> $denied") } - } elseif ($sc.Kind -eq "forwarding") { + } elseif ($sc.Kind -in @("forwarding", "service-forwarding")) { Remove-Item -LiteralPath $sc.ReadyFile -Force -ErrorAction SilentlyContinue $forwardNonce = [guid]::NewGuid().ToString('N') $fixture = Join-Path $BinaryDir 'mxc-forwarding-agent.exe' @@ -663,6 +735,7 @@ try { $workload = Join-Path $DemoDir 'mxc-forwarding-agent.exe' Copy-Item -LiteralPath $fixture -Destination $workload -Force $command = @($workload, $sc.ReadyFile, $forwardNonce) + if ($sc.Kind -eq "service-forwarding") { $command += 'http' } } else { $command = @($cmdExe, "/c", "exit 0") } @@ -719,12 +792,18 @@ try { if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure (not a policy result)" } $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact absent" } } - } elseif ($sc.Kind -eq "forwarding") { + } elseif ($sc.Kind -in @("forwarding", "service-forwarding")) { try { if ($createExitCode -ne 0) { throw "sandbox create failed: $createOutStr" } - Test-Forwarding $sandboxName $sc.ReadyFile $forwardNonce - Ok "$($sc.Name): both forwarded request/reply exchanges succeeded" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "two exact TCP replies through OpenShell" } + if ($sc.Kind -eq 'forwarding') { + Test-Forwarding $sandboxName $sc.ReadyFile $forwardNonce + $reason = 'two exact TCP replies through OpenShell' + } else { + Test-ServiceForwarding $sandboxName $sc.ReadyFile $forwardNonce + $reason = 'two exact HTTP replies; deleted route returns 404' + } + Ok "$($sc.Name): $reason" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = $reason } } catch { Bad "$($sc.Name): $($_.Exception.Message)" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = $_.Exception.Message } @@ -839,7 +918,8 @@ Files in this bundle ($resultDir): What PASS means: every selected scenario ran against real MXC and met its expected verdict - positive writes produced their artifact, deny writes were blocked with either a control write or driver-launch evidence, and forwarding verified two exact TCP replies -through OpenShell. Missing coverage exits non-zero; network-policy proves +through OpenShell. Service forwarding verifies two HTTP replies and HTTP 404 +after endpoint deletion. Missing coverage exits non-zero; network-policy proves admission and execution, not network enforcement. Forwarding proves managed ingress works, not that direct host ingress is blocked. "@ From c276ca3c76dfedcaf6f77637dc3b0a72e4f9c004 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 8 Oct 2026 11:44:55 -0700 Subject: [PATCH 07/22] feat(mxc): adopt stable MXC 1.0.0 contract Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 23 ++ crates/openshell-driver-mxc/README.md | 9 +- .../examples/probe-mxc-host.ps1 | 101 ++--- .../examples/run-mxc-e2e.ps1 | 28 +- crates/openshell-driver-mxc/src/driver.rs | 35 +- .../openshell-driver-mxc/src/etw_consumer.rs | 9 +- crates/openshell-driver-mxc/src/mxc.rs | 22 +- crates/openshell-driver-mxc/src/policy.rs | 33 +- .../src/policy_map/config.rs | 101 +++-- .../src/policy_map/loss.rs | 8 +- .../src/policy_map/map.rs | 317 +++++++++++---- .../src/policy_map/mod.rs | 13 +- .../src/policy_map/report.rs | 28 +- .../tests/policy_mapper_examples.rs | 99 +++-- .../tests/policy_mapper_matrix.rs | 351 ++++++++++------ .../tests/wxc_exec_real.rs | 380 ++++++++---------- docs/how-it-works/gateways/configuration.mdx | 2 +- rfc/0013-native-windows-mxc/README.md | 6 + 18 files changed, 913 insertions(+), 652 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index bbda299333..b80e2330cc 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -314,6 +314,29 @@ in the gateway build graph, but their Unix-socket standalone binaries do not. ## Unsupported Driver Contract +MXC requests and mapper artifacts use the stable 1.0.0 schema. Upgrade the +native host executor to the signed MXC 1.0.0 release before running E2E; preserve +the old executable for rollback and verify the release checksum, architecture, +signature, and `--probe` output. Remove `default_configuration_id` from gateway +configurations: it is retired and rejected. Do not restore alpha-version or +wildcard compatibility knobs. Standalone network mapping emits numeric CIDRs +and TCP ports and reports unrepresentable DNS/L7 semantics as losses; full +network governance remains in the host supervisor. IsolationSession remains +unsupported by this runtime pair, and its filesystem grants are not enforceable. + +MXC does not support external-resource label admission. Its factory explicitly +acknowledges admission as disabled; do not add `resource_admission` to MXC TOML +or weaken admission defaults for other drivers. Caller driver JSON still +requires `allow_driver_config = true`. This does not waive native isolation, +workload policy, authenticated transport, or outer-fence confirmation. + +UI controls remain in `SandboxPolicy`. Advertise `openshell.policy.ui.v1` using +existing extension metadata, not a new per-driver boolean. The shared gateway +checks effective policy before driver validation and provisioning; drivers +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 diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 2755131b9b..1ad28b4a20 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -98,9 +98,12 @@ etw_audit = false Only `process_container` supports this architecture. `isolation_session` is rejected during sandbox validation. -Legacy configurations may retain `default_configuration_id`; it remains unused -by ProcessContainer and does not enable IsolationSession. New configurations -should omit that field. +MXC 1.0.0 is required. Pre-1.0 executors and the retired +`default_configuration_id` setting are not supported; remove that setting +before upgrading. Both live launches and standalone mapper output use the +stable directional `network.egress` / `network.ingress` schema. The standalone +mapper emits numeric CIDRs and TCP ports, and reports DNS and L7 semantics as +mapping losses; the host supervisor continues to enforce the full network policy. Supply the workload command and working directory per sandbox: diff --git a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 index 343e88e88d..0de4292496 100644 --- a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -65,15 +65,23 @@ function Invoke-WxcDryRun([string] $wxc, [hashtable] $config) { return Invoke-Native @($wxc, "--config-base64", $b64, "--dry-run") } -function Invoke-WxcPhase([string] $wxc, [hashtable] $config, [switch] $Experimental) { +function Invoke-WxcPhase( + [string] $wxc, + [hashtable] $config, + [string] $Operation, + [string] $ContainerId +) { $json = $config | ConvertTo-Json -Depth 20 -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) $b64 = [Convert]::ToBase64String($bytes) - if ($Experimental) { - return Invoke-Native @($wxc, "--config-base64", $b64, "--experimental") - } else { - return Invoke-Native @($wxc, "--config-base64", $b64) + $arguments = @($wxc, "--config-base64", $b64) + if (-not [string]::IsNullOrWhiteSpace($Operation)) { + $arguments += @("--operation", $Operation) + } + if (-not [string]::IsNullOrWhiteSpace($ContainerId)) { + $arguments += @("--container-id", $ContainerId) } + return Invoke-Native $arguments } function Invoke-WxcProbe([string] $wxc) { @@ -90,8 +98,8 @@ try { if ($null -ne $ubr) { $osRevision = $ubr } } catch {} $osBuildFull = "$osBuild.$osRevision" -$isoSessionMinBuild = 26300 -$isoSessionMinRevision = 8553 +$isoSessionMinBuild = 26340 +$isoSessionMinRevision = 9212 # ── wxc-exec info ───────────────────────────────────────────────────────────── @@ -112,6 +120,7 @@ if (Test-Path $WxcExecPath) { # ── Probe section ───────────────────────────────────────────────────────────── $probeOutput = $null +$probeData = $null $dryRunExitCode = $null $dryRunOutput = $null $pcTrialResult = "absent" @@ -127,10 +136,13 @@ if ($wxcInfo.exists) { # --probe $probeResult = Invoke-WxcProbe -wxc $WxcExecPath $probeOutput = $probeResult.Output + try { + $probeData = $probeOutput | ConvertFrom-Json + } catch {} # dry-run trial (minimal processcontainer config) $dryConfig = @{ - version = "0.6.0-alpha" + version = "1.0.0" containerId = "probe-dryrun" containment = "processcontainer" process = @{ @@ -148,7 +160,7 @@ if ($wxcInfo.exists) { # processcontainer one-shot trial $pcConfig = @{ - version = "0.6.0-alpha" + version = "1.0.0" containerId = "probe-pc-oneshot" containment = "processcontainer" process = @{ @@ -198,27 +210,20 @@ if ($wxcInfo.exists) { # isolation_session provision trial $isoConfig = @{ - version = "0.6.0-alpha" - phase = "provision" + version = "1.0.0" containment = "isolation_session" - filesystem = @{ - readwritePaths = @() - readonlyPaths = @() - } - experimental = @{ - isolation_session = @{ - configurationId = "composable" - provision = @{} - } + network = @{ + egress = @{ default = "allow" } + ingress = @{ default = "allow"; hostLoopback = "allow" } } } - $isoResult = Invoke-WxcPhase -wxc $WxcExecPath -config $isoConfig -Experimental + $isoResult = Invoke-WxcPhase -wxc $WxcExecPath -config $isoConfig -Operation "provision" $isoOutput = $isoResult.Output $isoOutputLower = $isoOutput.ToLower() if ($isoOutputLower -match "backend_unavailable" -or $isoOutputLower -match "0x80040154") { $isoTrialResult = "unavailable" - $isoTrialMessage = "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553" + $isoTrialMessage = "backend_unavailable: IsoSessionApp.dll absent or OS build < 26340.9212" } elseif ($isoResult.ExitCode -eq 0) { # Provision succeeded — deprovision immediately to avoid orphaning. $isoTrialResult = "live" @@ -232,32 +237,10 @@ if ($wxcInfo.exists) { } catch {} if ($null -ne $sandboxId) { - # Stop first (a provisioned-but-unstarted session may still accept it; - # ignore failures), then deprovision. Surface the deprovision error - # text — an orphaned session blocks the single-session backend. - $stopConfig = @{ - version = "0.6.0-alpha" - phase = "stop" - sandboxId = $sandboxId - experimental = @{ - isolation_session = @{ - # Unit variant: serialize as null, not {} (malformed_request otherwise). - stop = $null - } - } - } - Invoke-WxcPhase -wxc $WxcExecPath -config $stopConfig -Experimental | Out-Null - $deprovConfig = @{ - version = "0.6.0-alpha" - phase = "deprovision" - sandboxId = $sandboxId - experimental = @{ - isolation_session = @{ - deprovision = $null - } - } - } - $deprovResult = Invoke-WxcPhase -wxc $WxcExecPath -config $deprovConfig -Experimental + # Deprovision immediately. Surface the error text because an + # orphaned session blocks the single-session backend. + $deprovConfig = @{ version = "1.0.0" } + $deprovResult = Invoke-WxcPhase -wxc $WxcExecPath -config $deprovConfig -Operation "deprovision" -ContainerId $sandboxId if ($deprovResult.ExitCode -eq 0) { $isoTrialMessage = "isolation_session live (provisioned $sandboxId, deprovisioned cleanly)" } else { @@ -300,6 +283,22 @@ if ($null -eq $dryRunExitCode) { $dryRunVerdict = "failed: exit $dryRunExitCode" } +$isolationTier = "unknown" +$hostLoopbackSupported = $null +$governedEgressVerdict = "unavailable: wxc-exec probe data missing" +if ($null -ne $probeData) { + if ($null -ne $probeData.tier) { $isolationTier = [string]$probeData.tier } + if ($null -ne $probeData.probes -and + $probeData.probes.PSObject.Properties.Name -contains "baseContainerSupportsIngressHostLoopbackAllow") { + $hostLoopbackSupported = [bool]$probeData.probes.baseContainerSupportsIngressHostLoopbackAllow + if ($hostLoopbackSupported) { + $governedEgressVerdict = "supported" + } else { + $governedEgressVerdict = "unavailable: isolation tier $isolationTier does not support ingress.hostLoopback=allow" + } + } +} + # ── Assemble report ─────────────────────────────────────────────────────────── $report = [ordered]@{ @@ -322,12 +321,17 @@ $report = [ordered]@{ result = $pcTrialResult message = $pcTrialMessage } + processcontainerCapabilities = [ordered]@{ + isolationTier = $isolationTier + ingressHostLoopbackAllow = $hostLoopbackSupported + } isolationSessionTrial = [ordered]@{ result = $isoTrialResult message = $isoTrialMessage } verdicts = [ordered]@{ processcontainer = $pcVerdict + governedEgress = $governedEgressVerdict isolation_session = $isoVerdict dryRun = $dryRunVerdict } @@ -344,6 +348,7 @@ if ($Full) { Write-Host "wxc-exec: $WxcExecPath (exists=$($wxcInfo.exists))" Write-Host "verdicts:" Write-Host " processcontainer : $pcVerdict" + Write-Host " governed egress : $governedEgressVerdict" Write-Host " isolation_session : $isoVerdict" Write-Host " dry-run : $dryRunVerdict" Write-Host "(re-run with -Full for the complete JSON report, or -OutFile caps.json to save it)" diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index e8fe06dced..4b727e8ff6 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -436,7 +436,7 @@ function Probe-Backend([string] $backendName, [string] $wxc) { $probeDir = Join-Path $env:TEMP "mxc-e2e-probe" New-Item -ItemType Directory -Force $probeDir | Out-Null $config = @{ - version = "0.6.0-alpha" + version = "1.0.0" containerId = "e2e-probe-pc" containment = "processcontainer" process = @{ commandLine = "C:\Windows\System32\cmd.exe /c exit 0"; cwd = $probeDir; timeout = 30000 } # ms (MXC process.timeout is milliseconds) @@ -457,21 +457,17 @@ function Probe-Backend([string] $backendName, [string] $wxc) { if ($backendName -eq "isolation_session") { $config = @{ - version = "0.6.0-alpha" - phase = "provision" + version = "1.0.0" containment = "isolation_session" - filesystem = @{ readwritePaths = @(); readonlyPaths = @() } - experimental = @{ - isolation_session = @{ - configurationId = "composable" - provision = @{} - } + network = @{ + egress = @{ default = "allow" } + ingress = @{ default = "allow"; hostLoopback = "allow" } } } $json = $config | ConvertTo-Json -Depth 20 -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) $b64 = [Convert]::ToBase64String($bytes) - $outObj = & $wxc --config-base64 $b64 --experimental 2>&1 + $outObj = & $wxc --config-base64 $b64 --operation provision 2>&1 $exitCode = $LASTEXITCODE $output = ($outObj -join "`n").ToLower() if ($output -match "backend_unavailable" -or $output -match "0x80040154") { @@ -488,19 +484,11 @@ function Probe-Backend([string] $backendName, [string] $wxc) { $sandboxId = $parsed.result.sandboxId } catch {} if ($null -ne $sandboxId) { - $deprovConfig = @{ - version = "0.6.0-alpha" - phase = "deprovision" - sandboxId = $sandboxId - experimental = @{ - # Unit variant: null, not @{} (malformed_request otherwise). - isolation_session = @{ deprovision = $null } - } - } + $deprovConfig = @{ version = "1.0.0" } $deprovJson = $deprovConfig | ConvertTo-Json -Depth 20 -Compress $deprovBytes = [System.Text.Encoding]::UTF8.GetBytes($deprovJson) $deprovB64 = [Convert]::ToBase64String($deprovBytes) - & $wxc --config-base64 $deprovB64 --experimental 2>&1 | Out-Null + & $wxc --config-base64 $deprovB64 --operation deprovision --container-id $sandboxId 2>&1 | Out-Null } return @{ Live = $true; Reason = "isolation_session probe: provisioned and deprovisioned" } } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 5253a3ae7f..30cbdf6bdb 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -78,10 +78,6 @@ pub struct MxcComputeConfig { pub state_dir: PathBuf, pub grpc_endpoint: String, pub backend: MxcBackend, - /// Legacy isolation-session setting, accepted for existing - /// `ProcessContainer` configurations but unused by that backend. - #[serde(skip_serializing)] - pub default_configuration_id: Option, pub pc_least_privilege: bool, pub pc_capabilities: Vec, pub pc_allow_local_network: bool, @@ -115,7 +111,6 @@ impl Default for MxcComputeConfig { state_dir, grpc_endpoint: String::new(), backend: MxcBackend::ProcessContainer, - default_configuration_id: None, pc_least_privilege: false, pc_capabilities: Vec::new(), pc_allow_local_network: true, @@ -757,7 +752,7 @@ async fn run_lifecycle_inner( timeout: 0, }; let network = MxcNetwork { - default_policy: "block".to_string(), + egress_default: "deny".to_string(), proxy: Some(proxy_addr), allow_local_network: context.config.pc_allow_local_network, }; @@ -1454,33 +1449,13 @@ mod tests { } #[test] - fn legacy_isolation_setting_does_not_change_processcontainer_contract() { - let config: MxcComputeConfig = serde_json::from_value(serde_json::json!({ + fn gateway_config_rejects_pre_1_0_configuration_id() { + let error = serde_json::from_value::(serde_json::json!({ "backend": "process_container", "default_configuration_id": "composable", })) - .expect("legacy ProcessContainer config"); - assert_eq!(config.backend, MxcBackend::ProcessContainer); - assert_eq!( - config.default_configuration_id.as_deref(), - Some("composable") - ); - assert!( - serde_json::to_value(&config) - .expect("config") - .get("default_configuration_id") - .is_none() - ); - let (mut backend, sandbox, _dir) = preflight_fixture(); - backend.config.backend = MxcBackend::IsolationSession; - backend.config.default_configuration_id = config.default_configuration_id; - assert!( - backend - .validate_sandbox_create(&sandbox) - .expect_err("IsolationSession still unsupported") - .message() - .contains("requires process_container") - ); + .expect_err("MXC pre-1.0 configurationId must not be accepted"); + assert!(error.to_string().contains("default_configuration_id")); } #[test] diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 8117d2e70a..208dda4e59 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -1731,11 +1731,10 @@ fn map_config_state(ctx: &EventContext, ev: &DecodedEtwEvent) -> OcsfEvent { "EnforceOsPolicy" => "MXC sandbox OS policy enforced".to_string(), "SandboxConsoleReferencePlumbed" => "MXC sandbox console reference plumbed".to_string(), // The one network-plane event the provider emits. Empirically the OS - // Sandboxing provider fires this event *only* when an egress proxy is - // configured for the sandbox, but it does **not** surface the port for - // MXC's URL-based proxy — `proxyPort` is always 0 (MXC redirects egress via - // a `network.proxy.localhost` policy URL, not the OS built-in proxy-port - // mechanism this field reflects). The real per-sandbox listening port is + // Sandboxing provider fires this event when governed egress is + // configured for the sandbox, but `proxyPort` is always 0 because the + // OpenShell listener is supplied through process proxy environment and + // MXC receives only loopback-only directional policy. The real per-sandbox listening port is // recorded on the host proxy's own Network Activity [4001] "Listen" event. // So the presence of this event means a proxy WAS configured; only append a // port on the off chance a future provider/build populates it. diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index c7ed821952..f39f20f2ce 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -11,9 +11,8 @@ use thiserror::Error; use tokio::process::Command; use tracing::{debug, info}; -/// MXC config schema version. The mapper and one-shot launcher share the -/// MXC 0.8 directional network schema. -pub const MXC_SCHEMA_VERSION: &str = "0.8.0-alpha"; +/// Stable MXC schema version shared by the mapper and live launcher. +pub const MXC_SCHEMA_VERSION: &str = "1.0.0"; /// Environment flag selecting the in-process mock `wxc-exec` shim. When set to /// `"1"`, the invoker does not spawn `wxc-exec.exe`. This wiring shim has no @@ -46,9 +45,9 @@ pub struct MxcFilesystem { /// Network redirect fragment emitted when governed egress is enabled. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MxcNetwork { - pub default_policy: String, + pub egress_default: String, pub proxy: Option, - /// When true, includes `"allowLocalNetwork": true` in the network JSON. + /// When true, emits permissive MXC 1.0 directional ingress and host loopback. /// Required for node.js to initialize inside a processcontainer — without /// it, node.exe DLL initialization fails with `STATUS_DLL_INIT_FAILED`. pub allow_local_network: bool, @@ -137,13 +136,6 @@ fn redact_env_for_debug(config: &serde_json::Value) -> serde_json::Value { } fn network_json(network: &MxcNetwork) -> serde_json::Value { - // MXC 0.8.0-alpha schema uses a directional egress/ingress format. - // "block" default_policy maps to egress.default "deny"; "allow" maps to "allow". - let egress_default = if network.default_policy == "block" { - "deny" - } else { - "allow" - }; let mut value = if network.proxy.is_some() { // Use direct loopback egress rather than runtimeConfig.networkProxy proxy // mode. Proxy mode routes all outbound TCP through processmodel.dll's WFP @@ -165,7 +157,7 @@ fn network_json(network: &MxcNetwork) -> serde_json::Value { "ingress": { "default": "allow", "hostLoopback": "allow" }, }) } else { - serde_json::json!({ "egress": { "default": egress_default } }) + serde_json::json!({ "egress": { "default": network.egress_default } }) }; if network.proxy.is_none() && network.allow_local_network { value["ingress"] = serde_json::json!({ "default": "allow", "hostLoopback": "allow" }); @@ -469,10 +461,10 @@ mod tests { #[test] fn network_json_emits_directional_format() { - // MXC 0.8.0-alpha: egress/ingress replaces the legacy + // MXC 1.0.0: egress/ingress replaces the legacy // defaultPolicy / allowedHosts / proxy.localhost shape. let network = MxcNetwork { - default_policy: "block".into(), + egress_default: "deny".into(), proxy: Some("127.0.0.1:18080".parse().unwrap()), allow_local_network: false, }; diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 753785e3d2..8e4d0b9271 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -157,6 +157,20 @@ impl PolicyMapper for EmbeddedPolicyMapper { ) })?; + if ctx.containment == "isolation_session" + && policy.filesystem.as_ref().is_some_and(|filesystem| { + filesystem.include_workdir + || !filesystem.read_only.is_empty() + || !filesystem.read_write.is_empty() + }) + { + return Err(MapError::Unsupported(vec![LossItem { + rule_kind: "filesystem_policy".into(), + detail: "MXC 1.0 isolation_session cannot enforce OpenShell filesystem grants" + .into(), + }])); + } + let (config, loss, trimmed_policy, proxy_addr) = if let Some(addr) = ctx.egress { // Pattern C: MXC handles filesystem + a proxy redirect, while the // host CONNECT proxy receives the network-only trimmed policy. @@ -265,7 +279,7 @@ mod tests { fn embedded_maps_policy_read_write_to_share() { let mapper = EmbeddedPolicyMapper; let policy = fs_policy(&["C:/work/openshell-mxc-demo"], &["C:/tools"]); - let ctx = demo_ctx(); + let ctx = processcontainer_ctx(); let config = mapper.map(Some(&policy), &ctx).unwrap(); // Forward slashes normalized to Windows backslashes by the bridge. assert!( @@ -305,6 +319,23 @@ mod tests { assert!(matches!(err, MapError::Unsupported(_))); } + #[test] + fn embedded_rejects_filesystem_policy_on_isolation_session() { + let mapper = EmbeddedPolicyMapper; + let policy = fs_policy(&["C:/work/demo"], &[]); + let err = mapper.map(Some(&policy), &demo_ctx()).unwrap_err(); + match err { + MapError::Unsupported(items) => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].rule_kind, "filesystem_policy"); + assert!(items[0].detail.contains("cannot enforce")); + } + MapError::Internal(message) => { + panic!("expected unsupported filesystem policy, got internal error: {message}") + } + } + } + #[test] fn embedded_rejects_explicit_ui_on_isolation_session() { use openshell_core::proto::UiPolicy; diff --git a/crates/openshell-driver-mxc/src/policy_map/config.rs b/crates/openshell-driver-mxc/src/policy_map/config.rs index f64b64a54a..3ba58085cb 100644 --- a/crates/openshell-driver-mxc/src/policy_map/config.rs +++ b/crates/openshell-driver-mxc/src/policy_map/config.rs @@ -7,34 +7,18 @@ use openshell_core::proto::SandboxPolicy; use serde_json::{Value, json}; use super::loss::{LossItem, add_loss}; +use crate::mxc::MXC_SCHEMA_VERSION; /// Placeholder command written into `process.commandLine` when the caller does /// not supply a real workload command. pub const DEFAULT_COMMAND: &str = "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\""; -/// Default MXC schema version emitted in `version`. -pub const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha"; +/// Stable MXC schema used by every mapper output. +pub const DEFAULT_MXC_VERSION: &str = MXC_SCHEMA_VERSION; /// Default MXC containment backend for the coarse mapping. pub const DEFAULT_CONTAINMENT: &str = "bubblewrap"; -/// Select a default `network.enforcementMode` for the backend, or `None` to -/// omit the field (backends that derive enforcement from host lists / proxy). -pub fn default_enforcement_mode( - containment: &str, - allowed_hosts: &[String], -) -> Option<&'static str> { - if allowed_hosts.is_empty() { - return None; - } - match containment { - "processcontainer" | "process" => Some("both"), - "wslc" | "seatbelt" | "microvm" | "vm" | "windows_sandbox" => None, - // lxc, bubblewrap, hyperlight, and anything else default to firewall. - _ => Some("firewall"), - } -} - /// Backend-specific advisory about how filesystem default-deny differs from /// `OpenShell` Landlock. pub fn filesystem_default_deny_message(containment: &str) -> String { @@ -61,42 +45,45 @@ pub fn filesystem_default_deny_message(containment: &str) -> String { pub fn add_backend_specific_config( config: &mut Value, containment: &str, - allowed_hosts: &[String], + has_direct_egress: bool, items: &mut Vec, ) { match containment { - "processcontainer" | "process" if !allowed_hosts.is_empty() => { + "processcontainer" | "process" if has_direct_egress => { config["processContainer"] = json!({ "capabilities": ["internetClient"] }); } "lxc" => { config["lxc"] = json!({ "distribution": "alpine", "release": "3.20" }); } - backend @ ("windows_sandbox" | "isolation_session" | "vm") if !allowed_hosts.is_empty() => { + backend @ ("windows_sandbox" | "isolation_session" | "vm") if has_direct_egress => { add_loss( items, "containment", "error", - &format!("{backend} is not a v0 target for OpenShell network policy mapping."), + &format!( + "MXC 1.0 `{backend}` cannot enforce this direct directional egress policy." + ), "OpenShell network policy", - "MXC network behavior is unsupported or unknown for this backend.", + "The mapping must be rejected instead of sending an unenforceable network grant.", ); } - "microvm" if !allowed_hosts.is_empty() => { + "microvm" | "hyperlight" if has_direct_egress => { add_loss( items, "containment", "error", - "microvm network policy enforcement is not defined for this mapper.", + "This experimental containment backend is outside the stable MXC 1.0 contract.", "OpenShell network policy", - "MXC network behavior is unsupported or unknown for microvm.", + "The stable-schema mapping must be rejected.", ); } _ => {} } } -/// Add a backend-specific advisory about host-allowlist fidelity. Only fires -/// when the source policy declares network rules. +/// Reject direct network mapping on stable-schema backends that cannot enforce +/// the generated directional policy. Only fires when the source policy declares +/// network rules. pub fn add_backend_network_loss( policy: &SandboxPolicy, containment: &str, @@ -106,38 +93,40 @@ pub fn add_backend_network_loss( return; } match containment { - "seatbelt" => add_loss( + "isolation_session" | "vm" | "windows_sandbox" | "microvm" | "hyperlight" => add_loss( items, "network_policies", "error", - "MXC Seatbelt cannot faithfully enforce arbitrary allowedHosts.", - "host allowlist", - "Seatbelt allowlists can broaden to allow-all outbound.", - ), - "processcontainer" | "process" => add_loss( - items, - "network_policies", - "warning", - "Windows ProcessContainer host allowlists are possible but fragile.", - "host allowlist", - "Review firewall/capability behavior before treating this as parity.", - ), - "wslc" => add_loss( - items, - "network_policies", - "warning", - "WSLC host filtering relies on bridged networking plus in-container iptables.", - "host allowlist", - "Backend privileges and runner behavior determine parity.", + "The selected backend cannot enforce stable MXC 1.0 directional egress rules for this mapping.", + "directional network policy", + "The caller must reject the mapping or use governed ProcessContainer egress.", ), - "vm" | "windows_sandbox" => add_loss( + _ => {} + } +} + +/// Reject filesystem grants on stable-schema backends that cannot enforce +/// them. Empty filesystem lists remain valid because they do not claim a grant. +pub fn add_backend_filesystem_loss( + policy: &SandboxPolicy, + containment: &str, + items: &mut Vec, +) { + let Some(filesystem) = &policy.filesystem else { + return; + }; + if containment == "isolation_session" + && (filesystem.include_workdir + || !filesystem.read_only.is_empty() + || !filesystem.read_write.is_empty()) + { + add_loss( items, - "network_policies", + "filesystem_policy.grants", "error", - "MXC Windows Sandbox / vm cannot faithfully enforce arbitrary allowedHosts.", - "host allowlist", - "Network policy enforcement is unsupported or unknown for this backend.", - ), - _ => {} + "MXC 1.0 isolation_session cannot enforce OpenShell filesystem grants.", + "filesystem path grants", + "The caller must reject the mapping instead of silently dropping the grants.", + ); } } diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs index 6af3b9a32b..0737b15ba5 100644 --- a/crates/openshell-driver-mxc/src/policy_map/loss.rs +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -26,14 +26,14 @@ pub struct LossItem { /// loss report so reviewers understand the mapping is not symmetric. pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[ "MXC processContainer UI refinements have no portable OpenShell policy equivalent: isolation, desktopSystemControl, systemSettings, and ime.", - "MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.", + "MXC lifecycle fields destroyOnExit and preservePolicy have no OpenShell policy equivalent.", "MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.", "MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.", "MXC explicit deniedPaths are not expressible in current OpenShell policy YAML, which relies on default-deny filesystem behavior instead.", "MXC fallback.allowDaclMutation (host DACL mutation consent) has no OpenShell policy equivalent.", - "MXC network.allowLocalNetwork (inbound bind/listen permission) has no OpenShell policy equivalent.", - "MXC network.proxy configuration has no OpenShell policy equivalent.", - "MXC experimental backend blocks (windows_sandbox, wslc, seatbelt, isolation_session) are outside OpenShell policy YAML.", + "MXC directional ingress and host-loopback actions have no direct OpenShell policy equivalent.", + "MXC runtimeConfig.networkProxy is runtime configuration rather than an OpenShell policy field.", + "MXC backend-specific configuration blocks are outside OpenShell policy YAML.", ]; pub fn add_loss( diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 14d8fc9cb5..82e089e5ec 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -4,12 +4,12 @@ //! Coarse OpenShell-policy → MXC `ContainerConfig` mapping. //! //! Operates on the typed [`SandboxPolicy`] (parse it with -//! `openshell_policy::parse_sandbox_policy`). Network policy is flattened into -//! an MXC host allowlist; everything MXC cannot express is recorded as a loss -//! item. The top-level `network_policies` map is iterated in sorted key order -//! so the output is deterministic (the proto map is unordered). +//! `openshell_policy::parse_sandbox_policy`). Numeric destinations and ports +//! map to MXC 1.0 directional rules; everything MXC cannot express is recorded +//! as a loss item. The top-level `network_policies` map is iterated in sorted +//! key order so the output is deterministic (the proto map is unordered). -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use openshell_core::proto::{ NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, UiClipboardAccess, UiPolicy, @@ -17,19 +17,15 @@ use openshell_core::proto::{ use serde_json::{Value, json}; use super::config::{ - DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_network_loss, - add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_filesystem_loss, + add_backend_network_loss, add_backend_specific_config, filesystem_default_deny_message, }; use super::loss::{LossItem, add_loss}; -use crate::mxc::MXC_SCHEMA_VERSION; /// Options controlling the generated MXC config. Fields not relevant to the /// coarse map (e.g. `proxy_redirect`) are reserved for the governed-egress split. #[derive(Clone, Debug)] pub struct MxcMappingOptions { - /// MXC schema version written into coarse-map output. The governed-egress - /// split uses the driver's MXC 0.8 schema. - pub mxc_version: String, /// MXC containment backend. pub containment: String, /// `process.commandLine` value. @@ -42,8 +38,6 @@ pub struct MxcMappingOptions { pub env: Vec, /// `process.timeout`. pub timeout_ms: u64, - /// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness. - pub allow_wildcards: bool, /// Governed-egress redirect address (used by the governed-egress split, not the /// coarse map). pub proxy_redirect: Option, @@ -52,14 +46,12 @@ pub struct MxcMappingOptions { impl Default for MxcMappingOptions { fn default() -> Self { Self { - mxc_version: DEFAULT_MXC_VERSION.to_owned(), containment: DEFAULT_CONTAINMENT.to_owned(), command: DEFAULT_COMMAND.to_owned(), container_id: "openshell-policy".to_owned(), cwd: None, env: Vec::new(), timeout_ms: 0, - allow_wildcards: false, proxy_redirect: None, } } @@ -146,6 +138,7 @@ fn build_split_mxc_config( } let filesystem = map_filesystem(policy, opts, items); + add_backend_filesystem_loss(policy, &opts.containment, items); let proxy_supported = matches!(opts.containment.as_str(), "processcontainer" | "process"); if !proxy_supported { @@ -176,7 +169,7 @@ fn build_split_mxc_config( } // Direct Internet egress is denied. Proxy-aware clients can reach only the // OpenShell proxy (and other host loopback listeners) through 127.0.0.1. - if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { + if proxy_supported && proxy_addr.ip() != IpAddr::from([127, 0, 0, 1]) { add_loss( items, "proxy_redirect", @@ -201,7 +194,7 @@ fn build_split_mxc_config( }); let mut config = json!({ - "version": MXC_SCHEMA_VERSION, + "version": DEFAULT_MXC_VERSION, "containerId": opts.container_id, "containment": opts.containment, "lifecycle": { @@ -218,7 +211,7 @@ fn build_split_mxc_config( // No network hosts, so backend-specific network blocks (processContainer // internetClient, etc.) are not added — correct for the proxy path. - add_backend_specific_config(&mut config, &opts.containment, &[], items); + add_backend_specific_config(&mut config, &opts.containment, false, items); add_static_policy_loss(policy, opts, items); config } @@ -240,19 +233,23 @@ fn build_mxc_config( } let filesystem = map_filesystem(policy, opts, items); - let allowed_hosts = map_network(policy, opts, items); - - let mut network = json!({ - "defaultPolicy": "block", - "allowedHosts": allowed_hosts, - "blockedHosts": [], - }); - if let Some(mode) = default_enforcement_mode(&opts.containment, &allowed_hosts) { - network["enforcementMode"] = json!(mode); + add_backend_filesystem_loss(policy, &opts.containment, items); + let allow_rules = map_network(policy, opts, items); + let has_direct_egress = !allow_rules.is_empty(); + let mut egress = json!({ "default": "deny" }); + if has_direct_egress { + egress["allow"] = json!(allow_rules); } + let network = json!({ + "egress": egress, + "ingress": { + "default": "deny", + "hostLoopback": "deny", + }, + }); let mut config = json!({ - "version": opts.mxc_version, + "version": DEFAULT_MXC_VERSION, "containerId": opts.container_id, "containment": opts.containment, "lifecycle": { @@ -267,7 +264,7 @@ fn build_mxc_config( config["ui"] = ui; } - add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); + add_backend_specific_config(&mut config, &opts.containment, has_direct_egress, items); add_static_policy_loss(policy, opts, items); config } @@ -423,7 +420,7 @@ fn map_network( policy: &SandboxPolicy, opts: &MxcMappingOptions, items: &mut Vec, -) -> Vec { +) -> Vec { if !policy.network_middlewares.is_empty() { add_loss( items, @@ -447,7 +444,7 @@ fn map_network( let mut rules: Vec<(&String, &NetworkPolicyRule)> = policy.network_policies.iter().collect(); rules.sort_by(|a, b| a.0.cmp(b.0)); - let mut allowed_hosts: Vec = Vec::new(); + let mut allow_rules: Vec = Vec::new(); for (key, rule) in rules { let rule_path = format!("network_policies.{key}"); @@ -459,12 +456,12 @@ fn map_network( "error", "OpenShell policy entry has no endpoints.", "network endpoints", - "No MXC host allowlist entries were produced for this policy.", + "No MXC directional allow rules were produced for this policy.", ); } for (index, endpoint) in rule.endpoints.iter().enumerate() { let endpoint_path = format!("{rule_path}.endpoints[{index}]"); - map_endpoint(endpoint, &endpoint_path, &mut allowed_hosts, opts, items); + map_endpoint(endpoint, &endpoint_path, &mut allow_rules, items); } if rule.binaries.is_empty() { @@ -494,43 +491,51 @@ fn map_network( } add_backend_network_loss(policy, &opts.containment, items); - allowed_hosts + allow_rules } fn map_endpoint( endpoint: &NetworkEndpoint, path: &str, - allowed_hosts: &mut Vec, - opts: &MxcMappingOptions, + allow_rules: &mut Vec, items: &mut Vec, ) { - // host - if endpoint.host.is_empty() { - add_loss( - items, - &format!("{path}.host"), - "error", - "Endpoint has no host.", - "network endpoint host", - "Endpoint was not added to MXC allowedHosts.", - ); - } else if contains_wildcard(&endpoint.host) { - let (message, impact) = if opts.allow_wildcards { - append_unique(allowed_hosts, endpoint.host.clone()); + let mut cidrs = Vec::new(); + let numeric_host = if endpoint.host.is_empty() { + None + } else { + normalize_cidr(&endpoint.host) + }; + + if let Some(cidr) = &numeric_host { + append_unique(&mut cidrs, cidr.clone()); + } else if endpoint.host.is_empty() { + if endpoint.allowed_ips.is_empty() { + add_loss( + items, + &format!("{path}.host"), + "error", + "Endpoint has neither a numeric host nor allowed_ips.", + "network endpoint destination", + "No MXC 1.0 directional allow rule is emitted.", + ); + } + } else { + let (feature, message) = if contains_wildcard(&endpoint.host) { ( + "OpenShell wildcard host matching", format!( - "Wildcard host emitted despite non-portable MXC semantics: {}.", + "Wildcard host '{}' cannot be represented by MXC 1.0 directional CIDR rules.", endpoint.host ), - "Backend behavior is not portable and may fail or broaden access.", ) } else { ( + "OpenShell DNS host matching", format!( - "Wildcard host omitted because MXC has no portable syntax: {}.", + "DNS host '{}' cannot be represented by MXC 1.0 directional CIDR rules.", endpoint.host ), - "Generated MXC config is more restrictive for this endpoint.", ) }; add_loss( @@ -538,51 +543,96 @@ fn map_endpoint( &format!("{path}.host"), "error", &message, - "OpenShell wildcard host matching", - impact, + feature, + "The hostname is omitted; supply allowed_ips or use governed egress for DNS policy.", ); - } else { - append_unique(allowed_hosts, endpoint.host.clone()); } - // port / ports (the proto normalizes a single port into `ports`) - if !endpoint.ports.is_empty() { - let (field, repr) = if endpoint.ports.len() == 1 { - ("port", endpoint.ports[0].to_string()) + for (index, raw) in endpoint.allowed_ips.iter().enumerate() { + if let Some(cidr) = normalize_cidr(raw) { + append_unique(&mut cidrs, cidr); } else { - ("ports", format!("{:?}", endpoint.ports)) - }; - add_loss( - items, - &format!("{path}.{field}"), - "error", - &format!("MXC allowedHosts cannot encode port constraint {repr}."), - "port-scoped outbound policy", - "MXC allows or blocks the host as a whole.", - ); + add_loss( + items, + &format!("{path}.allowed_ips[{index}]"), + "error", + &format!("'{raw}' is not a valid IP address or CIDR for MXC 1.0."), + "DNS result pinning / SSRF override", + "The invalid destination is omitted from the directional allow rule.", + ); + } } - // allowed_ips - for ip in &endpoint.allowed_ips { - append_unique(allowed_hosts, ip.clone()); + if !endpoint.allowed_ips.is_empty() && numeric_host.is_none() { + let reason = if endpoint.host.is_empty() { + "OpenShell permits DNS names whose resolved address is in allowed_ips, while MXC also permits direct connections to those CIDRs." + } else { + "MXC can carry the CIDRs but cannot bind them to the requested DNS hostname." + }; add_loss( items, &format!("{path}.allowed_ips"), - "warning", - &format!( - "MXC can carry CIDR/IP '{ip}', but cannot bind it to DNS for '{}'.", - endpoint.host - ), + "error", + reason, "DNS result pinning / SSRF override", - "The CIDR/IP becomes a standalone allowed destination.", + "The valid CIDRs are emitted, but the caller must reject the coarse mapping or accept the broader direct-IP scope.", ); } report_endpoint_l7_losses(endpoint, path, items); + + if cidrs.is_empty() { + return; + } + + let selected_ports: Vec = if !endpoint.ports.is_empty() { + endpoint.ports.clone() + } else if endpoint.port > 0 { + vec![endpoint.port] + } else { + Vec::new() + }; + let had_explicit_ports = !selected_ports.is_empty(); + let mut valid_ports = Vec::new(); + for (index, port) in selected_ports.into_iter().enumerate() { + if (1..=u32::from(u16::MAX)).contains(&port) { + if !valid_ports.contains(&port) { + valid_ports.push(port); + } + } else { + add_loss( + items, + &format!("{path}.ports[{index}]"), + "error", + &format!("Port {port} is outside the MXC 1.0 range 1..=65535."), + "port-scoped outbound policy", + "The invalid port is omitted from the directional allow rule.", + ); + } + } + if had_explicit_ports && valid_ports.is_empty() { + return; + } + let port_rules: Vec = if valid_ports.is_empty() { + vec![json!({ "protocol": "tcp" })] + } else { + valid_ports + .into_iter() + .map(|port| json!({ "protocol": "tcp", "port": port })) + .collect() + }; + let rule = json!({ + "to": cidrs + .into_iter() + .map(|cidr| json!({ "cidr": cidr })) + .collect::>(), + "ports": port_rules, + }); + append_unique_value(allow_rules, rule); } fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut Vec) { - if !endpoint.protocol.is_empty() { + if !endpoint.protocol.is_empty() && endpoint.protocol != "tcp" { add_loss( items, &format!("{path}.protocol"), @@ -625,9 +675,9 @@ fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut items, &format!("{path}.enforcement"), "warning", - "MXC enforcementMode is backend-wide, not per endpoint.", + "MXC 1.0 directional rules are enforce-only and have no per-endpoint enforcement selector.", "per-endpoint enforcement", - "The mapper chooses a backend-level enforcement mode.", + "The generated directional rule is enforced.", ); } } @@ -707,6 +757,73 @@ fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut if endpoint.graphql_max_body_bytes > 0 { add_graphql_loss(items, path, "graphql_max_body_bytes"); } + + let string_losses: &[(&str, &str, &str)] = &[ + ( + endpoint.path.as_str(), + "path", + "path-scoped endpoint routing", + ), + ( + endpoint.credential_signing.as_str(), + "credential_signing", + "proxy-side credential signing", + ), + ( + endpoint.signing_service.as_str(), + "signing_service", + "credential-signing service selection", + ), + ( + endpoint.signing_region.as_str(), + "signing_region", + "credential-signing region selection", + ), + ]; + for (value, field, feature) in string_losses { + if !value.is_empty() { + add_proxy_only_loss(items, path, field, feature); + } + } + + if endpoint.advisor_proposed { + add_proxy_only_loss(items, path, "advisor_proposed", "policy-advisor provenance"); + } + if endpoint.json_rpc_max_body_bytes > 0 { + add_proxy_only_loss( + items, + path, + "json_rpc_max_body_bytes", + "JSON-RPC request-size policy", + ); + } + if endpoint.mcp.is_some() { + add_proxy_only_loss(items, path, "mcp", "MCP protocol policy"); + } + if endpoint.credential_binding.is_some() { + add_proxy_only_loss( + items, + path, + "credential_binding", + "provider credential binding", + ); + } + if endpoint.allow_uninspected_credentials { + add_proxy_only_loss( + items, + path, + "allow_uninspected_credentials", + "uninspected credential traffic approval", + ); + } + if endpoint.provider_credentialed { + add_proxy_only_loss( + items, + path, + "provider_credentialed", + "provider credential provenance", + ); + } } fn add_graphql_loss(items: &mut Vec, path: &str, field: &str) { @@ -720,6 +837,17 @@ fn add_graphql_loss(items: &mut Vec, path: &str, field: &str) { ); } +fn add_proxy_only_loss(items: &mut Vec, path: &str, field: &str, feature: &str) { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC 1.0 has no equivalent for {feature}."), + feature, + "The coarse config preserves only its directional CIDR/port restriction; governed egress is required for this behavior.", + ); +} + fn add_static_policy_loss( policy: &SandboxPolicy, opts: &MxcMappingOptions, @@ -783,6 +911,29 @@ fn append_unique(list: &mut Vec, value: String) { } } +fn append_unique_value(list: &mut Vec, value: Value) { + if !list.contains(&value) { + list.push(value); + } +} + +fn normalize_cidr(value: &str) -> Option { + let (address, prefix) = match value.split_once('/') { + Some((address, prefix)) => (address, Some(prefix.parse::().ok()?)), + None => (value, None), + }; + let address = address.parse::().ok()?; + let max_prefix: u8 = match address { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let prefix = prefix.unwrap_or(max_prefix); + if prefix > max_prefix { + return None; + } + Some(format!("{address}/{prefix}")) +} + fn contains_wildcard(host: &str) -> bool { host.contains('*') } diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 670e31a617..13cb5b861a 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -13,16 +13,19 @@ //! //! Two mapping shapes are intended: //! -//! - [`map_to_mxc`] — the *coarse / standalone* mapping. `OpenShell` network -//! policy is flattened into an MXC host allowlist (`network.allowedHosts`), -//! and anything MXC cannot express (ports, protocol, L7 rules, binary scope) -//! is recorded in the loss report. Use this when MXC enforces network on its -//! own, with no `OpenShell` proxy in the loop. +//! - [`map_to_mxc`] — the *coarse / standalone* mapping. Numeric destinations +//! and ports become MXC 1.0 directional CIDR rules; DNS, L7, and binary scope +//! that MXC cannot express are recorded in the loss report. Use this when MXC +//! enforces network on its own, with no `OpenShell` proxy in the loop. //! - [`split_policy`] — the *lossless* split for the Windows MXC compute //! driver: MXC handles filesystem + containment + loopback-only egress, //! while the full `OpenShell` network policy is preserved in a trimmed policy //! enforced by the host CONNECT proxy. //! +//! Every generated MXC artifact uses the same stable 1.0 schema as the live +//! driver. The embedded driver may use a coarse mapping as an intermediate +//! policy translation but does not send that JSON to MXC. +//! //! The report/loss-report helpers are only exercised by the example and the //! integration tests, so the Windows lib build would otherwise warn on them; //! `#![allow(dead_code)]` keeps the module quiet without per-item churn. diff --git a/crates/openshell-driver-mxc/src/policy_map/report.rs b/crates/openshell-driver-mxc/src/policy_map/report.rs index 0b1ec37b77..8ca7e10a83 100644 --- a/crates/openshell-driver-mxc/src/policy_map/report.rs +++ b/crates/openshell-driver-mxc/src/policy_map/report.rs @@ -5,15 +5,18 @@ use serde_json::{Value, json}; +use super::config::DEFAULT_MXC_VERSION; use super::loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS, summarize_missing_mxc}; -/// Build the structured `loss-report.json` value. +/// Build the structured `loss-report.json` value for a generated mapper artifact. +/// +/// `target.schemaVersion` is the same stable schema used by the live MXC +/// driver. The mapper does not expose a pre-1.0 compatibility mode. pub fn build_loss_report( source_policy: &str, generated_config: &str, items: &[LossItem], schema_errors: &[LossItem], - mxc_version: &str, containment: &str, schema: Option<&str>, ) -> Value { @@ -23,7 +26,7 @@ pub fn build_loss_report( "sourcePolicy": source_policy, "generatedConfig": generated_config, "target": { - "schemaVersion": mxc_version, + "schemaVersion": DEFAULT_MXC_VERSION, "containment": containment, }, "schemaValidation": { @@ -62,7 +65,19 @@ pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> Str } }; - let allowed = join_strs(&config["network"]["allowedHosts"]); + let allowed = config["network"]["egress"]["allow"] + .as_array() + .into_iter() + .flatten() + .filter_map(|rule| rule["to"].as_array()) + .flatten() + .filter_map(|peer| peer["cidr"].as_str()) + .collect::>(); + let allowed = if allowed.is_empty() { + "(none)".to_owned() + } else { + allowed.join(", ") + }; let rw = join_strs(&config["filesystem"]["readwritePaths"]); let ro = join_strs(&config["filesystem"]["readonlyPaths"]); @@ -84,7 +99,7 @@ pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> Str String::new(), format!("- Containment: `{containment}`"), format!("- Schema validation: `{schema_valid}`"), - format!("- Allowed hosts: `{allowed}`"), + format!("- Allowed CIDRs: `{allowed}`"), format!("- Read-write paths: `{rw}`"), format!("- Read-only paths: `{ro}`"), String::new(), @@ -131,7 +146,8 @@ pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> Str "## Notes".to_owned(), String::new(), "- OpenShell network policies are binary-, port-, protocol-, and often L7-scoped.".to_owned(), - "- This coarse mapper emits only MXC host/IP/CIDR allowlists plus filesystem lists.".to_owned(), + "- This coarse mapper emits MXC 1.0 directional CIDR/port rules plus filesystem lists.".to_owned(), + "- DNS, binary scope, and L7 policy require governed egress and are reported as mapping errors.".to_owned(), "- Treat any `error` item in the loss report as a semantic broadening or unsupported parity gap.".to_owned(), String::new(), ]); diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index 2c4bf4f0a9..6523258848 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -11,8 +11,8 @@ //! Byte-for-byte parity with the previous raw-YAML mapper is intentionally //! *not* asserted: routing through the canonical typed `SandboxPolicy` //! normalizes ports and uses an unordered proto map (we sort keys). Instead we -//! assert the substantive invariants — filesystem fidelity, the host allowlist, -//! deny-by-default, and that broadening features are flagged as losses. +//! assert the substantive invariants — filesystem fidelity, MXC 1.0 directional +//! networking, deny-by-default, and explicit losses for proxy-only behavior. #![cfg(target_os = "windows")] @@ -56,6 +56,17 @@ fn str_list(value: &Value) -> Vec { .unwrap_or_default() } +fn allowed_cidrs(config: &Value) -> Vec { + config["network"]["egress"]["allow"] + .as_array() + .into_iter() + .flatten() + .filter_map(|rule| rule["to"].as_array()) + .flatten() + .filter_map(|peer| peer["cidr"].as_str().map(str::to_owned)) + .collect() +} + fn proxy_addr() -> std::net::SocketAddr { "127.0.0.1:18080".parse().unwrap() } @@ -80,13 +91,14 @@ fn all_example_policies_map_with_invariants() { let result = map_to_mxc(&policy, &MxcMappingOptions::default()); let cfg = &result.config; - // Deny-by-default network posture is always emitted. - assert_eq!( - cfg["network"]["defaultPolicy"], - "block", - "{} must emit defaultPolicy=block", - path.display() - ); + // Stable MXC 1.0 directional deny-by-default posture is always emitted. + assert_eq!(cfg["version"], "1.0.0", "{} schema", path.display()); + assert_eq!(cfg["network"]["egress"]["default"], "deny"); + assert_eq!(cfg["network"]["ingress"]["default"], "deny"); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "deny"); + assert!(cfg["network"].get("defaultPolicy").is_none()); + assert!(cfg["network"].get("allowedHosts").is_none()); + assert!(cfg["network"].get("enforcementMode").is_none()); // Filesystem fidelity: read_write / read_only copied exactly. if let Some(fs) = &policy.filesystem { @@ -104,14 +116,19 @@ fn all_example_policies_map_with_invariants() { ); } - // Every non-wildcard endpoint host appears in allowedHosts. - let allowed = str_list(&cfg["network"]["allowedHosts"]); + // Hostname policies stay fail-closed and require governed egress; the + // coarse artifact contains only numeric CIDRs. + let allowed = allowed_cidrs(cfg); for rule in policy.network_policies.values() { for ep in &rule.endpoints { - if !ep.host.is_empty() && !ep.host.contains('*') { + if !ep.host.is_empty() && ep.host.parse::().is_err() { assert!( - allowed.contains(&ep.host), - "{} missing host {} in allowedHosts", + result.loss.iter().any(|item| { + item.severity == "error" + && item.path.contains(".host") + && item.message.contains(&ep.host) + }), + "{} missing DNS/glob loss for {}", path.display(), ep.host ); @@ -119,14 +136,14 @@ fn all_example_policies_map_with_invariants() { } } - // allowedHosts is deduplicated. + // Directional destination CIDRs are deduplicated. let mut sorted = allowed.clone(); sorted.sort(); sorted.dedup(); assert_eq!( sorted.len(), allowed.len(), - "duplicate hosts for {}", + "duplicate CIDRs for {}", path.display() ); @@ -210,7 +227,7 @@ fn all_example_policies_split_with_expected_invariants() { assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); } - assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["version"], "1.0.0"); assert_eq!(cfg["network"]["egress"]["default"], "deny"); assert_eq!( cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], @@ -221,16 +238,10 @@ fn all_example_policies_split_with_expected_invariants() { assert!(cfg["network"].get("proxy").is_none()); assert!( !result.loss.iter().any(|item| item.severity == "error"), - "processcontainer split must not emit error losses for {}: {:?}", + "processcontainer split must delegate middleware to the supervisor for {}: {:?}", path.display(), result.loss ); - assert_eq!( - result.proxy_policy.network_middlewares, - policy.network_middlewares, - "supervisor handoff must preserve middleware for {}", - path.display() - ); } } @@ -242,24 +253,22 @@ fn quickstart_coarse_mapping() { let result = map_to_mxc(&policy, &MxcMappingOptions::default()); let cfg = &result.config; - assert_eq!( - str_list(&cfg["network"]["allowedHosts"]), - vec!["api.github.com".to_owned()] - ); + assert!(allowed_cidrs(cfg).is_empty()); assert_eq!( str_list(&cfg["filesystem"]["readwritePaths"]), vec!["/sandbox", "/tmp", "/dev/null"] ); assert_eq!(cfg["containment"], "bubblewrap"); - // The github_api endpoint loses port, protocol, access, and binary scope. + // DNS, protocol, access, and binary scope require governed egress. The port + // itself is representable but no directional rule is emitted without a CIDR. let has = |severity: &str, needle: &str| { result .loss .iter() .any(|i| i.severity == severity && i.path.contains(needle)) }; - assert!(has("error", "endpoints[0].port"), "expected port loss"); + assert!(has("error", "endpoints[0].host"), "expected DNS host loss"); assert!( has("error", "endpoints[0].protocol"), "expected protocol loss" @@ -385,11 +394,19 @@ fn split_policy_rejects_proxy_redirect_on_isolation_session() { .collect(); assert_eq!( errors.len(), - 1, - "expected one containment error: {errors:?}" + 2, + "expected filesystem and containment errors: {errors:?}" ); - assert_eq!(errors[0].path, "containment"); - assert!(errors[0].message.contains("processcontainer")); + let filesystem = errors + .iter() + .find(|item| item.path == "filesystem_policy.grants") + .expect("isolation_session filesystem error"); + assert!(filesystem.message.contains("cannot enforce")); + let containment = errors + .iter() + .find(|item| item.path == "containment") + .expect("isolation_session proxy-containment error"); + assert!(containment.message.contains("processcontainer")); assert!(result.mxc_config["network"].get("proxy").is_none()); } @@ -404,16 +421,18 @@ fn network_only_policy_has_empty_filesystem() { assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty()); assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty()); - assert_eq!( - str_list(&cfg["network"]["allowedHosts"]), - vec!["api.anthropic.com".to_owned()] - ); + assert!(allowed_cidrs(cfg).is_empty()); + assert!(result.loss.iter().any(|item| { + item.severity == "error" + && item.path.contains(".host") + && item.message.contains("api.anthropic.com") + })); } // ── New tests: proxy JSON shape and non-127.0.0.1 guard ────────────────────── #[test] -fn split_with_loopback_addr_emits_loopback_only_08_shape() { +fn split_with_loopback_addr_emits_loopback_only_1_0_shape() { let path = examples_root().join("sandbox-policy-quickstart/policy.yaml"); let yaml = std::fs::read_to_string(&path).expect("read quickstart"); let policy = parse_sandbox_policy(&yaml).expect("parse quickstart"); @@ -426,7 +445,7 @@ fn split_with_loopback_addr_emits_loopback_only_08_shape() { let result = split_policy(&policy, &opts).expect("split returns Some"); let cfg = &result.mxc_config; - assert_eq!(cfg["version"], "0.8.0-alpha"); + assert_eq!(cfg["version"], "1.0.0"); assert_eq!(cfg["network"]["egress"]["default"], "deny"); assert_eq!( cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"], diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 28e0fdb585..e56f8ef0b4 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -22,13 +22,14 @@ )] use openshell_core::proto::{ - FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, - MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, - NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy, + FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, McpOptions, + MiddlewareEndpointSelector, NetworkAccessPreset, NetworkBinary, NetworkCredentialBinding, + NetworkEndpoint, NetworkEnforcementMode, NetworkMiddlewareConfig, NetworkPolicyRule, + NetworkTlsMode, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy, }; use openshell_driver_mxc::{ - EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, - split_policy, + DEFAULT_MXC_VERSION, EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, + map_to_mxc, split_policy, }; use openshell_policy::{serialize_sandbox_policy, validate_sandbox_policy}; use serde_json::Value; @@ -59,6 +60,17 @@ fn str_list(v: &Value) -> Vec { .unwrap_or_default() } +fn allowed_cidrs(config: &Value) -> Vec { + config["network"]["egress"]["allow"] + .as_array() + .into_iter() + .flatten() + .filter_map(|rule| rule["to"].as_array()) + .flatten() + .filter_map(|peer| peer["cidr"].as_str().map(str::to_owned)) + .collect() +} + fn default_opts() -> MxcMappingOptions { MxcMappingOptions::default() // bubblewrap containment } @@ -132,6 +144,27 @@ fn assert_single_loss( // ─── QUADRANT A: mappable fields, assert exact MXC output ─────────────────── +/// The standalone mapper and governed-egress path both emit the stable MXC 1.0 +/// directional network schema. +#[test] +fn a_schema_versions_use_the_same_stable_network_shape() { + let policy = SandboxPolicy::default(); + let coarse = map_to_mxc(&policy, &default_opts()).config; + assert_eq!(DEFAULT_MXC_VERSION, "1.0.0"); + assert_eq!(coarse["version"], DEFAULT_MXC_VERSION); + assert!(coarse["network"].get("egress").is_some()); + assert!(coarse["network"].get("ingress").is_some()); + assert!(coarse["network"].get("allowedHosts").is_none()); + assert!(coarse["network"].get("defaultPolicy").is_none()); + + let governed = split_policy(&policy, &pc_split_opts()) + .expect("governed split must exist when a proxy redirect is configured") + .mxc_config; + assert_eq!(governed["version"], DEFAULT_MXC_VERSION); + assert!(governed["network"].get("allowedHosts").is_none()); + assert!(governed["network"].get("egress").is_some()); +} + /// filesystem.read_write → readwritePaths verbatim, order preserved. #[test] fn a_rw_paths_verbatim() { @@ -239,27 +272,28 @@ fn a_include_workdir_no_cwd_emits_info_loss() { assert_eq!(rw, vec!["/work"]); } -/// Plain endpoint host → appears in allowedHosts. +/// Numeric endpoint host → normalized into a directional CIDR rule. #[test] -fn a_plain_host_in_allowed_hosts() { +fn a_numeric_host_in_directional_allow_rule() { let policy = net_policy( "api", NetworkEndpoint { - host: "api.example.com".into(), + host: "198.51.100.7".into(), ..Default::default() }, ); let r = map_to_mxc(&policy, &bubblewrap_opts()); - let hosts = str_list(&r.config["network"]["allowedHosts"]); - assert!( - hosts.contains(&"api.example.com".to_owned()), - "host must appear in allowedHosts; got: {hosts:?}" + assert_eq!(allowed_cidrs(&r.config), vec!["198.51.100.7/32"]); + assert_eq!( + r.config["network"]["egress"]["allow"][0]["ports"][0]["protocol"], + "tcp" ); } -/// endpoint.allowed_ips → each IP appended to allowedHosts + a "warning" loss. +/// endpoint.allowed_ips → valid destinations become CIDRs, while the lost DNS +/// binding remains a fail-closed mapping error. #[test] -fn a_allowed_ips_appended_with_warning() { +fn a_allowed_ips_map_to_cidrs_with_dns_binding_error() { let policy = net_policy( "api", NetworkEndpoint { @@ -269,37 +303,21 @@ fn a_allowed_ips_appended_with_warning() { }, ); let r = map_to_mxc(&policy, &bubblewrap_opts()); - let hosts = str_list(&r.config["network"]["allowedHosts"]); - assert!( - hosts.contains(&"10.0.0.1".to_owned()), - "IP 10.0.0.1 must be in allowedHosts" - ); - assert!( - hosts.contains(&"10.0.0.2".to_owned()), - "IP 10.0.0.2 must be in allowedHosts" - ); - let warnings: Vec<_> = r - .loss - .iter() - .filter(|i| i.severity == "warning" && i.path.contains("allowed_ips")) - .collect(); - assert!( - !warnings.is_empty(), - "expected warning loss for allowed_ips; got: {:?}", - r.loss - ); + assert_eq!(allowed_cidrs(&r.config), vec!["10.0.0.1/32", "10.0.0.2/32"]); + assert_single_loss(&r.loss, ".host", "error", "DNS host binding"); + assert_single_loss(&r.loss, ".allowed_ips", "error", "allowed_ips scope"); } -/// Duplicate hosts across rules → deduplicated in allowedHosts. +/// Duplicate directional rules are deduplicated. #[test] -fn a_duplicate_hosts_deduplicated() { +fn a_duplicate_directional_rules_deduplicated() { let mut policy = SandboxPolicy::default(); let ep_a = NetworkEndpoint { - host: "shared.example.com".into(), + host: "203.0.113.10".into(), ..Default::default() }; let ep_b = NetworkEndpoint { - host: "shared.example.com".into(), + host: "203.0.113.10".into(), ..Default::default() }; policy.network_policies.insert( @@ -319,14 +337,13 @@ fn a_duplicate_hosts_deduplicated() { }, ); let r = map_to_mxc(&policy, &bubblewrap_opts()); - let hosts = str_list(&r.config["network"]["allowedHosts"]); - let count = hosts - .iter() - .filter(|h| h.as_str() == "shared.example.com") - .count(); + assert_eq!(allowed_cidrs(&r.config), vec!["203.0.113.10/32"]); assert_eq!( - count, 1, - "duplicate hosts must be deduplicated; got: {hosts:?}" + r.config["network"]["egress"]["allow"] + .as_array() + .unwrap() + .len(), + 1 ); } @@ -390,13 +407,13 @@ fn a_split_network_verbatim_and_version_preserved() { ); } -/// Split path emits MXC 0.8 loopback-only governed-egress fields. +/// Split path emits MXC 1.0 loopback-only governed-egress fields. #[test] -fn a_split_proxy_uses_loopback_only_08_fields() { +fn a_split_proxy_uses_loopback_only_1_0_fields() { let policy = SandboxPolicy::default(); let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); assert!(result.mxc_config.get("runtimeConfig").is_none()); - assert_eq!(result.mxc_config["version"], "0.8.0-alpha"); + assert_eq!(result.mxc_config["version"], "1.0.0"); assert_eq!(result.mxc_config["network"]["egress"]["default"], "deny"); assert_eq!( result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], @@ -411,19 +428,48 @@ fn a_split_proxy_uses_loopback_only_08_fields() { // containment, assert exactly one loss item with the documented path fragment // and severity. -/// endpoint.ports → "error" (path contains ".port"). +/// endpoint.ports → exact MXC 1.0 TCP destination-port selectors. #[test] -fn b_ports_error() { +fn a_ports_map_to_directional_rules() { let policy = net_policy( "r", NetworkEndpoint { - host: "api.example.com".into(), - ports: vec![443], + host: "198.51.100.7".into(), + ports: vec![80, 443], ..Default::default() }, ); let r = map_to_mxc(&policy, &bubblewrap_opts()); - assert_single_loss(&r.loss, ".port", "error", "endpoint.ports"); + assert_eq!( + r.config["network"]["egress"]["allow"][0]["ports"], + serde_json::json!([ + { "protocol": "tcp", "port": 80 }, + { "protocol": "tcp", "port": 443 } + ]) + ); + assert!(r.loss.iter().all(|item| !item.path.contains(".port"))); +} + +#[test] +fn b_invalid_ports_are_rejected_without_broadening() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "198.51.100.7".into(), + ports: vec![0, 65_536], + ..Default::default() + }, + ); + let result = map_to_mxc(&policy, &bubblewrap_opts()); + assert!(allowed_cidrs(&result.config).is_empty()); + assert_eq!( + result + .loss + .iter() + .filter(|item| item.path.contains(".ports[") && item.severity == "error") + .count(), + 2 + ); } /// endpoint.protocol → "error". @@ -448,7 +494,7 @@ fn b_tls_skip_warning() { "r", NetworkEndpoint { host: "api.example.com".into(), - tls: openshell_core::proto::NetworkTlsMode::Skip as i32, + tls: NetworkTlsMode::Skip as i32, ..Default::default() }, ); @@ -458,13 +504,13 @@ fn b_tls_skip_warning() { /// endpoint.tls = "full" (any non-skip) → "error". #[test] -#[allow(deprecated)] +#[allow(deprecated)] // Exercise rejection of an authored pre-1.0 TLS mode. fn b_tls_non_skip_error() { let policy = net_policy( "r", NetworkEndpoint { host: "api.example.com".into(), - tls: openshell_core::proto::NetworkTlsMode::Terminate as i32, + tls: NetworkTlsMode::Terminate as i32, ..Default::default() }, ); @@ -479,7 +525,7 @@ fn b_enforcement_audit_error() { "r", NetworkEndpoint { host: "api.example.com".into(), - enforcement: openshell_core::proto::NetworkEnforcementMode::Audit as i32, + enforcement: NetworkEnforcementMode::Audit as i32, ..Default::default() }, ); @@ -499,7 +545,7 @@ fn b_enforcement_non_audit_warning() { "r", NetworkEndpoint { host: "api.example.com".into(), - enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32, + enforcement: NetworkEnforcementMode::Enforce as i32, ..Default::default() }, ); @@ -519,7 +565,7 @@ fn b_access_error() { "r", NetworkEndpoint { host: "api.example.com".into(), - access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32, + access: NetworkAccessPreset::ReadOnly as i32, ..Default::default() }, ); @@ -686,9 +732,9 @@ fn b_graphql_max_body_bytes_error() { ); } -/// Wildcard host ("*.example.com") with allow_wildcards=false → "error", host NOT in allowedHosts. +/// Wildcard hosts are not representable in MXC 1.0 directional CIDR rules. #[test] -fn b_wildcard_host_deny_wildcards_error_host_absent() { +fn b_wildcard_host_error_and_no_allow_rule() { let policy = net_policy( "r", NetworkEndpoint { @@ -696,13 +742,7 @@ fn b_wildcard_host_deny_wildcards_error_host_absent() { ..Default::default() }, ); - let opts = MxcMappingOptions { - allow_wildcards: false, - containment: "bubblewrap".to_owned(), - ..Default::default() - }; - let r = map_to_mxc(&policy, &opts); - // Must have an "error" loss for the wildcard host. + let r = map_to_mxc(&policy, &bubblewrap_opts()); let err_loss: Vec<_> = r .loss .iter() @@ -713,47 +753,52 @@ fn b_wildcard_host_deny_wildcards_error_host_absent() { "expected error loss for wildcard host; got: {:?}", r.loss ); - // Host must NOT be in allowedHosts when allow_wildcards=false. - let hosts = str_list(&r.config["network"]["allowedHosts"]); assert!( - !hosts.contains(&"*.example.com".to_owned()), - "wildcard host must be absent from allowedHosts when allow_wildcards=false; got: {hosts:?}" + allowed_cidrs(&r.config).is_empty(), + "wildcard host must not create an MXC directional allow rule" ); } -/// Wildcard host with allow_wildcards=true → "error" (semantics warning), host IS in allowedHosts. #[test] -fn b_wildcard_host_allow_wildcards_error_host_present() { +fn b_proxy_only_endpoint_fields_each_emit_error() { let policy = net_policy( "r", NetworkEndpoint { - host: "*.example.com".into(), + host: "198.51.100.7".into(), + path: "/v1/**".into(), + advisor_proposed: true, + credential_signing: "sigv4".into(), + signing_service: "bedrock".into(), + signing_region: "us-west-2".into(), + json_rpc_max_body_bytes: 65_536, + mcp: Some(McpOptions { + strict_tool_names: Some(true), + allow_all_known_mcp_methods: Some(false), + versions: vec!["2025-11-25".into()], + }), + credential_binding: Some(NetworkCredentialBinding { + provider: "aws".into(), + }), + allow_uninspected_credentials: true, + provider_credentialed: true, ..Default::default() }, ); - let opts = MxcMappingOptions { - allow_wildcards: true, - containment: "bubblewrap".to_owned(), - ..Default::default() - }; - let r = map_to_mxc(&policy, &opts); - // Still an "error" loss (MXC semantics warning), even though we emitted the host. - let err_loss: Vec<_> = r - .loss - .iter() - .filter(|i| i.severity == "error" && i.path.contains(".host")) - .collect(); - assert!( - !err_loss.is_empty(), - "expected error loss for wildcard host even with allow_wildcards=true; got: {:?}", - r.loss - ); - // Host IS in allowedHosts when allow_wildcards=true. - let hosts = str_list(&r.config["network"]["allowedHosts"]); - assert!( - hosts.contains(&"*.example.com".to_owned()), - "wildcard host must appear in allowedHosts when allow_wildcards=true; got: {hosts:?}" - ); + let result = map_to_mxc(&policy, &bubblewrap_opts()); + for field in [ + "path", + "advisor_proposed", + "credential_signing", + "signing_service", + "signing_region", + "json_rpc_max_body_bytes", + "mcp", + "credential_binding", + "allow_uninspected_credentials", + "provider_credentialed", + ] { + assert_single_loss(&result.loss, field, "error", field); + } } /// rule.binaries non-empty → "error" per binary. @@ -909,12 +954,12 @@ fn b_run_as_group_warning() { assert_single_loss(&r.loss, "run_as_group", "warning", "process.run_as_group"); } -/// Seam-level: EmbeddedPolicyMapper.map over a policy with one error-class field -/// (a port) via MapCtx{egress: None} returns Err(MapError::Unsupported(_)). +/// Seam-level: EmbeddedPolicyMapper.map over a policy with unsupported fields +/// via MapCtx{egress: None} returns Err(MapError::Unsupported(_)). #[test] fn b_seam_returns_unsupported_on_error_field() { let mapper = EmbeddedPolicyMapper; - // isolation_session containment + network policy → error loss from add_backend_specific_config. + // isolation_session containment cannot enforce filesystem or network policy. let mut policy = SandboxPolicy { filesystem: Some(FilesystemPolicy { read_write: vec!["C:/work".into()], @@ -946,6 +991,30 @@ fn b_seam_returns_unsupported_on_error_field() { ); } +#[test] +fn b_isolation_session_reports_filesystem_grants_as_error() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["C:/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let result = map_to_mxc( + &policy, + &MxcMappingOptions { + containment: "isolation_session".into(), + ..Default::default() + }, + ); + assert_single_loss( + &result.loss, + "filesystem_policy.grants", + "error", + "isolation_session filesystem grants", + ); +} + // ─── QUADRANT C: restrictive defaults ("default deny posture") ─────────────── /// Empty SandboxPolicy (all None/empty) with default options produces the most @@ -956,19 +1025,15 @@ fn c_empty_policy_default_deny_posture() { let r = map_to_mxc(&policy, &bubblewrap_opts()); let cfg = &r.config; - // Network: default deny, no allowed/blocked hosts. - assert_eq!( - cfg["network"]["defaultPolicy"], "block", - "defaultPolicy must be 'block' on empty policy" - ); - assert!( - str_list(&cfg["network"]["allowedHosts"]).is_empty(), - "allowedHosts must be [] on empty policy" - ); - assert!( - str_list(&cfg["network"]["blockedHosts"]).is_empty(), - "blockedHosts must be [] on empty policy" - ); + // Network: stable 1.0 directional default deny, with no allow/deny rules. + assert_eq!(cfg["network"]["egress"]["default"], "deny"); + assert!(cfg["network"]["egress"].get("allow").is_none()); + assert!(cfg["network"]["egress"].get("deny").is_none()); + assert_eq!(cfg["network"]["ingress"]["default"], "deny"); + assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "deny"); + assert!(cfg["network"].get("defaultPolicy").is_none()); + assert!(cfg["network"].get("allowedHosts").is_none()); + assert!(cfg["network"].get("blockedHosts").is_none()); // UI: fully locked down. assert_eq!(cfg["ui"]["disable"], true, "ui.disable must be true"); @@ -1008,11 +1073,7 @@ fn c_empty_policy_default_deny_posture() { "processContainer must be absent when no hosts are mapped" ); - // No enforcementMode key when allowedHosts is empty. - assert!( - cfg["network"].get("enforcementMode").is_none(), - "enforcementMode must be absent when allowedHosts is empty" - ); + assert!(cfg["network"].get("enforcementMode").is_none()); } /// Split path with network rules present: unsupported host lists stay absent. @@ -1199,15 +1260,14 @@ const HANDLED_RULE_KEYS: &[&str] = &["name", "endpoints", "binaries"]; /// Per-endpoint keys that the mapper accounts for (mapping or loss item). /// -/// "host" — mapped to allowedHosts (or loss if wildcard/empty) -/// "port" — normalized to ports at parse; covered by ports loss -/// "ports" — error loss +/// "host" — numeric values map to directional CIDRs; DNS/globs are errors +/// "port" / "ports" — mapped to directional TCP port selectors /// "protocol" — error loss /// "tls" — warning (skip) or error loss /// "enforcement" — error (audit) or warning (other) loss /// "access" — error loss /// "rules" — error loss -/// "allowed_ips" — appended to allowedHosts + warning loss +/// "allowed_ips" — mapped to directional CIDRs plus binding/scope error /// "deny_rules" — error loss /// "allow_encoded_slash" — error loss /// "websocket_credential_rewrite" — error loss @@ -1215,11 +1275,7 @@ const HANDLED_RULE_KEYS: &[&str] = &["name", "endpoints", "binaries"]; /// "persisted_queries" — error loss /// "graphql_persisted_queries" — error loss /// "graphql_max_body_bytes" — error loss -/// "path" — not currently read by the mapper (no loss emitted); -/// included here so the drift guard does not trip on -/// existing schema fields the mapper silently ignores. -/// If the mapper needs to enforce path-scoped routing, -/// remove this entry and add an explicit loss item. +/// "path" and credential/L7 config — explicit governed-egress-only errors const HANDLED_ENDPOINT_KEYS: &[&str] = &[ "host", "port", @@ -1238,6 +1294,13 @@ const HANDLED_ENDPOINT_KEYS: &[&str] = &[ "graphql_persisted_queries", "graphql_max_body_bytes", "path", + "allow_uninspected_credentials", + "credential_signing", + "signing_service", + "signing_region", + "credential_binding", + "json_rpc", + "mcp", ]; #[test] @@ -1333,15 +1396,22 @@ fn handled_fields_inventory() { // Two ports → serializes as `ports: [80, 443]` (array form). ports: vec![80, 443], protocol: "graphql".into(), - tls: openshell_core::proto::NetworkTlsMode::Skip as i32, - enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32, - access: openshell_core::proto::NetworkAccessPreset::Full as i32, + tls: NetworkTlsMode::Skip as i32, + enforcement: NetworkEnforcementMode::Enforce as i32, + access: NetworkAccessPreset::Full as i32, allowed_ips: vec!["10.0.0.1".into()], allow_encoded_slash: true, websocket_credential_rewrite: true, request_body_credential_rewrite: true, + allow_uninspected_credentials: true, persisted_queries: "deny".into(), - graphql_max_body_bytes: 65536, + graphql_max_body_bytes: 65_536, + credential_signing: "sigv4".into(), + signing_service: "bedrock".into(), + signing_region: "us-west-2".into(), + credential_binding: Some(NetworkCredentialBinding { + provider: "aws".into(), + }), rules: vec![L7Rule { allow: Some(L7Allow { method: "GET".into(), @@ -1369,6 +1439,23 @@ fn handled_fields_inventory() { ports: vec![443], ..Default::default() }; + let mcp_ep = NetworkEndpoint { + host: "mcp.example.com".into(), + protocol: "mcp".into(), + json_rpc_max_body_bytes: 131_072, + mcp: Some(McpOptions { + strict_tool_names: Some(true), + allow_all_known_mcp_methods: Some(false), + versions: vec!["2025-11-25".into()], + }), + ..Default::default() + }; + let json_rpc_ep = NetworkEndpoint { + host: "rpc.example.com".into(), + protocol: "json-rpc".into(), + json_rpc_max_body_bytes: 65_536, + ..Default::default() + }; let full_rule_policy = SandboxPolicy { version: 1, @@ -1378,7 +1465,7 @@ fn handled_fields_inventory() { "rule".to_owned(), NetworkPolicyRule { name: "rule".to_owned(), - endpoints: vec![full_ep, single_port_ep], + endpoints: vec![full_ep, single_port_ep, mcp_ep, json_rpc_ep], binaries: vec![NetworkBinary { path: "/usr/bin/curl".into(), }], diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 7517738e67..784918af3f 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -15,9 +15,9 @@ //! Two families: //! //! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on -//! schema acceptance. These pass on this box even though no enforcement -//! backend is live (dry-run validates the JSON schema without spinning up the -//! `AppContainer` or isolation session). +//! schema acceptance. Most require no live enforcement backend. Tests that +//! request a host capability such as bidirectional host loopback probe that +//! capability first because MXC validates platform support during dry-run. //! //! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason //! and return early when the backend is not live. The probe distinguishes @@ -135,8 +135,10 @@ fn wxc_version(wxc: &Path) -> Option<(u64, u64, u64, String)> { // ── (a) Dry-run contract tests ──────────────────────────────────────────────── // -// These PASS on any box that has the wxc-exec binary — no enforcement backend -// is required because --dry-run only validates the JSON schema. +// Most PASS on any box that has the wxc-exec binary — no enforcement backend is +// required. MXC also validates requested platform capabilities during dry-run, +// so capability-specific configs probe those capabilities before asserting +// schema acceptance. /// Minimal processcontainer one-shot config accepted by `--dry-run`. #[test] @@ -149,7 +151,7 @@ fn dryrun_accepts_minimal_processcontainer_config() { let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ - "version": "0.6.0-alpha", + "version": "1.0.0", "containerId": "test-minimal", "containment": "processcontainer", "process": { @@ -183,7 +185,7 @@ fn dryrun_accepts_processcontainer_ui_policy_matrix() { let temp_path = tempdir.path().to_string_lossy().into_owned(); for clipboard in ["none", "read", "write", "all"] { let config = serde_json::json!({ - "version": "0.7.0-alpha", + "version": "1.0.0", "containerId": format!("test-ui-{clipboard}"), "containment": "processcontainer", "process": { @@ -209,10 +211,9 @@ fn dryrun_accepts_processcontainer_ui_policy_matrix() { } } -/// MXC 0.8 and the 0.9 development schema reject the shared top-level -/// UI object on `isolation_session`, while omission remains accepted. Older -/// 0.7 builds accepted and ignored the object, so `OpenShell`'s gateway-level -/// capability check is the stable enforcement boundary across versions. +/// MXC 1.0 rejects the shared top-level UI object on `isolation_session`, while +/// omission remains accepted. `OpenShell`'s gateway-level capability check is +/// the stable enforcement boundary. #[test] #[ignore = "requires real wxc-exec"] fn dryrun_current_schema_rejects_isolation_session_ui() { @@ -224,31 +225,20 @@ fn dryrun_current_schema_rejects_isolation_session_ui() { eprintln!("SKIP: could not determine wxc-exec version"); return; }; - if (major, minor) < (0, 8) { + if (major, minor) < (1, 0) { eprintln!("SKIP: {raw_version} predates the isolation_session UI rejection contract"); return; } - let mut base = serde_json::json!({ - "version": "0.8.0-alpha", - "phase": "provision", + let base = serde_json::json!({ + "version": "1.0.0", "containment": "isolation_session", "network": { - "defaultPolicy": "allow", - "allowLocalNetwork": true, - }, - }); - let args: &[&str] = if (major, minor) >= (0, 9) { - base.as_object_mut().unwrap().remove("phase"); - base["version"] = serde_json::json!("0.9.0-alpha"); - base["network"] = serde_json::json!({ "egress": { "default": "allow" }, "ingress": { "default": "allow", "hostLoopback": "allow" }, - }); - &["--experimental", "--operation", "provision"] - } else { - &["--experimental"] - }; + }, + }); + let args: &[&str] = &["--operation", "provision"]; let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, args); let output = format!("{stdout} {stderr}").to_ascii_lowercase(); if code != 0 @@ -278,7 +268,7 @@ fn dryrun_current_schema_rejects_isolation_session_ui() { ); } -/// Network block without proxy (defaultPolicy block, empty host lists) accepted. +/// Stable directional network block without proxy is accepted. #[test] #[ignore = "requires real wxc-exec"] fn dryrun_accepts_network_block_without_proxy() { @@ -289,7 +279,7 @@ fn dryrun_accepts_network_block_without_proxy() { let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ - "version": "0.6.0-alpha", + "version": "1.0.0", "containerId": "test-net-block", "containment": "processcontainer", "process": { @@ -301,7 +291,7 @@ fn dryrun_accepts_network_block_without_proxy() { "readwritePaths": [temp_path], }, "network": { - "defaultPolicy": "block", + "egress": { "default": "deny" }, }, }); @@ -312,82 +302,6 @@ fn dryrun_accepts_network_block_without_proxy() { ); } -/// The ONLY accepted proxy shape in MXC 0.6.0-alpha: `{"localhost": }`. -/// Verified empirically against the real binary — any other shape is rejected -/// with "Request error". -#[test] -#[ignore = "requires real wxc-exec"] -fn dryrun_accepts_localhost_proxy_shape() { - let Some(wxc) = wxc_path() else { - eprintln!("SKIP: wxc-exec not found"); - return; - }; - - let (_tempdir, temp_path) = temp_fixture(); - let config = serde_json::json!({ - "version": "0.6.0-alpha", - "containerId": "test-proxy-localhost", - "containment": "processcontainer", - "process": { - "commandLine": "cmd /c exit 0", - "cwd": temp_path, - "timeout": 0, - }, - "filesystem": { - "readwritePaths": [temp_path], - }, - "network": { - "defaultPolicy": "block", - "proxy": { "localhost": 18080 }, - }, - }); - - let (code, stdout, stderr) = dry_run(&wxc, &config); - assert_eq!( - code, 0, - "{{\"localhost\": N}} proxy shape rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" - ); -} - -/// The `{"host": ..., "port": ...}` proxy shape is REJECTED by MXC 0.6.0-alpha. -/// This test guards the schema contract discovered via dry-run bisection. -/// See docs4gtb/mxc-box-capabilities.md §"Schema contract findings". -#[test] -#[ignore = "requires real wxc-exec"] -fn dryrun_rejects_host_port_proxy_shape() { - let Some(wxc) = wxc_path() else { - eprintln!("SKIP: wxc-exec not found"); - return; - }; - - let (_tempdir, temp_path) = temp_fixture(); - let config = serde_json::json!({ - "version": "0.6.0-alpha", - "containerId": "test-proxy-hostport", - "containment": "processcontainer", - "process": { - "commandLine": "cmd /c exit 0", - "cwd": temp_path, - "timeout": 0, - }, - "filesystem": { - "readwritePaths": [temp_path], - }, - "network": { - "defaultPolicy": "block", - // MXC 0.6.0-alpha rejects {"host","port"} — verified empirically. - "proxy": { "host": "127.0.0.1", "port": 18080 }, - }, - }); - - let (code, _stdout, _stderr) = dry_run(&wxc, &config); - assert_ne!( - code, 0, - "{{\"host\",\"port\"}} proxy shape was unexpectedly ACCEPTED — \ - schema may have widened in a newer wxc-exec build" - ); -} - /// Unknown containment value is rejected. #[test] #[ignore = "requires real wxc-exec"] @@ -399,7 +313,7 @@ fn dryrun_rejects_unknown_containment() { let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ - "version": "0.6.0-alpha", + "version": "1.0.0", "containerId": "test-bad-containment", "containment": "nonsense", "process": { @@ -417,7 +331,7 @@ fn dryrun_rejects_unknown_containment() { } /// The most important dry-run test: build a typed Windows policy, run -/// `split_policy` (MXC 0.8 loopback-only proxy access at 127.0.0.1:18080, +/// `split_policy` (MXC 1.0 loopback-only proxy access at 127.0.0.1:18080, /// containment /// "processcontainer"), and verify the resulting config with `--dry-run`. /// @@ -430,6 +344,10 @@ fn dryrun_accepts_split_policy_output() { eprintln!("SKIP: wxc-exec not found"); return; }; + if let Err(reason) = probe_processcontainer_host_loopback(&wxc) { + eprintln!("SKIP: processcontainer governed egress unavailable: {reason}"); + return; + } let (_tempdir, temp_path) = temp_fixture(); let policy = SandboxPolicy { @@ -467,7 +385,7 @@ fn dryrun_accepts_split_policy_output() { } let mxc_config = result.mxc_config; - assert_eq!(mxc_config["version"], "0.8.0-alpha"); + assert_eq!(mxc_config["version"], "1.0.0"); assert_eq!(mxc_config["network"]["egress"]["default"], "deny"); assert_eq!( mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"], @@ -509,6 +427,89 @@ fn dryrun_accepts_split_policy_output() { ); } +/// The standalone mapper must emit the same stable MXC 1.0 schema as the live +/// governed-egress path. Exercise a numeric destination and ports so this test +/// would fail if the mapper regressed to the retired host-list shape. Some MXC +/// builds select a backend that recognizes the directional schema but cannot +/// enforce egress rules; that explicit capability error still proves the 1.0 +/// fields were parsed rather than rejected as unknown schema. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_standalone_mapper_output() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let (_tempdir, temp_path) = temp_fixture(); + let mut policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: vec![temp_path.clone()], + }), + ..Default::default() + }; + policy.network_policies.insert( + "numeric-egress".into(), + NetworkPolicyRule { + name: "numeric-egress".into(), + endpoints: vec![NetworkEndpoint { + host: "198.51.100.7".into(), + ports: vec![80, 443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let options = openshell_driver_mxc::MxcMappingOptions { + containment: "processcontainer".into(), + command: "cmd /c exit 0".into(), + container_id: "standalone-mapper-dryrun".into(), + cwd: Some(temp_path), + ..Default::default() + }; + let result = openshell_driver_mxc::map_to_mxc(&policy, &options); + assert!( + result + .loss + .iter() + .all(|item| item.path != "network_policies.numeric-egress.endpoints[0].host") + ); + let config = result.config; + assert_eq!(config["version"], "1.0.0"); + assert_eq!(config["network"]["egress"]["default"], "deny"); + assert_eq!( + config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "198.51.100.7/32" + ); + assert_eq!( + config["network"]["egress"]["allow"][0]["ports"], + serde_json::json!([ + { "protocol": "tcp", "port": 80 }, + { "protocol": "tcp", "port": 443 } + ]) + ); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + let output = format!("{stdout} {stderr}").to_ascii_lowercase(); + if code != 0 + && output + .contains("network.egress allow/deny rules are not supported by the selected backend") + { + eprintln!( + "PASS: MXC parsed the 1.0 directional network schema; selected backend cannot enforce egress rules" + ); + return; + } + assert_eq!( + code, + 0, + "standalone mapper output rejected by MXC 1.0 --dry-run\nconfig={}\nstdout={stdout}\nstderr={stderr}", + serde_json::to_string_pretty(&config).unwrap_or_default() + ); +} + // ── (b) Enforcement tests — probe-gated ─────────────────────────────────────── // // These skip on this box (processcontainer velocity keys not enabled; @@ -531,7 +532,7 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ - "version": "0.6.0-alpha", + "version": "1.0.0", "containerId": "probe-pc", "containment": "processcontainer", "process": { @@ -594,55 +595,46 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { Ok(()) } -/// Probe the released binary's live `network.proxy` support separately from -/// ordinary `ProcessContainer` support. Some builds accept the proxy JSON during -/// `--dry-run` but return `ERROR_INVALID_PARAMETER` from the live launcher. -fn probe_processcontainer_proxy(wxc: &PathBuf) -> Result<(), String> { - let (_tempdir, temp_path) = temp_fixture(); - let proxy_listener = std::net::TcpListener::bind("127.0.0.1:0") - .map_err(|error| format!("failed to reserve proxy probe port: {error}"))?; - let proxy_port = proxy_listener - .local_addr() - .map_err(|error| format!("failed to read proxy probe port: {error}"))? - .port(); - let config = serde_json::json!({ - "version": "0.6.0-alpha", - "containerId": "probe-pc-proxy", - "containment": "processcontainer", - "process": { - "commandLine": "C:\\Windows\\System32\\cmd.exe /c exit 0", - "cwd": temp_path, - "timeout": 30_000, - }, - "filesystem": { - "readwritePaths": [temp_path], - }, - "processContainer": { - "leastPrivilege": false, - }, - "network": { - "defaultPolicy": "block", - "proxy": { "localhost": proxy_port }, - }, - }); +/// Probe the released binary's native host-loopback capability separately from +/// ordinary `ProcessContainer` support. Schema validation also checks requested +/// platform capabilities, so loopback-dependent tests require a positive probe. +fn probe_processcontainer_host_loopback(wxc: &Path) -> Result<(), String> { + if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|v| v == "1") { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } - let json = serde_json::to_string(&config).unwrap(); - let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - let output = Command::new(wxc) - .arg("--config-base64") - .arg(&b64) + let out = Command::new(wxc) + .arg("--probe") .output() - .map_err(|error| format!("wxc-exec proxy probe failed to spawn: {error}"))?; + .map_err(|e| format!("wxc-exec --probe spawn failed: {e}"))?; + if !out.status.success() { + return Err(format!( + "wxc-exec --probe returned exit {}: stdout={} stderr={}", + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + )); + } - if output.status.success() { + let probe: serde_json::Value = serde_json::from_slice(&out.stdout) + .map_err(|e| format!("could not parse wxc-exec --probe JSON: {e}"))?; + let supported = probe + .pointer("/probes/baseContainerSupportsIngressHostLoopbackAllow") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + "wxc-exec --probe omitted baseContainerSupportsIngressHostLoopbackAllow".to_string() + })?; + if supported { return Ok(()); } + let tier = probe["tier"].as_str().unwrap_or("unknown"); Err(format!( - "live network.proxy probe returned exit {}: stdout={} stderr={}", - output.status.code().unwrap_or(-1), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), + "wxc-exec --probe reports baseContainerSupportsIngressHostLoopbackAllow=false \ + for isolation tier {tier}" )) } @@ -659,18 +651,11 @@ fn probe_isolation_session(wxc: &PathBuf) -> Result { } let config = serde_json::json!({ - "version": "0.6.0-alpha", - "phase": "provision", + "version": "1.0.0", "containment": "isolation_session", - "filesystem": { - "readwritePaths": [], - "readonlyPaths": [], - }, - "experimental": { - "isolation_session": { - "configurationId": "composable", - "provision": {} - } + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, } }); @@ -680,7 +665,8 @@ fn probe_isolation_session(wxc: &PathBuf) -> Result { let out = Command::new(wxc) .arg("--config-base64") .arg(&b64) - .arg("--experimental") + .arg("--operation") + .arg("provision") .output() .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; @@ -691,7 +677,7 @@ fn probe_isolation_session(wxc: &PathBuf) -> Result { if combined.contains("backend_unavailable") || combined.contains("0x80040154") { return Err( - "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553".to_string(), + "backend_unavailable: IsoSessionApp.dll absent or OS build < 26340.9212".to_string(), ); } @@ -742,15 +728,7 @@ impl<'a> DeprovisionGuard<'a> { fn run_deprovision(wxc: &PathBuf, sandbox_id: &str) { let config = serde_json::json!({ - "version": "0.6.0-alpha", - "phase": "deprovision", - "sandboxId": sandbox_id, - "experimental": { - "isolation_session": { - // Unit variant: must be null, not {} (malformed_request otherwise). - "deprovision": null - } - } + "version": "1.0.0", }); let json = serde_json::to_string(&config).unwrap_or_default(); let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); @@ -758,7 +736,10 @@ impl<'a> DeprovisionGuard<'a> { let _ = Command::new(wxc) .arg("--config-base64") .arg(&b64) - .arg("--experimental") + .arg("--operation") + .arg("deprovision") + .arg("--container-id") + .arg(sandbox_id) .output(); } } @@ -794,7 +775,7 @@ fn pc_oneshot_in_policy_write_succeeds() { let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); let config = serde_json::json!({ - "version": "0.6.0-alpha", + "version": "1.0.0", "containerId": "pc-in-policy-write", "containment": "processcontainer", "process": { @@ -853,8 +834,8 @@ async fn pc_https_egress_reads_injected_ca_bundle() { eprintln!("SKIP: processcontainer not live: {reason}"); return; } - if let Err(reason) = probe_processcontainer_proxy(&wxc) { - eprintln!("SKIP: processcontainer network.proxy not live: {reason}"); + if let Err(reason) = probe_processcontainer_host_loopback(&wxc) { + eprintln!("SKIP: processcontainer host loopback not live: {reason}"); return; } @@ -1008,7 +989,7 @@ fn pc_oneshot_out_of_policy_write_denied() { let granted_str = granted_dir.path().to_string_lossy().into_owned(); let config = serde_json::json!({ - "version": "0.6.0-alpha", + "version": "1.0.0", "containerId": "pc-out-of-policy-write", "containment": "processcontainer", "process": { @@ -1080,21 +1061,17 @@ fn iso_lifecycle_round_trip() { // start let start_config = serde_json::json!({ - "version": "0.6.0-alpha", - "phase": "start", - "sandboxId": sandbox_id, - "experimental": { - "isolation_session": { - "start": {} - } - } + "version": "1.0.0", }); let json = serde_json::to_string(&start_config).unwrap(); let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let out = Command::new(&wxc) .arg("--config-base64") .arg(&b64) - .arg("--experimental") + .arg("--operation") + .arg("start") + .arg("--container-id") + .arg(&sandbox_id) .output() .expect("start"); assert!( @@ -1109,13 +1086,12 @@ fn iso_lifecycle_round_trip() { // (HRESULT 0x80070057). 0 is the documented no-timeout value and matches // what the driver's exec path sends by default (MxcProcess.timeout = 0). let exec_config = serde_json::json!({ - "version": "0.6.0-alpha", - "phase": "exec", - "sandboxId": sandbox_id, + "version": "1.0.0", "process": { "commandLine": "cmd /c exit 0", "cwd": "C:\\Windows\\Temp", "env": [], + "inheritDefaultEnv": true, "timeout": 0, } }); @@ -1124,7 +1100,10 @@ fn iso_lifecycle_round_trip() { let out = Command::new(&wxc) .arg("--config-base64") .arg(&b64) - .arg("--experimental") + .arg("--operation") + .arg("exec") + .arg("--container-id") + .arg(&sandbox_id) .output() .expect("exec"); assert_eq!( @@ -1136,22 +1115,17 @@ fn iso_lifecycle_round_trip() { // stop let stop_config = serde_json::json!({ - "version": "0.6.0-alpha", - "phase": "stop", - "sandboxId": sandbox_id, - "experimental": { - "isolation_session": { - // Unit variant: must be null, not {} (malformed_request otherwise). - "stop": null - } - } + "version": "1.0.0", }); let json = serde_json::to_string(&stop_config).unwrap(); let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); let out = Command::new(&wxc) .arg("--config-base64") .arg(&b64) - .arg("--experimental") + .arg("--operation") + .arg("stop") + .arg("--container-id") + .arg(&sandbox_id) .output() .expect("stop"); assert!( diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 7d2b4bf3ec..b14c81b7e4 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -1273,7 +1273,7 @@ etw_audit = false The packaged supervisor and sandbox binaries default to siblings of `openshell-gateway.exe`; explicit paths are useful for development layouts. The driver protects host supervisor tokens and descriptors with an owner-only Windows DACL. -Existing ProcessContainer configurations may retain `default_configuration_id`. This legacy IsolationSession-only field is accepted for configuration compatibility but has no effect on ProcessContainer. It does not enable the unsupported `isolation_session` backend; omit it in new configurations. +The MXC driver requires MXC 1.0.0 and emits its stable directional network schema. Remove the retired `default_configuration_id` setting before upgrading; the gateway rejects it. The `isolation_session` backend remains unsupported. 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. diff --git a/rfc/0013-native-windows-mxc/README.md b/rfc/0013-native-windows-mxc/README.md index a9229e9468..08f54d2d67 100644 --- a/rfc/0013-native-windows-mxc/README.md +++ b/rfc/0013-native-windows-mxc/README.md @@ -22,6 +22,12 @@ The authenticated Sandbox Protocol is the only runtime control and forwarding transport. MXC supplies the Windows outer fence; the existing supervisor owns policy evaluation, credentials, network proxying, and the gateway session. +The runtime requires the signed MXC 1.0.0 executor. Live requests and standalone +mapper artifacts share its stable directional network schema. Retired alpha +compatibility settings, including `default_configuration_id`, are rejected; +numeric CIDR/port rules belong to MXC, while DNS and L7 policy remain governed +by the host supervisor. IsolationSession is still outside this runtime's scope. + ## Motivation Docker Desktop and WSL2 add a Linux VM to Windows workflows. MXC provides a From bbc850a6ebc65ef125a7d905a66dcfbea12dfed6 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 8 Oct 2026 15:09:20 -0700 Subject: [PATCH 08/22] test(mxc): sync Windows policies and isolated CLI harness fixes Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 6 + crates/openshell-driver-mxc/README.md | 16 +- .../WINDOWS-INTEGRATION.md | 22 +++ .../e2e-policies/openclaw-gateway.yaml | 32 ++++ .../examples/e2e-policies/ws-agent.yaml | 16 ++ .../examples/inference.yaml | 25 +++ .../examples/run-mxc-e2e.ps1 | 123 ++++++++++++- .../examples/run-ocsf-audit.ps1 | 114 +++++++++++- .../tests/windows_demo_examples.rs | 167 ++++++++++++++++++ .../tests/wxc_exec_real.rs | 11 +- 10 files changed, 513 insertions(+), 19 deletions(-) create mode 100644 crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml create mode 100644 crates/openshell-driver-mxc/examples/inference.yaml create mode 100644 crates/openshell-driver-mxc/tests/windows_demo_examples.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index b80e2330cc..a989e144a4 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -456,6 +456,12 @@ skill or design doc. ## Final Report Checklist +When syncing from the `windows` branch, consult the selective integration +checkpoint in `crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md`. Preserve +the host-supervisor/Windows-boundary architecture rather than restoring legacy +relay/proxy demo wrappers. The WebSocket, OpenClaw, and inference policy fixtures +have parser/mapper tests; do not report them as live runtime scenarios. + Every substantial Windows build run should report: | Item | Required detail | diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 1ad28b4a20..888520ba24 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -158,7 +158,10 @@ from Cargo's passed count. The E2E runner uses native release artifacts (override with `-BinaryDir`), OpenSSL on PATH for disposable Ed25519 keys, and per-run writable fixtures under `target/windows-e2e-results`. It preserves the tracked gateway TOML and isolates -CLI registration via `XDG_CONFIG_HOME`. Signing keys have an owner-only DACL, +CLI registration via disposable APPDATA, LOCALAPPDATA, and XDG directories. +Inherited gateway overrides are cleared for the run and restored exactly +(including empty versus absent entries). The caller's gateway aliases are not +modified. Signing keys have an owner-only DACL, remain outside result bundles, and are removed unless `-KeepRunning` is requested. E2E runs real MXC only: there is no mock mode, and an inherited @@ -181,3 +184,14 @@ mode, exposes a named endpoint with `openshell service expose`, and verifies two unique GET responses through gateway host-based routing. It deletes the endpoint and requires HTTP 404 afterward. Requests connect only to the gateway with the exposed URL's Host header, so no wildcard DNS setup is required. + +The `ws-agent.yaml` and `openclaw-gateway.yaml` policies preserve the Windows +branch's workload grants. `inference.yaml` is a template: replace its filesystem, +curl executable, inference host, and port placeholders before use. Authorize +curl, which owns the network socket, not its cmd launcher. These policies have +parser/mapper regression coverage; they do not add live WebSocket, OpenClaw, +or provider-backed inference scenarios to the six-scenario E2E runner. + +With `-KeepRunning`, the runner restores the caller's environment but retains +the disposable CLI/runtime directory for the live gateway and prints its path. +Remove that directory only after stopping the gateway. diff --git a/crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md b/crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md new file mode 100644 index 0000000000..d8dd9de68c --- /dev/null +++ b/crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md @@ -0,0 +1,22 @@ +# Windows integration checkpoint + +Reviewed Windows branch through +`d89e4359b810d3d7205bb7d4a706f3c773a59e1f` (PR #4252). +This is a selective port, not a branch merge or a claim of complete demo parity. + +- #4252 (`d89e4359b`): MXC 1.0.0 schema, directional networking, and UI contract + already ported in `ce2ca474a`. +- #4199 (`f6ecca206`): authorize the socket-owning curl executable in the + inference policy template and real HTTPS test, rather than its cmd launcher. +- #4192 (`ed35eb9f3`): disposable CLI state, cleared inherited gateway + overrides, exact environment restoration, and strict fresh gateway + registration in the E2E and OCSF runners. +- Restore the WebSocket and OpenClaw policy fixtures with their original + grants, updating comments to the host-supervisor/Windows-boundary architecture. + +Keep the always-present `openshell-windows-sandbox`, authenticated Sandbox +Protocol, and host isolation-backend supervisor. Do not restore the old relay +executable, driver-owned CONNECT proxy, or legacy demo/task wrappers requiring +them. Provider-backed inference, live WebSocket, OpenClaw, and OCSF demo coverage +are not implied by policy parsing or by the six core E2E scenarios. Network-fence +evidence remains the documented compatibility TODO, not a verified guarantee. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml new file mode 100644 index 0000000000..506ffaa61a --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# OpenClaw gateway policy fixture ported from windows. +# Stage Node, OpenClaw, home, temporary files, and logs under this writable path. +# Node loads user32/gdi32: explicitly permit graphical UI to disable MXC 1.0 +# win32k lockdown. Clipboard and input injection remain disabled. +# The host supervisor governs the Node outbound grant. Managed ingress uses the +# Windows sandbox boundary; neither path needs a driver-owned proxy or relay. +# Fixture validation is not live OpenClaw coverage or verified exclusive fencing. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/openshell-openclaw" + +ui: + allow_graphical_ui: true + clipboard: none + allow_input_injection: false + +network_policies: + qualification_allowed: + name: qualification-allowed + endpoints: + - host: example.com + port: 443 + protocol: tcp + binaries: + - path: "C:/openshell-openclaw/node.exe" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml new file mode 100644 index 0000000000..d1c50ed8f4 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# WebSocket-agent policy fixture ported from windows. +# Stage the workload under the read-only directory; adapt the path before use. +# Managed ingress uses the always-present Windows sandbox boundary and host +# supervisor, not an optional relay binary or a driver-owned network proxy. +# No network_policies means no authorized outbound destinations. This fixture +# does not itself exercise a live WebSocket session or prove network fencing. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - "C:/work/openshell-mxc-ws" + read_write: [] diff --git a/crates/openshell-driver-mxc/examples/inference.yaml b/crates/openshell-driver-mxc/examples/inference.yaml new file mode 100644 index 0000000000..5122b7b617 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/inference.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Policy template: replace the path, host, and port placeholders before use. +# Authorize the socket-owning curl executable, not its cmd.exe launcher (#4199). +# The host supervisor evaluates REST policy and injects provider credentials. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "__OPENSHELL_DEMO_SHARE__" + +network_policies: + nvidia_inference: + name: nvidia-inference + endpoints: + - host: "__INFERENCE_HOST__" + port: __INFERENCE_PORT__ + protocol: rest + access: read-write + enforcement: enforce + binaries: + - path: '__CURL_EXE__' diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 4b727e8ff6..61b31a5634 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -76,12 +76,120 @@ if (-not $BinaryDir) { $BinaryDir = Join-Path $targetRoot "$target/release" } $savedEnv = @{} -foreach ($name in @("OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY", "OPENSHELL_MXC_MOCK_WXC", "OPENSHELL_WXC_EXEC_PATH", "OPENSHELL_COMPUTE_DRIVER", "OPENSHELL_MXC_SHARE_DIR", "XDG_CONFIG_HOME")) { - $savedEnv[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +$processEnvironment = [Environment]::GetEnvironmentVariables("Process") +foreach ($name in @("OPENSHELL_MXC_MOCK_WXC", "OPENSHELL_WXC_EXEC_PATH", "OPENSHELL_COMPUTE_DRIVER", "OPENSHELL_MXC_SHARE_DIR")) { + $exists = $processEnvironment.Contains($name) + $savedEnv[$name] = [pscustomobject]@{ + Exists = $exists + Value = if ($exists) { [string] $processEnvironment[$name] } else { $null } + } } $secretDir = Join-Path $resultsRoot "keys-$stamp-$([guid]::NewGuid().ToString('N'))" $transcriptStarted = $false +$cliStateRoot = $null +$cliEnvironmentSnapshot = @{} +$cliEnvironmentNames = @( + "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", + "OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_INSECURE", + "OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY_NAME" +) + +function Set-ProcessEnvironmentVariableExact { + param( + [Parameter(Mandatory = $true)] + [string] $Name, + [Parameter(Mandatory = $true)] + [bool] $Exists, + [AllowNull()] + [string] $Value + ) + + if (-not $Exists) { + Remove-Item "Env:$Name" -ErrorAction SilentlyContinue + return + } + + if ($Value.Length -eq 0) { + # Windows PowerShell 5.1 maps an empty value passed through + # Environment.SetEnvironmentVariable to deletion. Call Win32 directly + # so an inherited empty entry remains distinguishable from absence. + if (-not ("OpenShellMxcProcessEnvironmentNative" -as [type])) { + Add-Type -TypeDefinition @' +using System.Runtime.InteropServices; + +public static class OpenShellMxcProcessEnvironmentNative +{ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetEnvironmentVariable(string name, string value); +} +'@ + } + if (-not [OpenShellMxcProcessEnvironmentNative]::SetEnvironmentVariable($Name, [string]::Empty)) { + $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw "failed to restore empty process environment variable '$Name' (Win32 error $errorCode)" + } + return + } + + [Environment]::SetEnvironmentVariable($Name, $Value, "Process") +} + +function Enter-IsolatedCliEnvironment { + $processEnvironment = [Environment]::GetEnvironmentVariables("Process") + foreach ($name in $cliEnvironmentNames) { + $exists = $processEnvironment.Contains($name) + $script:cliEnvironmentSnapshot[$name] = [pscustomobject]@{ + Exists = $exists + Value = if ($exists) { [string] $processEnvironment[$name] } else { $null } + } + } + $script:cliStateRoot = Join-Path ([IO.Path]::GetTempPath()) "openshell-mxc-e2e-cli-$PID-$([Guid]::NewGuid().ToString('N'))" + $isolatedPaths = @{ + APPDATA = Join-Path $script:cliStateRoot "appdata" + LOCALAPPDATA = Join-Path $script:cliStateRoot "localappdata" + XDG_CONFIG_HOME = Join-Path $script:cliStateRoot "xdg-config" + XDG_STATE_HOME = Join-Path $script:cliStateRoot "xdg-state" + XDG_DATA_HOME = Join-Path $script:cliStateRoot "xdg-data" + } + try { + New-Item -ItemType Directory -Force -Path @($isolatedPaths.Values) | Out-Null + foreach ($entry in $isolatedPaths.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") + } + foreach ($name in $cliEnvironmentNames | Where-Object { -not $isolatedPaths.ContainsKey($_) }) { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } + } catch { + Exit-IsolatedCliEnvironment + throw + } +} + +function Exit-IsolatedCliEnvironment { + foreach ($name in $cliEnvironmentNames) { + if (-not $script:cliEnvironmentSnapshot.ContainsKey($name)) { continue } + $snapshot = $script:cliEnvironmentSnapshot[$name] + Set-ProcessEnvironmentVariableExact -Name $name -Exists $snapshot.Exists -Value $snapshot.Value + } + if ($script:cliStateRoot -and (Test-Path -LiteralPath $script:cliStateRoot)) { + if ($KeepRunning -and $gw -and -not $gw.HasExited) { + Write-Host "Retaining isolated CLI/runtime state for live gateway: $script:cliStateRoot" + return + } + # Resolve the generated target before recursive cleanup; never remove an + # arbitrary caller path or the temporary directory itself. + $cleanupPath = [IO.Path]::GetFullPath($script:cliStateRoot) + $tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not $cleanupPath.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or + (Split-Path $cleanupPath -Leaf) -notmatch '^openshell-mxc-(e2e|ocsf)-cli-[0-9]+-[0-9a-f]{32}$') { + throw "refusing unsafe CLI state cleanup: $cleanupPath" + } + Remove-Item -LiteralPath $cleanupPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } function Info([string]$m) { Write-Host " $m" } function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } @@ -247,14 +355,13 @@ function Stop-Gw($p) { function Register-Cli { if ($script:registered) { return } - $env:OPENSHELL_GATEWAY = "" $addResult = Invoke-NativeCaptured $cli @( "gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName ) $addText = ($addResult.Output -join "`n") if ($addText) { $addResult.Output | ForEach-Object { Info $_ } } - if ($addResult.ExitCode -ne 0 -and $addText -notmatch '(?i)already exists') { + if ($addResult.ExitCode -ne 0) { throw "gateway add failed (exit $($addResult.ExitCode)): $addText" } @@ -510,6 +617,7 @@ $backendProbe = @{ Live = $false; Reason = "not probed" } $runId = Get-Date -Format 'MMddHHmmss' try { + Enter-IsolatedCliEnvironment # Start the transcript inside the guarded region so a Start-Transcript failure # is caught and the results bundle is still produced. Pre-flight runs # immediately below, so the transcript still captures the whole run. @@ -541,7 +649,6 @@ try { # Capture the pristine TOML once; every scenario renders a fresh copy from this. $tomlBase = Get-Content $tomlTemplate -Raw - $env:XDG_CONFIG_HOME = Join-Path $resultDir "cli-config" $openssl = (Get-Command openssl -ErrorAction Stop).Source New-Item -ItemType Directory $secretDir | Out-Null $ownerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value @@ -929,7 +1036,11 @@ ingress works, not that direct host ingress is blocked. } if (Test-Path $secretDir) { Remove-Item -LiteralPath $secretDir -ErrorAction SilentlyContinue } } - foreach ($name in $savedEnv.Keys) { [Environment]::SetEnvironmentVariable($name, $savedEnv[$name], "Process") } + Exit-IsolatedCliEnvironment + foreach ($name in $savedEnv.Keys) { + $snapshot = $savedEnv[$name] + Set-ProcessEnvironmentVariableExact -Name $name -Exists $snapshot.Exists -Value $snapshot.Value + } } if ($harnessError -or $failCount -gt 0) { diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 374ee2c042..17e90fd7cd 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -61,6 +61,109 @@ $resultDir = Join-Path $here "results-$stamp" New-Item -ItemType Directory -Force $resultDir | Out-Null Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null +$cliStateRoot = $null +$cliEnvironmentSnapshot = @{} +$cliEnvironmentNames = @( + "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", + "OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_INSECURE", + "OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY_NAME" +) + +function Set-ProcessEnvironmentVariableExact { + param( + [Parameter(Mandatory = $true)] + [string] $Name, + [Parameter(Mandatory = $true)] + [bool] $Exists, + [AllowNull()] + [string] $Value + ) + + if (-not $Exists) { + Remove-Item "Env:$Name" -ErrorAction SilentlyContinue + return + } + + if ($Value.Length -eq 0) { + # Windows PowerShell 5.1 maps an empty value passed through + # Environment.SetEnvironmentVariable to deletion. Call Win32 directly + # so an inherited empty entry remains distinguishable from absence. + if (-not ("OpenShellMxcProcessEnvironmentNative" -as [type])) { + Add-Type -TypeDefinition @' +using System.Runtime.InteropServices; + +public static class OpenShellMxcProcessEnvironmentNative +{ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetEnvironmentVariable(string name, string value); +} +'@ + } + if (-not [OpenShellMxcProcessEnvironmentNative]::SetEnvironmentVariable($Name, [string]::Empty)) { + $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw "failed to restore empty process environment variable '$Name' (Win32 error $errorCode)" + } + return + } + + [Environment]::SetEnvironmentVariable($Name, $Value, "Process") +} + +function Enter-IsolatedCliEnvironment { + $processEnvironment = [Environment]::GetEnvironmentVariables("Process") + foreach ($name in $cliEnvironmentNames) { + $exists = $processEnvironment.Contains($name) + $script:cliEnvironmentSnapshot[$name] = [pscustomobject]@{ + Exists = $exists + Value = if ($exists) { [string] $processEnvironment[$name] } else { $null } + } + } + $script:cliStateRoot = Join-Path ([IO.Path]::GetTempPath()) "openshell-mxc-ocsf-cli-$PID-$([Guid]::NewGuid().ToString('N'))" + $isolatedPaths = @{ + APPDATA = Join-Path $script:cliStateRoot "appdata" + LOCALAPPDATA = Join-Path $script:cliStateRoot "localappdata" + XDG_CONFIG_HOME = Join-Path $script:cliStateRoot "xdg-config" + XDG_STATE_HOME = Join-Path $script:cliStateRoot "xdg-state" + XDG_DATA_HOME = Join-Path $script:cliStateRoot "xdg-data" + } + try { + New-Item -ItemType Directory -Force -Path @($isolatedPaths.Values) | Out-Null + foreach ($entry in $isolatedPaths.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") + } + foreach ($name in $cliEnvironmentNames | Where-Object { -not $isolatedPaths.ContainsKey($_) }) { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } + } catch { + Exit-IsolatedCliEnvironment + throw + } +} + +function Exit-IsolatedCliEnvironment { + foreach ($name in $cliEnvironmentNames) { + if (-not $script:cliEnvironmentSnapshot.ContainsKey($name)) { continue } + $snapshot = $script:cliEnvironmentSnapshot[$name] + Set-ProcessEnvironmentVariableExact -Name $name -Exists $snapshot.Exists -Value $snapshot.Value + } + if ($script:cliStateRoot -and (Test-Path -LiteralPath $script:cliStateRoot)) { + if ($KeepRunning -and $gw -and -not $gw.HasExited) { + Write-Host "Retaining isolated CLI/runtime state for live gateway: $script:cliStateRoot" + return + } + # Resolve the generated target before recursive cleanup; never remove an + # arbitrary caller path or the temporary directory itself. + $cleanupPath = [IO.Path]::GetFullPath($script:cliStateRoot) + $tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not $cleanupPath.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or + (Split-Path $cleanupPath -Leaf) -notmatch '^openshell-mxc-(e2e|ocsf)-cli-[0-9]+-[0-9a-f]{32}$') { + throw "refusing unsafe CLI state cleanup: $cleanupPath" + } + Remove-Item -LiteralPath $cleanupPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } function Info([string]$m) { Write-Host " $m" } function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } @@ -94,6 +197,7 @@ $gatewayEtwSessions = @() $passed = $true try { + Enter-IsolatedCliEnvironment # 1. Validate artifacts + privilege. Step "Validate package artifacts" foreach ($f in @($gateway, $cli, $supervisor, $sandbox, $policySrc, $tomlSrc)) { @@ -238,11 +342,10 @@ try { # 9. Register CLI -> gateway. Step "Register CLI -> gateway" - $env:OPENSHELL_GATEWAY = "" - try { & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } } - catch { Info "gateway add: $($_.Exception.Message) (continuing - likely already registered)" } - try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } - catch { Info "gateway select: $($_.Exception.Message) (continuing)" } + & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } + if ($LASTEXITCODE -ne 0) { throw "gateway add failed (exit $LASTEXITCODE)" } + & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } + if ($LASTEXITCODE -ne 0) { throw "gateway select failed (exit $LASTEXITCODE)" } Ok "selected gateway '$GatewayName'" # 10. Create N sandboxes. Each drives the Sandboxing provider -> a full OCSF @@ -284,6 +387,7 @@ finally { } # ---- summarise the OCSF audit trail -------------------------------------- + Exit-IsolatedCliEnvironment $logText = @() if (Test-Path (Join-Path $resultDir "gateway.log")) { $logText += Get-Content (Join-Path $resultDir "gateway.log") } if (Test-Path (Join-Path $resultDir "gateway.err.log")) { $logText += Get-Content (Join-Path $resultDir "gateway.err.log") } diff --git a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs new file mode 100644 index 0000000000..c4cb655f01 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Selectively ported Windows policy and harness regressions. These are not +//! substitutes for real MXC WebSocket, `OpenClaw`, or inference runtime coverage. +#![cfg(target_os = "windows")] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use openshell_core::proto::{NetworkAccessPreset, NetworkEnforcementMode, SandboxPolicy}; +use openshell_driver_mxc::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use openshell_policy::{parse_sandbox_policy, validate_sandbox_policy}; + +fn examples() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("examples") +} + +fn map_fixture(yaml: &str) -> (SandboxPolicy, MappedConfig) { + let policy = parse_sandbox_policy(yaml).expect("valid authored policy"); + validate_sandbox_policy(&policy).expect("valid policy semantics"); + let mapped = EmbeddedPolicyMapper + .map( + Some(&policy), + &MapCtx { + sandbox_id: "windows-policy-fixture".into(), + egress: Some("127.0.0.1:18080".parse().unwrap()), + containment: "processcontainer".into(), + }, + ) + .expect("policy maps with supervisor-owned egress"); + (policy, mapped) +} + +#[test] +fn websocket_fixture_grants_read_only_workload_without_egress() { + let yaml = std::fs::read_to_string(examples().join("e2e-policies/ws-agent.yaml")).unwrap(); + let (policy, mapped) = map_fixture(&yaml); + assert!(policy.network_policies.is_empty()); + assert_eq!(mapped.readonly_paths, [r"C:\work\openshell-mxc-ws"]); + assert!(mapped.readwrite_paths.is_empty()); + assert!(mapped.ui.unwrap().disable); +} + +#[test] +fn openclaw_fixture_preserves_explicit_ui_and_node_network_grants() { + let yaml = + std::fs::read_to_string(examples().join("e2e-policies/openclaw-gateway.yaml")).unwrap(); + let (policy, mapped) = map_fixture(&yaml); + assert_eq!(mapped.readwrite_paths, [r"C:\openshell-openclaw"]); + assert!(mapped.readonly_paths.is_empty()); + let ui = mapped.ui.unwrap(); + assert!(!ui.disable); + assert!(!ui.injection); + let config = openshell_driver_mxc::map_to_mxc( + &policy, + &openshell_driver_mxc::MxcMappingOptions::default(), + ); + assert_eq!(config.config["ui"]["clipboard"], "none"); + let rule = &policy.network_policies["qualification_allowed"]; + assert_eq!(rule.binaries[0].path, "C:/openshell-openclaw/node.exe"); + assert_eq!(rule.endpoints[0].host, "example.com"); + assert_eq!(rule.endpoints[0].ports, [443]); + assert_eq!( + mapped.trimmed_policy.unwrap().network_policies, + policy.network_policies + ); +} + +#[test] +fn inference_template_authorizes_socket_owning_curl_not_cmd() { + let yaml = std::fs::read_to_string(examples().join("inference.yaml")) + .unwrap() + .replace("__OPENSHELL_DEMO_SHARE__", "C:/work/inference") + .replace("__CURL_EXE__", r"C:\Windows\System32\curl.exe") + .replace("__INFERENCE_HOST__", "integrate.api.nvidia.com") + .replace("__INFERENCE_PORT__", "443"); + assert!(!yaml.contains("__")); + let (policy, mapped) = map_fixture(&yaml); + assert_eq!(mapped.readwrite_paths, [r"C:\work\inference"]); + let rule = &policy.network_policies["nvidia_inference"]; + assert_eq!(rule.binaries.len(), 1); + assert_eq!(rule.binaries[0].path, r"C:\Windows\System32\curl.exe"); + let endpoint = &rule.endpoints[0]; + assert_eq!(endpoint.access, NetworkAccessPreset::ReadWrite as i32); + assert_eq!(endpoint.enforcement, NetworkEnforcementMode::Enforce as i32); + assert_eq!(endpoint.protocol, "rest"); + assert_eq!( + mapped.trimmed_policy.unwrap().network_policies, + policy.network_policies + ); +} + +#[test] +fn runners_isolate_and_restore_exact_process_environment() { + // Parse the actual runners, but execute only their environment helpers: + // no gateway, executor, firewall, ETW session, or caller CLI state is touched. + let script = r#" +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $env:OPENSHELL_MXC_RUNNER, [ref]$tokens, [ref]$errors) +if ($errors.Count -gt 0) { throw "runner parse errors: $($errors.Message -join '; ')" } +foreach ($functionName in @("Set-ProcessEnvironmentVariableExact", "Enter-IsolatedCliEnvironment", "Exit-IsolatedCliEnvironment")) { + $function = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName + }, $true) + if ($null -eq $function) { throw "missing $functionName" } + Invoke-Expression $function.Extent.Text +} +$cliStateRoot = $null +$cliEnvironmentSnapshot = @{} +$cliEnvironmentNames = @( + "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", + "OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_INSECURE", + "OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY_NAME") +$KeepRunning = $false +$gw = $null +$before = [Environment]::GetEnvironmentVariables("Process") +if (-not $before.Contains("OPENSHELL_GATEWAY") -or ([string]$before["OPENSHELL_GATEWAY"]).Length -ne 0) { + throw "empty entry was not inherited" +} +Enter-IsolatedCliEnvironment +try { + $isolated = [Environment]::GetEnvironmentVariables("Process") + foreach ($name in $cliEnvironmentNames) { + if ($name.StartsWith("OPENSHELL_")) { + if ($isolated.Contains($name)) { throw "$name not removed" } + } elseif (-not ([string]$isolated[$name]).StartsWith($cliStateRoot)) { + throw "$name not isolated" + } + } +} finally { Exit-IsolatedCliEnvironment } +$after = [Environment]::GetEnvironmentVariables("Process") +foreach ($name in $cliEnvironmentNames) { + if ($before.Contains($name) -ne $after.Contains($name) -or $before[$name] -cne $after[$name]) { + throw "environment entry $name not restored exactly" + } +} +if (Test-Path -LiteralPath $cliStateRoot) { throw "temporary state not removed" } +"#; + for runner in ["run-mxc-e2e.ps1", "run-ocsf-audit.ps1"] { + let output = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) + .env("OPENSHELL_MXC_RUNNER", examples().join(runner)) + .env("OPENSHELL_GATEWAY", "") + .env("OPENSHELL_GATEWAY_ENDPOINT", "http://127.0.0.1:1") + .env_remove("OPENSHELL_GATEWAY_NAME") + .output() + .expect("launch Windows PowerShell"); + assert!( + output.status.success(), + "{runner} environment regression failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 784918af3f..330816a074 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -854,6 +854,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { 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%\" \ @@ -861,12 +862,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\"", curl.display() ); - let command = vec![ - cmd_string.clone(), - "/d".to_string(), - "/c".to_string(), - script, - ]; + 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, @@ -894,7 +890,8 @@ async fn pc_https_egress_reads_injected_ca_bundle() { access: NetworkAccessPreset::ReadOnly as i32, ..Default::default() }], - binaries: vec![NetworkBinary { path: cmd_string }], + // curl owns the socket; cmd only launches it (#4199). + binaries: vec![NetworkBinary { path: curl_string }], }, )]), ..Default::default() From 95ab160669cbc2753f17792ecf6d882e23f8c725 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 8 Oct 2026 15:21:07 -0700 Subject: [PATCH 09/22] fix(mxc): keep proxy CA material in granted boundary state Signed-off-by: Drew Newberry --- .../WINDOWS-INTEGRATION.md | 3 + crates/openshell-mxc-boundary/README.md | 5 ++ crates/openshell-mxc-boundary/src/runtime.rs | 64 +++++++++++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md b/crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md index d8dd9de68c..4c1eca1148 100644 --- a/crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md +++ b/crates/openshell-driver-mxc/WINDOWS-INTEGRATION.md @@ -13,6 +13,9 @@ This is a selective port, not a branch merge or a claim of complete demo parity. registration in the E2E and OCSF runners. - Restore the WebSocket and OpenClaw policy fixtures with their original grants, updating comments to the host-supervisor/Windows-boundary architecture. +- Adaptation found by real E2E: store injected proxy CA files under the already + protected/granted boundary runtime directory. Ambient host temporary paths + become inaccessible when CLI APPDATA/LOCALAPPDATA is isolated. Keep the always-present `openshell-windows-sandbox`, authenticated Sandbox Protocol, and host isolation-backend supervisor. Do not restore the old relay diff --git a/crates/openshell-mxc-boundary/README.md b/crates/openshell-mxc-boundary/README.md index 58835da0eb..07a4c34b8e 100644 --- a/crates/openshell-mxc-boundary/README.md +++ b/crates/openshell-mxc-boundary/README.md @@ -75,3 +75,8 @@ therefore remains fail-closed, including on an otherwise qualified PSEC host. Unit tests exercise real Windows process launch, output retention, and a real ephemeral TCP forwarding target. These tests do not qualify MXC enforcement; that requires the real-MXC integration and E2E lanes on a supported host. + +The bootstrap path must be absolute. Injected proxy CA files are stored beside +the consumed bootstrap, under the generation's protected boundary-state +directory already granted by the driver. They do not use ambient host +APPDATA/TEMP paths, which may be isolated or inaccessible inside MXC. diff --git a/crates/openshell-mxc-boundary/src/runtime.rs b/crates/openshell-mxc-boundary/src/runtime.rs index 19f781e7bf..cd9fc5cd72 100644 --- a/crates/openshell-mxc-boundary/src/runtime.rs +++ b/crates/openshell-mxc-boundary/src/runtime.rs @@ -54,6 +54,11 @@ pub fn run_boundary(config_path: &Path) -> Result<(), String> { let config: BoundaryConfig = serde_json::from_slice(&bytes) .map_err(|error| format!("decode boundary config {}: {error}", config_path.display()))?; validate_config(&config)?; + let runtime_directory = config_path + .parent() + .filter(|path| path.is_absolute()) + .ok_or_else(|| "MXC bootstrap requires an absolute runtime directory".to_string())? + .to_path_buf(); std::fs::remove_file(config_path) .map_err(|error| format!("consume boundary config {}: {error}", config_path.display()))?; @@ -69,11 +74,8 @@ pub fn run_boundary(config_path: &Path) -> Result<(), String> { } }; let tls = Arc::new(load_tls_server_config(&tls)?); - let listener = tokio::net::TcpListener::bind(address) - .await - .map_err(|error| format!("bind MXC boundary listener at {address}: {error}"))?; - let boundary = Arc::new(BoundaryRuntime::new(config)?); - tracing::info!(%address, "MXC Sandbox Protocol listener ready"); + let boundary = Arc::new(BoundaryRuntime::new(config, runtime_directory)?); + tracing::info!(%address, "MXC Sandbox Protocol reverse control ready"); loop { let (stream, _) = listener .accept() @@ -445,6 +447,8 @@ enum Lifecycle { struct BoundaryRuntime { config: BoundaryConfig, + runtime_directory: PathBuf, + proxy_url: String, authenticator: SandboxProtocolAuthenticator, connections: SandboxConnectionRegistry, connection_shutdowns: Mutex>>, @@ -462,7 +466,11 @@ struct BoundaryRuntime { } impl BoundaryRuntime { - fn new(config: BoundaryConfig) -> Result { + fn new( + launch: crate::launch::MxcBoundaryConfig, + runtime_directory: PathBuf, + ) -> Result { + let config = launch.runtime; let sandbox_id = SandboxId::parse(config.boundary_id.clone()) .map_err(|error| format!("validate MXC sandbox ID: {error}"))?; let generation = openshell_core::sandbox_generation::SandboxGenerationId::parse( @@ -491,6 +499,8 @@ impl BoundaryRuntime { ), connections: SandboxConnectionRegistry::new(config.session_id, config.session_rotation), config, + runtime_directory, + proxy_url: launch.proxy_url, connection_shutdowns: Mutex::new(HashMap::new()), active_connection: Mutex::new(None), lifecycle: Mutex::new(Lifecycle::AwaitingAttach), @@ -830,6 +840,7 @@ impl BoundaryRuntime { } } let ca_paths = match install_ca_material( + &self.runtime_directory, &self.config.generation, ca_cert.as_deref().map(str::as_bytes), ca_bundle.as_deref().map(str::as_bytes), @@ -1216,6 +1227,7 @@ fn workload_environment( } fn install_ca_material( + runtime_directory: &Path, generation: &str, certificate: Option<&[u8]>, bundle: Option<&[u8]>, @@ -1223,7 +1235,10 @@ fn install_ca_material( let (Some(certificate), Some(bundle)) = (certificate, bundle) else { return Ok(None); }; - let directory = std::env::temp_dir().join(format!("openshell-ca-{generation}")); + // The driver already grants and protects the generation's boundary state. + // Host APPDATA/TEMP may be isolated or inaccessible inside ProcessContainer; + // never rely on those ambient paths or broaden workload filesystem grants. + let directory = runtime_directory.join(format!("openshell-ca-{generation}")); std::fs::create_dir_all(&directory) .map_err(|error| format!("create MXC CA directory: {error}"))?; let certificate_path = directory.join("openshell-ca.pem"); @@ -1743,6 +1758,41 @@ mod tests { .expect("shutdown notification"); } + #[test] + fn ca_material_stays_under_the_explicit_boundary_runtime_directory() { + let root = std::env::temp_dir().join(format!( + "openshell-ca-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let paths = install_ca_material(&root, "test-generation", Some(b"cert"), Some(b"bundle")) + .unwrap() + .unwrap(); + assert_eq!( + paths.0, + root.join("openshell-ca-test-generation/openshell-ca.pem") + ); + assert_eq!( + paths.1, + root.join("openshell-ca-test-generation/ca-bundle.pem") + ); + assert_eq!(std::fs::read(&paths.0).unwrap(), b"cert"); + assert_eq!(std::fs::read(&paths.1).unwrap(), b"bundle"); + assert!( + install_ca_material(&root, "unused", None, Some(b"bundle")) + .unwrap() + .is_none() + ); + assert!(!root.join("openshell-ca-unused").exists()); + std::fs::remove_file(paths.0).unwrap(); + std::fs::remove_file(paths.1).unwrap(); + std::fs::remove_dir(root.join("openshell-ca-test-generation")).unwrap(); + std::fs::remove_dir(root).unwrap(); + } + #[test] fn installed_ca_replaces_case_insensitive_trust_overrides() { let certificate = Path::new(r"C:\trusted\ca.pem"); From 52d4f5e7a6215ac037b16d0f85c80c1cfabced30 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 8 Oct 2026 17:33:55 -0700 Subject: [PATCH 10/22] fix(mxc): address Windows qualification review comments Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 19 +++++ crates/openshell-driver-mxc/README.md | 48 +++++++++++++ .../examples/run-mxc-e2e.ps1 | 3 + .../tests/windows_demo_examples.rs | 72 +++++++++++++++++++ .../tests/wxc_exec_real.rs | 2 + crates/openshell-gateway/Cargo.toml | 1 + tasks/scripts/windows-msvc.ps1 | 39 +++++++++- 7 files changed, 182 insertions(+), 2 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index a989e144a4..de1229f70e 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -370,6 +370,25 @@ validation; GitHub Actions does not re-run it after the full suite. ## Test Accounting Guidance +For a separate qualified Windows test host, use +`tasks/scripts/test-mxc-remote.ps1 -HostName -UserName `. +It builds locally with the existing Windows tasks, detects native remote +architecture through Windows system configuration (not an emulated SSH shell), +caches uploads by SHA256, and runs the unchanged real E2E harness. Results and +host probes return to `target/windows-remote-results`, including nonzero test +outcomes. The remote host needs SSH, OpenSSL, and MXC, not a compiler toolchain. +Package the matching `libz3.dll` from the target-specific Cargo cache alongside +the gateway: Windows prebuilt Z3 uses an import library, not static linkage. +The `windows:build:*` tasks now stage the pinned DLL, check PE architecture, +reject conflicting cache hashes, and verify the copied hash. Local MXC E2E +requires the adjacent DLL and reports a preflight error if it is missing. +Use `-Z3DllPath` when discovery is ambiguous. The native Visual C++ runtime +must be present on the remote host. Verify DLL architecture and the gateway +loader before E2E; do not count missing-DLL crashes as MXC enforcement failures. +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 diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 888520ba24..e2462cc4f4 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -135,6 +135,54 @@ not required; driver-owned guest TLS fields are rejected. ## Validation +Windows release build tasks stage the pinned, target-matching `libz3.dll` beside +the gateway and verify its architecture and copied SHA256. Conflicting cached +DLLs fail the build instead of selecting an arbitrary runtime. Local E2E requires +this adjacent DLL; rebuild for the native architecture if preflight reports it +missing. + +### Remote Windows host + +Build on a Windows MSVC development machine and run the unchanged real E2E +harness on a separate MXC host: + +```powershell +.\tasks\scripts\test-mxc-remote.ps1 -HostName 172.16.178.137 -UserName test ` + -WxcExecPath 'C:\Tools\MXC\wxc-exec.exe' +``` + +Install the public SSH key on the target and verify its host key before running. +The script defaults to `~/.ssh/openshell-mxc-test`; override `-IdentityFile` as +needed. Both SSH and SCP must be on PATH locally. The target needs Windows +OpenSSH Server, OpenSSL on its SSH-session PATH, and a qualified MXC runtime, +but does not need Rust, MSVC, mise, or a source checkout. + +The script detects native Windows architecture independently of the SSH shell, +uses the existing `windows:build:*` task, and validates the executable PE +architecture before testing. `-BuildDirectory` selects a dedicated Cargo cache. +`-SkipBuild` reuses existing artifacts without claiming they match current +sources. `-Scenario` selects an existing harness scenario. + +The package includes `libz3.dll` from the target-specific Cargo build cache. +Windows prebuilt Z3 uses a dynamic runtime DLL. Supply `-Z3DllPath` if multiple +different cached versions exist or when using a custom Z3 build. The remote +host needs the native Visual C++ runtime; the runner validates DLL architecture +and gateway startup before invoking E2E. + +The example gateway explicitly enables caller driver config so tests can submit +their workload command and directory. MXC does not implement external-resource +label admission. The temporary outer-fence assertions permit compatibility +testing but do not qualify exclusive network mediation or live revocation. + +Uploads are cached by SHA256 and verified before execution. Each run has an +isolated remote directory under `%USERPROFILE%\openshell-mxc-tests`; old runs +are retained. Full logs, host capabilities, source status, artifact hashes, and +the results ZIP return to `target/windows-remote-results` in this checkout. +Signing-key directories are not downloaded. Test failures and incomplete +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 diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 61b31a5634..673c61096c 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -646,6 +646,9 @@ try { if (-not (Test-Path $policyDir)) { throw "e2e-policies/ directory not found at $policyDir" } + if (-not (Test-Path -LiteralPath (Join-Path $BinaryDir 'libz3.dll') -PathType Leaf)) { + throw "Missing libz3.dll beside the gateway. Re-run windows:build for this architecture, or package the matching Z3 runtime with these binaries." + } # Capture the pristine TOML once; every scenario renders a fresh copy from this. $tomlBase = Get-Content $tomlTemplate -Raw diff --git a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs index c4cb655f01..6690129adc 100644 --- a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs @@ -165,3 +165,75 @@ if (Test-Path -LiteralPath $cliStateRoot) { throw "temporary state not removed" ); } } + +#[test] +fn windows_build_stages_only_unambiguous_target_matching_z3() { + // Synthetic PE headers exercise packaging checks, not MXC containment. + let root = tempfile::tempdir().unwrap(); + let script = r#" +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$tokens = $null +$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 +$TargetDir = $env:OPENSHELL_TEST_ROOT +$PrebuiltZ3Version = '5.1.0' +$env:Z3_LIBRARY_PATH_OVERRIDE = $null +$release = Join-Path $TargetDir 'aarch64-pc-windows-msvc/release' +$sourceDir = Join-Path $release 'build/z3-sys-first/out/z3-5.1.0/bin' +New-Item -ItemType Directory -Path $sourceDir -Force | Out-Null +$source = Join-Path $sourceDir 'libz3.dll' +$bytes = New-Object byte[] 128 +$bytes[0] = 0x4D; $bytes[1] = 0x5A; $bytes[60] = 64 +$bytes[64] = 0x50; $bytes[65] = 0x45 +$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' } +# 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' } +$bytes[68] = 0x64; $bytes[69] = 0x86 +[IO.File]::WriteAllBytes($source, $bytes) +$rejected = $false +try { Stage-Z3Runtime 'aarch64-pc-windows-msvc' } catch { + if ($_.Exception.Message -notmatch 'architecture') { throw } + $rejected = $true +} +if (-not $rejected) { throw 'wrong architecture accepted' } +$secondDir = Join-Path $release 'build/z3-sys-second/out/z3-5.1.0/bin' +New-Item -ItemType Directory -Path $secondDir -Force | Out-Null +$bytes[69] = 0xAA +[IO.File]::WriteAllBytes((Join-Path $secondDir 'libz3.dll'), $bytes) +$rejected = $false +try { Stage-Z3Runtime 'aarch64-pc-windows-msvc' } catch { + if ($_.Exception.Message -notmatch 'Ambiguous') { throw } + $rejected = $true +} +if (-not $rejected) { throw 'conflicting runtimes accepted' } +"#; + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env( + "OPENSHELL_BUILD_SCRIPT", + examples().join("../../../tasks/scripts/windows-msvc.ps1"), + ) + .env("OPENSHELL_TEST_ROOT", root.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "Z3 staging regression failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 330816a074..f6aa1c9afc 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -916,6 +916,8 @@ async fn pc_https_egress_reads_injected_ca_bundle() { 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); diff --git a/crates/openshell-gateway/Cargo.toml b/crates/openshell-gateway/Cargo.toml index fa977a2c2a..17b9c959e3 100644 --- a/crates/openshell-gateway/Cargo.toml +++ b/crates/openshell-gateway/Cargo.toml @@ -74,6 +74,7 @@ telemetry = ["openshell-core/telemetry", "openshell-server/telemetry"] ## `rust:verify:defaults-without-telemetry`. defaults-without-telemetry = ["in-tree-compute-drivers"] vendored-z3 = ["openshell-server/vendored-z3"] +prebuilt-z3 = ["openshell-server/prebuilt-z3"] [lints] workspace = true diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index a88f6744f3..f30577ed78 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -58,6 +58,7 @@ $PrebuiltZ3ServerFeatures = "--features openshell-server/prebuilt-z3,openshell-p $PrebuiltZ3Version = "5.1.0" $Z3WorkspaceFeatures = $PrebuiltZ3WorkspaceFeatures $Z3ServerFeatures = $PrebuiltZ3ServerFeatures +$Z3GatewayFeatures = "--features prebuilt-z3" function Get-VsInstallRoots { $programFiles = @( @@ -514,6 +515,39 @@ function Invoke-Build([string] $RustTarget) { -RustTarget $RustTarget ` -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor --bin openshell-windows-sandbox $Z3WorkspaceFeatures" ` -LogName "build-$RustTarget-release.log" + Stage-Z3Runtime $RustTarget +} + +function Stage-Z3Runtime([string] $RustTarget) { + $release = Join-Path $TargetDir "$RustTarget/release" + if ($env:Z3_LIBRARY_PATH_OVERRIDE) { + $candidates = @(Get-Item -LiteralPath (Join-Path $env:Z3_LIBRARY_PATH_OVERRIDE 'libz3.dll') -ErrorAction SilentlyContinue) + } else { + # Only use the pinned release from this target's build cache. Never pick + # the newest arbitrary DLL, or trust a stale adjacent runtime. + $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) + 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)) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { throw "Invalid Z3 DLL: $source" } + $reader.BaseStream.Position = 0x3C + $offset = $reader.ReadInt32() + if ($offset -lt 64 -or $offset -gt $reader.BaseStream.Length - 6) { throw 'Invalid Z3 PE offset.' } + $reader.BaseStream.Position = $offset + if ($reader.ReadUInt32() -ne 0x00004550) { throw 'Invalid Z3 PE signature.' } + $expected = if ($RustTarget -eq 'aarch64-pc-windows-msvc') { 0xAA64 } else { 0x8664 } + if ($reader.ReadUInt16() -ne $expected) { throw "Z3 DLL architecture does not match $RustTarget." } + } finally { $reader.Dispose() } + $destination = Join-Path $release 'libz3.dll' + 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.' } + Write-Host "==> Staged $destination (SHA256=$($hashes[0]))" } function Invoke-Test([string] $RustTarget) { @@ -542,7 +576,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { foreach ($test in $tests) { Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3WorkspaceFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --target $RustTarget $test $Z3GatewayFeatures" ` -LogName "test-$RustTarget-unsupported-$test.log" } @@ -551,7 +585,7 @@ function Invoke-UnsupportedContractTests([string] $RustTarget) { $variant = if ($features) { $features.Replace(",", "-") } else { "protocol-only" } Invoke-VsCargo ` -RustTarget $RustTarget ` - -CargoArgs "cargo test -p openshell-gateway --lib --target $RustTarget --no-default-features $featureArgs $Z3ServerFeatures" ` + -CargoArgs "cargo test -p openshell-gateway --lib --target $RustTarget --no-default-features $featureArgs $Z3GatewayFeatures" ` -LogName "test-$RustTarget-selective-$variant.log" } } @@ -618,6 +652,7 @@ if ($Action -in @("check", "lint", "build", "build-mxc-fixtures", "test", "test- $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures + $Z3GatewayFeatures = if ($z3Features.WorkspaceFeatures) { "--features prebuilt-z3" } else { "" } $env:LIBCLANG_PATH = Resolve-LibclangPath Add-PathEntry $env:LIBCLANG_PATH Write-Host "==> LIBCLANG_PATH=$env:LIBCLANG_PATH" From df958f3613f57733cd761a60233eb20245b786e9 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 8 Oct 2026 22:44:13 -0700 Subject: [PATCH 11/22] fix(mxc): pass native Windows build and HTTPS qualification Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 57 ++++-- .../build-openshell-mxc-windows/reference.md | 1 + Cargo.lock | 3 +- crates/openshell-cli/src/commands/gateway.rs | 17 -- crates/openshell-cli/src/main.rs | 61 +----- crates/openshell-cli/src/run.rs | 25 +-- .../sandbox_create_lifecycle_integration.rs | 12 -- crates/openshell-core/README.md | 13 ++ .../openshell-core/src/extension_protocol.rs | 34 ++++ .../openshell-driver-docker/src/isolation.rs | 1 - .../src/isolation.rs | 1 - crates/openshell-driver-mxc/README.md | 71 ++++++- .../examples/e2e-policies/https-ca.yaml | 19 ++ .../examples/mxc-gateway.toml | 7 +- .../examples/run-mxc-e2e.ps1 | 58 +++++- crates/openshell-driver-mxc/src/driver.rs | 171 +++++++++++++---- crates/openshell-driver-mxc/src/isolation.rs | 133 ++++++++----- .../tests/windows_demo_examples.rs | 43 ++++- .../tests/wxc_exec_real.rs | 146 ++++----------- .../openshell-driver-vm/src/isolation/mod.rs | 1 - crates/openshell-gateway/src/lib.rs | 8 + .../src/contract.rs | 35 +--- .../tests/backend_conformance.rs | 1 + crates/openshell-mxc-boundary/Cargo.toml | 6 +- crates/openshell-mxc-boundary/README.md | 15 +- crates/openshell-mxc-boundary/src/audit.rs | 7 +- crates/openshell-mxc-boundary/src/backend.rs | 166 +++++++++++++++- crates/openshell-mxc-boundary/src/identity.rs | 141 ++++++++++++++ crates/openshell-mxc-boundary/src/launch.rs | 177 ++++++++++++++++++ crates/openshell-mxc-boundary/src/lib.rs | 4 + crates/openshell-mxc-boundary/src/runtime.rs | 115 ++++++------ .../tests/composition.rs | 24 ++- crates/openshell-sandbox-backend/README.md | 1 + .../src/boundary_protocol.rs | 20 +- .../openshell-sandbox-backend/src/runtime.rs | 29 +-- crates/openshell-sandbox/Cargo.toml | 1 - crates/openshell-server/src/compute/mod.rs | 54 ++++-- crates/openshell-server/src/grpc/mod.rs | 1 - crates/openshell-server/src/grpc/policy.rs | 12 -- crates/openshell-server/src/test_support.rs | 1 - .../src/main_session.rs | 1 - crates/openshell-supervisor/README.md | 13 +- .../openshell-supervisor/src/backend_setup.rs | 19 ++ .../src/backend_setup/mxc.rs | 82 ++++++++ .../src/backend_setup/tests.rs | 92 +++++++++ .../src/isolation_backends.rs | 29 --- crates/openshell-supervisor/src/lib.rs | 4 +- docs/how-it-works/gateways/configuration.mdx | 28 ++- docs/how-it-works/policies/overview.mdx | 5 +- docs/how-it-works/policies/schema.mdx | 28 +++ docs/how-it-works/sandboxes/overview.mdx | 19 -- proto/compute_driver.proto | 4 - proto/openshell.proto | 4 - rfc/0013-native-windows-mxc/README.md | 13 +- .../openshell/v1/internal/converter/health.go | 1 - .../v1/internal/converter/health_test.go | 8 +- sdk/go/openshell/v1/types/health.go | 7 +- sdk/go/proto/openshellv1/openshell.pb.go | 23 +-- skills/generate-sandbox-policy/SKILL.md | 9 +- skills/openshell-cli/SKILL.md | 22 +-- tasks/scripts/test-mxc-remote-tests.ps1 | 79 ++++++++ tasks/scripts/windows-msvc.ps1 | 6 +- 62 files changed, 1551 insertions(+), 637 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/https-ca.yaml create mode 100644 crates/openshell-mxc-boundary/src/identity.rs create mode 100644 crates/openshell-mxc-boundary/src/launch.rs create mode 100644 crates/openshell-supervisor/src/backend_setup/mxc.rs delete mode 100644 crates/openshell-supervisor/src/isolation_backends.rs create mode 100644 tasks/scripts/test-mxc-remote-tests.ps1 diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index de1229f70e..cd96e10491 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 a34cb2eb31..179eaef086 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 e9b1c4406a..7b28dc2c7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4691,6 +4691,8 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sha2 0.10.9", + "tempfile", "tokio", "tokio-rustls", "tokio-stream", @@ -4855,7 +4857,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 6d75626d65..ffb7dae76e 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 44f5151c53..5f20fc7f3c 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1686,16 +1686,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. @@ -3586,18 +3578,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, @@ -5527,48 +5512,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 cbda2d9d00..807a79cb72 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 fbd9198ab3..e9584cd6e9 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 b4cdb05f5d..1c61a72ef5 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 aef96b46d2..4aabee1cd4 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 549c71c4de..d921b8c348 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 ba5343463d..6a94e6abaa 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 e2462cc4f4..29c9488780 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 0000000000..09c13aa828 --- /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 7c492fd6ac..d67a271c56 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 673c61096c..bbe7ed286c 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 30cbdf6bdb..3ddddc1c9d 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 f18a4077e1..1aef7cb60c 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 6690129adc..317c597094 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 f6aa1c9afc..485c45a07c 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 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 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 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 c89f9284dd..a660ad98c7 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 b7f80b5f67..463edd3bc0 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 9f619c0919..246394d28e 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 c1873db08a..2fb1f3fdba 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 c11a197434..300f6423da 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 07a4c34b8e..473213e485 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 a795eb0a51..f7766d346d 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 daf3ab3bf9..3a1cc5caef 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 0000000000..609db7d026 --- /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 0000000000..a5c7987276 --- /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 68e0692fd3..003f9e2e5e 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 cd9fc5cd72..68703ba923 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 a58ffbe47b..25c9269a0a 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 69033f3767..2186210cb4 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 a30f83b8e7..70d491d318 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 396175289e..b20ca44f3b 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 392840d07a..88bce43fab 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 b82f2e60a0..f7a5de9a36 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -369,9 +369,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 @@ -795,7 +792,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( @@ -1125,8 +1121,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 ))); @@ -7430,7 +7432,6 @@ impl ComputeDriver for NoopTestDriver { "test", [], )), - supports_ui_policy: false, }, )) } @@ -7608,7 +7609,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, @@ -7635,7 +7635,6 @@ 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, @@ -8053,7 +8052,6 @@ mod tests { } metadata }), - supports_ui_policy: false, })) } @@ -8461,7 +8459,6 @@ mod tests { "test", [], )), - supports_ui_policy: false, })) } @@ -8723,7 +8720,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, @@ -8766,7 +8762,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), @@ -8785,7 +8781,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, @@ -8812,7 +8808,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); } @@ -8820,7 +8816,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")) @@ -8830,6 +8830,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()); @@ -8843,6 +8864,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 98ec894704..b366dc4a58 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -299,7 +299,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 0dd9a0d214..6d1709d2df 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -8762,18 +8762,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 9980c14a81..0cd4abf2d5 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 c7de3fe0e9..80759131d1 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 d846507991..eebaed0deb 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 c312ca6a36..80385a7d8d 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 0000000000..e24b625217 --- /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 3fbabe0cc1..0d7c7386f6 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 7538066396..0000000000 --- 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 097c6714f6..4cf041a191 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; @@ -329,6 +328,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(); @@ -688,7 +688,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 b14c81b7e4..342d9e72e1 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -676,7 +676,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 @@ -732,8 +732,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 @@ -1239,6 +1241,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 @@ -1277,6 +1283,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 d5800bf0ac..4d5757af00 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 693efb209c..9bc7007977 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 02553add7d..f10cccb561 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -267,11 +267,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: @@ -1005,20 +1000,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 255309320a..60bda56914 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 1522ea7739..a4c32bcd42 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 08f54d2d67..c4706cfd5d 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 c1cd07fea4..8e1c2b98f6 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 3fc3a09646..ab8e0c2415 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 1b112ace3c..3039f1c80d 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 54ebe97f29..8a3f7a78d1 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 { @@ -11712,8 +11702,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). @@ -18293,13 +18283,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 b606959946..b2390654ae 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 13464f5f44..fef7125902 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -502,11 +502,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 @@ -514,10 +509,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 @@ -553,6 +544,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 @@ -567,11 +564,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 @@ -643,7 +635,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 0000000000..0971360804 --- /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 f30577ed78..4ff92c2290 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]))" } From c3ec1f1640efe3154753ea2a5cb3a4422919891d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 12 Oct 2026 09:40:31 -0700 Subject: [PATCH 12/22] docs(mxc): align host setup and authentication guidance Signed-off-by: Drew Newberry --- crates/openshell-driver-mxc/README.md | 9 +++-- docs/how-it-works/gateways/configuration.mdx | 42 +++++++++++++++++--- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 29c9488780..d816321efd 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -19,10 +19,11 @@ workload ``` Caller driver config is disabled by default, so command-based MXC workflows -need explicit administrator opt-in. Host filesystem grants have no trusted -label resolver and are rejected while resource admission is enabled. -See [resource admission configuration](../../docs/how-it-works/gateways/configuration.mdx#external-resource-admission) -for the independent controls and the security consequences of opting out. +need explicit administrator opt-in with `allow_driver_config = true`. +MXC does not support external-resource label admission. Host filesystem grants +rely on sandbox policy and a trusted gateway operator. See +[resource admission configuration](../../docs/how-it-works/gateways/configuration.mdx#external-resource-admission) +for the independent caller configuration gate and resource admission controls. This driver implements the gateway's ordinary in-process `ComputeDriver` contract and is linked into `openshell-gateway`. Runtime readiness comes from diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 342d9e72e1..f6249010a8 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -1245,6 +1245,28 @@ The Windows sandbox initiates its authenticated control connection to a generati 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. +#### Prepare the Windows host + +Copy `wxc-exec.exe` to a regular directory outside an MSIX or `WindowsApps` +package directory. Package ACLs can prevent the gateway from starting it. +If creation fails with `wxc-exec spawn failed: Access is denied. (os error 5)`, +check that `wxc_exec_path` points to the standalone copy. + +Run the capability probe before creating a sandbox: + +```shell +& "C:\mxc\wxc-exec.exe" --probe +``` + +The runtime pair requires native ProcessContainer host-loopback support. +The probe must report `probes.baseContainerSupportsIngressHostLoopbackAllow` +as `true`. A Windows build number alone does not establish support. If the +capability is unavailable, use an MXC runtime and host that provide it; +`prepare-system-drive` does not add it. The AppContainer + DACL fallback +cannot supply this runtime pair's required ProcessContainer network boundary. + +#### Configure MXC + ```toml [openshell] version = 2 @@ -1253,13 +1275,13 @@ version = 2 bind_address = "127.0.0.1:17670" log_level = "info" compute_driver = "mxc" -# Required when gateway TLS is enabled. The gateway injects this bundle into -# the host supervisor. -guest_tls_ca = "C:\\OpenShell\\certs\\ca.pem" -guest_tls_cert = "C:\\OpenShell\\certs\\client.pem" -guest_tls_key = "C:\\OpenShell\\certs\\client-key.pem" +# Required when gateway TLS is enabled. The host supervisor verifies the +# gateway certificate with this CA and authenticates with its launch token. +guest_tls_ca = "C:\\OpenShell\\certs\\ca.pem" [openshell.drivers.mxc] +# Administrator opt-in for caller-supplied workload commands and directories. +allow_driver_config = true wxc_exec_path = "C:\\mxc\\wxc-exec.exe" supervisor_binary_path = "C:\\OpenShell\\openshell-supervisor.exe" sandbox_binary_path = "C:\\OpenShell\\openshell-windows-sandbox.exe" @@ -1279,6 +1301,12 @@ etw_audit = false The packaged supervisor and sandbox binaries default to siblings of `openshell-gateway.exe`; explicit paths are useful for development layouts. The driver protects host supervisor tokens and descriptors with an owner-only Windows DACL. +The supervisor does not need a gateway client certificate or key. MXC does not +support external-resource label admission; do not add `resource_admission` to +its driver table. Host filesystem grants rely on sandbox policy and a trusted +gateway operator. Caller-supplied commands and directories still require +`allow_driver_config = true`. + The MXC driver requires MXC 1.0.0 and emits its stable directional network schema. Remove the retired `default_configuration_id` setting before upgrading; the gateway rejects it. The `isolation_session` backend remains unsupported. 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. @@ -1438,7 +1466,9 @@ compute_driver = "kyma" socket_path = "/run/openshell/kyma-compute-driver.sock" ``` -## Preflight package configuration {#gateway-config-preflight} + + +## Preflight package configuration Before starting a package-managed gateway, validate the selected file without changing it: From 15877788c45d7e74110a529cd6b649304df7e3eb Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 12 Oct 2026 15:14:50 -0700 Subject: [PATCH 13/22] fix(mxc): attribute proxy traffic to socket owners and validate providers Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 14 + Cargo.lock | 1 + crates/openshell-driver-mxc/README.md | 32 +- .../examples/run-mxc-e2e.ps1 | 49 ++- .../tests/wxc_exec_real.rs | 45 ++ crates/openshell-mxc-boundary/Cargo.toml | 3 +- crates/openshell-mxc-boundary/README.md | 6 + crates/openshell-mxc-boundary/src/identity.rs | 41 +- crates/openshell-mxc-boundary/src/launch.rs | 5 +- crates/openshell-mxc-boundary/src/lib.rs | 2 + crates/openshell-mxc-boundary/src/runtime.rs | 85 +++- .../src/socket_owner.rs | 400 ++++++++++++++++++ .../src/identity_source.rs | 6 + .../openshell-supervisor-network/src/proxy.rs | 13 +- .../openshell-supervisor-network/src/run.rs | 6 +- .../src/backend_setup/mxc.rs | 36 +- docs/how-it-works/policies/overview.mdx | 5 + tasks/scripts/windows-msvc.ps1 | 43 +- tasks/windows.toml | 15 + 19 files changed, 783 insertions(+), 24 deletions(-) create mode 100644 crates/openshell-mxc-boundary/src/socket_owner.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index cd96e10491..33c850e812 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -530,3 +530,17 @@ Every substantial Windows build run should report: | Artifacts | Binary paths, size, and SHA256 when available. | | Skips | Explicitly explain tests not run for a non-native architecture, unsupported driver package exclusions, and Windows cfg-gated tests. | | Follow-ups | Only concrete follow-ups tied to failures or requested scope. | + +## Focused MXC network qualification + +Use `mise run --skip-tools windows:test:mxc-boundary` for boundary unit tests and +real Windows kernel socket-owner lookup. `windows:test:mxc-network` requires the +native release build and Internet access; it runs the injected-CA HTTPS test and +the allow-child/deny-child socket-owner regression through the authenticated +harness. `windows:test:mxc-loopback` executes its test inside a real native MXC +container with host loopback denied, separately from ordinary unit coverage. +Do not enable broad host loopback to turn an unsupported-runtime failure green. +The MSVC wrapper selects native test architecture from the machine environment, +so an emulated SSH PowerShell does not incorrectly reject ARM64 qualification. +Keep Windows kernel calls in the boundary library and retain shared supervisor +unsafe-code bans, per-generation proxy authentication, and executable digests. diff --git a/Cargo.lock b/Cargo.lock index 7b28dc2c7f..a438d9a878 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4682,6 +4682,7 @@ name = "openshell-mxc-boundary" version = "0.0.0" dependencies = [ "async-trait", + "base64 0.22.1", "clap", "openshell-core", "openshell-isolation-interface", diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index d816321efd..a6c7339331 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -76,10 +76,10 @@ sensitive host-local services to workloads using this compatibility path. - The host supervisor owns an authenticated per-generation explicit proxy. Missing or cross-sandbox credentials receive HTTP 407 before policy evaluation. MXC denies direct Internet egress. -- The current explicit-proxy path attributes traffic to the admitted main - workload binary. It does not distinguish descendant processes. MXC process - policy must therefore prevent an untrusted allowed child binary from - inheriting broader per-binary network rights. +- The explicit proxy resolves the actual socket-owning executable from the + Windows kernel TCP table, hashes it, and rechecks ownership before policy + evaluation. Allowing a launcher such as `cmd.exe` does not authorize a child + such as `curl.exe`; missing, ambiguous, or changed ownership denies the request. - The loopback fence permits `127.0.0.1/32`; it does not isolate unrelated host services bound to that address. Treat the gateway host as trusted. - Host supervisor tokens and descriptors live beneath an owner-only Windows @@ -297,3 +297,27 @@ or provider-backed inference scenarios to the six-scenario E2E runner. With `-KeepRunning`, the runner restores the caller's environment but retains the disposable CLI/runtime directory for the live gateway and prints its path. Remove that directory only after stopping the gateway. + +### Focused native qualification + +After building the native release binaries, run: + +```powershell +mise run --skip-tools windows:test:mxc-boundary +mise run --skip-tools windows:test:mxc-network +mise run --skip-tools windows:test:mxc-loopback +``` + +The network task runs real injected-CA HTTPS and both child-curl policy cases. +It requires Internet access. Native target selection uses the machine architecture, +including when SSH launches an emulated PowerShell. The loopback task hosts its +test inside a real ProcessContainer with host loopback denied; it is a separate +qualification and never relaxes the fence or reports an unsupported run as a pass. +On the ARM64 test host (Windows build 28120, MXC 1.0.0), this qualification +currently fails with socket error 10013. Enabling the documented private-network +capability while retaining host-loopback denial also fails; no safe OpenShell +workaround has been established. + +Provider environment publications reject case-insensitive duplicate names, +invalid environment entries, and names reserved for the six injected TLS trust +variables before installing them. Rejected updates retain the prior revision. diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index bbe7ed286c..38512c25d5 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -766,6 +766,17 @@ try { Description = 'curl trusts the injected CA and observes the OpenShell HTTPS proxy certificate' } } + if ($Scenario -in @('socket-owner-allow', 'socket-owner-deny')) { + $allScenarios += @{ + Name = $Scenario; PolicyFile = Join-Path $policyDir 'https-ca.yaml' + SandboxId = 'own'; Backends = 'process_container'; Kind = 'socket-owner' + PosTarget = Join-Path $DemoDir 'example.html' + ControlTarget = Join-Path $DemoDir 'owner-launched.txt' + ErrorTarget = Join-Path $DemoDir 'curl-stderr.txt' + ExpectAllowed = ($Scenario -eq 'socket-owner-allow') + Description = 'network policy follows child curl rather than parent cmd' + } + } 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 @@ -814,10 +825,13 @@ try { $defaultDemoDir.Replace('\', '/'), $DemoDir.Replace('\', '/') ) - $curlExe = Join-Path $env:SYSTEMROOT 'System32/curl.exe' + $curlExe = [IO.Path]::Combine([IO.Path]::GetDirectoryName($cmdExe), '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("'", "''")) + if ($sc.Kind -eq 'socket-owner' -and -not $sc.ExpectAllowed) { + $policyText = $policyText.Replace($curlExe, $cmdExe) + } Set-Content -Path $policyUsed -Value $policyText -Encoding UTF8 # Per-scenario gateway logs land in the bundle under their own names. @@ -838,6 +852,12 @@ try { } else { $command = @($cmdExe, "/c", "echo denied 1> $denied") } + } elseif ($sc.Kind -eq 'socket-owner') { + Remove-Item -LiteralPath $sc.PosTarget, $sc.ControlTarget, $sc.ErrorTarget -Force -ErrorAction SilentlyContinue + $child = 'echo launched > "{0}" && "{1}" --disable --fail --silent --show-error --max-time 30 --ssl-revoke-best-effort --cacert "%CURL_CA_BUNDLE%" --stderr "{2}" --output "{3}" https://example.com/' -f $sc.ControlTarget, $curlExe, $sc.ErrorTarget, $sc.PosTarget + $batch = Join-Path $DemoDir 'socket-owner.cmd' + Set-Content -LiteralPath $batch -Value ("@echo off`r`n" + $child + "`r`nexit /b %errorlevel%`r`n") -Encoding ASCII + $command = @($cmdExe, '/d', '/c', $batch) } 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 @@ -913,7 +933,32 @@ try { $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" # Evaluate. - if ($sc.Kind -eq 'https') { + if ($sc.Kind -eq 'socket-owner') { + try { + if (-not (Wait-File $sc.ControlTarget 30)) { throw "parent cmd did not launch: $createOutStr" } + $deadline = (Get-Date).AddSeconds(45) + do { + $curlError = Get-Content $sc.ErrorTarget -Raw -ErrorAction SilentlyContinue + $response = (Test-Path -LiteralPath $sc.PosTarget) -and ((Get-Item $sc.PosTarget).Length -gt 0) + if ($response -or $curlError) { break } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + if ($sc.ExpectAllowed) { + if ($createExitCode -ne 0 -or -not $response -or (Get-Item $sc.PosTarget).Length -eq 0) { + $curlError = Get-Content $sc.ErrorTarget -Raw -ErrorAction SilentlyContinue + throw "allowed child curl failed: $curlError $createOutStr" + } + } else { + $curlError = Get-Content $sc.ErrorTarget -Raw -ErrorAction SilentlyContinue + if ($response -or $curlError -notmatch '403') { throw "expected child curl policy denial: $curlError" } + } + Ok "$($sc.Name): socket-owning child curl policy verified" + $results += [pscustomobject]@{ Scenario=$sc.Name; Result='PASS'; Reason='child socket-owner policy verified' } + } catch { + Bad "$($sc.Name): $($_.Exception.Message)" + $results += [pscustomobject]@{ Scenario=$sc.Name; Result='FAIL'; Reason=$_.Exception.Message } + } + } elseif ($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' } diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 485c45a07c..73d17f6ea6 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -324,6 +324,51 @@ fn dryrun_rejects_unknown_containment() { assert_ne!(code, 0, "unknown containment 'nonsense' should be rejected"); } +/// Exercise both sides of child-process attribution through the authenticated gateway harness. +#[test] +#[ignore = "requires real wxc-exec, native release binaries, and outbound HTTPS"] +fn pc_proxy_scopes_network_policy_to_socket_owner() { + let wxc = wxc_path().expect("real MXC socket-owner qualification requires wxc-exec"); + probe_processcontainer(&wxc).expect("native ProcessContainer must be live"); + probe_processcontainer_host_loopback(&wxc).expect("native governed loopback must be available"); + for scenario in ["socket-owner-allow", "socket-owner-deny"] { + let directory = tempfile::tempdir().unwrap(); + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let target = if cfg!(target_arch = "aarch64") { + "aarch64-pc-windows-msvc" + } else { + "x86_64-pc-windows-msvc" + }; + let target_root = + std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| repo.join("target"), PathBuf::from); + let binary_dir = 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") + .unwrap() + .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", scenario, "-Port", &port.to_string()]) + .arg("-WxcExecPath") + .arg(&wxc) + .arg("-BinaryDir") + .arg(&binary_dir) + .arg("-DemoDir") + .arg(directory.path()) + .output() + .unwrap(); + assert!( + result.status.success(), + "{scenario}: {} {}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + } +} + /// The most important dry-run test: build a typed Windows policy, run /// `split_policy` (MXC 1.0 loopback-only proxy access at 127.0.0.1:18080, /// containment diff --git a/crates/openshell-mxc-boundary/Cargo.toml b/crates/openshell-mxc-boundary/Cargo.toml index 300f6423da..92c64ab779 100644 --- a/crates/openshell-mxc-boundary/Cargo.toml +++ b/crates/openshell-mxc-boundary/Cargo.toml @@ -35,9 +35,10 @@ serde = { workspace = true } sha2 = { workspace = true } tracing = { workspace = true } url = { workspace = true } -windows = { workspace = true, features = ["Win32_Security_Authorization"] } +windows = { workspace = true, features = ["Win32_Security_Authorization", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] } [target.'cfg(target_os = "windows")'.dev-dependencies] +base64 = { workspace = true } tempfile = "3" [lints] diff --git a/crates/openshell-mxc-boundary/README.md b/crates/openshell-mxc-boundary/README.md index 473213e485..0e2f64f90f 100644 --- a/crates/openshell-mxc-boundary/README.md +++ b/crates/openshell-mxc-boundary/README.md @@ -89,3 +89,9 @@ The bootstrap path must be absolute. Injected proxy CA files are stored beside the consumed bootstrap, under the generation's protected boundary-state directory already granted by the driver. They do not use ambient host APPDATA/TEMP paths, which may be isolated or inaccessible inside MXC. + +The host supervisor uses this library's Windows TCP owner lookup to attribute +proxy connections to the socket-owning executable. Windows kernel calls remain +in this library; the safe shared proxy receives measured per-connection identity. +Provider environment keys are validated before initial launch and later updates, +with case-insensitive collision detection and reserved TLS trust names rejected. diff --git a/crates/openshell-mxc-boundary/src/identity.rs b/crates/openshell-mxc-boundary/src/identity.rs index 609db7d026..1988a2b81c 100644 --- a/crates/openshell-mxc-boundary/src/identity.rs +++ b/crates/openshell-mxc-boundary/src/identity.rs @@ -129,7 +129,46 @@ mod tests { #[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"); + if std::env::var_os("OPENSHELL_MXC_LOOPBACK_CHILD").is_none() { + use base64::Engine as _; + let directory = tempfile::tempdir().unwrap(); + let executable = directory.path().join("native-loopback-test.exe"); + std::fs::copy(std::env::current_exe().unwrap(), &executable).unwrap(); + let system_root = std::env::var("SYSTEMROOT").unwrap(); + let request = serde_json::json!({ + "version": "1.0.0", "containerId": "openshell-native-loopback-test", + "containment": "processcontainer", + "process": { + "commandLine": format!("\"{}\" --exact identity::tests::native_same_container_loopback --ignored --nocapture", executable.display()), + "cwd": directory.path(), "timeout": 60000, + "env": [format!("SYSTEMROOT={system_root}"), format!("WINDIR={system_root}"), + format!("LOCALAPPDATA={}", directory.path().display()), "OPENSHELL_MXC_LOOPBACK_CHILD=1".to_string()] + }, + "filesystem": { "readwritePaths": [directory.path()] }, + "network": { "egress": { "default": "deny" }, + "ingress": { "default": "deny", "hostLoopback": "deny" } } + }); + let encoded = base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&request).unwrap()); + let wxc = std::env::var("OPENSHELL_WXC_EXEC_PATH") + .unwrap_or_else(|_| "C:/Tools/MXC/wxc-exec.exe".into()); + let output = std::process::Command::new(wxc) + .args(["--config-base64", &encoded]) + .output() + .unwrap(); + assert!( + output.status.success(), + "native MXC loopback failed: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "child test must execute" + ); + return; + } + current_appcontainer_sid().expect("child 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 = diff --git a/crates/openshell-mxc-boundary/src/launch.rs b/crates/openshell-mxc-boundary/src/launch.rs index a5c7987276..3c133b4de2 100644 --- a/crates/openshell-mxc-boundary/src/launch.rs +++ b/crates/openshell-mxc-boundary/src/launch.rs @@ -72,9 +72,8 @@ impl MxcLaunchDescriptor { } 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. + /// Supply digest evidence for launch metadata. Network enforcement resolves + /// the actual socket-owning executable separately for each connection. pub fn binary_identity(&self) -> Result { self.validate()?; let bytes = std::fs::read(&self.workload_binary).map_err(|error| { diff --git a/crates/openshell-mxc-boundary/src/lib.rs b/crates/openshell-mxc-boundary/src/lib.rs index 003f9e2e5e..d06070d79c 100644 --- a/crates/openshell-mxc-boundary/src/lib.rs +++ b/crates/openshell-mxc-boundary/src/lib.rs @@ -16,6 +16,8 @@ pub mod backend; mod identity; #[cfg(target_os = "windows")] pub mod launch; +#[cfg(target_os = "windows")] +pub mod socket_owner; /// 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 68703ba923..9a75b4281f 100644 --- a/crates/openshell-mxc-boundary/src/runtime.rs +++ b/crates/openshell-mxc-boundary/src/runtime.rs @@ -852,6 +852,9 @@ impl BoundaryRuntime { return guest_error(BoundaryErrorKind::Denied, "MXC start policy changed"); } } + if let Err(error) = validate_provider_environment(provider_env) { + return guest_error(BoundaryErrorKind::Invalid, error); + } let ca_paths = match install_ca_material( &self.runtime_directory, &self.config.generation, @@ -1187,8 +1190,14 @@ fn workload_environment( ca_paths: Option<&(PathBuf, PathBuf)>, ) -> HashMap { let mut environment = child.clone(); - environment.extend(providers.clone()); - environment.extend(overrides.iter().cloned()); + // Provider values override child aliases using Windows environment semantics. + for (key, value) in providers + .iter() + .chain(overrides.iter().map(|(key, value)| (key, value))) + { + environment.retain(|existing, _| !existing.eq_ignore_ascii_case(key)); + environment.insert(key.clone(), value.clone()); + } for (key, value) in [ ("HTTP_PROXY", proxy_url), ("HTTPS_PROXY", proxy_url), @@ -1617,12 +1626,48 @@ fn guest_error(kind: BoundaryErrorKind, message: impl Into) -> Response } } +/// Reject ambiguous Windows keys before launch or publication, without logging values. +fn validate_provider_environment(environment: &HashMap) -> Result<(), String> { + let mut seen = std::collections::BTreeMap::new(); + let mut keys: Vec<_> = environment.keys().collect(); + keys.sort(); + for key in keys { + if key.is_empty() || key.contains(['=', '\0']) || environment[key].contains('\0') { + return Err("MXC provider environment contains an invalid key or value".into()); + } + let folded = key.to_ascii_uppercase(); + if [ + "NODE_EXTRA_CA_CERTS", + "DENO_CERT", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + ] + .contains(&folded.as_str()) + { + return Err(format!( + "MXC provider environment key {key:?} is reserved for TLS trust" + )); + } + if let Some(previous) = seen.insert(folded, key) { + return Err(format!( + "MXC provider environment keys {previous:?} and {key:?} collide on Windows" + )); + } + } + Ok(()) +} + fn publish_provider_environment( installed: &mut (u64, HashMap, u64), generation: u64, revision: u64, environment: &HashMap, ) -> Response { + if let Err(error) = validate_provider_environment(environment) { + return guest_error(BoundaryErrorKind::Invalid, error); + } // The revision is an opaque fingerprint, not publication order. A newer // generation can repair an identical revision or install a smaller value. let applied = generation > installed.2; @@ -1817,6 +1862,42 @@ mod tests { } } + #[test] + fn provider_environment_overrides_case_aliases_in_child_environment() { + let child = HashMap::from([("token".into(), "old".into())]); + let providers = HashMap::from([("TOKEN".into(), "new".into())]); + let environment = workload_environment(&child, &providers, &[], "http://proxy", None); + assert!(!environment.contains_key("token")); + assert_eq!(environment["TOKEN"], "new"); + } + + #[test] + fn provider_publications_reject_ambiguous_or_reserved_keys_atomically() { + for environment in [ + HashMap::from([ + ("TOKEN".into(), "one".into()), + ("token".into(), "two".into()), + ]), + HashMap::from([("ssl_cert_file".into(), "untrusted".into())]), + HashMap::from([("Node_Extra_CA_Certs".into(), "untrusted".into())]), + HashMap::from([("BAD=KEY".into(), "value".into())]), + ] { + let mut installed = (7, HashMap::from([("GOOD".into(), "kept".into())]), 3); + let previous = installed.clone(); + assert!(matches!( + publish_provider_environment(&mut installed, 4, 8, &environment), + Response::Error { + kind: BoundaryErrorKind::Invalid, + .. + } + )); + assert_eq!( + installed, previous, + "invalid publication must retain the prior environment" + ); + } + } + #[test] fn provider_publications_are_ordered_independently_of_opaque_revisions() { let mut installed = (99, HashMap::new(), 0); diff --git a/crates/openshell-mxc-boundary/src/socket_owner.rs b/crates/openshell-mxc-boundary/src/socket_owner.rs new file mode 100644 index 0000000000..ca751f906a --- /dev/null +++ b/crates/openshell-mxc-boundary/src/socket_owner.rs @@ -0,0 +1,400 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows TCP socket-owner and process-image resolution. + +use std::mem::{offset_of, size_of, size_of_val}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::path::PathBuf; + +type Result = std::result::Result; +use windows::Win32::Foundation::{CloseHandle, ERROR_INSUFFICIENT_BUFFER, HANDLE}; +use windows::Win32::NetworkManagement::IpHelper::{ + GetExtendedTcpTable, MIB_TCP_STATE_ESTAB, MIB_TCP6ROW_OWNER_PID, MIB_TCP6TABLE_OWNER_PID, + MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID, TCP_TABLE_OWNER_PID_ALL, +}; +use windows::Win32::Networking::WinSock::{AF_INET, AF_INET6}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, +}; +use windows::core::PWSTR; + +#[derive(Clone, Copy)] +struct WorkloadProxyTcpConnection { + workload: SocketAddr, + proxy: SocketAddr, +} +impl std::fmt::Display for WorkloadProxyTcpConnection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} -> {}", self.workload, self.proxy) + } +} +impl WorkloadProxyTcpConnection { + fn new(workload: SocketAddr, proxy: SocketAddr) -> Self { + Self { workload, proxy } + } +} + +/// Resolve the unique owning process from the Windows kernel TCP table. +pub fn resolve_connection_owner(workload: SocketAddr, proxy: SocketAddr) -> Result<(PathBuf, u32)> { + resolve_tcp_peer_identity(WorkloadProxyTcpConnection::new(workload, proxy)) +} + +struct ProcessHandle(HANDLE); + +impl Drop for ProcessHandle { + fn drop(&mut self) { + // SAFETY: `self.0` is a valid handle returned by `OpenProcess`, and this + // guard is its sole owner. + #[allow(unsafe_code)] + let _ = unsafe { CloseHandle(self.0) }; + } +} + +/// Resolve the process that owns the workload side of an accepted proxy TCP +/// connection and return its PID and executable image path. +fn resolve_tcp_peer_identity(connection: WorkloadProxyTcpConnection) -> Result<(PathBuf, u32)> { + let mut owners = match (connection.workload, connection.proxy) { + (SocketAddr::V4(workload), SocketAddr::V4(proxy)) => ipv4_owner_pids(workload, proxy)?, + (SocketAddr::V6(workload), SocketAddr::V6(proxy)) => ipv6_owner_pids(&workload, &proxy)?, + _ => { + return Err(format!( + "TCP connection address families do not match: {connection}" + )); + } + }; + owners.sort_unstable(); + owners.dedup(); + + let pid = match owners.as_slice() { + [pid] => *pid, + [] => { + return Err(format!( + "No Windows process owns proxy connection {connection}" + )); + } + pids => { + return Err(format!( + "Ambiguous Windows proxy connection ownership for {connection}: PIDs [{}]", + pids.iter() + .map(u32::to_string) + .collect::>() + .join(", ") + )); + } + }; + + Ok((process_image_path(pid)?, pid)) +} + +fn ipv4_owner_pids( + workload: std::net::SocketAddrV4, + proxy: std::net::SocketAddrV4, +) -> Result> { + let (buffer, byte_len) = tcp_table(u32::from(AF_INET.0))?; + let rows = table_rows::( + &buffer, + byte_len, + offset_of!(MIB_TCPTABLE_OWNER_PID, table), + size_of::(), + )?; + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).expect("TCP state constant fits u32"); + Ok(rows + .into_iter() + .filter(|row| { + row.dwState == established + && IpAddr::V4(Ipv4Addr::from(row.dwLocalAddr.to_ne_bytes())) + == IpAddr::V4(*workload.ip()) + && tcp_port(row.dwLocalPort) == workload.port() + && IpAddr::V4(Ipv4Addr::from(row.dwRemoteAddr.to_ne_bytes())) + == IpAddr::V4(*proxy.ip()) + && tcp_port(row.dwRemotePort) == proxy.port() + }) + .map(|row| row.dwOwningPid) + .collect()) +} + +fn ipv6_owner_pids( + workload: &std::net::SocketAddrV6, + proxy: &std::net::SocketAddrV6, +) -> Result> { + let (buffer, byte_len) = tcp_table(u32::from(AF_INET6.0))?; + let rows = table_rows::( + &buffer, + byte_len, + offset_of!(MIB_TCP6TABLE_OWNER_PID, table), + size_of::(), + )?; + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).expect("TCP state constant fits u32"); + Ok(rows + .into_iter() + .filter(|row| ipv6_row_matches(row, workload, proxy, established)) + .map(|row| row.dwOwningPid) + .collect()) +} + +fn ipv6_row_matches( + row: &MIB_TCP6ROW_OWNER_PID, + workload: &std::net::SocketAddrV6, + proxy: &std::net::SocketAddrV6, + established: u32, +) -> bool { + row.dwState == established + && Ipv6Addr::from(row.ucLocalAddr) == *workload.ip() + && u32::from_be(row.dwLocalScopeId) == workload.scope_id() + && tcp_port(row.dwLocalPort) == workload.port() + && Ipv6Addr::from(row.ucRemoteAddr) == *proxy.ip() + && u32::from_be(row.dwRemoteScopeId) == proxy.scope_id() + && tcp_port(row.dwRemotePort) == proxy.port() +} + +fn tcp_port(raw: u32) -> u16 { + let low_word = u16::try_from(raw & u32::from(u16::MAX)).expect("masked TCP port fits u16"); + u16::from_be(low_word) +} + +fn tcp_table(address_family: u32) -> Result<(Vec, usize)> { + let mut byte_len = 0u32; + // SAFETY: A null table pointer is the documented size-query form. The + // mutable size pointer is valid for the duration of the call. + #[allow(unsafe_code)] + let initial = unsafe { + GetExtendedTcpTable( + None, + &raw mut byte_len, + false, + address_family, + TCP_TABLE_OWNER_PID_ALL, + 0, + ) + }; + if initial != ERROR_INSUFFICIENT_BUFFER.0 && initial != 0 { + return Err(format!( + "GetExtendedTcpTable size query failed with Win32 error {initial}" + )); + } + + for _ in 0..3 { + let mut buffer = vec![0u32; (byte_len as usize).div_ceil(size_of::()).max(1)]; + let mut actual_len = u32::try_from(buffer.len() * size_of::()) + .map_err(|_| format!("TCP table buffer is too large"))?; + // SAFETY: The u32-backed buffer has sufficient alignment and capacity + // for the requested byte count. The API writes at most `actual_len` + // bytes and updates it when the table grows concurrently. + #[allow(unsafe_code)] + let status = unsafe { + GetExtendedTcpTable( + Some(buffer.as_mut_ptr().cast()), + &raw mut actual_len, + false, + address_family, + TCP_TABLE_OWNER_PID_ALL, + 0, + ) + }; + if status == 0 { + return Ok((buffer, actual_len as usize)); + } + if status != ERROR_INSUFFICIENT_BUFFER.0 { + return Err(format!( + "GetExtendedTcpTable failed with Win32 error {status}" + )); + } + byte_len = actual_len; + } + + Err(format!( + "GetExtendedTcpTable changed size during three consecutive reads" + )) +} + +fn table_rows( + buffer: &[u32], + byte_len: usize, + first_row_offset: usize, + row_stride: usize, +) -> Result> { + if byte_len < size_of::() { + return Err(format!("Windows TCP table is missing its header")); + } + if row_stride < size_of::() { + return Err(format!( + "Windows TCP table row stride {row_stride} is smaller than row size {}", + size_of::() + )); + } + let count = buffer[0] as usize; + let required = if count == 0 { + size_of::() + } else { + first_row_offset + .checked_add( + (count - 1) + .checked_mul(row_stride) + .ok_or_else(|| format!("Windows TCP row count overflow"))?, + ) + .and_then(|last_row| last_row.checked_add(size_of::())) + .ok_or_else(|| format!("Windows TCP table size overflow"))? + }; + if required > byte_len || required > size_of_val(buffer) { + return Err(format!( + "Windows TCP table is truncated: {count} rows require {required} bytes, got {byte_len}" + )); + } + + let mut rows = Vec::with_capacity(count); + // SAFETY: Bounds were checked above. `read_unaligned` supports the padding + // permitted before the first row and between generated table rows. + #[allow(unsafe_code)] + unsafe { + let first = buffer.as_ptr().cast::().add(first_row_offset); + for index in 0..count { + rows.push(std::ptr::read_unaligned( + first.add(index * row_stride).cast::(), + )); + } + } + Ok(rows) +} + +fn process_image_path(pid: u32) -> Result { + // SAFETY: The access mask and PID are plain values; the returned handle is + // immediately placed under an RAII guard. + #[allow(unsafe_code)] + let handle = ProcessHandle( + unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } + .map_err(|error| format!("Failed to open socket-owning PID {pid}: {error}"))?, + ); + let mut path = vec![0u16; 32_768]; + let mut path_len = u32::try_from(path.len()).expect("Windows path buffer length fits u32"); + // SAFETY: The handle is valid, and the UTF-16 output buffer and in/out + // length pointer remain valid for the call. + #[allow(unsafe_code)] + unsafe { + QueryFullProcessImageNameW( + handle.0, + PROCESS_NAME_WIN32, + PWSTR(path.as_mut_ptr()), + &raw mut path_len, + ) + } + .map_err(|error| { + format!("Failed to query executable path for socket-owning PID {pid}: {error}") + })?; + path.truncate(path_len as usize); + Ok(PathBuf::from(String::from_utf16(&path).map_err( + |error| format!("Socket-owning PID {pid} returned an invalid UTF-16 path: {error}"), + )?)) +} + +#[cfg(test)] +mod tests { + use std::io::Read; + use std::net::{TcpListener, TcpStream}; + use std::process::{Command, Stdio}; + + use super::*; + + const CHILD_PORT_ENV: &str = "OPENSHELL_TEST_WINDOWS_SOCKET_OWNER_PORT"; + + #[test] + fn socket_owner_child() { + let Ok(port) = std::env::var(CHILD_PORT_ENV) else { + return; + }; + let mut stream = TcpStream::connect(("127.0.0.1", port.parse::().unwrap())).unwrap(); + let mut byte = [0u8; 1]; + let _ = stream.read(&mut byte); + } + + #[test] + fn resolves_child_that_owns_ipv4_connection() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let proxy = listener.local_addr().unwrap(); + let current_exe = std::env::current_exe().unwrap(); + let mut child = Command::new(¤t_exe) + .args([ + "--exact", + "socket_owner::tests::socket_owner_child", + "--nocapture", + ]) + .env(CHILD_PORT_ENV, proxy.port().to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + let (accepted, workload) = listener.accept().unwrap(); + let result = resolve_tcp_peer_identity(WorkloadProxyTcpConnection::new(workload, proxy)); + drop(accepted); + let status = child.wait().unwrap(); + assert!(status.success()); + + let (path, pid) = result.unwrap(); + assert_eq!(pid, child.id()); + assert_eq!(path, current_exe); + } + + #[test] + fn parses_table_with_header_and_inter_row_padding() { + #[repr(C)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct SyntheticRow { + value: u32, + pid: u32, + } + + const FIRST_ROW_OFFSET: usize = 8; + const ROW_STRIDE: usize = 12; + let buffer = vec![ + 2, // dwNumEntries + 0xAAAA_AAAA, // header padding + 11, + 101, + 0xBBBB_BBBB, // inter-row padding + 22, + 202, + ]; + let rows = table_rows::( + &buffer, + size_of_val(buffer.as_slice()), + FIRST_ROW_OFFSET, + ROW_STRIDE, + ) + .expect("padded table should parse using its declared layout"); + + assert_eq!( + rows, + vec![ + SyntheticRow { + value: 11, + pid: 101, + }, + SyntheticRow { + value: 22, + pid: 202, + }, + ] + ); + } + + #[test] + fn matches_ipv6_scope_ids_from_network_byte_order() { + let workload = std::net::SocketAddrV6::new("fe80::1".parse().unwrap(), 51_234, 0, 17); + let proxy = std::net::SocketAddrV6::new("fe80::2".parse().unwrap(), 31_234, 0, 23); + let established = u32::try_from(MIB_TCP_STATE_ESTAB.0).unwrap(); + let row = MIB_TCP6ROW_OWNER_PID { + ucLocalAddr: workload.ip().octets(), + dwLocalScopeId: workload.scope_id().to_be(), + dwLocalPort: u32::from(workload.port().to_be()), + ucRemoteAddr: proxy.ip().octets(), + dwRemoteScopeId: proxy.scope_id().to_be(), + dwRemotePort: u32::from(proxy.port().to_be()), + dwState: established, + dwOwningPid: 42, + }; + + assert!(ipv6_row_matches(&row, &workload, &proxy, established)); + } +} diff --git a/crates/openshell-supervisor-network/src/identity_source.rs b/crates/openshell-supervisor-network/src/identity_source.rs index f1b5445902..fe53debd99 100644 --- a/crates/openshell-supervisor-network/src/identity_source.rs +++ b/crates/openshell-supervisor-network/src/identity_source.rs @@ -25,6 +25,12 @@ use std::sync::atomic::AtomicU32; use openshell_binary_identity::ProcfsIdentityResolver as SharedProcfsIdentityResolver; use openshell_isolation_interface::contract::{BinaryIdentity, ResolveError}; +/// Trusted platform resolver for an accepted direct-proxy TCP connection. +/// Errors remain supplied errors: they must never fall back to launch identity. +pub type ProxyIdentityResolver = dyn Fn(std::net::SocketAddr, std::net::SocketAddr) -> Result + + Send + + Sync; + /// In-pod binary-identity resolver: reads and hashes the executable resolved /// for an accepted connection from procfs. Resolution fails closed; it never /// fabricates identity fields. diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 13cbce0d73..91b77135f6 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -262,7 +262,7 @@ impl ProxyHandle { backend_host_gateway: Option, network_mediation_source: Option>, policy_dns_store: Option>, - direct_listener_identity: Option, + direct_listener_identity: Option>, required_proxy_authorization: Option>, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default @@ -470,7 +470,16 @@ impl ProxyHandle { let stream: BoundaryDuplexStream = Box::new(stream); ( stream, - direct_listener_identity.clone().map(Ok), + direct_listener_identity.as_ref().map(|resolver| { + workload_addr.zip(proxy_addr).map_or_else( + || { + Err(ResolveError::Failed( + "direct proxy TCP endpoints unavailable".into(), + )) + }, + |(workload, proxy)| resolver(workload, proxy), + ) + }), workload_addr.zip(proxy_addr), None, ) diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 51418d62e4..b4f136835f 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -46,7 +46,7 @@ use openshell_isolation_interface::contract::NetworkMediationSource; pub struct ProxyListenerConfig { pub bind_addr: SocketAddr, pub authorization: Arc, - pub binary_identity: openshell_isolation_interface::contract::BinaryIdentity, + pub identity_resolver: Arc, } impl std::fmt::Debug for ProxyListenerConfig { @@ -54,7 +54,7 @@ impl std::fmt::Debug for ProxyListenerConfig { f.debug_struct("ProxyListenerConfig") .field("bind_addr", &self.bind_addr) .field("authorization", &"") - .field("binary_identity", &self.binary_identity) + .field("identity_resolver", &"") .finish() } } @@ -518,7 +518,7 @@ pub async fn run_networking( .map(|runtime| runtime.store.clone()), proxy_listener .as_ref() - .map(|proxy| proxy.binary_identity.clone()), + .map(|proxy| proxy.identity_resolver.clone()), proxy_listener .as_ref() .map(|proxy| proxy.authorization.clone()), diff --git a/crates/openshell-supervisor/src/backend_setup/mxc.rs b/crates/openshell-supervisor/src/backend_setup/mxc.rs index e24b625217..21f505e23e 100644 --- a/crates/openshell-supervisor/src/backend_setup/mxc.rs +++ b/crates/openshell-supervisor/src/backend_setup/mxc.rs @@ -63,13 +63,45 @@ impl PreparedBackend for MxcLaunch { 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()?; + let identity_cache = + Arc::new(openshell_supervisor_network::identity::BinaryIdentityCache::new()); + let identity_resolver = Arc::new(move |workload, listener| { + use openshell_isolation_interface::contract::{ + BinaryIdentity, ExecutableIdentity, ResolveError, + }; + let (path, pid) = + openshell_mxc_boundary::socket_owner::resolve_connection_owner(workload, listener) + .map_err(ResolveError::Failed)?; + let hash = identity_cache + .verify_or_cache(&path) + .map_err(|error| ResolveError::Failed(error.to_string()))?; + let digest = hash.parse().map_err(|error| { + ResolveError::Failed(format!("invalid executable digest: {error}")) + })?; + // The connection must still have the same owner after integrity hashing. + if openshell_mxc_boundary::socket_owner::resolve_connection_owner(workload, listener) + .map_err(ResolveError::Failed)? + != (path.clone(), pid) + { + return Err(ResolveError::Failed( + "proxy socket owner changed during identity resolution".into(), + )); + } + Ok(BinaryIdentity { + executable: ExecutableIdentity { + path, + digest: Some(digest), + }, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }) + }); Ok(BuiltBackend { payload, proxy_listener: Some(ProxyListenerConfig { bind_addr: proxy.bind_addr, authorization: proxy.authorization.into(), - binary_identity, + identity_resolver, }), backend: Arc::new(MxcRuntimeBackend::new( services.ca_file_paths, diff --git a/docs/how-it-works/policies/overview.mdx b/docs/how-it-works/policies/overview.mdx index 4d5757af00..964ed190c2 100644 --- a/docs/how-it-works/policies/overview.mdx +++ b/docs/how-it-works/policies/overview.mdx @@ -67,6 +67,11 @@ order: An invalid image policy keeps the workload from starting until you repair it. OpenShell does not skip it and use the default. +For Windows MXC, network rules follow the executable that owns the TCP socket: +allowing a shell does not automatically allow its child processes to connect. +Provider environment names must be unique ignoring ASCII case and cannot use +names reserved for OpenShell's injected TLS trust settings. + ### Base and Effective Policies The selected sandbox policy is the base policy. Attached providers can diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 4ff92c2290..81f2ee2897 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -312,9 +312,18 @@ function Get-VsTargetArch([string] $RustTarget) { } } +function Get-NativeHostArch { + # SSH may launch an emulated PowerShell; select the machine's native target. + switch ([Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE', 'Machine')) { + 'ARM64' { return 'arm64' } + 'AMD64' { return 'amd64' } + default { throw 'Windows validation requires a native ARM64 or x64 machine.' } + } +} + function Assert-NativeTestTarget([string] $RustTarget) { $targetArch = Get-VsTargetArch $RustTarget - $hostArch = Get-HostArch + $hostArch = Get-NativeHostArch if ($targetArch -ne $hostArch) { throw "Windows tests require a native runner. Target $RustTarget maps to $targetArch, but the host is $hostArch." } @@ -322,7 +331,7 @@ function Assert-NativeTestTarget([string] $RustTarget) { function Get-SelectedTargets([string] $RequestedTarget) { if ($RequestedTarget -eq "native") { - switch (Get-HostArch) { + switch (Get-NativeHostArch) { "arm64" { return @("aarch64-pc-windows-msvc") } default { return @("x86_64-pc-windows-msvc") } } @@ -648,7 +657,7 @@ if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real") } } -if ($Action -in @("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { +if ($Action -in @("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -698,6 +707,32 @@ switch ($Action) { Invoke-UnsupportedContractTests $rustTarget } } + "test-mxc-boundary" { + foreach ($rustTarget in $targets) { + Assert-NativeTestTarget $rustTarget + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-mxc-boundary --lib --target $rustTarget -- --test-threads=1" ` + -LogName "test-$rustTarget-mxc-boundary.log" + } + } + "test-mxc-loopback" { + foreach ($rustTarget in $targets) { + Assert-NativeTestTarget $rustTarget + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-mxc-boundary --lib --target $rustTarget native_same_container_loopback -- --ignored --nocapture --test-threads=1" ` + -LogName "test-$rustTarget-mxc-loopback.log" + } + } + "test-mxc-network" { + foreach ($rustTarget in $targets) { + Assert-NativeTestTarget $rustTarget + foreach ($testName in @('pc_https_egress_reads_injected_ca_bundle', 'pc_proxy_scopes_network_policy_to_socket_owner')) { + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-driver-mxc --test wxc_exec_real --target $rustTarget $testName -- --ignored --exact --test-threads=1 --nocapture" ` + -LogName "test-$rustTarget-$testName.log" + } + } + } "test-mxc-real" { foreach ($rustTarget in $targets) { Invoke-MxcRealTests $rustTarget diff --git a/tasks/windows.toml b/tasks/windows.toml index 8c753edf33..a712a4f181 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -105,3 +105,18 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts description = "Build test-only MXC forwarding fixture for ARM64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 build-mxc-fixtures aarch64-pc-windows-msvc" + +["windows:test:mxc-boundary"] +description = "Run Windows boundary unit and kernel socket-owner tests on the native host" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-boundary native" + +["windows:test:mxc-loopback"] +description = "Qualify same-container loopback inside real MXC with host loopback denied" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-loopback native" + +["windows:test:mxc-network"] +description = "Qualify real HTTPS and child socket-owner policy against native MXC (requires Internet)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-network native" From f2c5c630ffc80b4eb282fa932b614005e056e5a2 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 12 Oct 2026 18:04:08 -0700 Subject: [PATCH 14/22] fix(mxc): repair audit configuration and qualification runner Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 8 + crates/openshell-driver-mxc/README.md | 14 ++ .../examples/README-ocsf-audit.txt | 27 ++- .../examples/mxc-ocsf-audit.toml | 5 + .../examples/run-ocsf-audit.ps1 | 203 ++++++++++++++---- crates/openshell-driver-mxc/src/driver.rs | 35 ++- .../openshell-driver-mxc/src/etw_consumer.rs | 27 +++ .../tests/windows_demo_examples.rs | 71 ++++++ crates/openshell-server/src/config_file.rs | 12 ++ tasks/scripts/windows-msvc.ps1 | 31 ++- tasks/windows.toml | 5 + 11 files changed, 380 insertions(+), 58 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 33c850e812..367037f0dd 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -544,3 +544,11 @@ The MSVC wrapper selects native test architecture from the machine environment, so an emulated SSH PowerShell does not incorrectly reject ARM64 qualification. Keep Windows kernel calls in the boundary library and retain shared supervisor unsafe-code bans, per-generation proxy authentication, and executable digests. + +Use `windows:test:mxc-audit` to validate the shipped audit gateway config with +its real parser, test JSONL coverage classification, and run the live seven-event +ETW qualification. Build native release binaries first and set +`OPENSHELL_WXC_EXEC_PATH`. The runner requires elevation or Performance Log Users +membership and leaves a results bundle under the target directory. Ordinary MXC +E2E success does not establish ETW audit coverage. Coverage must use structured +JSONL and fail when an expected event is absent or has the wrong OCSF class. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index a6c7339331..e6b23b909a 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -321,3 +321,17 @@ workaround has been established. Provider environment publications reject case-insensitive duplicate names, invalid environment entries, and names reserved for the six injected TLS trust variables before installing them. Rejected updates retain the prior revision. + +For live ETW audit coverage, set `OPENSHELL_WXC_EXEC_PATH` and run +`mise run --skip-tools windows:test:mxc-audit` after building native release +binaries. This requires elevation or Performance Log Users membership. It checks +the shipped config and coverage classifier before requiring all seven event types +in the durable JSONL output; the ordinary E2E scenarios do not check this path. + +On the ARM64 test host (Windows build 28120, MXC 1.0.0), Sandboxing ETW records +come from the shared Appinfo broker rather than the driver-owned executor. +The consumer cannot correlate these records using the executor's PID and process +generation, so live audit qualification currently fails even though the workload +completes. The executor diagnostic log redacts its container identity. A trusted +broker-to-launch correlation is needed; treating the shared broker PID as a +sandbox identity would misattribute concurrent sandboxes and is not supported. diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt index 51bedd26a7..e9acdd1f9c 100644 --- a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -12,7 +12,7 @@ WHAT THIS PROVES / PRODUCES The deliverable is the OCSF log: openshell-ocsf..log, one OCSF event object per line - the same schema and medium the Linux OpenShell pipeline - produces (Windows is at functional parity). + produces. OCSF classes you will see: [6002] Application Lifecycle - sandbox created @@ -22,7 +22,8 @@ WHAT THIS PROVES / PRODUCES PREREQUISITES (on this test box) - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) - - process_container backend live (it was for our earlier runs) + - process_container backend live + - OpenSSL on PATH to generate disposable supervisor signing keys - Run ELEVATED (Run as administrator) OR from an account in the 'Performance Log Users' group. Opening the real-time ETW session needs this; without it the run fails fast with a clear message. @@ -46,6 +47,9 @@ WHAT YOU GET BACK FILES IN THIS PACKAGE openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) openshell.exe the CLI + openshell-supervisor.exe host supervisor + openshell-windows-sandbox.exe sandbox runtime + libz3.dll matching native Z3 runtime mxc-ocsf-audit.toml gateway/driver config (process_container, etw_audit=true, egress proxy) ocsf-audit.yaml sandbox policy (read-write grant to the share dir) run-ocsf-audit.ps1 the orchestrator you run @@ -54,9 +58,6 @@ FILES IN THIS PACKAGE USEFUL OPTIONS -SandboxCount Create n sandboxes (default 2). More sandboxes = more events. - -NoProxy Skip the per-sandbox egress proxy. This omits ONLY the - SandboxProxyConfigured config event; everything else is - still produced. (Default is proxy ON for the full set.) -ShareDir Host folder granted read-write to the workload. The script derives a disposable policy and per-sandbox config for it. -WxcExecPath Path to wxc-exec.exe on this box. @@ -65,10 +66,10 @@ USEFUL OPTIONS -KeepRunning Leave the gateway running afterward for inspection. NOTES + - Disposable supervisor signing keys are protected outside the results bundle + and removed after the gateway stops (retained with -KeepRunning). - The control plane between CLI and gateway runs with --disable-tls on loopback; that is unrelated to the OCSF audit path this example exercises. - - A "supervisor session not connected" / ssh 255 message during sandbox create - is EXPECTED on MXC and harmless - the agent already ran in-driver. - The proxy path requires the host-side CONNECT proxy and an absolute agent binary (the packaged config uses C:\Windows\System32\cmd.exe); the run script handles this for you. @@ -78,3 +79,15 @@ NOTES - MXC process audit events record the executable basename only. Command-line arguments are omitted from OCSF JSON and shorthand because they can contain credentials, signed URLs, or PII. + +COVERAGE + All seven required ETW event types must be present in structured JSONL. + Console shorthand formatting does not affect coverage. The supervisor proxy + is always provisioned; an OS proxy-configuration event is not required. + +CURRENT QUALIFICATION LIMITATION + On the ARM64 Windows build 28120 / MXC 1.0.0 test host, the Sandboxing provider + emits from the shared Appinfo broker. The consumer's executor PID/generation + anchor cannot correlate those records, so this qualification remains FAIL. + Captured records are not sufficient: all seven attributed JSONL event types + are still required. Do not use the shared broker PID as a sandbox identity. diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index ac42b57fd9..375d3f5a22 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -13,7 +13,12 @@ # disposable copy of this file. Workload command and # cwd are sandbox-scoped and passed separately through --driver-config-json. +[openshell] +version = 2 + [openshell.drivers.mxc] +# This disposable example supplies its workload through --driver-config-json. +allow_driver_config = true # Path to wxc-exec.exe on the box (patched by the run script; default is the # location observed on the MXC test boxes). wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 17e90fd7cd..0e2f40d5f6 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -53,6 +53,88 @@ $PSNativeCommandUseErrorActionPreference = $false try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} $OutputEncoding = [System.Text.Encoding]::UTF8 +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList, [int]$timeoutSeconds = 0) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $filePath + $startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { throw "failed to start $filePath" } + } catch { + $message = $_.Exception.Message + if ($message -match '(?i)Application Control policy has blocked this file') { + $sha256 = try { (Get-FileHash -LiteralPath $filePath -Algorithm SHA256).Hash } catch { "unavailable" } + $signature = try { (Get-AuthenticodeSignature -LiteralPath $filePath).Status } catch { "unavailable" } + throw "Application Control blocked CLI launch '$filePath' (SHA256=$sha256; Authenticode=$signature). Review CodeIntegrity/Operational event 3077 to identify the blocking policy, then deploy or allow an approved binary. Original error: $message" + } + throw + } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if ($timeoutSeconds -gt 0 -and -not $process.WaitForExit($timeoutSeconds * 1000)) { + $process.Kill() + } + $process.WaitForExit() + + return @{ + ExitCode = $process.ExitCode + Output = @($stdout.Result, $stderr.Result) | Where-Object { $_ } + } +} + +# Coverage comes from structured ETW-origin event messages, independent of the +# console formatter. Class checks prevent unrelated log text from satisfying it. +function Get-MxcAuditCoverage([object[]] $Events) { + $rules = [ordered]@{ + "sandbox lifecycle (start)" = @(6002, "MXC sandbox '*' created with policy enforcement") + "OS policy enforced" = @(5019, "MXC sandbox OS policy enforced") + "OS policy configured" = @(5019, "MXC sandbox OS policy configured") + "win32k lockdown applied" = @(5019, "MXC sandbox win32k lockdown applied") + "UI restrictions applied" = @(5019, "MXC sandbox UI restrictions applied") + "console reference plumbed" = @(5019, "MXC sandbox console reference plumbed") + "process launch (executable identity)" = @(1007, "MXC sandbox launched process: *") + } + $coverage = [ordered]@{} + foreach ($name in $rules.Keys) { + $rule = $rules[$name] + $coverage[$name] = @($Events | Where-Object { + $_.class_uid -eq $rule[0] -and $_.message -like $rule[1] + }).Count -gt 0 + } + return $coverage +} + $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } # Results bundle (everything we hand back) ------------------------------------ @@ -192,8 +274,10 @@ $tomlSrc = Join-Path $here "mxc-ocsf-audit.toml" $toml = Join-Path $resultDir "mxc-ocsf-audit.used.toml" # disposable patched copy (bundled) $helloPath = Join-Path $ShareDir "hello.txt" +$secretDir = Join-Path $here ('audit-keys-' + [guid]::NewGuid().ToString('N')) $gw = $null $gatewayEtwSessions = @() +$createdSandboxes = @() $passed = $true try { @@ -221,6 +305,21 @@ try { } Info "wxc-exec: $WxcExecPath" + # Supervisor sessions require gateway-minted JWTs. Keep disposable private + # keys outside the results bundle and restrict them to this test account. + $openssl = (Get-Command openssl -ErrorAction Stop).Source + New-Item -ItemType Directory $secretDir | Out-Null + $ownerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + & icacls.exe $secretDir /inheritance:r /grant:r "*$($ownerSid):(OI)(CI)F" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'cannot protect disposable audit signing keys' } + foreach ($keyArgs in @( + @('genpkey', '-algorithm', 'ED25519', '-out', (Join-Path $secretDir 'signing.pem')), + @('pkey', '-in', (Join-Path $secretDir 'signing.pem'), '-pubout', '-out', (Join-Path $secretDir 'public.pem')) + )) { + if ((Invoke-NativeCaptured $openssl $keyArgs).ExitCode -ne 0) { throw 'audit signing-key generation failed' } + } + Set-Content (Join-Path $secretDir 'kid') -Value 'mxc-ocsf-audit' -Encoding ASCII + # 2. Patch the disposable gateway config and derive a sandbox policy whose # filesystem grant matches -ShareDir. Step "Prepare gateway config and sandbox policy (disposable copies)" @@ -233,6 +332,17 @@ try { } else { $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true") } + $tomlText += @" + +[openshell.gateway.auth] +allow_unauthenticated_users = true + +[openshell.gateway.gateway_jwt] +signing_key_path = '$(Join-Path $secretDir 'signing.pem')' +public_key_path = '$(Join-Path $secretDir 'public.pem')' +kid_path = '$(Join-Path $secretDir 'kid')' +gateway_id = 'mxc-ocsf-audit' +"@ Set-Content $toml -Value $tomlText -Encoding UTF8 $shareDirPolicy = $ShareDir.Replace('\', '/') @@ -248,20 +358,15 @@ try { $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" if (-not (Test-Path $cmdExe -PathType Leaf)) { throw "cmd.exe not found at '$cmdExe'" } $helloPathCommand = $helloPath.Replace('\', '/') + New-Item -ItemType Directory -Force $ShareDir | Out-Null + $batch = Join-Path $ShareDir 'audit-workload.cmd' + Set-Content -LiteralPath $batch -Encoding ASCII -Value ('@echo hello from openshell ocsf audit > "{0}"' -f $helloPathCommand) $driverConfig = @{ mxc = @{ - command = @($cmdExe, "/c", "echo hello from openshell ocsf audit 1>`"$helloPathCommand`"") + command = @($cmdExe, '/d', '/c', $batch) cwd = $shareDirPolicy } } | ConvertTo-Json -Compress -Depth 4 - # Windows PowerShell 5.1 strips embedded quotes when constructing a native - # command line. Escape them so openshell.exe receives valid JSON. - $driverConfigArg = if ($PSVersionTable.PSVersion.Major -lt 7) { - $driverConfig.Replace('"', '\"') - } else { - $driverConfig - } - Info "backend=process_container etw_audit=true runtime=supervisor+sandbox" Info "workload cwd=$shareDirPolicy policy grant=$shareDirPolicy" @@ -342,23 +447,38 @@ try { # 9. Register CLI -> gateway. Step "Register CLI -> gateway" - & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } - if ($LASTEXITCODE -ne 0) { throw "gateway add failed (exit $LASTEXITCODE)" } - & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } - if ($LASTEXITCODE -ne 0) { throw "gateway select failed (exit $LASTEXITCODE)" } + foreach ($cliArgs in @( + @('gateway', 'add', "http://127.0.0.1:$Port", '--local', '--name', $GatewayName), + @('gateway', 'select', $GatewayName) + )) { + $cliResult = Invoke-NativeCaptured $cli $cliArgs 30 + $cliResult.Output | ForEach-Object { Info $_ } + if ($cliResult.ExitCode -ne 0) { throw "gateway registration failed (exit $($cliResult.ExitCode))" } + } Ok "selected gateway '$GatewayName'" # 10. Create N sandboxes. Each drives the Sandboxing provider -> a full OCSF - # event burst. The post-create interactive attach failure is EXPECTED on - # MXC (no in-sandbox supervisor) and harmless - the agent already ran. + # event burst through the authenticated supervisor and sandbox runtime. Step "Create $SandboxCount sandbox(es) (drives the Sandboxing provider)" for ($i = 1; $i -le $SandboxCount; $i++) { $name = "ocsf$i" Info "-- creating $name --" - try { & $cli sandbox create --name $name --policy $policy --driver-config-json $driverConfigArg --no-tty 2>&1 | ForEach-Object { Info $_ } } - catch { Info "sandbox create attach: $($_.Exception.Message) (expected on MXC - agent ran in-driver; continuing)" } + Remove-Item -LiteralPath $helloPath -Force -ErrorAction SilentlyContinue + $created = Invoke-NativeCaptured $cli @('sandbox', 'create', '--name', $name, + '--policy', $policy, '--driver-config-json', $driverConfig, '--detach') 90 + $created.Output | ForEach-Object { Info $_ } + if ($created.ExitCode -ne 0) { throw "sandbox create failed (exit $($created.ExitCode))" } + $createdSandboxes += $name + $workloadDeadline = (Get-Date).AddSeconds(30) + while (-not (Test-Path -LiteralPath $helloPath -PathType Leaf) -and (Get-Date) -lt $workloadDeadline) { + Start-Sleep -Milliseconds 200 + } + if (-not (Test-Path -LiteralPath $helloPath -PathType Leaf)) { throw 'audit workload did not produce its artifact' } + # Give real-time ETW delivery time to complete before retiring attribution. Start-Sleep -Seconds 3 - try { & $cli sandbox delete $name 2>&1 | Out-Null } catch {} + $deleted = Invoke-NativeCaptured $cli @('sandbox', 'delete', $name) 30 + if ($deleted.ExitCode -ne 0) { throw "sandbox delete failed (exit $($deleted.ExitCode)): $($deleted.Output)" } + $createdSandboxes = @($createdSandboxes | Where-Object { $_ -ne $name }) } } catch { @@ -366,6 +486,14 @@ catch { $passed = $false } finally { + # A failed artifact/coverage check must not orphan a created runtime or leave + # inherited log handles open when the gateway is stopped and results zipped. + if (-not $KeepRunning -and $gw -and -not $gw.HasExited) { + foreach ($name in $createdSandboxes) { + try { $null = Invoke-NativeCaptured $cli @('sandbox', 'delete', $name) 30 } + catch { Info "cleanup of $name failed: $($_.Exception.Message)" } + } + } if (-not $KeepRunning -and $gw -and $gatewayEtwSessions.Count -eq 0) { $gatewayEtwSessions = @(Get-MxcEtwSessions | Where-Object { $_.Pid -eq $gw.Id }) } @@ -386,6 +514,13 @@ finally { } } + if (-not $KeepRunning -and (Test-Path -LiteralPath $secretDir)) { + foreach ($keyFile in @('signing.pem', 'public.pem', 'kid')) { + Remove-Item -LiteralPath (Join-Path $secretDir $keyFile) -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $secretDir -ErrorAction SilentlyContinue + } + # ---- summarise the OCSF audit trail -------------------------------------- Exit-IsolatedCliEnvironment $logText = @() @@ -405,13 +540,14 @@ finally { $jsonlPath = if ($jsonlFiles.Count) { $jsonlFiles[0].FullName } else { $null } $classNames = @{ 6002 = "Application Lifecycle"; 5019 = "Device Config State Change"; 1007 = "Process Activity"; 2004 = "Detection Finding" } $classCounts = @{ 6002 = 0; 5019 = 0; 1007 = 0; 2004 = 0 } - $jsonlCount = 0; $jsonlBad = 0; $sids = @(); $hosts = @() + $jsonlCount = 0; $jsonlBad = 0; $sids = @(); $hosts = @(); $auditEvents = @() if ($jsonlPath) { $raw = @(Get-Content $jsonlPath -ErrorAction SilentlyContinue | Where-Object { $_.Trim() -ne "" }) $jsonlCount = $raw.Count foreach ($line in $raw) { try { $o = $line | ConvertFrom-Json + $auditEvents += $o if ($o.class_uid -ne $null -and $classCounts.ContainsKey([int]$o.class_uid)) { $classCounts[[int]$o.class_uid]++ } if ($o.metadata -and $o.metadata.uid) { $sids += [string]$o.metadata.uid } if ($o.device -and $o.device.hostname) { $hosts += [string]$o.device.hostname } @@ -421,28 +557,19 @@ finally { $hosts = @($hosts | Select-Object -Unique) } - # Event-type coverage (detected from the human-readable shorthand lines). - function Seen([string]$pat) { [bool]($logText | Select-String -Pattern $pat -Quiet) } - - # Expected happy-path ETW->OCSF event types for THIS run. The egress-proxy - # event only fires when the proxy is enabled, so it only counts toward the - # expected total when -NoProxy was NOT passed. - $coreEvents = [ordered]@{ - "sandbox lifecycle (start)" = Seen "(?i)ocsf:.*LIFECYCLE:" - "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" - "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" - "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" - "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" - "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" - "process launch (executable identity)" = Seen "(?i)ocsf:.*PROC:LAUNCH" + # Require all seven ETW event types in durable structured output. + $coreEvents = Get-MxcAuditCoverage $auditEvents + function SeenFinding([string] $Kind) { + @($auditEvents | Where-Object { + $_.class_uid -eq 2004 -and $_.message -like "*$Kind*" + }).Count -gt 0 } - if ($proxyOn) { $coreEvents["egress proxy configured"] = Seen "(?i)ocsf:.*proxy configured" } # Findings are anomaly / fallback signals - reported separately, NOT part of # the expected-coverage denominator (a clean run may emit none). $findingEvents = [ordered]@{ - "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" - "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" + "ActivityError finding" = SeenFinding "ActivityError" + "FallbackError finding" = SeenFinding "FallbackError" } $coreExpected = $coreEvents.Count @@ -467,7 +594,7 @@ user : $env:USERNAME (admin=$admin perfLogUsers=$plu) verdict : $verdict event coverage : $coreObserved of $coreExpected expected event types fired (+ $findingsObserved anomaly finding(s)) queue overload : $(if ($consumerOverloaded) { 'YES - ETW records dropped; audit coverage gap' } else { 'no dropped ETW records observed' }) -proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits egress proxy event)' }) +proxy : authenticated supervisor proxy (ETW proxy event is not required) workload output : $(if ($workloadCompleted) { $helloPath } else { '(missing)' }) wxc_exec : $WxcExecPath backend : process_container @@ -499,7 +626,7 @@ started, decoded the Sandboxing provider, attributed each event to a sandbox_id, reported no callback-queue overload, mapped events to OCSF, and wrote a durable JSONL audit log covering all $coreExpected expected event types across $classesSeen OCSF class(es) - the full Windows OCSF path -end-to-end, at parity with the Linux pipeline. +end-to-end for the Sandboxing provider event types listed above. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 3ddddc1c9d..5364d40bb5 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -767,17 +767,30 @@ async fn run_lifecycle_inner( .map_err(|error| format!("start MXC ProcessContainer: {error}"))?; attach_child_logs(&context.sandbox.name, "sandbox", &mut boundary); attach_child_logs(&context.sandbox.name, "supervisor", &mut supervisor); - if context.config.etw_audit - && let Some(pid) = boundary.id() - && let Ok(process_start_key) = crate::etw_consumer::child_process_start_key(&boundary) - && let Ok(mut attribution) = context.attribution.lock() - { - attribution.register_launch( - &context.sandbox.id, - &context.sandbox.name, - pid, - process_start_key, - ); + if context.config.etw_audit { + match boundary + .id() + .ok_or_else(|| "MXC child PID is unavailable".to_string()) + .and_then(|pid| { + crate::etw_consumer::child_process_start_key(&boundary).map(|key| (pid, key)) + }) { + Ok((pid, process_start_key)) => { + let mut attribution = context + .attribution + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + attribution.register_launch( + &context.sandbox.id, + &context.sandbox.name, + pid, + process_start_key, + ); + tracing::debug!(target: "mxc_etw", pid, process_start_key, + sandbox_id = %context.sandbox.id, "registered driver-owned MXC audit launch"); + } + Err(error) => tracing::warn!(sandbox_id = %context.sandbox.id, %error, + "MXC ETW audit launch registration failed"), + } } let (shutdown_tx, shutdown_rx) = oneshot::channel(); let (terminated_tx, terminated_rx) = watch::channel(false); diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 208dda4e59..d69d1e4630 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -1567,6 +1567,9 @@ impl EtwEventProcessor { // instant `wxc-exec` starts, which can beat the driver's // `register_launch`. Hold the event for replay instead of dropping // it (see `drain_and_emit`). + tracing::debug!(target: "mxc_etw", pid = ev.process_id, + process_start_key = ?ev.process_start_key, + "buffering uncorrelated MXC audit event"); idx.buffer_unresolved(ev); None } @@ -2069,6 +2072,30 @@ mod tests { } } + #[tokio::test] + async fn live_child_process_generation_is_stable_and_distinct() { + let mut keys = Vec::new(); + for _ in 0..2 { + let mut child = tokio::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn owned telemetry probe"); + let first = child_process_start_key(&child); + let second = child_process_start_key(&child); + child.kill().await.expect("stop owned telemetry probe"); + let first = first.expect("live driver-owned handle has a generation key"); + assert_eq!(first, second.unwrap()); + keys.push(first); + } + assert_ne!( + keys[0], keys[1], + "different children require different generation keys" + ); + } + #[test] fn extracts_process_start_key_from_etw_extended_data() { let unrelated = EVENT_HEADER_EXTENDED_DATA_ITEM { diff --git a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs index 317c597094..252d85450c 100644 --- a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs @@ -266,3 +266,74 @@ if (-not $rejected) { throw 'conflicting runtimes accepted' } String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn audit_coverage_uses_structured_events_and_rejects_missing_or_wrong_classes() { + let script = r#" +$ErrorActionPreference = 'Stop' +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $env:OPENSHELL_MXC_RUNNER, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw "parse errors: $errors" } +$function = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Get-MxcAuditCoverage' +}, $true) +if (!$function) { throw 'missing coverage function' } +Invoke-Expression $function.Extent.Text +foreach ($name in @('Quote-NativeArgument', 'Invoke-NativeCaptured')) { + $helper = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if (!$helper) { throw "missing $name" } + Invoke-Expression $helper.Extent.Text +} +foreach ($code in @(0, 7)) { + $result = Invoke-NativeCaptured 'powershell.exe' @('-NoProfile', '-Command', + "[Console]::Error.WriteLine('status on stderr'); exit $code") 10 + if ($result.ExitCode -ne $code -or ($result.Output -join '') -notmatch 'status on stderr') { + throw 'native stderr must be captured without hiding the exit code' + } +} +$events = @( + @{ class_uid=6002; message="MXC sandbox 'test' created with policy enforcement" }, + @{ class_uid=5019; message='MXC sandbox OS policy enforced' }, + @{ class_uid=5019; message='MXC sandbox OS policy configured' }, + @{ class_uid=5019; message='MXC sandbox win32k lockdown applied' }, + @{ class_uid=5019; message='MXC sandbox UI restrictions applied' }, + @{ class_uid=5019; message='MXC sandbox console reference plumbed' }, + @{ class_uid=1007; message='MXC sandbox launched process: cmd.exe' } +) | ForEach-Object { $_ | ConvertTo-Json -Compress | ConvertFrom-Json } +$coverage = Get-MxcAuditCoverage $events +if ($coverage.Count -ne 7 -or @($coverage.Values | Where-Object { $_ }).Count -ne 7) { + throw 'complete structured audit must satisfy seven event types' +} +foreach ($index in 0..6) { + $subset = @(for ($i=0; $i -lt 7; $i++) { if ($i -ne $index) { $events[$i] } }) + if (@((Get-MxcAuditCoverage $subset).Values | Where-Object { $_ }).Count -ne 6) { + throw "missing event $index must fail coverage" + } +} +$events[6].class_uid = 5019 +if ((Get-MxcAuditCoverage $events)['process launch (executable identity)']) { + throw 'matching text with wrong class must not pass' +} +if (@((Get-MxcAuditCoverage @()).Values | Where-Object { $_ }).Count -ne 0) { + throw 'empty audit must not pass' +} +"#; + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env( + "OPENSHELL_MXC_RUNNER", + examples().join("run-ocsf-audit.ps1"), + ) + .output() + .expect("launch PowerShell coverage regression"); + assert!( + output.status.success(), + "{} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 243d8e9120..77e2482863 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -1472,6 +1472,18 @@ ssh_gateway_port = 8080 )); } + #[test] + fn mxc_audit_example_loads_with_workload_opt_in() { + let path = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../openshell-driver-mxc/examples/mxc-ocsf-audit.toml" + )); + let file = load(path).expect("shipped MXC audit config must load"); + let driver = &file.openshell.drivers["mxc"]; + assert_eq!(driver["allow_driver_config"].as_bool(), Some(true)); + assert_eq!(driver["etw_audit"].as_bool(), Some(true)); + } + #[test] fn rejects_missing_version_in_nonempty_file() { let tmp = write_raw_tmp("[openshell]\n\n[openshell.gateway]\nname = \"test\"\n"); diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 81f2ee2897..500f8701e1 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -6,7 +6,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "artifacts", "ci")] + [ValidateSet("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "test-mxc-audit", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -657,7 +657,7 @@ if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real") } } -if ($Action -in @("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "ci")) { +if ($Action -in @("check", "lint", "build", "build-mxc-fixtures", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-boundary", "test-mxc-loopback", "test-mxc-network", "test-mxc-audit", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -733,6 +733,33 @@ switch ($Action) { } } } + "test-mxc-audit" { + foreach ($rustTarget in $targets) { + Assert-NativeTestTarget $rustTarget + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-driver-mxc --lib --target $rustTarget etw_consumer::tests -- --test-threads=1 --nocapture" ` + -LogName "test-$rustTarget-mxc-audit-attribution.log" + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-server --lib --target $rustTarget $Z3ServerFeatures mxc_audit_example -- --nocapture" ` + -LogName "test-$rustTarget-mxc-audit-config.log" + Invoke-VsCargo -RustTarget $rustTarget ` + -CargoArgs "cargo test --release -p openshell-driver-mxc --test windows_demo_examples --target $rustTarget -- --test-threads=1 --nocapture" ` + -LogName "test-$rustTarget-mxc-audit-examples.log" + $package = Join-Path $TargetDir "mxc-ocsf-qualification-$rustTarget" + New-Item -ItemType Directory -Force $package | Out-Null + foreach ($binary in @('openshell-gateway.exe', 'openshell.exe', 'openshell-supervisor.exe', 'openshell-windows-sandbox.exe', 'libz3.dll')) { + Copy-Item -LiteralPath (Join-Path $TargetDir "$rustTarget/release/$binary") -Destination $package -Force + } + foreach ($artifact in @('run-ocsf-audit.ps1', 'mxc-ocsf-audit.toml', 'ocsf-audit.yaml')) { + Copy-Item -LiteralPath (Join-Path $RepoRoot "crates/openshell-driver-mxc/examples/$artifact") -Destination $package -Force + } + $wxc = $env:OPENSHELL_WXC_EXEC_PATH + if (-not $wxc) { throw 'Set OPENSHELL_WXC_EXEC_PATH to the real MXC executor.' } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $package 'run-ocsf-audit.ps1') ` + -WxcExecPath $wxc -ShareDir (Join-Path $package 'work') + if ($LASTEXITCODE -ne 0) { throw "Live MXC audit qualification failed; results are in $package" } + } + } "test-mxc-real" { foreach ($rustTarget in $targets) { Invoke-MxcRealTests $rustTarget diff --git a/tasks/windows.toml b/tasks/windows.toml index a712a4f181..d991b1d5e9 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -120,3 +120,8 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts description = "Qualify real HTTPS and child socket-owner policy against native MXC (requires Internet)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-network native" + +["windows:test:mxc-audit"] +description = "Validate MXC audit fixtures and real seven-event JSONL coverage (requires ETW privileges and native release binaries)" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-audit native" From 68d61b008d0c43c54aec0871f4a41a00a6e30740 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 12 Oct 2026 21:51:41 -0700 Subject: [PATCH 15/22] test(mxc): refresh schema inventory after main rebase Signed-off-by: Drew Newberry --- crates/openshell-server/src/storage_proto.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index f4a26745a9..0c8b931768 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -151,9 +151,9 @@ mod tests { const PUBLIC_RPC_SCHEMA_SHA256: &str = "b6734ba353b4ffe81240309f7971ef48f6473e14ce2196412beabe1984b0548d"; const DURABLE_SCHEMA_SHA256: &str = - "5281143e2825539af6148785c91f9df67f654eb74d215f57940af9314867a55f"; + "74dcecdc7375511bd7638d044e13f5ae85eb8d4a5c221fbfeb14f6ce622d5f6e"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "761dea31a521b0650840fe2a823ad6e36a265ed323ba4506889781d630df0ee3"; + "5f5165548d2ec333689fc88ab652af1977760bb976007a634e3ed30f28782424"; // Encoded with the schema that omitted stable_placeholder. Keep these // bytes fixed so the current encoder cannot hide a compatibility change. const LEGACY_STATIC_PROFILE: &str = "0a240a116c65676163792d70726f66696c652d6964120d6c65676163792d737461746963280712510a0d6c65676163792d73746174696312184c6567616379207374617469632063726564656e7469616c2a260a076170695f6b65791a1153594e5448455449435f4150495f4b455920012a06626561726572"; From 9c8ffd49516b9a282de6776a030b2a700e4bc459 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 12 Oct 2026 22:28:32 -0700 Subject: [PATCH 16/22] test(mxc): add workload-to-host service e2e coverage Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 14 ++- crates/openshell-driver-mxc/README.md | 14 ++- .../examples/e2e-policies/host-service.yaml | 23 ++++ .../examples/mxc-forwarding-agent.rs | 25 +++- .../examples/run-mxc-e2e.ps1 | 113 +++++++++++++++++- .../tests/windows_demo_examples.rs | 63 ++++++++++ .../openshell-supervisor-network/src/opa.rs | 40 +++++++ 7 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/e2e-policies/host-service.yaml diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 367037f0dd..bfbd8f59b1 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -452,7 +452,19 @@ 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. +scenario is not part of the default seven offline-capable E2E scenarios. + +The default `host-service` scenario runs the HTTP fixture on an active host LAN +IPv4 address and a dynamic port, then checks two nonce-bound replies from curl +inside MXC through the authenticated proxy. Its `/32` allowlist and explicit +GET paths permit only the intended service; a denied path must yield HTTP 403 +(curl exit 22) and never reach the host request log. Host-side readiness and +request proofs stay outside the workload's writable directory. The service is +stopped even after failed runs. Use `-Scenario host-service` for focused checks. +No Internet access or firewall modification is required; an absent usable host +IPv4 address or failed host health check is a failure, not a successful denial. +Loopback remains blocked by the shared proxy, so do not broaden SSRF controls +to run this test. When reporting `windows:ci`, distinguish these categories: diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index e6b23b909a..fd17c1eeb0 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -270,7 +270,7 @@ explicitly loads the boundary-injected CA bundle and records the peer certificat 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 +distribution point; it does not qualify revocation enforcement. The default seven E2E scenarios remain independent of Internet access. The `forwarding` scenario starts a TCP listener inside MXC, then verifies two @@ -287,12 +287,22 @@ unique GET responses through gateway host-based routing. It deletes the endpoint and requires HTTP 404 afterward. Requests connect only to the gateway with the exposed URL's Host header, so no wildcard DNS setup is required. +The default `host-service` scenario runs an HTTP fixture on the Windows host's +active LAN IPv4 address and a dynamic port. A real MXC workload uses curl through +the authenticated supervisor proxy, with that exact address allowed by a `/32` +policy. Two GETs must return the fixture's nonce; a disallowed path must return +HTTP 403 with curl exit 22 and never appear in the host-side request log. The +host service is checked before launch and stopped after testing. Missing host +IPv4 connectivity fails the scenario. This uses the host's LAN address because +the shared proxy always rejects loopback destinations. Select it with +`-Scenario host-service`; no Internet access or firewall changes are required. + The `ws-agent.yaml` and `openclaw-gateway.yaml` policies preserve the Windows branch's workload grants. `inference.yaml` is a template: replace its filesystem, curl executable, inference host, and port placeholders before use. Authorize curl, which owns the network socket, not its cmd launcher. These policies have parser/mapper regression coverage; they do not add live WebSocket, OpenClaw, -or provider-backed inference scenarios to the six-scenario E2E runner. +or provider-backed inference scenarios to the seven-scenario E2E runner. With `-KeepRunning`, the runner restores the caller's environment but retains the disposable CLI/runtime directory for the live gateway and prints its path. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/host-service.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/host-service.yaml new file mode 100644 index 0000000000..e39eb7a2fe --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/host-service.yaml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 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: + host_service: + name: host-service + endpoints: + - host: '__HOST_SERVICE_IP__' + port: __HOST_SERVICE_PORT__ + allowed_ips: ['__HOST_SERVICE_IP__/32'] + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /allowed/* + binaries: + - path: '__CURL_EXE__' diff --git a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs index 9afa9f8c13..55078a318c 100644 --- a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs +++ b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Test-only TCP fixture for real MXC ingress E2E; not a shipped runtime binary. +//! Test-only TCP/HTTP fixture for real MXC E2E; not a shipped runtime binary. use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpListener; @@ -9,14 +9,25 @@ use std::time::Duration; fn main() -> std::io::Result<()> { let arguments: Vec = std::env::args().collect(); - if !(arguments.len() == 3 || (arguments.len() == 4 && arguments[3] == "http")) { + if !(arguments.len() == 3 || (matches!(arguments.len(), 4 | 6) && arguments[3] == "http")) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "usage: mxc-forwarding-agent READY_FILE NONCE [http]", + "usage: mxc-forwarding-agent READY_FILE NONCE [http [BIND_IPV4 REQUEST_LOG]]", )); } - let http_mode = arguments.len() == 4; - let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))?; + let http_mode = arguments.len() >= 4; + let bind_ip = if arguments.len() == 6 { + arguments[4] + .parse::() + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))? + } else { + std::net::Ipv4Addr::LOCALHOST + }; + let request_log = arguments.get(5); + if let Some(path) = request_log { + std::fs::write(path, "")?; + } + let listener = TcpListener::bind((bind_ip, 0))?; let ready_file = std::path::Path::new(&arguments[1]); let staged_ready = ready_file.with_extension("tmp"); std::fs::write(&staged_ready, listener.local_addr()?.port().to_string())?; @@ -59,6 +70,10 @@ fn main() -> std::io::Result<()> { let target = fields.next().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::InvalidData, "missing HTTP target") })?; + if let Some(path) = request_log { + let mut log = std::fs::OpenOptions::new().append(true).open(path)?; + writeln!(log, "{target}")?; + } let body = format!("{}:{target}", arguments[2]); write!( socket, diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 38512c25d5..af2f515cc1 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. +# host-service - workload reaches a host HTTP service; denied path never arrives. # https-ca - opt-in outbound HTTPS using the injected proxy CA. [CmdletBinding()] @@ -384,6 +385,46 @@ function Wait-File([string]$path, [int]$seconds) { return (Test-Path $path) } +function Get-HostServiceAddress { + foreach ($adapter in [Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces()) { + if ($adapter.OperationalStatus -ne [Net.NetworkInformation.OperationalStatus]::Up) { continue } + foreach ($unicast in $adapter.GetIPProperties().UnicastAddresses) { + $ip = $unicast.Address + if ($ip.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetwork -and + -not [Net.IPAddress]::IsLoopback($ip) -and + -not $ip.ToString().StartsWith('169.254.') -and $ip.ToString() -ne '0.0.0.0') { + return $ip.ToString() + } + } + } + throw 'host-service requires an active host IPv4 address outside loopback and link-local ranges' +} + +function Test-HostServiceResult([string]$work, [string]$nonce, [string]$requestLog) { + if (-not (Wait-File (Join-Path $work 'host-service.done') 60)) { throw 'host-service workload did not complete' } + if ((Get-Content -LiteralPath (Join-Path $work 'host-service.done') -Raw).Trim() -ne $nonce) { + throw 'host-service completion marker does not match this workload' + } + foreach ($name in @('one', 'two')) { + $body = Get-Content -LiteralPath (Join-Path $work "host-service.$name.body") -Raw + $exitCode = (Get-Content -LiteralPath (Join-Path $work "host-service.$name.exit") -Raw).Trim() + if ($exitCode -ne '0' -or $body -ne "${nonce}:/allowed/$name") { + throw "host-service allowed request $name did not return the exact host response" + } + } + $deniedExit = (Get-Content -LiteralPath (Join-Path $work 'host-service.denied.exit') -Raw).Trim() + $deniedStatus = (Get-Content -LiteralPath (Join-Path $work 'host-service.denied.status') -Raw).Trim() + if ($deniedExit -ne '22' -or $deniedStatus -ne '403') { + throw "host-service denied request must receive policy HTTP 403 (curl exit 22), got $deniedStatus / $deniedExit" + } + $requests = @(Get-Content -LiteralPath $requestLog) + if ($requests.Count -ne 3 -or $requests[0] -ne '/health' -or + $requests[1] -ne '/allowed/one' -or $requests[2] -ne '/allowed/two') { + throw "host-service received unexpected requests: $($requests -join ', ')" + } + return 'two exact host responses; denied request returned HTTP 403 and never reached the service' +} + # Exercise the public gRPC forward path, never dial the workload port directly. # The nonce identifies this workload, not an unrelated host-loopback listener. function Test-Forwarding([string]$sandboxName, [string]$readyFile, [string]$nonce) { @@ -610,6 +651,7 @@ function Probe-Backend([string] $backendName, [string] $wxc) { $results = @() $gw = $null +$hostService = $null $harnessError = $null $backendProbe = @{ Live = $false; Reason = "not probed" } # Unique per-run suffix so a stale sandbox record from an earlier run can never @@ -750,10 +792,15 @@ try { Backends = "process_container"; Kind = "service-forwarding" ReadyFile = (Join-Path $DemoDir "service-forwarding-port.txt") Description = "named HTTP service routes two requests into MXC and stops routing after deletion" + }, + @{ + Name = 'host-service'; PolicyFile = Join-Path $policyDir 'host-service.yaml' + SandboxId = 'host'; Backends = 'process_container'; Kind = 'host-service' + Description = 'workload HTTP egress reaches a host service only for explicitly allowed paths' } ) - # External connectivity is deliberately opt-in; the default six scenarios + # External connectivity is deliberately opt-in; the default seven 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') { @@ -826,6 +873,30 @@ try { $DemoDir.Replace('\', '/') ) $curlExe = [IO.Path]::Combine([IO.Path]::GetDirectoryName($cmdExe), 'curl.exe') + if ($sc.Kind -eq 'host-service') { + $hostIp = Get-HostServiceAddress + $hostNonce = [guid]::NewGuid().ToString('N') + # The workload has write access only to DemoDir, not these host-side proofs. + $hostReady = Join-Path $resultDir 'host-service.port' + $hostRequests = Join-Path $resultDir 'host-service.requests' + $fixture = Join-Path $BinaryDir 'mxc-forwarding-agent.exe' + if (-not (Test-Path -LiteralPath $fixture)) { $fixture = Join-Path $BinaryDir 'examples/mxc-forwarding-agent.exe' } + if (-not (Test-Path -LiteralPath $fixture)) { throw 'Missing host-service fixture; run windows:build:mxc-fixtures' } + $hostArguments = @($hostReady, $hostNonce, 'http', $hostIp, $hostRequests) + $hostService = Start-Process -FilePath $fixture -PassThru -WindowStyle Hidden ` + -ArgumentList (($hostArguments | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') ` + -RedirectStandardOutput (Join-Path $resultDir 'host-service.stdout.log') ` + -RedirectStandardError (Join-Path $resultDir 'host-service.stderr.log') + if (-not (Wait-File $hostReady 10) -or $hostService.HasExited) { throw 'host-service fixture did not start' } + $hostPort = [int](Get-Content -LiteralPath $hostReady -Raw).Trim() + $hostUrl = "http://${hostIp}:$hostPort" + $health = Invoke-NativeCaptured $curlExe @('--disable', '--proxy', '', '--noproxy', '*', + '--fail', '--silent', '--show-error', '--max-time', '10', "$hostUrl/health") 15 + if ($health.ExitCode -ne 0 -or ($health.Output -join '').Trim() -ne "${hostNonce}:/health") { + throw 'host-service health check failed before sandbox launch' + } + $policyText = $policyText.Replace('__HOST_SERVICE_IP__', $hostIp).Replace('__HOST_SERVICE_PORT__', "$hostPort") + } # 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("'", "''")) @@ -852,6 +923,20 @@ try { } else { $command = @($cmdExe, "/c", "echo denied 1> $denied") } + } elseif ($sc.Kind -eq 'host-service') { + Remove-Item -Path (Join-Path $DemoDir 'host-service.*') -Force -ErrorAction SilentlyContinue + $batch = Join-Path $DemoDir 'host-service.cmd' + $lines = @('@echo off') + foreach ($name in @('one', 'two')) { + $lines += '"{0}" --disable --proxy "%HTTP_PROXY%" --noproxy "" --fail --silent --show-error --max-time 15 --output "{1}" "{2}/allowed/{3}"' -f $curlExe, (Join-Path $DemoDir "host-service.$name.body"), $hostUrl, $name + $lines += 'echo %errorlevel%> "{0}"' -f (Join-Path $DemoDir "host-service.$name.exit") + } + $lines += '"{0}" --disable --proxy "%HTTP_PROXY%" --noproxy "" --fail --silent --show-error --max-time 15 --output "{1}" --write-out "%%{{http_code}}" "{2}/denied" > "{3}"' -f $curlExe, (Join-Path $DemoDir 'host-service.denied.body'), $hostUrl, (Join-Path $DemoDir 'host-service.denied.status') + $lines += 'echo %errorlevel%> "{0}"' -f (Join-Path $DemoDir 'host-service.denied.exit') + $lines += 'echo {0}> "{1}"' -f $hostNonce, (Join-Path $DemoDir 'host-service.done') + $lines += 'exit /b 0' + Set-Content -LiteralPath $batch -Value ($lines -join "`r`n") -Encoding ASCII + $command = @($cmdExe, '/d', '/c', $batch) } elseif ($sc.Kind -eq 'socket-owner') { Remove-Item -LiteralPath $sc.PosTarget, $sc.ControlTarget, $sc.ErrorTarget -Force -ErrorAction SilentlyContinue $child = 'echo launched > "{0}" && "{1}" --disable --fail --silent --show-error --max-time 30 --ssl-revoke-best-effort --cacert "%CURL_CA_BUNDLE%" --stderr "{2}" --output "{3}" https://example.com/' -f $sc.ControlTarget, $curlExe, $sc.ErrorTarget, $sc.PosTarget @@ -916,12 +1001,13 @@ try { # Run sandbox create; require artifact evidence for live workload tests. $createOut = $null; $createExitCode = 0 try { - $createResult = Invoke-NativeCaptured $cli @( + $createArguments = @( "sandbox", "create", "--name", $sandboxName, "--policy", [string]$policyUsed, - "--driver-config-json", $driverConfig, - "--no-tty" + "--driver-config-json", $driverConfig ) + $createArguments += if ($sc.Kind -eq 'host-service') { '--detach' } else { '--no-tty' } + $createResult = Invoke-NativeCaptured $cli $createArguments $createOut = $createResult.Output $createExitCode = $createResult.ExitCode } catch { @@ -933,7 +1019,18 @@ try { $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" # Evaluate. - if ($sc.Kind -eq 'socket-owner') { + if ($sc.Kind -eq 'host-service') { + try { + if ($createExitCode -ne 0) { throw "host-service sandbox creation failed: $createOutStr" } + if ($hostService.HasExited) { throw 'host-service fixture exited before scoring' } + $reason = Test-HostServiceResult $DemoDir $hostNonce $hostRequests + Ok "host-service: $reason" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = 'PASS'; Reason = $reason } + } catch { + Bad "host-service: $($_.Exception.Message)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = 'FAIL'; Reason = $_.Exception.Message } + } + } elseif ($sc.Kind -eq 'socket-owner') { try { if (-not (Wait-File $sc.ControlTarget 30)) { throw "parent cmd did not launch: $createOutStr" } $deadline = (Get-Date).AddSeconds(45) @@ -1079,6 +1176,10 @@ catch { Bad "harness error: $harnessError" } finally { + if ($hostService -and -not $hostService.HasExited) { + Stop-Process -Id $hostService.Id -Force -ErrorAction SilentlyContinue + $hostService.WaitForExit() + } # --- Summary + results bundle --------------------------------------------- Step "Summary" $results | Format-Table -AutoSize @@ -1121,6 +1222,8 @@ through OpenShell. Service forwarding verifies two HTTP replies and HTTP 404 after endpoint deletion. Missing coverage exits non-zero; network-policy proves admission and execution, not network enforcement. Forwarding proves managed ingress works, not that direct host ingress is blocked. +Host-service verifies two exact workload-to-host HTTP replies and HTTP 403 for +a denied path, with a host-side request log proving that denied request never arrived. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) diff --git a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs index 252d85450c..22ab37ffb5 100644 --- a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs @@ -66,6 +66,69 @@ fn https_fixture_authorizes_direct_curl_with_read_only_https() { ); } +#[test] +fn host_service_scoring_rejects_transport_failures_and_unenforced_requests() { + let root = tempfile::tempdir().unwrap(); + let script = r#" +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $env:OPENSHELL_MXC_RUNNER, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw "runner parse errors: $($errors.Message -join '; ')" } +foreach ($name in @('Wait-File', 'Test-HostServiceResult')) { + $helper = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if (!$helper) { throw "missing $name" } + Invoke-Expression $helper.Extent.Text +} +$work = Join-Path $env:OPENSHELL_MXC_SCORE_ROOT 'work with spaces' +New-Item -ItemType Directory $work | Out-Null +$requestLog = Join-Path $env:OPENSHELL_MXC_SCORE_ROOT 'host.requests' +$nonce = 'unique-host-service-response' +function Reset-Proofs { + foreach ($name in @('one', 'two')) { + [IO.File]::WriteAllText((Join-Path $work "host-service.$name.body"), "${nonce}:/allowed/$name") + [IO.File]::WriteAllText((Join-Path $work "host-service.$name.exit"), '0') + } + [IO.File]::WriteAllText((Join-Path $work 'host-service.denied.exit'), '22') + [IO.File]::WriteAllText((Join-Path $work 'host-service.denied.status'), '403') + [IO.File]::WriteAllText((Join-Path $work 'host-service.done'), $nonce) + [IO.File]::WriteAllText($requestLog, "/health`n/allowed/one`n/allowed/two`n") +} +Reset-Proofs +if ((Test-HostServiceResult $work $nonce $requestLog) -notmatch 'never reached') { throw 'valid host-service run must pass' } +foreach ($case in @('transport', 'denial-reached-host', 'wrong-response', 'wrong-workload')) { + Reset-Proofs + switch ($case) { + 'transport' { + [IO.File]::WriteAllText((Join-Path $work 'host-service.denied.exit'), '7') + [IO.File]::WriteAllText((Join-Path $work 'host-service.denied.status'), '000') + } + 'denial-reached-host' { [IO.File]::AppendAllText($requestLog, "/denied`n") } + 'wrong-response' { [IO.File]::WriteAllText((Join-Path $work 'host-service.one.body'), 'unrelated service') } + 'wrong-workload' { [IO.File]::WriteAllText((Join-Path $work 'host-service.done'), 'stale-run') } + } + $rejected = $false + try { Test-HostServiceResult $work $nonce $requestLog | Out-Null } catch { $rejected = $true } + if (!$rejected) { throw "invalid run $case must fail" } +} +"#; + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_MXC_RUNNER", examples().join("run-mxc-e2e.ps1")) + .env("OPENSHELL_MXC_SCORE_ROOT", root.path()) + .output() + .expect("launch PowerShell host-service regression"); + assert!( + output.status.success(), + "{} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn openclaw_fixture_preserves_explicit_ui_and_node_network_grants() { let yaml = diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 6c28c3e11d..2a95754ec8 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -5291,6 +5291,46 @@ process: OpaEngine::from_strings(TEST_POLICY, L7_TEST_DATA).expect("Failed to load L7 test data") } + #[test] + fn mxc_host_service_fixture_enforces_paths_and_socket_owner() { + let binary = crate::test_support::executable_path("/usr/bin/curl"); + let yaml = + include_str!("../../openshell-driver-mxc/examples/e2e-policies/host-service.yaml") + .replace("__HOST_SERVICE_IP__", "192.168.1.20") + .replace("__HOST_SERVICE_PORT__", "49152") + .replace( + "C:/work/openshell-mxc-e2e", + &std::env::temp_dir().to_string_lossy().replace('\\', "/"), + ) + .replace("__CURL_EXE__", binary.to_str().unwrap()); + let engine = OpaEngine::from_strings(TEST_POLICY, &yaml).expect("host-service policy"); + let network = NetworkInput { + host: "192.168.1.20".into(), + port: 49152, + binary_path: binary.clone(), + binary_sha256: "unused".into(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + assert!(engine.evaluate_network(&network).unwrap().allowed); + assert_eq!( + engine.query_allowed_ips(&network).unwrap(), + ["192.168.1.20/32"] + ); + for (path, allowed) in [ + ("/allowed/one", true), + ("/allowed/two", true), + ("/denied", false), + ] { + let mut input = l7_input("192.168.1.20", 49152, "GET", path); + input["exec"]["path"] = binary.to_str().unwrap().into(); + assert_eq!(eval_l7(&engine, &input), allowed, "{path}"); + } + let mut wrong_binary = network; + wrong_binary.binary_path = crate::test_support::executable_path("/usr/bin/other"); + assert!(!engine.evaluate_network(&wrong_binary).unwrap().allowed); + } + fn l7_input(host: &str, port: u16, method: &str, path: &str) -> serde_json::Value { l7_input_with_query(host, port, method, path, serde_json::json!({})) } From 0378ec93069e7f6e31943c1edbbc0c71d060be22 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 12 Oct 2026 22:54:51 -0700 Subject: [PATCH 17/22] test(mxc): verify direct host localhost connectivity Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 19 +++--- crates/openshell-driver-mxc/README.md | 15 ++--- .../examples/e2e-policies/host-service.yaml | 9 +-- .../examples/mxc-forwarding-agent.rs | 15 ++--- .../examples/run-mxc-e2e.ps1 | 60 +++++++------------ .../tests/windows_demo_examples.rs | 30 ++++++---- .../openshell-supervisor-network/src/opa.rs | 40 ------------- 7 files changed, 60 insertions(+), 128 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index bfbd8f59b1..2e60a7249f 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -454,17 +454,14 @@ fixture permits unavailable CRL information using best-effort Schannel revocatio for the ephemeral private CA; this is not revocation qualification. This scenario is not part of the default seven offline-capable E2E scenarios. -The default `host-service` scenario runs the HTTP fixture on an active host LAN -IPv4 address and a dynamic port, then checks two nonce-bound replies from curl -inside MXC through the authenticated proxy. Its `/32` allowlist and explicit -GET paths permit only the intended service; a denied path must yield HTTP 403 -(curl exit 22) and never reach the host request log. Host-side readiness and -request proofs stay outside the workload's writable directory. The service is -stopped even after failed runs. Use `-Scenario host-service` for focused checks. -No Internet access or firewall modification is required; an absent usable host -IPv4 address or failed host health check is a failure, not a successful denial. -Loopback remains blocked by the shared proxy, so do not broaden SSRF controls -to run this test. +The default `host-service` scenario runs the HTTP fixture on host `127.0.0.1` +and a dynamic port, then checks two nonce-bound replies from direct curl requests +inside MXC. Host-side readiness and request proofs stay outside the workload's +writable directory. The service is stopped even after failed runs. Use +`-Scenario host-service` for focused checks. No Internet access, LAN address, or +firewall modification is required. A failed host health check or workload +request fails the test. This verifies direct localhost connectivity; retain +the existing shared proxy SSRF controls. When reporting `windows:ci`, distinguish these categories: diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index fd17c1eeb0..ff3192817f 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -287,15 +287,12 @@ unique GET responses through gateway host-based routing. It deletes the endpoint and requires HTTP 404 afterward. Requests connect only to the gateway with the exposed URL's Host header, so no wildcard DNS setup is required. -The default `host-service` scenario runs an HTTP fixture on the Windows host's -active LAN IPv4 address and a dynamic port. A real MXC workload uses curl through -the authenticated supervisor proxy, with that exact address allowed by a `/32` -policy. Two GETs must return the fixture's nonce; a disallowed path must return -HTTP 403 with curl exit 22 and never appear in the host-side request log. The -host service is checked before launch and stopped after testing. Missing host -IPv4 connectivity fails the scenario. This uses the host's LAN address because -the shared proxy always rejects loopback destinations. Select it with -`-Scenario host-service`; no Internet access or firewall changes are required. +The default `host-service` scenario runs an HTTP fixture on host `127.0.0.1` +and a dynamic port. A real MXC workload makes two direct curl requests; both +must return the fixture's nonce and appear in its host-side request log. The +host service is checked before launch and stopped after testing. Select it with +`-Scenario host-service`; no Internet access, LAN address, or firewall changes +are required. This verifies direct localhost connectivity. The `ws-agent.yaml` and `openclaw-gateway.yaml` policies preserve the Windows branch's workload grants. `inference.yaml` is a template: replace its filesystem, diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/host-service.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/host-service.yaml index e39eb7a2fe..a34f09fce0 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/host-service.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/host-service.yaml @@ -10,14 +10,7 @@ network_policies: host_service: name: host-service endpoints: - - host: '__HOST_SERVICE_IP__' + - host: 127.0.0.1 port: __HOST_SERVICE_PORT__ - allowed_ips: ['__HOST_SERVICE_IP__/32'] - protocol: rest - enforcement: enforce - rules: - - allow: - method: GET - path: /allowed/* binaries: - path: '__CURL_EXE__' diff --git a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs index 55078a318c..2c9f136b78 100644 --- a/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs +++ b/crates/openshell-driver-mxc/examples/mxc-forwarding-agent.rs @@ -9,25 +9,18 @@ use std::time::Duration; fn main() -> std::io::Result<()> { let arguments: Vec = std::env::args().collect(); - if !(arguments.len() == 3 || (matches!(arguments.len(), 4 | 6) && arguments[3] == "http")) { + if !(arguments.len() == 3 || (matches!(arguments.len(), 4 | 5) && arguments[3] == "http")) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "usage: mxc-forwarding-agent READY_FILE NONCE [http [BIND_IPV4 REQUEST_LOG]]", + "usage: mxc-forwarding-agent READY_FILE NONCE [http [REQUEST_LOG]]", )); } let http_mode = arguments.len() >= 4; - let bind_ip = if arguments.len() == 6 { - arguments[4] - .parse::() - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))? - } else { - std::net::Ipv4Addr::LOCALHOST - }; - let request_log = arguments.get(5); + let request_log = arguments.get(4); if let Some(path) = request_log { std::fs::write(path, "")?; } - let listener = TcpListener::bind((bind_ip, 0))?; + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))?; let ready_file = std::path::Path::new(&arguments[1]); let staged_ready = ready_file.with_extension("tmp"); std::fs::write(&staged_ready, listener.local_addr()?.port().to_string())?; diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index af2f515cc1..0b5709d7d4 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -36,7 +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. -# host-service - workload reaches a host HTTP service; denied path never arrives. +# host-service - workload reaches an HTTP service on host 127.0.0.1. # https-ca - opt-in outbound HTTPS using the injected proxy CA. [CmdletBinding()] @@ -385,19 +385,17 @@ function Wait-File([string]$path, [int]$seconds) { return (Test-Path $path) } -function Get-HostServiceAddress { - foreach ($adapter in [Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces()) { - if ($adapter.OperationalStatus -ne [Net.NetworkInformation.OperationalStatus]::Up) { continue } - foreach ($unicast in $adapter.GetIPProperties().UnicastAddresses) { - $ip = $unicast.Address - if ($ip.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetwork -and - -not [Net.IPAddress]::IsLoopback($ip) -and - -not $ip.ToString().StartsWith('169.254.') -and $ip.ToString() -ne '0.0.0.0') { - return $ip.ToString() - } - } +function Write-HostServiceBatch([string]$batch, [string]$curlExe, [string]$hostUrl, [string]$work, [string]$nonce) { + $lines = @('@echo off') + foreach ($name in @('one', 'two')) { + $lines += '"{0}" --disable --proxy "" --noproxy "*" --fail --silent --show-error --max-time 15 --output "{1}" "{2}/{3}"' -f $curlExe, (Join-Path $work "host-service.$name.body"), $hostUrl, $name + # A space before > prevents echo 0>file from redirecting stdin instead + # of recording curl's success code. Scoring trims the trailing space. + $lines += 'echo %errorlevel% > "{0}"' -f (Join-Path $work "host-service.$name.exit") } - throw 'host-service requires an active host IPv4 address outside loopback and link-local ranges' + $lines += 'echo {0}> "{1}"' -f $nonce, (Join-Path $work 'host-service.done') + $lines += 'exit /b 0' + Set-Content -LiteralPath $batch -Value ($lines -join "`r`n") -Encoding ASCII } function Test-HostServiceResult([string]$work, [string]$nonce, [string]$requestLog) { @@ -408,21 +406,16 @@ function Test-HostServiceResult([string]$work, [string]$nonce, [string]$requestL foreach ($name in @('one', 'two')) { $body = Get-Content -LiteralPath (Join-Path $work "host-service.$name.body") -Raw $exitCode = (Get-Content -LiteralPath (Join-Path $work "host-service.$name.exit") -Raw).Trim() - if ($exitCode -ne '0' -or $body -ne "${nonce}:/allowed/$name") { - throw "host-service allowed request $name did not return the exact host response" + if ($exitCode -ne '0' -or $body -ne "${nonce}:/$name") { + throw "host-service request $name did not return the exact host response" } } - $deniedExit = (Get-Content -LiteralPath (Join-Path $work 'host-service.denied.exit') -Raw).Trim() - $deniedStatus = (Get-Content -LiteralPath (Join-Path $work 'host-service.denied.status') -Raw).Trim() - if ($deniedExit -ne '22' -or $deniedStatus -ne '403') { - throw "host-service denied request must receive policy HTTP 403 (curl exit 22), got $deniedStatus / $deniedExit" - } $requests = @(Get-Content -LiteralPath $requestLog) if ($requests.Count -ne 3 -or $requests[0] -ne '/health' -or - $requests[1] -ne '/allowed/one' -or $requests[2] -ne '/allowed/two') { + $requests[1] -ne '/one' -or $requests[2] -ne '/two') { throw "host-service received unexpected requests: $($requests -join ', ')" } - return 'two exact host responses; denied request returned HTTP 403 and never reached the service' + return 'two exact responses from the host service on 127.0.0.1' } # Exercise the public gRPC forward path, never dial the workload port directly. @@ -796,7 +789,7 @@ try { @{ Name = 'host-service'; PolicyFile = Join-Path $policyDir 'host-service.yaml' SandboxId = 'host'; Backends = 'process_container'; Kind = 'host-service' - Description = 'workload HTTP egress reaches a host service only for explicitly allowed paths' + Description = 'workload connects directly to an HTTP service on host 127.0.0.1' } ) @@ -874,7 +867,7 @@ try { ) $curlExe = [IO.Path]::Combine([IO.Path]::GetDirectoryName($cmdExe), 'curl.exe') if ($sc.Kind -eq 'host-service') { - $hostIp = Get-HostServiceAddress + $hostIp = '127.0.0.1' $hostNonce = [guid]::NewGuid().ToString('N') # The workload has write access only to DemoDir, not these host-side proofs. $hostReady = Join-Path $resultDir 'host-service.port' @@ -882,7 +875,7 @@ try { $fixture = Join-Path $BinaryDir 'mxc-forwarding-agent.exe' if (-not (Test-Path -LiteralPath $fixture)) { $fixture = Join-Path $BinaryDir 'examples/mxc-forwarding-agent.exe' } if (-not (Test-Path -LiteralPath $fixture)) { throw 'Missing host-service fixture; run windows:build:mxc-fixtures' } - $hostArguments = @($hostReady, $hostNonce, 'http', $hostIp, $hostRequests) + $hostArguments = @($hostReady, $hostNonce, 'http', $hostRequests) $hostService = Start-Process -FilePath $fixture -PassThru -WindowStyle Hidden ` -ArgumentList (($hostArguments | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') ` -RedirectStandardOutput (Join-Path $resultDir 'host-service.stdout.log') ` @@ -895,7 +888,7 @@ try { if ($health.ExitCode -ne 0 -or ($health.Output -join '').Trim() -ne "${hostNonce}:/health") { throw 'host-service health check failed before sandbox launch' } - $policyText = $policyText.Replace('__HOST_SERVICE_IP__', $hostIp).Replace('__HOST_SERVICE_PORT__', "$hostPort") + $policyText = $policyText.Replace('__HOST_SERVICE_PORT__', "$hostPort") } # Preserve the exact Windows executable path used in the launch; # network binary identities are compared as strings, not FS paths. @@ -926,16 +919,7 @@ try { } elseif ($sc.Kind -eq 'host-service') { Remove-Item -Path (Join-Path $DemoDir 'host-service.*') -Force -ErrorAction SilentlyContinue $batch = Join-Path $DemoDir 'host-service.cmd' - $lines = @('@echo off') - foreach ($name in @('one', 'two')) { - $lines += '"{0}" --disable --proxy "%HTTP_PROXY%" --noproxy "" --fail --silent --show-error --max-time 15 --output "{1}" "{2}/allowed/{3}"' -f $curlExe, (Join-Path $DemoDir "host-service.$name.body"), $hostUrl, $name - $lines += 'echo %errorlevel%> "{0}"' -f (Join-Path $DemoDir "host-service.$name.exit") - } - $lines += '"{0}" --disable --proxy "%HTTP_PROXY%" --noproxy "" --fail --silent --show-error --max-time 15 --output "{1}" --write-out "%%{{http_code}}" "{2}/denied" > "{3}"' -f $curlExe, (Join-Path $DemoDir 'host-service.denied.body'), $hostUrl, (Join-Path $DemoDir 'host-service.denied.status') - $lines += 'echo %errorlevel%> "{0}"' -f (Join-Path $DemoDir 'host-service.denied.exit') - $lines += 'echo {0}> "{1}"' -f $hostNonce, (Join-Path $DemoDir 'host-service.done') - $lines += 'exit /b 0' - Set-Content -LiteralPath $batch -Value ($lines -join "`r`n") -Encoding ASCII + Write-HostServiceBatch $batch $curlExe $hostUrl $DemoDir $hostNonce $command = @($cmdExe, '/d', '/c', $batch) } elseif ($sc.Kind -eq 'socket-owner') { Remove-Item -LiteralPath $sc.PosTarget, $sc.ControlTarget, $sc.ErrorTarget -Force -ErrorAction SilentlyContinue @@ -1222,8 +1206,8 @@ through OpenShell. Service forwarding verifies two HTTP replies and HTTP 404 after endpoint deletion. Missing coverage exits non-zero; network-policy proves admission and execution, not network enforcement. Forwarding proves managed ingress works, not that direct host ingress is blocked. -Host-service verifies two exact workload-to-host HTTP replies and HTTP 403 for -a denied path, with a host-side request log proving that denied request never arrived. +Host-service verifies two exact direct HTTP replies from a service bound to +host 127.0.0.1, with host-side request evidence. It checks localhost connectivity. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) diff --git a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs index 22ab37ffb5..59f7cefe91 100644 --- a/crates/openshell-driver-mxc/tests/windows_demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/windows_demo_examples.rs @@ -67,7 +67,7 @@ fn https_fixture_authorizes_direct_curl_with_read_only_https() { } #[test] -fn host_service_scoring_rejects_transport_failures_and_unenforced_requests() { +fn host_service_scoring_records_success_and_rejects_false_responses() { let root = tempfile::tempdir().unwrap(); let script = r#" $ErrorActionPreference = 'Stop' @@ -76,7 +76,7 @@ $tokens = $null; $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile( $env:OPENSHELL_MXC_RUNNER, [ref]$tokens, [ref]$errors) if ($errors.Count) { throw "runner parse errors: $($errors.Message -join '; ')" } -foreach ($name in @('Wait-File', 'Test-HostServiceResult')) { +foreach ($name in @('Quote-NativeArgument', 'Invoke-NativeCaptured', 'Wait-File', 'Write-HostServiceBatch', 'Test-HostServiceResult')) { $helper = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true) @@ -87,26 +87,34 @@ $work = Join-Path $env:OPENSHELL_MXC_SCORE_ROOT 'work with spaces' New-Item -ItemType Directory $work | Out-Null $requestLog = Join-Path $env:OPENSHELL_MXC_SCORE_ROOT 'host.requests' $nonce = 'unique-host-service-response' +$batch = Join-Path $work 'host-service.cmd' +Write-HostServiceBatch $batch 'C:/Windows/System32/curl.exe' 'http://127.0.0.1:49152' $work $nonce +$recordLine = @(Get-Content -LiteralPath $batch | Where-Object { $_.StartsWith('echo %errorlevel%') })[0] +foreach ($code in @(0, 22)) { + $probeBatch = Join-Path $work 'record-exit.cmd' + Set-Content -LiteralPath $probeBatch -Encoding ASCII -Value ("@echo off`r`ncmd.exe /d /c exit $code`r`n$recordLine`r`nexit /b 0") + $result = Invoke-NativeCaptured 'cmd.exe' @('/d', '/c', $probeBatch) 10 + if ($result.ExitCode -ne 0) { throw "batch exit probe failed: $($result.Output -join '; ')" } + $recorded = Get-Content -LiteralPath (Join-Path $work 'host-service.one.exit') -Raw + if (!$recorded -or $recorded.Trim() -ne "$code") { throw "generated batch failed to record curl exit $code" } +} function Reset-Proofs { foreach ($name in @('one', 'two')) { - [IO.File]::WriteAllText((Join-Path $work "host-service.$name.body"), "${nonce}:/allowed/$name") + [IO.File]::WriteAllText((Join-Path $work "host-service.$name.body"), "${nonce}:/$name") [IO.File]::WriteAllText((Join-Path $work "host-service.$name.exit"), '0') } - [IO.File]::WriteAllText((Join-Path $work 'host-service.denied.exit'), '22') - [IO.File]::WriteAllText((Join-Path $work 'host-service.denied.status'), '403') [IO.File]::WriteAllText((Join-Path $work 'host-service.done'), $nonce) - [IO.File]::WriteAllText($requestLog, "/health`n/allowed/one`n/allowed/two`n") + [IO.File]::WriteAllText($requestLog, "/health`n/one`n/two`n") } Reset-Proofs -if ((Test-HostServiceResult $work $nonce $requestLog) -notmatch 'never reached') { throw 'valid host-service run must pass' } -foreach ($case in @('transport', 'denial-reached-host', 'wrong-response', 'wrong-workload')) { +if ((Test-HostServiceResult $work $nonce $requestLog) -notmatch '127.0.0.1') { throw 'valid host-service run must pass' } +foreach ($case in @('transport', 'unexpected-request', 'wrong-response', 'wrong-workload')) { Reset-Proofs switch ($case) { 'transport' { - [IO.File]::WriteAllText((Join-Path $work 'host-service.denied.exit'), '7') - [IO.File]::WriteAllText((Join-Path $work 'host-service.denied.status'), '000') + [IO.File]::WriteAllText((Join-Path $work 'host-service.one.exit'), '7') } - 'denial-reached-host' { [IO.File]::AppendAllText($requestLog, "/denied`n") } + 'unexpected-request' { [IO.File]::AppendAllText($requestLog, "/unexpected`n") } 'wrong-response' { [IO.File]::WriteAllText((Join-Path $work 'host-service.one.body'), 'unrelated service') } 'wrong-workload' { [IO.File]::WriteAllText((Join-Path $work 'host-service.done'), 'stale-run') } } diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 2a95754ec8..6c28c3e11d 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -5291,46 +5291,6 @@ process: OpaEngine::from_strings(TEST_POLICY, L7_TEST_DATA).expect("Failed to load L7 test data") } - #[test] - fn mxc_host_service_fixture_enforces_paths_and_socket_owner() { - let binary = crate::test_support::executable_path("/usr/bin/curl"); - let yaml = - include_str!("../../openshell-driver-mxc/examples/e2e-policies/host-service.yaml") - .replace("__HOST_SERVICE_IP__", "192.168.1.20") - .replace("__HOST_SERVICE_PORT__", "49152") - .replace( - "C:/work/openshell-mxc-e2e", - &std::env::temp_dir().to_string_lossy().replace('\\', "/"), - ) - .replace("__CURL_EXE__", binary.to_str().unwrap()); - let engine = OpaEngine::from_strings(TEST_POLICY, &yaml).expect("host-service policy"); - let network = NetworkInput { - host: "192.168.1.20".into(), - port: 49152, - binary_path: binary.clone(), - binary_sha256: "unused".into(), - ancestors: Vec::new(), - cmdline_paths: Vec::new(), - }; - assert!(engine.evaluate_network(&network).unwrap().allowed); - assert_eq!( - engine.query_allowed_ips(&network).unwrap(), - ["192.168.1.20/32"] - ); - for (path, allowed) in [ - ("/allowed/one", true), - ("/allowed/two", true), - ("/denied", false), - ] { - let mut input = l7_input("192.168.1.20", 49152, "GET", path); - input["exec"]["path"] = binary.to_str().unwrap().into(); - assert_eq!(eval_l7(&engine, &input), allowed, "{path}"); - } - let mut wrong_binary = network; - wrong_binary.binary_path = crate::test_support::executable_path("/usr/bin/other"); - assert!(!engine.evaluate_network(&wrong_binary).unwrap().allowed); - } - fn l7_input(host: &str, port: u16, method: &str, path: &str) -> serde_json::Value { l7_input_with_query(host, port, method, path, serde_json::json!({})) } From b9d217ee7323a5b0de4252f54fa3cb9f13f9503e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 13 Oct 2026 08:13:10 -0700 Subject: [PATCH 18/22] fix(mxc): resolve Windows lint and generated binding drift Signed-off-by: Drew Newberry --- crates/openshell-mxc-boundary/src/socket_owner.rs | 12 +++++------- sdk/go/proto/sandboxv1/sandbox.pb.go | 1 + 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/openshell-mxc-boundary/src/socket_owner.rs b/crates/openshell-mxc-boundary/src/socket_owner.rs index ca751f906a..6f5d8a0e69 100644 --- a/crates/openshell-mxc-boundary/src/socket_owner.rs +++ b/crates/openshell-mxc-boundary/src/socket_owner.rs @@ -177,7 +177,7 @@ fn tcp_table(address_family: u32) -> Result<(Vec, usize)> { for _ in 0..3 { let mut buffer = vec![0u32; (byte_len as usize).div_ceil(size_of::()).max(1)]; let mut actual_len = u32::try_from(buffer.len() * size_of::()) - .map_err(|_| format!("TCP table buffer is too large"))?; + .map_err(|_| "TCP table buffer is too large".to_string())?; // SAFETY: The u32-backed buffer has sufficient alignment and capacity // for the requested byte count. The API writes at most `actual_len` // bytes and updates it when the table grows concurrently. @@ -203,9 +203,7 @@ fn tcp_table(address_family: u32) -> Result<(Vec, usize)> { byte_len = actual_len; } - Err(format!( - "GetExtendedTcpTable changed size during three consecutive reads" - )) + Err("GetExtendedTcpTable changed size during three consecutive reads".to_string()) } fn table_rows( @@ -215,7 +213,7 @@ fn table_rows( row_stride: usize, ) -> Result> { if byte_len < size_of::() { - return Err(format!("Windows TCP table is missing its header")); + return Err("Windows TCP table is missing its header".to_string()); } if row_stride < size_of::() { return Err(format!( @@ -231,10 +229,10 @@ fn table_rows( .checked_add( (count - 1) .checked_mul(row_stride) - .ok_or_else(|| format!("Windows TCP row count overflow"))?, + .ok_or_else(|| "Windows TCP row count overflow".to_string())?, ) .and_then(|last_row| last_row.checked_add(size_of::())) - .ok_or_else(|| format!("Windows TCP table size overflow"))? + .ok_or_else(|| "Windows TCP table size overflow".to_string())? }; if required > byte_len || required > size_of_val(buffer) { return Err(format!( diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 83d1143877..bd23ee0c22 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -979,6 +979,7 @@ type NetworkEndpoint struct { // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: // - If host is also set: domain must resolve to an IP in this list. // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked // regardless of this field. From d88d3d5dee758a390791133643db0ae5a83050d5 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 13 Oct 2026 08:26:41 -0700 Subject: [PATCH 19/22] test(mxc): validate proxy identity using the live socket owner Signed-off-by: Drew Newberry --- .../src/backend_setup/tests.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index 0d7c7386f6..790ff5d997 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -698,10 +698,23 @@ fn mxc_setup_preserves_admitted_identity_and_constructs_only_mxc() { let proxy = built.proxy_listener.unwrap(); assert_eq!(proxy.bind_addr, launch.proxy.bind_addr); assert_eq!(proxy.authorization.as_ref(), launch.proxy.authorization); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, peer) = listener.accept().unwrap(); + let socket_identity = (proxy.identity_resolver)(peer, server.local_addr().unwrap()).unwrap(); assert_eq!( - proxy.binary_identity.executable.path, + std::fs::canonicalize(&socket_identity.executable.path).unwrap(), + std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap() + ); + assert!(socket_identity.executable.digest.is_some()); + assert_ne!( + socket_identity.executable.path, launch.proxy.workload_binary ); + // A missing connection must fail closed instead of using the launch binary. + assert!((proxy.identity_resolver)(peer, "127.0.0.1:0".parse().unwrap()).is_err()); + drop(client); + drop(server); // Selecting a Windows implementation does not waive main's identity checks. let mut foreign_auth = setup.auth.clone(); From e4e798642bf95f20275e0a3afbca751d57cac26d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 13 Oct 2026 08:36:39 -0700 Subject: [PATCH 20/22] test(mxc): restamp expected global policy after the UI overlay Signed-off-by: Drew Newberry --- crates/openshell-server/src/grpc/policy.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 6d1709d2df..479a414c6d 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -8769,6 +8769,7 @@ mod tests { let mut policy = openshell_policy::restrictive_default_policy(); policy.ui = Some(openshell_core::proto::UiPolicy::default()); global_policy.ui = policy.ui; + openshell_core::policy_identity::stamp_global_token_grant_owners(&mut global_policy); let sandbox = test_sandbox("create-global", "create-global", policy, Vec::new()); let inputs = resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox) .await From 30bc3c98b441c6949db8436327f5fc81d7d282ca Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 13 Oct 2026 17:33:57 -0700 Subject: [PATCH 21/22] test(mxc): reconcile schema inventory with provider label API Signed-off-by: Drew Newberry --- crates/openshell-server/src/storage_proto.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 0c8b931768..9a4d6af87e 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -148,8 +148,9 @@ mod tests { // Operator credential export adds four public messages and no durable types. // ListProvidersRequest field 4 adds a public-only label selector. Older // requests decode an empty selector; durable schemas remain unchanged. + // The public inventory includes the label selector alongside MXC UI fields. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "b6734ba353b4ffe81240309f7971ef48f6473e14ce2196412beabe1984b0548d"; + "7871afc3d1ff603fdd15ed4211d539e435d27c3da443b8bee5a608aacc70738f"; const DURABLE_SCHEMA_SHA256: &str = "74dcecdc7375511bd7638d044e13f5ae85eb8d4a5c221fbfeb14f6ce622d5f6e"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = From 6f83bf13e4ec7f03ff20bfb716c2d02b52ed44ad Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 13 Oct 2026 18:57:31 -0700 Subject: [PATCH 22/22] fix(mxc): reject unsupported foreground attachment before provisioning Signed-off-by: Drew Newberry --- crates/openshell-driver-mxc/README.md | 5 ++-- crates/openshell-driver-mxc/src/driver.rs | 27 ++++++++++++++++++++ crates/openshell-server/src/compute/mod.rs | 2 +- docs/how-it-works/gateways/configuration.mdx | 3 +++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index ff3192817f..da460acaa2 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -39,8 +39,9 @@ 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 -and retains main-process attachment independently of the optional Unix SSH -adapter. TCP readiness remains gated by authenticated session acceptance. +and retains main-process I/O. MXC has no foreground attachment adapter; create +command workloads with `--detach`. Preflight rejects foreground attachment +before provisioning. TCP readiness remains gated by authenticated session acceptance. Windows boundary execution is implemented by the `openshell-mxc-boundary` library behind the dedicated `openshell-windows-sandbox.exe`. The compute driver does not diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 5364d40bb5..3d2bef6cef 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -291,6 +291,15 @@ impl MxcComputeBackend { } pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + if sandbox + .spec + .as_ref() + .is_some_and(|spec| spec.await_main_process_attachment) + { + return Err(tonic::Status::failed_precondition( + "MXC does not support foreground attachment to the main process. Create the sandbox with --detach.", + )); + } openshell_core::resource_admission::check_sandbox_driver_config( self.config.allow_driver_config, sandbox, @@ -1364,6 +1373,24 @@ mod tests { assert!(!backend.config.state_dir.exists()); } + #[tokio::test] + async fn foreground_attachment_is_rejected_before_provisioning() { + let (backend, mut sandbox, _dir) = preflight_fixture(); + sandbox.spec.as_mut().unwrap().command = + vec!["cmd.exe".into(), "/c".into(), "exit 0".into()]; + sandbox.spec.as_mut().unwrap().await_main_process_attachment = true; + let error = backend.validate_sandbox_create(&sandbox).unwrap_err(); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("--detach")); + let error = backend.create_sandbox(&sandbox).await.unwrap_err(); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("foreground attachment")); + assert!(backend.get_sandbox(&sandbox.id).await.is_none()); + assert!(!backend.config.state_dir.exists()); + sandbox.spec.as_mut().unwrap().await_main_process_attachment = false; + assert!(backend.validate_sandbox_create(&sandbox).is_ok()); + } + #[test] fn resource_admission_is_unsupported_and_acknowledged() { let config = MxcComputeConfig::default(); diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index f7a5de9a36..2f4d21ba0c 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -8804,7 +8804,7 @@ mod tests { }); let error = runtime - .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs, false) .await .expect_err("an unsupported driver must reject the effective UI policy"); diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index f6249010a8..332588bfa6 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -1239,6 +1239,9 @@ runtime-selected profile. The MXC driver is Windows-only and opt-in. It links into the gateway and invokes Microsoft MXC through `wxc-exec.exe`. The driver starts `openshell-supervisor --role=isolation-backend` on the host and `openshell-windows-sandbox.exe` inside each ProcessContainer. The standard authenticated Sandbox Protocol and supervisor session provide lifecycle, exec, forwarding, provider refresh, and network policy. +MXC does not support foreground attachment to the main process. Use `--detach` +when creating a command workload; foreground requests fail during preflight. + 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.