From 49b4f0eb7f2af88557ad1a171249c83176e4b505 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 17 Sep 2026 12:06:06 -0700 Subject: [PATCH 01/39] feat(mxc): add UI policy, credentials, relay lifecycle, and proxy auth Signed-off-by: Drew Newberry --- .../build-openshell-mxc-windows/SKILL.md | 39 +- .../build-openshell-mxc-windows/reference.md | 10 +- .github/workflows/windows-msvc.yml | 3 + AGENTS.md | 1 + CONTRIBUTING.md | 29 +- Cargo.lock | 15 + architecture/compute-runtimes.md | 30 +- architecture/gateway.md | 14 +- architecture/sandbox.md | 10 + architecture/security-policy.md | 33 +- architecture/windows-msvc-build.md | 24 +- crates/openshell-cli/src/commands/common.rs | 41 + crates/openshell-cli/src/commands/gateway.rs | 17 + crates/openshell-cli/src/main.rs | 80 +- crates/openshell-cli/src/run.rs | 79 +- .../sandbox_create_lifecycle_integration.rs | 137 ++ crates/openshell-core/src/policy.rs | 27 + .../src/provider_credentials.rs | 12 + crates/openshell-driver-docker/src/lib.rs | 1 + .../openshell-driver-kubernetes/src/driver.rs | 1 + crates/openshell-driver-mxc/Cargo.toml | 9 + crates/openshell-driver-mxc/README.md | 122 +- .../examples/README-openclaw-forward.txt | 140 ++ .../e2e-policies/openclaw-gateway.yaml | 49 + .../examples/e2e-policies/ws-agent.yaml | 42 + .../examples/install-nodejs-openclaw.ps1 | 157 ++ .../examples/mxc-github-provider-profile.yml | 29 + .../examples/mxc-openclaw-gateway.toml | 55 + .../examples/mxc-openclaw-isolation.toml | 44 + .../examples/mxc-openclaw-localnet.toml | 33 + .../mxc-provider-credential-policy.yaml | 36 + .../mxc-provider-credential-probe.ps1 | 175 ++ .../examples/mxc-provider-credential.toml | 18 + .../examples/mxc-ws-agent.rs | 622 +++++ .../examples/mxc-ws-gateway.toml | 66 + .../examples/openclaw-capture.mjs | 202 ++ .../examples/run-mxc-e2e.ps1 | 19 +- .../examples/run-openclaw-forward-test.ps1 | 722 ++++++ .../examples/run-provider-credential-test.ps1 | 461 ++++ .../examples/run-ws-agent-test.ps1 | 747 ++++++ .../src/control_channel.rs | 410 ++++ crates/openshell-driver-mxc/src/driver.rs | 2168 +++++++++++++++-- crates/openshell-driver-mxc/src/lib.rs | 10 +- crates/openshell-driver-mxc/src/mxc.rs | 306 ++- crates/openshell-driver-mxc/src/policy.rs | 128 +- .../src/policy_map/loss.rs | 2 +- .../src/policy_map/map.rs | 182 +- .../src/policy_map/mod.rs | 4 +- crates/openshell-driver-mxc/src/relay.rs | 686 ++++++ .../tests/openclaw_appcontainer_compat.rs | 58 + .../tests/openclaw_environment_cleanup.ps1 | 68 + .../tests/policy_mapper_examples.rs | 82 +- .../tests/policy_mapper_matrix.rs | 165 +- .../tests/wxc_exec_real.rs | 186 +- crates/openshell-driver-podman/src/driver.rs | 1 + crates/openshell-driver-vm/src/driver.rs | 1 + crates/openshell-gateway/Cargo.toml | 1 + crates/openshell-gateway/src/lib.rs | 35 +- crates/openshell-policy/src/lib.rs | 199 +- crates/openshell-sdk/src/client.rs | 2 + crates/openshell-server/src/compute/mod.rs | 588 ++++- crates/openshell-server/src/credentials.rs | 5 + crates/openshell-server/src/grpc/mod.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 548 ++++- crates/openshell-server/src/grpc/provider.rs | 70 + crates/openshell-server/src/grpc/sandbox.rs | 165 +- .../openshell-server/src/grpc/validation.rs | 43 +- crates/openshell-server/src/lib.rs | 100 +- crates/openshell-server/src/storage_proto.rs | 13 +- crates/openshell-server/src/test_support.rs | 1 + .../openshell-supervisor-network/src/host.rs | 164 +- .../src/identity.rs | 2 +- .../openshell-supervisor-network/src/opa.rs | 16 + .../openshell-supervisor-network/src/proxy.rs | 76 + crates/openshell-supervisor-relay/Cargo.toml | 42 + crates/openshell-supervisor-relay/src/imp.rs | 1090 +++++++++ crates/openshell-supervisor-relay/src/main.rs | 32 + .../tests/control_channel_contract.rs | 666 +++++ crates/openshell-tui/src/lib.rs | 1 + docs/reference/gateway-config.mdx | 41 +- docs/reference/policy-schema.mdx | 40 + docs/sandboxes/manage-sandboxes.mdx | 26 +- docs/sandboxes/policies.mdx | 17 +- proto/compute_driver.proto | 4 + proto/openshell.proto | 14 + proto/sandbox.proto | 30 + 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 | 53 +- sdk/go/proto/sandboxv1/sandbox.pb.go | 453 ++-- skills/generate-sandbox-policy/SKILL.md | 30 +- skills/generate-sandbox-policy/examples.md | 2 +- skills/openshell-cli/SKILL.md | 13 +- tasks/scripts/windows-msvc.ps1 | 48 +- tasks/windows.toml | 9 +- 102 files changed, 12789 insertions(+), 821 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/README-openclaw-forward.txt 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/install-nodejs-openclaw.ps1 create mode 100644 crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 create mode 100644 crates/openshell-driver-mxc/examples/mxc-provider-credential.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-ws-agent.rs create mode 100644 crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml create mode 100644 crates/openshell-driver-mxc/examples/openclaw-capture.mjs create mode 100644 crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 create mode 100644 crates/openshell-driver-mxc/src/control_channel.rs create mode 100644 crates/openshell-driver-mxc/src/relay.rs create mode 100644 crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs create mode 100644 crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 create mode 100644 crates/openshell-supervisor-relay/Cargo.toml create mode 100644 crates/openshell-supervisor-relay/src/imp.rs create mode 100644 crates/openshell-supervisor-relay/src/main.rs create mode 100644 crates/openshell-supervisor-relay/tests/control_channel_contract.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 32647a1cbc..1120e6cfcc 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -17,6 +17,7 @@ Windows MSVC for the supported deliverables: - `openshell-gateway.exe` - `openshell.exe` +- `openshell-supervisor-relay.exe` (Windows-only MXC workload relay) It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. @@ -115,7 +116,7 @@ The lane targets a Windows host with Visual Studio Build Tools and rustup. | Visual C++ ARM64 tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 -property installationPath` | Required for native ARM64 check, build, and tests and for x64-to-ARM64 check/build. Tests always require a native runner. | | Visual C++ ARM64 Spectre-mitigated libraries | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Runtimes.ARM64.Spectre -property installationPath` | Required by `regorus` through `msvc_spectre_libs`; the build fails when the selected MSVC toolset lacks `lib\spectre\arm64`. | | Visual C++ Clang tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath` | Provides host-native `libclang.dll` for `bindgen` and `clang-cl.exe` for ARM64 crypto dependencies such as `aws-lc-sys`. On ARM64, the wrapper uses `VC\Tools\Llvm\Arm64\bin`. | -| Visual C++ CMake tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath` | Provides CMake and Ninja for bundled Z3 and other native dependencies. The x64-to-ARM64 path adds Ninja to `PATH`; Z3 uses MSVC's Visual Studio generator. | +| Visual C++ CMake tools | `vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath` | Provides CMake and Ninja for native dependencies. The x64-to-ARM64 path adds Ninja to `PATH`; Z3 uses an architecture-specific prebuilt release. | | Windows SDK | `where.exe rc.exe` from a Developer PowerShell | Install an SDK containing target libraries and ARM64 tools. | | Rust via rustup | `rustc --version` | Add each target being validated: `x86_64-pc-windows-msvc` and/or `aarch64-pc-windows-msvc`. The wrapper also adds the selected target. | | mise | `mise --version` | Used as a task runner only. | @@ -135,6 +136,8 @@ from this skill. | `CARGO_TARGET_DIR` | `target` under repo root | Override Cargo output location. Use a short absolute path when x64-to-ARM64 builds approach Windows path-length limits. | | `Z3_LIBRARY_PATH_OVERRIDE` | unset | Directory containing an x64 system `libz3.lib`; not valid for ARM64. | | `Z3_SYS_Z3_HEADER` | unset | Full `z3.h` path required with a system Z3 library. | +| `Z3_SYS_Z3_VERSION` | `4.16.0` | Pinned official prebuilt Z3 release selected by the wrapper. | +| `READ_ONLY_GITHUB_TOKEN` | unset | Optional token for the Z3 release lookup; GitHub Actions supplies `github.token`. | | `RUSTC_WRAPPER` | inherited | The wrapper resolves an available command to an absolute path. If it is unavailable, the wrapper warns and continues without compiler caching. | Legacy fork variables such as `OPENSHELL_UPSTREAM`, @@ -155,6 +158,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: @@ -163,12 +176,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 { @@ -203,8 +218,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 builds bundled Z3 with native MSVC `cl.exe` and the Visual -Studio generator. +`clang-cl`, and downloads the official prebuilt ARM64 Z3 static library. 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 @@ -241,6 +255,8 @@ crypto dependency builds. | `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. | @@ -310,13 +326,16 @@ 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. | - -The first check builds bundled Z3 from source through `z3-sys`. Cargo stores the -native build output in its target tree, so the Windows target cache reuses it. -The resulting release executables do not require `libz3.dll`. The artifact -report computes SHA256 through .NET directly and does not rely on the -`Get-FileHash` module being available inside the mise-launched Windows -PowerShell process. +| `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 +read-only workflow token; local users can set `READ_ONLY_GITHUB_TOKEN` if an +unauthenticated lookup is rate-limited. Cargo stores the extracted library in +its target tree, so the Windows target cache reuses it. The artifact report +computes SHA256 through .NET directly and does not rely on the `Get-FileHash` +module being available inside the mise-launched Windows PowerShell process. ## Common Fix Patterns diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index 99acac6034..bc278664dc 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -68,11 +68,11 @@ file. For ARM64, verify the Visual Studio instance contains the ARM64 MSVC tools, ARM64 Spectre-mitigated libraries, Clang tools, CMake tools, and a Windows SDK. Clang supplies host-native `libclang.dll` for `bindgen` and `clang-cl.exe` for -ARM64 crypto dependencies such as `aws-lc-sys`. Native builds use the normal -bundled-Z3 CMake path. An x64-to-ARM64 check/build discovers and adds -host-native Ninja to `PATH`, builds bundled Z3 with native MSVC `cl.exe` and -the Visual Studio generator, and lets the crypto crates select `clang-cl`. Use -a short `CARGO_TARGET_DIR` if Windows path-length limits are reached. +ARM64 crypto dependencies such as `aws-lc-sys`. Native and x64-to-ARM64 builds +use the official prebuilt Z3 4.16.0 static library for the target architecture. +An x64-to-ARM64 check/build discovers and adds host-native Ninja to `PATH`, +while the crypto crates select `clang-cl`. Use a short `CARGO_TARGET_DIR` if +Windows path-length limits are reached. ## Unsupported Driver Rules diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 76ba5075e2..00c41d9ca9 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -56,6 +56,7 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: + READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" @@ -121,6 +122,7 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: + READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" @@ -178,6 +180,7 @@ jobs: rust_target: aarch64-pc-windows-msvc runs-on: ${{ matrix.runner }} env: + READ_ONLY_GITHUB_TOKEN: ${{ github.token }} SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_VERSION: windows-msvc-${{ matrix.arch }} SCCACHE_CLIENT_SIDE: "1" diff --git a/AGENTS.md b/AGENTS.md index 954ffe1147..215779f673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `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-supervisor-relay/` | MXC supervisor relay | **Windows-only** standalone binary the MXC driver spawns inside a ProcessContainer/isolation session in place of `agent_command`; launches the real target process, exposes a JSON control channel (launch/shutdown/forward) over its own inherited stdin/stdout, and bridges dynamic TCP forwards (`openshell forward service`) to it | | `crates/openshell-prover/` | Policy prover | Policy verification and proof generation | | `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization | | `crates/openshell-supervisor-middleware/` | Middleware runtime | Generic middleware registry, remote service integration, and chain execution | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c519da904..931d97500f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -340,14 +340,19 @@ cargo build -p openshell-prover --features bundled-z3 For x86-64 and ARM64 Windows MSVC builds, use one of these Z3 paths: -- Bundled Z3 (the default for `windows:*` tasks): `z3-sys` builds Z3 from - source and links it into the OpenShell binaries. This keeps the release - executables independent of `libz3.dll` when they are copied to another host. +- Prebuilt Z3 (the default for `windows:*` tasks): `z3-sys` downloads the + pinned Z3 4.16.0 GitHub release for the target architecture on the first + build. Cargo reuses the extracted archive from its target directory. Windows + CI authenticates the GitHub API request with `READ_ONLY_GITHUB_TOKEN` and + preserves the archive in the architecture-specific Cargo target cache. For + cold local builds, you may set `READ_ONLY_GITHUB_TOKEN` to avoid anonymous + GitHub API rate limits. - System Z3: point `Z3_LIBRARY_PATH_OVERRIDE` at the directory containing the - target-compatible MSVC Z3 library and `Z3_SYS_Z3_HEADER` at the full path to - `z3.h`. The `windows:*` tasks use this path automatically when - `Z3_LIBRARY_PATH_OVERRIDE` is set. Deploy the matching `libz3.dll` with any - binaries built against a dynamic system Z3. + target-compatible MSVC Z3 library and `Z3_SYS_Z3_HEADER` at the full path to `z3.h`. + The `windows:*` tasks use this path automatically when `Z3_LIBRARY_PATH_OVERRIDE` + is set. +- Bundled Z3: for direct Cargo builds, pass `--features bundled-z3` so `z3-sys` + builds Z3 from source. `openshell-prover` itself has no `bindgen`/`libclang` dependency, so building just this crate does not require `LIBCLANG_PATH`: @@ -360,17 +365,17 @@ cargo build -p openshell-prover --target x86_64-pc-windows-msvc --features bundl To build the full set of Windows binaries, including `openshell-gateway.exe` and `openshell.exe`, use the `windows:build:x64` mise task instead of a -single-crate `cargo build`. It builds Z3 from source (bundled) by default. A full -build also compiles crates that use `bindgen` (e.g. the MXC driver on Windows), -so it requires `libclang.dll`; if LLVM is not on the default search path, set -`LIBCLANG_PATH` to the directory containing `libclang.dll`: +single-crate `cargo build`. It downloads the pinned prebuilt Z3 release by default. A +full build also compiles crates that use `bindgen` (e.g. the MXC driver on +Windows), so it requires `libclang.dll`; if LLVM is not on the default search +path, set `LIBCLANG_PATH` to the directory containing `libclang.dll`: ```powershell $env:LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\\VC\Tools\Llvm\x64\bin' mise run --skip-tools windows:build:x64 ``` -To use a local x64 Z3 release instead of the bundled build, set +To use a local x64 Z3 release instead of the prebuilt download, set `Z3_LIBRARY_PATH_OVERRIDE` and `Z3_SYS_Z3_HEADER` before running the task: ```powershell diff --git a/Cargo.lock b/Cargo.lock index 05afb6dab3..431b79335a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4170,6 +4170,7 @@ dependencies = [ name = "openshell-driver-mxc" version = "0.0.0" dependencies = [ + "anyhow", "base64", "futures", "noyalib", @@ -4177,6 +4178,7 @@ dependencies = [ "openshell-ocsf", "openshell-policy", "openshell-supervisor-network", + "rand 0.9.4", "rustls", "serde", "serde_json", @@ -4184,6 +4186,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-tungstenite 0.26.2", "tonic", "tracing", "uuid", @@ -4716,6 +4719,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "openshell-supervisor-relay" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64", + "futures", + "serde_json", + "tokio", + "tokio-tungstenite 0.26.2", +] + [[package]] name = "openshell-tui" version = "0.0.0" diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 3018e6447d..8f7c5e5080 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -32,8 +32,8 @@ launches and monitors the policy-constrained workload itself. `compute_driver.proto` is the supported gateway/driver extension boundary. At initialization the gateway snapshots the driver's identity, version, -default image, gateway-lifecycle preference, and -`driver_reports_runtime_readiness` from `GetCapabilities`. The gateway includes +default image, gateway-lifecycle preference, runtime-readiness ownership, and +complete UI-policy enforcement support from `GetCapabilities`. The gateway includes the canonical `SandboxPolicy` in `DriverSandboxSpec.policy` for validation and creation. Drivers that enforce policy outside the standard supervisor fetch later revisions through `GetSandboxConfig` and acknowledge them through @@ -106,6 +106,18 @@ The capability RPC reports driver identity, version, and the default sandbox image used by the gateway. GPU availability stays driver-local and is validated when a sandbox create request asks for GPU resources. +UI policy is capability-negotiated at the configured driver-instance boundary. +`supports_ui_policy = true` means the driver completely enforces every field in +the current portable `SandboxPolicy.ui` contract; partial implementations must +report false. When `ui` is explicitly present, including as `{}`, the gateway +rejects create before the driver validation RPC or provisioning unless this +capability is true. An absent section bypasses this gate and preserves the +runtime's existing behavior. The startup snapshot is also exposed through +gateway info so clients can discover the selected runtime's support. UI cannot +be supplied by a gateway-global policy because it is applied at startup. When a +global dynamic policy is active, effective-policy reads retain the UI block from +the sandbox's creation policy. + The gateway records driver identity and version from the startup capability response. Elevated gateway info reports that initialized driver snapshot instead of re-querying drivers on each request. @@ -156,6 +168,20 @@ in-tree driver cannot silently depend on a server-only API. ## Stop and Start Lifecycle +On Windows, the MXC driver can wrap the workload in +`openshell-supervisor-relay`. Its inherited stdin/stdout control channel carries +the launch environment, shutdown requests, and multiplexed dynamic forwards. +The gateway accepts driver-reported readiness only after the configured target +port is reachable. Stop/delete interrupt readiness waits and await process +termination; they must not publish success while owned processes remain. + +With governed egress enabled, MXC denies direct Internet access and allows +host loopback. Proxy-aware workloads receive per-sandbox authenticated +`HTTP_PROXY`/`HTTPS_PROXY` URLs and public CA trust material. The host CONNECT +proxy enforces OpenShell network policy, but this configuration does not isolate +unrelated host-loopback services. See the MXC driver README for compatibility +settings and the remaining policy limitations. + The gateway persists lifecycle intent before mutating compute: ```text diff --git a/architecture/gateway.md b/architecture/gateway.md index fdf0c15f78..bccebfd9f4 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -347,6 +347,12 @@ Public RPC contracts and durable protobuf formats have separate ownership. The ` `ReportEndpointStatus` is a sandbox-authenticated public gateway RPC. Its request, response, and `EndpointObservation` messages belong only to the public closure. `EndpointStatus` and `EndpointResult` also belong to the durable closure because `Sandbox.status.endpoint_statuses` persists them. The repeated status field uses a new wire tag; stored sandboxes without it decode with an empty endpoint list and retain their lifecycle fields. A fixed payload encoded with the earlier sandbox schema verifies that no database rewrite is required. +`DeleteSandboxRequest` adds optional identity and resource-version preconditions +at tags 4 and 5; the workspace selector retains tag 3. Omitted preconditions +preserve existing deletion behavior. Matching clients and servers are required +when relying on these checks: an older server can ignore unknown fields. These +request-only additions do not change any durable storage payload. + The removed `NetworkBinary.harness` field remains reserved by number and name, so protobuf implementations cannot reuse its wire slot or source identifier. The durable-policy compatibility decoder reads the former boolean before Prost @@ -622,9 +628,11 @@ leave an ambiguous final dynamic-token state or a deleted custom profile that is still referenced by a sandbox. Policy and runtime settings are delivered together through the effective sandbox -config path. A gateway-global policy can override sandbox-scoped policy. The -sandbox supervisor polls for config revisions and hot-reloads dynamic policy -when the policy engine accepts the update. +config path. A gateway-global policy can override sandbox-scoped dynamic policy. +Startup-only UI remains anchored to each sandbox's creation policy, and global +policy writes containing UI are rejected. The sandbox supervisor polls for +config revisions and hot-reloads dynamic policy when the policy engine accepts +the update. External supervisor middleware registration is operator-owned configuration under `[[openshell.supervisor.middleware]]`. At startup the gateway connects to diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 859181b279..430b78c642 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -35,6 +35,16 @@ only when the set is already empty; any other outcome fails the spawn. 6. It opens a supervisor session back to the gateway for connect, exec, file sync, config polling, and log push. +## Deletion Authority + +The public delete API resolves a sandbox name to its immutable metadata ID and +holds that ID's lifecycle lock through the pre-mutation check. Callers that +already observed a sandbox may also supply its expected ID and resource +version. OpenShell revalidates those preconditions under the lifecycle and +gateway-global locks, then returns `ABORTED` without changing durable state or +calling the compute driver if either value drifted. An unguarded delete retains +the interactive CLI's existing name-based behavior. + ## Isolation Layers OpenShell uses overlapping controls rather than a single sandbox primitive: diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 78599e41d9..655886d87c 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -8,19 +8,43 @@ per-request egress decisions. For the field-by-field YAML reference, use [Policy Schema Reference](../docs/reference/policy-schema.mdx). +## Windows MXC Static Enforcement + +On native Windows, the MXC driver cannot rely on Linux Landlock or setuid. It +maps portable static filesystem and UI controls from `SandboxPolicy` into MXC +configuration. UI controls are available only with the MXC +`process_container` backend; all omitted UI fields retain deny-by-default +values, and other compute runtimes reject an explicit UI policy. MXC ignores +clipboard and input-injection fields when graphical UI is disabled, so the +mapper rejects those otherwise-unenforceable combinations. + ## Policy Areas | Area | Enforcement | |---|---| | Filesystem | Landlock restricts read-only and read-write paths. | | Process | The supervisor launches the agent as an unprivileged user with reduced capabilities. | +| UI | Within an explicit UI section, omitted display, clipboard, and input-injection fields deny. The MXC driver's OpenShell `process_container` backend (MXC containment `processcontainer`) can selectively enable them. Other configured backends reject the entire explicit section before provisioning. | | Network | The proxy evaluates destination, port, calling binary, and optional L7 rules. | | Provider access | Attached provider profiles contribute endpoint and binary rules; credentials remain bound to profile-authorized endpoints. | | Runtime settings | Typed settings are delivered with policy and can be global or sandbox scoped. | -Filesystem and process policy are startup-time controls. Network policy is +Filesystem, process, and UI policy are startup-time controls. Network policy is dynamic and can be hot-reloaded when the new policy validates successfully. +The UI schema names portable capabilities rather than Windows primitives: +graphical output, directional clipboard access, and synthetic input. The +configured compute driver advertises whether it completely enforces this +contract. Any explicit section, including `{}`, is rejected before driver +validation or provisioning when that capability is false. The MXC +`process_container` mapper translates the fields to MXC's top-level `ui` object +under its `processcontainer` containment value and treats omitted fields inside +the section as deny. That object is common to MXC's 0.8 stable and 0.9 +development schemas. Both schema lines reject it for `isolation_session`, so +that backend advertises no support and the mapper also rejects it in depth. +Linux, macOS, and other non-MXC paths advertise no support: explicit UI policy +fails closed, while an absent section leaves their runtime behavior unchanged. + Before applying Landlock, the supervisor enriches baseline filesystem paths that the runtime needs. Missing baseline paths are skipped so one absent runtime path does not weaken the whole ruleset. When GPU devices are present, GPU baseline @@ -223,8 +247,11 @@ generation, and whether the previous policy is active. Static controls, such as filesystem allowlists and process identity, require a new sandbox because they are applied before the child process starts. -Gateway-global policy can override sandbox-scoped policy. Use it sparingly -because it changes the effective access model for every sandbox on the gateway. +Gateway-global policy can override sandbox-scoped dynamic policy. Use it +sparingly because it changes the effective access model for every sandbox on +the gateway. A global policy cannot contain `ui`; effective-policy reads retain +the per-sandbox UI contract recorded at creation so a global update cannot +misrepresent startup enforcement. ## Policy Advisor diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index 6fb816b448..8795e78776 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -110,13 +110,15 @@ packaging-asset tests; its cross-platform Python, Markdown, license, and documentation checks still run. Test tasks require the Rust target architecture to match the Windows host, so an ARM64 test result is native coverage rather than x64 emulation coverage. -By default it enables bundled Z3 for reproducible Windows builds. `z3-sys` -builds Z3 from source and links it into the release binaries, so copying -`openshell-gateway.exe` and `openshell.exe` does not create an undeclared -`libz3.dll` runtime dependency. When `Z3_LIBRARY_PATH_OVERRIDE` points at a -directory containing `libz3.lib`, the wrapper uses that system Z3 instead and -requires `Z3_SYS_Z3_HEADER` to point at the full path to `z3.h`. Binaries built -against a dynamic system Z3 must be deployed with the matching `libz3.dll`. +By default it enables the `z3-sys` prebuilt-release feature and pins Z3 4.16.0. +On a clean target directory, `z3-sys` downloads the official static library for +the selected Windows architecture instead of compiling Z3 through +CMake/MSBuild. GitHub Actions supplies its read-only workflow token for the +release lookup, and the Cargo target cache preserves the extracted library for +subsequent runs. When `Z3_LIBRARY_PATH_OVERRIDE` points at a directory +containing `libz3.lib`, the wrapper uses that system Z3 instead and requires +`Z3_SYS_Z3_HEADER` to point at the full path to `z3.h`. Local clean builds use +the unauthenticated GitHub API unless `READ_ONLY_GITHUB_TOKEN` is set. GitHub Actions layers the Cargo target cache with sccache's GitHub Actions backend. The target cache lets Cargo skip intact dependency builds; sccache @@ -136,10 +138,10 @@ Spectre-mitigated libraries, host-native Clang tools, CMake tools, and an ARM64-capable Windows SDK. Clang provides `libclang.dll` for `bindgen` and `clang-cl.exe` for ARM64 crypto dependencies. During x64-to-ARM64 check/build, the wrapper discovers and adds the Visual Studio-bundled Ninja to `PATH` for -native dependencies. Bundled Z3 uses native MSVC `cl.exe` with the Visual -Studio generator, while the crypto crates select `clang-cl`. Artifact hashing -uses .NET SHA256 directly because module autoloading in the mise-launched -Windows PowerShell process is not guaranteed. +native dependencies. Z3 uses the official prebuilt ARM64 static library, so it +does not inherit compiler settings from those native dependencies. Artifact +hashing uses .NET SHA256 directly because module autoloading in the +mise-launched Windows PowerShell process is not guaranteed. The wrapper defaults Cargo compilation to four jobs. Set `OPENSHELL_WINDOWS_BUILD_JOBS` to a positive integer to override that limit. diff --git a/crates/openshell-cli/src/commands/common.rs b/crates/openshell-cli/src/commands/common.rs index 0f12c3df4e..29afe306af 100644 --- a/crates/openshell-cli/src/commands/common.rs +++ b/crates/openshell-cli/src/commands/common.rs @@ -878,6 +878,47 @@ pub fn parse_env_pairs(items: &[String]) -> Result> { Ok(map) } +/// Resolve `--env-from KEY[=ENVVAR]` values from the CLI process environment. +/// +/// This keeps environment values out of process arguments while preserving the +/// same sandbox environment validation as `--env KEY=VALUE`. +pub fn parse_env_from_pairs(items: &[String]) -> Result> { + let mut map = HashMap::new(); + + for item in items { + let (key, env_name) = match item.split_once('=') { + Some((key, env_name)) => (key.trim(), env_name.trim()), + None => (item.trim(), item.trim()), + }; + if !is_valid_env_name(key) { + return Err(miette::miette!( + "--env-from key must match [A-Za-z_][A-Za-z0-9_]*; got '{key}'" + )); + } + if key.starts_with("OPENSHELL_") { + return Err(miette::miette!( + "--env-from keys starting with OPENSHELL_ are reserved; got '{key}'" + )); + } + if !is_valid_env_name(env_name) { + return Err(miette::miette!( + "--env-from source must match [A-Za-z_][A-Za-z0-9_]*; got '{env_name}'" + )); + } + if map.contains_key(key) { + return Err(miette::miette!("duplicate --env-from sandbox key '{key}'")); + } + let value = std::env::var(env_name).map_err(|_| { + miette::miette!( + "--env-from source environment variable '{env_name}' is not set or is not valid Unicode" + ) + })?; + map.insert(key.to_string(), value); + } + + Ok(map) +} + /// Resolve `--secret-material-env KEY[=ENVVAR]` values from the CLI process /// environment (`ENVVAR` defaults to `KEY`) so secrets never transit argv. pub fn parse_secret_material_env_pairs(items: &[String]) -> Result> { diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 0e1fe92426..6073ac3a4b 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -45,6 +45,7 @@ struct ComputeDriverInfoView { struct ComputeDriverCapabilitiesView { driver_name: String, driver_version: String, + supports_ui_policy: bool, } /// Show gateway status. @@ -392,6 +393,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, }, } }) @@ -446,6 +448,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" + } + ); } } @@ -464,6 +475,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::>(), @@ -1822,6 +1834,7 @@ mod tests { capabilities: ComputeDriverCapabilitiesView { driver_name: "podman".to_string(), driver_version: "0.0.75".to_string(), + supports_ui_policy: false, }, }], }; @@ -1840,6 +1853,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 fdec4ce7e5..5f9d3b2935 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1272,7 +1272,7 @@ enum SandboxCommands { name: Option, /// Create the sandbox from a named sandbox template. - #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])] + #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs", "env_from"])] template: Option, /// Sandbox source: a community sandbox name (e.g., `ollama`), a rootfs @@ -1396,6 +1396,13 @@ enum SandboxCommands { #[arg(long = "env", value_name = "KEY=VALUE")] envs: Vec, + /// Set a sandbox environment variable from the CLI process environment. + /// + /// Format: `KEY[=ENVVAR]`. When `ENVVAR` is omitted, `KEY` is used. + /// The value does not appear in the CLI process arguments. Repeatable. + #[arg(long = "env-from", value_name = "KEY[=ENVVAR]")] + env_from: Vec, + /// Suppress warnings when --env values look like credentials. #[arg(long = "no-credential-warnings")] no_credential_warnings: bool, @@ -1477,8 +1484,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. @@ -3067,6 +3082,7 @@ async fn run_async() -> Result<()> { no_auto_providers, labels, envs, + env_from, no_credential_warnings, approval_mode, output, @@ -3104,7 +3120,14 @@ async fn run_async() -> Result<()> { } // Parse --env flags into a HashMap. - let env_map = run::parse_env_pairs(&envs)?; + let mut env_map = run::parse_env_pairs(&envs)?; + for (key, value) in run::parse_env_from_pairs(&env_from)? { + if env_map.insert(key.clone(), value).is_some() { + return Err(miette::miette!( + "duplicate sandbox environment key '{key}' supplied through --env and --env-from" + )); + } + } run::warn_credential_env_vars(&env_map, no_credential_warnings); // Parse --upload specs into [(local_path, sandbox_path, git_ignore)]. @@ -3261,11 +3284,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, @@ -4805,6 +4835,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 243e10d1bf..673d4a9da3 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4,8 +4,8 @@ //! CLI command implementations. pub use crate::commands::common::{ - PolicyGetView, parse_credential_expiry_cli_value, parse_env_pairs, parse_key_value_pairs, - parse_secret_material_env_pairs, warn_credential_env_vars, + PolicyGetView, parse_credential_expiry_cli_value, parse_env_from_pairs, parse_env_pairs, + parse_key_value_pairs, parse_secret_material_env_pairs, warn_credential_env_vars, }; use crate::commands::common::{ ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete, @@ -391,7 +391,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}" @@ -3171,14 +3173,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 { @@ -3229,6 +3252,8 @@ pub async fn sandbox_delete( .delete_sandbox(DeleteSandboxRequest { name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + expected_sandbox_id: expected_id.unwrap_or_default().to_string(), + expected_resource_version: expected_resource_version.unwrap_or_default(), }) .await { @@ -5996,7 +6021,7 @@ mod tests { use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, format_endpoint, format_log_line, git_sync_files, has_main_process_result, parse_cli_setting_value, - parse_credential_expiry_cli_value, parse_driver_config_json, + parse_credential_expiry_cli_value, parse_driver_config_json, parse_env_from_pairs, parse_secret_material_env_pairs, policy_revision_list_json, policy_revision_to_json, provisioning_timeout_message, ready_false_condition_message, resolve_from, rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, sandbox_upload_plan, @@ -6201,6 +6226,52 @@ mod tests { )); } + #[test] + fn parse_env_from_pairs_reads_named_and_same_name_environment_variables() { + let _named = EnvVarGuard::set("NAV_PARSE_ENV_FROM_NAMED", "named-value"); + let _same_name = EnvVarGuard::set("NAV_PARSE_ENV_FROM_SAME", "same-name-value"); + + let parsed = parse_env_from_pairs(&[ + "SANDBOX_NAMED=NAV_PARSE_ENV_FROM_NAMED".to_string(), + "NAV_PARSE_ENV_FROM_SAME".to_string(), + ]) + .expect("parse"); + assert_eq!( + parsed.get("SANDBOX_NAMED"), + Some(&"named-value".to_string()) + ); + assert_eq!( + parsed.get("NAV_PARSE_ENV_FROM_SAME"), + Some(&"same-name-value".to_string()) + ); + } + + #[test] + fn parse_env_from_pairs_rejects_missing_invalid_reserved_and_duplicate_keys() { + let _missing = EnvVarGuard::unset("NAV_PARSE_ENV_FROM_MISSING"); + let _present = EnvVarGuard::set("NAV_PARSE_ENV_FROM_PRESENT", "value"); + + for (input, expected) in [ + ("TARGET=NAV_PARSE_ENV_FROM_MISSING", "is not set"), + ("1BAD=NAV_PARSE_ENV_FROM_PRESENT", "key must match"), + ("TARGET=BAD-NAME", "source must match"), + ("OPENSHELL_RESERVED=NAV_PARSE_ENV_FROM_PRESENT", "reserved"), + ] { + let error = parse_env_from_pairs(&[input.to_string()]).expect_err("must reject"); + assert!( + error.to_string().contains(expected), + "unexpected error: {error}" + ); + } + + let error = parse_env_from_pairs(&[ + "TARGET=NAV_PARSE_ENV_FROM_PRESENT".to_string(), + "TARGET=NAV_PARSE_ENV_FROM_PRESENT".to_string(), + ]) + .expect_err("duplicate must reject"); + assert!(error.to_string().contains("duplicate --env-from")); + } + #[test] fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() { let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material"); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index d3cccefb76..d5500eb7ff 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -60,6 +60,7 @@ fn selected_workspace( #[derive(Clone, Default)] struct SandboxState { deleted_names: Arc>>>, + delete_requests: Arc>>, create_requests: Arc>>, fail_delete_sandbox_message: Arc>>, vm_error_after_started: Arc, @@ -330,6 +331,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() @@ -1383,6 +1389,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() } @@ -1480,6 +1490,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", @@ -1501,6 +1513,79 @@ async fn sandbox_delete_continues_after_entry_failure() { ); } +#[tokio::test] +async fn sandbox_delete_forwards_identity_preconditions() { + let server = run_server().await; + let tls = test_tls(&server); + + run::sandbox_delete( + &server.endpoint, + &["guarded-sandbox".to_string()], + false, + Some("sb-123"), + Some(17), + "default", + &tls, + "openshell", + ) + .await + .expect("identity-guarded delete should succeed"); + + let requests = delete_requests(&server).await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].name, "guarded-sandbox"); + assert_eq!(requests[0].expected_sandbox_id, "sb-123"); + assert_eq!(requests[0].expected_resource_version, 17); +} + +#[tokio::test] +async fn sandbox_delete_rejects_preconditions_for_multiple_names_before_rpc() { + let server = run_server().await; + let tls = test_tls(&server); + + let error = run::sandbox_delete( + &server.endpoint, + &["sandbox-a".to_string(), "sandbox-b".to_string()], + false, + Some("sb-123"), + None, + "default", + &tls, + "openshell", + ) + .await + .expect_err("identity preconditions must be single-sandbox only"); + + assert!( + error + .to_string() + .contains("require exactly one sandbox name") + ); + assert!(delete_requests(&server).await.is_empty()); +} + +#[tokio::test] +async fn sandbox_delete_rejects_resource_version_without_immutable_identity() { + let server = run_server().await; + let tls = test_tls(&server); + + let error = run::sandbox_delete( + &server.endpoint, + &["guarded-sandbox".to_string()], + false, + None, + Some(17), + "default", + &tls, + "openshell", + ) + .await + .expect_err("resource version without immutable ID must fail closed"); + + assert!(error.to_string().contains("requires --expected-id")); + assert!(delete_requests(&server).await.is_empty()); +} + #[tokio::test] async fn sandbox_create_keeps_command_sessions_by_default() { let server = run_server().await; @@ -2813,6 +2898,16 @@ async fn run_cli_sandbox_create_with_xdg( xdg_dir: &TempDir, name: &str, extra_args: &[&str], +) -> std::process::Output { + run_cli_sandbox_create_with_xdg_and_env(server, xdg_dir, name, extra_args, &[]).await +} + +async fn run_cli_sandbox_create_with_xdg_and_env( + server: &TestServer, + xdg_dir: &TempDir, + name: &str, + extra_args: &[&str], + environment: &[(&str, &str)], ) -> std::process::Output { let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")); for (key, _) in std::env::vars().filter(|(k, _)| k.starts_with("OPENSHELL_")) { @@ -2831,6 +2926,7 @@ async fn run_cli_sandbox_create_with_xdg( "--no-auto-providers", ]) .args(extra_args) + .envs(environment.iter().copied()) .env("XDG_CONFIG_HOME", xdg_dir.path()) .env("HOME", xdg_dir.path()) .env("OPENSHELL_PROVISION_TIMEOUT", "5") @@ -2849,6 +2945,47 @@ async fn run_cli_sandbox_create( run_cli_sandbox_create_with_xdg(server, &xdg_dir, name, extra_args).await } +async fn run_cli_sandbox_create_with_env( + server: &TestServer, + name: &str, + extra_args: &[&str], + environment: &[(&str, &str)], +) -> std::process::Output { + let xdg_dir = tempfile::tempdir().unwrap(); + prepare_cli_xdg(server, &xdg_dir); + run_cli_sandbox_create_with_xdg_and_env(server, &xdg_dir, name, extra_args, environment).await +} + +#[tokio::test] +async fn sandbox_create_env_from_reaches_request() { + let server = run_server().await; + let value = "qualification-value-not-in-argv"; + + let output = run_cli_sandbox_create_with_env( + &server, + "env-from-test", + &["--env-from", "SANDBOX_VALUE=HOST_VALUE", "--output=json"], + &[("HOST_VALUE", value)], + ) + .await; + assert!( + output.status.success(), + "sandbox create failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let requests = create_requests(&server).await; + let environment = &requests[0] + .spec + .as_ref() + .expect("spec should be present") + .environment; + assert_eq!( + environment.get("SANDBOX_VALUE").map(String::as_str), + Some(value) + ); +} + async fn run_cli_sandbox_template_create( server: &TestServer, name: &str, diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index af474325c5..8bf2828698 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. The MXC + // driver consumes the typed proto directly on Windows. // 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-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index f82fd066af..bc0cc2bd22 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -373,6 +373,18 @@ impl ProviderCredentialState { .revision } + /// Whether this snapshot contains endpoint-bound material that must be + /// resolved by a network proxy rather than exposed to the child process. + #[must_use] + pub fn requires_proxy_resolution(&self) -> bool { + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + !inner.static_credential_bindings.is_empty() + || !inner.current.dynamic_credentials.is_empty() + } + /// Remove a key from the credential snapshot's child env. /// /// Used when a sandbox-side service (e.g., metadata server) fails to start diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 813c07f6c7..62ba8c9f69 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -667,6 +667,7 @@ impl DockerComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, } } diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index b7114c0e99..7b33d4b963 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -579,6 +579,7 @@ impl KubernetesComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }) } diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 5907e92a84..a85e709d4e 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -34,9 +34,18 @@ uuid = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] openshell-supervisor-network = { path = "../openshell-supervisor-network" } windows = { workspace = true } +# WebSocket relay embedded in the gateway for ProcessContainer host<->sandbox +# connectivity (see src/relay.rs). +tokio-tungstenite = { workspace = true } +futures = { workspace = true } +# Per-forward auth nonce for the relay (see src/relay.rs module docs). +rand = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } tokio = { workspace = true } +tokio-tungstenite = { workspace = true } +futures = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. tempfile = "3" # Used by Windows-only integration tests to parse policy YAML into the typed diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 9c235b85c7..b8c52f9898 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -7,26 +7,29 @@ OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. 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 +readiness without a supervisor session. The gateway composes the create-time +effective `SandboxPolicy` and carries it on the driver-only copy of +`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. +The driver launches and monitors the configured workload and self-reports +readiness. Optional `openshell-supervisor-relay` wrapping provides launch, +shutdown, and dynamic forwarding over an inherited stdin/stdout control channel; +it does not implement the Linux `ConnectSupervisor` protocol. ## 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 | +| Capability | MXC driver | +|---|---| +| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | +| UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | +| Network policy | With `egress_proxy = true` on `process_container`, split into MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | +| Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | +| Process policy | Unsupported; MXC supplies OS isolation only. | +| Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | +| Network middleware | Rejected before launch until the host proxy receives the gateway middleware registry. | +| ETW/OCSF audit | Optional Windows Sandboxing ETW consumer attributes host events to OpenShell sandboxes and emits OCSF records. | +| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | The filesystem enforcement proof has two paths: @@ -45,8 +48,23 @@ 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. +# processContainer only: launch openshell-supervisor-relay instead of +# the per-sandbox command directly, giving the driver a control +# channel into the sandbox (launch handshake, dynamic `openshell forward +# service` bridging). target_port is the launched command's own listening +# port; 0 disables spawner wrapping (default -- the command runs directly). +pc_relay_spawner_path = "" +pc_relay_target_port = 0 +# processContainer only: env-inheritance tier for the launched process +# (safest first): default is a minimal Windows CreateProcessW bootstrap set +# (SYSTEMROOT/WINDIR/PATH/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an +# EMPTY env for runtimes that need a fully curated per-sandbox environment. +pc_minimal_env = false +# processContainer only: include "allowLocalNetwork": true in the MXC +# network section. This compatibility setting broadens network access and is +# not required by the BaseContainer qualification profile. +pc_allow_local_network = false +# Pattern C governed egress. Requires backend = "process_container". egress_proxy = false egress_proxy_addr = "" debug = false @@ -55,7 +73,7 @@ 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. +ephemeral port for each sandbox's authenticated host CONNECT proxy. 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: @@ -65,11 +83,11 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` --driver-config-json $config --env MODE=demo --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 `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Supply per-sandbox environment variables with `--env` or `--env-from`; gateway configuration does not carry workload commands or environment. Provider-owned keys override matching entries case-insensitively, but raw static values remain in the host proxy; MXC receives their revision-scoped placeholders. When governed egress is enabled, the driver replaces common TLS trust environment variables with paths to public proxy CA files staged under `/.openshell-proxy//`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. + +UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). -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. +`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: the driver reserves a unique ephemeral loopback port for every sandbox. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. The current policy permits all loopback ports, so sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. 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 @@ -98,6 +116,10 @@ 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. +Each sandbox receives a distinct proxy listener and a random per-sandbox credential through its proxy environment. Missing, incorrect, duplicate, or another sandbox's proxy credentials receive HTTP 407 before policy evaluation or forwarding. This authenticates requests to the OpenShell proxy; it does not restrict access to unrelated host-loopback services or authenticate individual processes inside a sandbox. Proxy credentials and command/environment payloads must not be logged. + +The MXC credential handoff is also fixed at sandbox creation. The gateway rejects expiring static provider credentials because the in-process MXC driver has no live credential-refresh channel. Dynamic token grants remain request-time operations in the host proxy. Recreate the sandbox after rotating or revoking a non-expiring static credential. + ## Prerequisites (live runs) - Windows 11 Insider build ≥ 26300.8553 @@ -116,11 +138,13 @@ 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. +or MXC-specific gateway composition variant. Provider resolver state uses a +separate, create-scoped in-process handoff because it intentionally cannot be +represented in the public compute-driver protobuf. When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus a loopback `network.proxy` -redirect, and the driver starts a host CONNECT proxy from the trimmed +instead: MXC receives filesystem grants plus loopback-only egress, +and the driver starts a host CONNECT proxy from the trimmed network-only `SandboxPolicy`. Policies containing `network_middlewares` are rejected synchronously until this host-proxy path can receive the gateway's built-in and remote middleware registry. The proxy uses the configured agent @@ -128,31 +152,34 @@ 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; +process env. In curated-environment mode, the driver stages the public CA files +under the authorized `/.openshell-proxy/` directory. Other +environment modes grant the sandbox's unique public-CA directory as an internal +read-write share. 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. +environment unless the gateway explicitly opts into another environment mode. + +When governed egress is disabled, any network rule fails closed during sandbox creation. -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 real-MXC lane also dry-runs every clipboard direction against the installed schema. The driver performs this mapping automatically; there is no separate policy-export command or example. -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. +## Provider credential example -## Packaging the demo for the demo box +[`examples/run-provider-credential-test.ps1`](examples/run-provider-credential-test.ps1) +creates an MXC sandbox with an attached GitHub provider. Its policy explicitly +allows the graphical UI subsystem required by Windows PowerShell while denying +clipboard access and input injection; the existing policy mapper translates +that portable section to MXC's `ui` object. The probe verifies that the sandbox +sees a revision-scoped `GITHUB_TOKEN` placeholder, the host CONNECT proxy +substitutes it for `api.github.com`, and the same placeholder is rejected for a +different allowed endpoint. -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. +This example uses `process_container`. The `IsoSessionApp.dll` and +`--features isolation_session` prerequisites above apply only to +`isolation_session` runs and are not required for this scenario. ## Real-MXC test lane @@ -163,6 +190,7 @@ 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:test:mxc-real:arm64` | Native ARM64 `tests/wxc_exec_real.rs` with the same contract | Pre-merge on an ARM64 Windows host with `wxc-exec` | | `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 | @@ -183,12 +211,12 @@ powershell -NoProfile -ExecutionPolicy Bypass ` **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). +`C:\mxc\wxc-exec.exe` lookup. Run the probe on the actual test host; a different +machine's capability report is not evidence that its backend is available here. ## Deferred work -- **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) +- **Interactive exec/connect** — gateway interactive-exec integration (follow-on); dynamic service forwarding is supported through the relay. +- **Persistent-session governed egress** remains fail-closed until `isolation_session` exposes an enforceable proxy path. - **Restart durability** (deprovision orphaned sessions on startup) → follow-on - **GPU passthrough** → not pursued in host-side-governance design diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt new file mode 100644 index 0000000000..c3aa149896 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt @@ -0,0 +1,140 @@ +OpenShell MXC - OpenClaw + dynamic forward test (both backends) +======================================================================= + +WHAT THIS PROVES + The full path for reaching a service inside an MXC sandbox that has NO + in-sandbox supervisor process (ProcessContainer additionally has NO + inbound network capability at all): + gateway -> MXC driver -> ProcessContainer OR isolation_session sandbox + -> openshell-supervisor-relay launches OpenClaw's gateway inside it, + with no relay awareness in OpenClaw itself + -> `openshell forward service --target-port 18889` opens a fresh, + on-demand WebSocket relay for THIS call only (nothing pre-declared + in the config beyond the startup liveness port; the relay is torn + down when the forward ends) + -> a real OpenClaw client on the HOST, talking only through that + forwarded port, authenticates with a token and gets a real + "ok: true" health response. + + Pass -Backend process_container (default) or -Backend isolation_session. + Both exercise the exact same dynamic-forward/control-channel code path in + the driver -- only the gateway config differs (mxc-openclaw-gateway.toml + vs mxc-openclaw-isolation.toml). isolation_session is simpler to configure: + it merges the per-sandbox environment onto the inherited host environment rather than + replacing it, so none of ProcessContainer's pc_minimal_env / LOCALAPPDATA + workaround is needed -- see mxc-openclaw-isolation.toml's own comments for + what else differs (ProcessContainer-only fields it ignores entirely). + +PREREQUISITES (on this test box) + - An ELEVATED (Administrator) PowerShell session, for -Backend + process_container specifically. On this box's wxc-exec build, + process_container falls back to an "AppContainer + DACL" isolation + tier that needs two privileged operations: (1) WRITE_DAC on share_dir + to stamp the AppContainer's ACL -- fixable non-elevated if you own + share_dir yourself (first run wins ownership; icacls /setowner fixes a + folder an earlier elevated run left owned by Administrators), but + (2) with egress_proxy = true (mxc-openclaw-gateway.toml's default), + wxc-exec also calls NetworkIsolationSetAppContainerConfig to grant the + AppContainer a loopback exemption so it can reach the host's egress + proxy -- that Windows API requires Administrator regardless of file + ownership. Non-elevated fails both with ERROR_ACCESS_DENIED (0x5), the + second as "Network proxy error: Failed to set loopback exemption: + 0x00000005". -Backend isolation_session does not hit either path. + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - process_container or isolation_session backend live (whichever -Backend + you pass) + - Your own OpenClaw install: a node.exe binary + the openclaw npm package + (the directory containing openclaw.mjs and its own node_modules). + Neither ships in this package -- point the script at your existing + install with -NodeExePath / -OpenClawInstallDir. Don't have one? Run + install-nodejs-openclaw.ps1 first (see below) -- it fetches both and + prints the exact paths to pass here. + - Windows has curl.exe / robocopy.exe built in (they do on Win10+). + - Outbound internet to nodejs.org and registry.npmjs.org, ONLY if you use + install-nodejs-openclaw.ps1 to fetch Node.js/OpenClaw. Not needed if you + already have both. + +DON'T HAVE NODE.JS / OPENCLAW YET? + powershell -NoProfile -ExecutionPolicy Bypass -File .\install-nodejs-openclaw.ps1 + Downloads a pinned, SHA256-verified Node.js build and installs the + "openclaw" package from the public npm registry, laid out exactly how this + test expects them. Prints the -NodeExePath / -OpenClawInstallDir values to + pass through. One-time step (or pass -Force to re-fetch); if you already + have a working install elsewhere, skip this and point directly at it. + +HOW TO RUN + 1. Open PowerShell in THIS folder. + 2. Run: + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` + -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` + -NodeExePath C:\path\to\node.exe ` + -OpenClawInstallDir C:\path\to\node_modules\openclaw + + Add -Backend isolation_session to exercise that backend instead of the + default process_container. + + The script COPIES your node.exe, the OpenClaw install, and this package's + own openclaw-capture.mjs / openshell-supervisor-relay.exe into a share_dir + (default C:\openshell-openclaw) before creating the sandbox -- the + AppContainer here can only read paths under share_dir, so everything the + sandboxed process touches has to live there. The OpenClaw copy uses + robocopy and only re-copies changed files on a rerun. + +WHAT YOU GET BACK + The script prints PASS/FAIL and creates: + results-openclaw-forward-.zip + Hand that zip back. It contains the transcript, gateway logs (including the + sandbox's own forwarded stdout/stderr), the `openshell forward service` + output, the raw OpenClaw health-check response, OpenClaw's own captured + log, and the exact config + policy used. + + The capture wrapper also makes one credential-free WebSocket handshake to + OpenClaw from inside the sandbox after the gateway reports ready. It records + only an outcome and response-byte count, never response content. This is a + diagnostic boundary check: a local response with a failed host-side health + check points at the sandbox-boundary/forward path; no local response points + at the sandboxed OpenClaw target. A `started-no-completion` outcome means + even the probe's bounded socket/timer callbacks stopped progressing after + OpenClaw reported ready, which is evidence of a blocked target event loop. + The diagnostic never changes the PASS/FAIL verdict, which still requires + the authenticated host-side OpenClaw client. + +FILES IN THIS PACKAGE + openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) + openshell.exe the CLI + openshell-supervisor-relay.exe generic spawn+relay-bridge binary the driver + launches inside the sandbox in place of + OpenClaw directly (OpenClaw itself has no + relay awareness) + openclaw-capture.mjs thin Node.js wrapper that appends the + sandboxed process's stdout/stderr to a log + file in share_dir and records only the + outcome/byte count of a credential-free + target-side self-probe (OpenShell's own + adapter code, not OpenClaw's) + mxc-openclaw-gateway.toml gateway/driver config (process_container, default) + mxc-openclaw-isolation.toml gateway/driver config (-Backend isolation_session) + mxc-openclaw-localnet.toml experimental alternate process_container config + (-UseLocalNetwork; currently non-functional, + see run-openclaw-forward-test.ps1's own comment) + openclaw-gateway.yaml sandbox policy (read-write grant to share_dir + only -- see the comment at its top for why) + run-openclaw-forward-test.ps1 the orchestrator you run + install-nodejs-openclaw.ps1 optional prerequisite: fetches Node.js + + OpenClaw if you don't already have them + README-openclaw-forward.txt this file + +NOTES + - The control plane between CLI and gateway runs with --disable-tls on + loopback (that's a separate test point, T2). This test's relay traffic + (host <-> sandbox) is a separate, unrelated WebSocket tunnel. + - A "supervisor session not connected" / ssh 255 message during sandbox + create is EXPECTED on MXC and harmless - the agent already ran in-driver. + - `pc_minimal_env = true` in mxc-openclaw-gateway.toml (process_container + only) means the sandboxed process gets ONLY the env vars passed by + run-openclaw-forward-test.ps1 to `sandbox create`. That includes the + non-obvious minimum Windows values needed for CreateProcessW, independent + of anything Node.js-specific. isolation_session does not need this mode. + - The relay is entirely on-demand: nothing is listening on any fixed host + port before you run `openshell forward service`, and nothing is left + listening after the forward process exits. 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..a41ddb21d6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# openclaw-gateway.yaml - Sandbox policy for the openclaw gateway running in a ProcessContainer. +# +# node.js (openclaw) needs: +# - Read-write access to share_dir (C:/openshell-openclaw) for node.exe, +# the openclaw install, home/temp dirs, and log files -- granted +# explicitly below via read_write, since the policy is the only source +# of filesystem grants (the driver no longer adds gateway-configured +# host paths on its own). This AppContainer configuration has no other +# read-only grants, so everything the sandboxed process touches +# (including its own Node.js runtime and the OpenClaw package) must +# live under share_dir; see run-openclaw-forward-test.ps1's staging +# step. run-openclaw-forward-test.ps1 patches this path (alongside the +# per-sandbox cwd) when -ShareDir overrides the default below. +# - TCP socket binding on port 18889 (loopback) — governed by pc_capabilities. +# - Outbound TCP through the egress proxy — governed by egress_proxy in the TOML. +# - Win32k syscall access (ui.allow_graphical_ui) even though it never +# opens a real window: Node.js touches user32/gdi32 during its own +# startup and dies with STATUS_DLL_INIT_FAILED under the Win32k +# lockdown that applies by default when a policy has no `ui:` section, +# confirmed empirically against mxc-release-binaries-v0.8.0. +# +# The network policy is enforced by the OpenShell host CONNECT proxy. MXC's +# default-deny egress independently blocks direct Internet bypass attempts. +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: + # The harness replaces the default share root when -ShareDir is set. + - 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..6160be6f5c --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# ws-agent.yaml - Sandbox policy for the WebSocket agent (mxc-ws-agent.exe, +# wrapped by openshell-supervisor-relay.exe -- see mxc-ws-gateway.toml). +# +# The wrapped server process only needs: +# - Execute access to mxc-ws-agent.exe / openshell-supervisor-relay.exe and +# their runtime DLLs (provided by the AppContainer inheriting access to +# system paths and share_dir). +# - TCP socket binding on port 22000 (governed by pc_capabilities in the +# gateway TOML, not by filesystem policy here). +# - Outbound TCP through the egress proxy, for openshell-supervisor-relay +# to dial the driver's on-demand relay — governed by egress_proxy in the +# TOML. +# - No writes to the host filesystem. +# +# workload directory (passed by run-ws-agent-test.ps1, default C:\work\openshell-mxc-ws) +# is granted explicitly below via read_only, since the policy is the only +# source of filesystem grants (the driver no longer adds gateway-configured +# host paths on its own) -- this makes the binary directory accessible even +# with an otherwise-empty filesystem_policy, without granting more than the +# no-writes-needed requirement above actually calls for. run-ws-agent-test.ps1 +# patches this path and the per-sandbox driver config when -AgentDir overrides +# the default below. +# +# This example intentionally omits network_policies, not because the driver +# would reject it: with egress_proxy = true (set in mxc-ws-gateway.toml), the +# driver takes the lossless split path (policy_map::split_policy) and +# delegates network_policies verbatim to the OpenShell host CONNECT proxy for +# enforcement -- an "info" loss item, not an error, so it would be accepted. +# (Only the no-proxy coarse path, or an unsupported rule shape, can turn a +# network_policies entry into a rejected "error" loss item -- see +# policy_map/map.rs.) This scenario just doesn't need host-enforced network +# rules beyond the loopback/pc_capabilities grant above. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - "C:/work/openshell-mxc-ws" + read_write: [] 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-github-provider-profile.yml b/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml new file mode 100644 index 0000000000..49f8ee64e1 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-github-provider-profile.yml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Test-only Windows variant of the built-in GitHub profile. MXC's host proxy +# uses the first per-sandbox command element as its static process identity, so +# this profile names inbox Windows PowerShell rather than the Linux gh/git paths +# in the production GitHub profile. + +id: mxc-github-e2e +display_name: MXC GitHub credential e2e +description: Test-only GitHub profile for the MXC provider credential example +category: source_control +credentials: + - name: api_token + description: GitHub token + env_vars: [GITHUB_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: + - "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml new file mode 100644 index 0000000000..b85617a216 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-openclaw-gateway.toml +# +# Runs OpenClaw's gateway inside an MXC ProcessContainer, with its port +# reachable from the host via dynamic `openshell forward service` bridging +# (openshell-supervisor-relay.exe + the driver's on-demand relay -- see +# crates/openshell-driver-mxc/src/relay.rs). There is no static/always-on +# bridge: every `forward service` call opens its own short-lived relay. +# +# Driven by run-openclaw-forward-test.ps1, which patches wxc_exec_path and +# stages node.exe / openclaw-capture.mjs / openshell-supervisor-relay.exe / +# the caller's OpenClaw install into the policy-authorized working directory +# (the AppContainer here can only read paths granted by policy -- see the +# script's "Stage artifacts into share_dir" step for why). + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" + +backend = "process_container" +pc_capabilities = ["privateNetworkClientServer"] +pc_least_privilege = false +# UI capability is now a SandboxPolicy concern, not gateway TOML -- see +# openclaw-gateway.yaml's `ui: { allow_graphical_ui: true, ... }`. Needed +# because Node.js (OpenClaw's runtime) touches user32/gdi32 during its own +# startup and dies with STATUS_DLL_INIT_FAILED under the Win32k lockdown +# that applies when a policy has no `ui:` section, confirmed empirically +# against mxc-release-binaries-v0.8.0, even though this target never +# actually opens a window. + +# Egress proxy for outbound TCP connectivity. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" + +# Do not seed from host env; the harness supplies a curated per-sandbox env. +pc_minimal_env = true + +# The workload command, cwd, and verified-minimal environment are supplied per +# sandbox by run-openclaw-forward-test.ps1 through `sandbox create`. + +# Launch the per-sandbox command via the generic openshell-supervisor-relay +# binary. The driver sends the command/environment over the +# control channel once the spawner announces readiness (the "launch" +# handshake), and spawns OpenClaw with no relay awareness. pc_relay_target_port +# is OpenClaw's own +# --port above: used as an early liveness check (does the target ever bind +# it?) and as the target port openshell-supervisor-relay bridges by default. +# There is no static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml new file mode 100644 index 0000000000..b81f5b5565 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-openclaw-isolation.toml +# +# Like mxc-openclaw-gateway.toml, but on the isolation_session backend +# (persistent provision -> start -> exec session) instead of ProcessContainer +# (one-shot AppContainer). Dynamic forward relies on the same +# pc_relay_spawner_path / control-channel mechanism either way -- the driver +# computes spawner wrapping before branching on backend, so nothing about +# openshell-supervisor-relay or the relay protocol differs between the two. +# +# Two things ARE genuinely different from the ProcessContainer config, and +# both make this one simpler: +# - No pc_minimal_env: isolation_session merges the per-sandbox environment onto the +# full inherited host environment (PATH/SystemRoot kept) rather than +# REPLACING it, so none of ProcessContainer's curated-minimal-env / +# LOCALAPPDATA workaround is needed here. +# - No pc_capabilities / pc_least_privilege / pc_allow_local_network / +# pc_network_allow: those fields only apply to the ProcessContainer +# branch in driver.rs and are silently ignored here. Egress is +# default-allow for isolation_session. +# +# Driven by run-openclaw-forward-test.ps1 -Backend isolation_session, which +# patches wxc_exec_path and stages node.exe / openclaw-capture.mjs / +# openshell-supervisor-relay.exe / the caller's OpenClaw install into +# the sandbox's policy-authorized working directory before creating it. + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" + +backend = "isolation_session" +default_configuration_id = "composable" + +# The workload command, cwd, and environment are supplied per sandbox by +# run-openclaw-forward-test.ps1 through `sandbox create`. + +# No static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml new file mode 100644 index 0000000000..f0e4c578d9 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-openclaw-localnet.toml +# Like mxc-openclaw-gateway.toml but uses allowLocalNetwork instead of egress_proxy. +# allowLocalNetwork=true lets the AppContainer reach the host's loopback (the gateway +# relay) without routing through the egress proxy, which breaks node.js DLL init. + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\FromSenthil\\mxc-fixes-env-vars\\wxc-exec.exe" + +backend = "process_container" +pc_capabilities = ["privateNetworkClientServer"] +pc_least_privilege = false +pc_allow_local_network = true +# UI capability is now a SandboxPolicy concern, not gateway TOML -- see +# openclaw-gateway.yaml's `ui: { allow_graphical_ui: true, ... }`. Needed +# because Node.js (OpenClaw's runtime) touches user32/gdi32 during its own +# startup and dies with STATUS_DLL_INIT_FAILED under the Win32k lockdown +# that applies when a policy has no `ui:` section, confirmed empirically +# against mxc-release-binaries-v0.8.0, even though this target never +# actually opens a window. + +# The workload command, cwd, and environment are supplied per sandbox by +# run-openclaw-forward-test.ps1 through `sandbox create`. + +# No static bridge -- relay bridging is entirely on-demand via +# ForwardSink::open_dynamic_forward / the control channel's "forward" op, +# driven by `openshell forward service --target-port 18889`. +pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" +pc_relay_target_port = 18889 + +debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml b/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml new file mode 100644 index 0000000000..cc6809fe70 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential-policy.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Base policy for the MXC provider-credential example. The attached provider +# profile contributes the api.github.com rule. This base rule separately allows +# github.com so the probe can prove credential endpoint binding independently of +# network admission: traffic is allowed, but use of GITHUB_TOKEN is not. Keeping +# the negative probe within GitHub avoids risking a token leak to an unrelated +# service if the behavior under test regresses. + +version: 1 + +ui: + # Windows PowerShell loads USER32 during startup and therefore requires the + # graphical UI subsystem. Clipboard and input injection remain denied. + allow_graphical_ui: true + clipboard: none + allow_input_injection: false + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-provider" + +network_policies: + credential_endpoint_mismatch_probe: + name: credential-endpoint-mismatch-probe + endpoints: + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + binaries: + - path: "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 new file mode 100644 index 0000000000..56477c2342 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# In-sandbox probe for the MXC provider-credential example. The probe verifies +# that GITHUB_TOKEN is a revision-scoped placeholder before making any network +# request, then exercises authorized substitution and endpoint mismatch. + +[CmdletBinding()] +param( + [string] $OutputDir = "C:\work\openshell-mxc-provider" +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$resultPath = Join-Path $OutputDir "mxc-provider-credential-result.txt" + +function Complete-Probe([string[]] $Checks) { + $passed = @($Checks | Where-Object { $_.StartsWith("[FAIL]") }).Count -eq 0 + $lines = @($Checks) + "OVERALL: $(if ($passed) { 'PASS' } else { 'FAIL' })" + $summary = ($lines -join "`n") + "`n" + [System.IO.File]::WriteAllText($resultPath, $summary, $utf8NoBom) + Write-Output $summary.TrimEnd() + if ($passed) { exit 0 } else { exit 1 } +} + +function Get-SafeProbeText([string] $Text, [string] $Token) { + if ([string]::IsNullOrEmpty($Text)) { return $Text } + + $safe = $Text + if (-not [string]::IsNullOrEmpty($Token)) { + $safe = $safe.Replace($Token, "") + } + $safe = [regex]::Replace( + $safe, + '(?i)(authorization:\s*bearer\s+)\S+', + '$1' + ) + $safe = ($safe -replace '\r?\n', ' | ').Trim() + if ($safe.Length -gt 512) { + $safe = $safe.Substring(0, 512) + "..." + } + return $safe +} + +function Invoke-CurlProbe( + [string] $CurlPath, + [string] $Url, + [string] $BodyPath, + [string] $CaBundle, + [string] $Token +) { + Remove-Item $BodyPath -Force -ErrorAction SilentlyContinue + + # Windows inbox curl uses Schannel, which ignores CURL_CA_BUNDLE as an + # environment variable. Pass the host proxy's generated bundle explicitly. + # Windows PowerShell also turns native stderr into ErrorRecord objects; use + # Continue locally and merge stderr into memory so curl failures become + # structured probe results. Avoid redirecting stderr to the shared folder: + # a file created by an earlier sandbox can carry a different AppContainer + # SID and cause PowerShell to throw UnauthorizedAccessException before curl + # starts. + $nativeOutput = @() + $exitCode = -1 + $previous = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $nativeOutput = @(& $CurlPath ` + --silent ` + --show-error ` + --connect-timeout 15 ` + --max-time 60 ` + --cacert $CaBundle ` + --ssl-revoke-best-effort ` + --output $BodyPath ` + --write-out "%{http_code}" ` + --header "Authorization: Bearer $Token" ` + --header "Accept: application/vnd.github+json" ` + --header "User-Agent: openshell-mxc-provider-credential-example" ` + $Url 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + $stdout = @($nativeOutput | Where-Object { + $_ -isnot [System.Management.Automation.ErrorRecord] + } | ForEach-Object { $_.ToString() }) + $nativeErrors = @($nativeOutput | Where-Object { + $_ -is [System.Management.Automation.ErrorRecord] + } | ForEach-Object { $_.Exception.Message }) + $httpCode = ($stdout -join "").Trim() + $stderr = Get-SafeProbeText ` + -Text ($nativeErrors -join [Environment]::NewLine) ` + -Token $Token + $body = if (Test-Path $BodyPath) { + [System.IO.File]::ReadAllText($BodyPath, [System.Text.Encoding]::UTF8) + } else { + "" + } + $errorText = if ($exitCode -eq 0) { + $null + } elseif ([string]::IsNullOrWhiteSpace($stderr)) { + "curl exited $exitCode" + } else { + $stderr + } + + return [pscustomobject]@{ + HttpCode = $httpCode + Body = $body + Error = $errorText + } +} + +$checks = @() +$stage = "environment validation" +try { + $token = $env:GITHUB_TOKEN + if ([string]::IsNullOrWhiteSpace($token)) { + $checks += "[FAIL] GITHUB_TOKEN is unavailable" + Complete-Probe $checks + } + if ($token -notmatch '^openshell:resolve:env:v[0-9]+_GITHUB_TOKEN$') { + $checks += "[FAIL] MXC did not receive a revision-scoped GITHUB_TOKEN placeholder" + $checks += "[INFO] no network request was attempted" + Complete-Probe $checks + } + + $checks += "[PASS] MXC received only a revision-scoped GITHUB_TOKEN placeholder" + $curlPath = Join-Path $env:SystemRoot "System32\curl.exe" + if (-not (Test-Path $curlPath)) { + $checks += "[FAIL] inbox curl.exe is unavailable" + Complete-Probe $checks + } + $caBundle = $env:CURL_CA_BUNDLE + if ([string]::IsNullOrWhiteSpace($caBundle) -or -not (Test-Path $caBundle)) { + $checks += "[FAIL] host proxy CA bundle is unavailable" + Complete-Probe $checks + } + $checks += "[PASS] host proxy CA bundle is available to inbox curl" + + $stage = "api.github.com request" + $github = Invoke-CurlProbe ` + -CurlPath $curlPath ` + -Url "https://api.github.com/user" ` + -BodyPath (Join-Path $OutputDir "github-user-response.json") ` + -CaBundle $caBundle ` + -Token $token + if ($null -eq $github.Error -and $github.HttpCode -eq "200" -and $github.Body.Contains('"login"')) { + $checks += "[PASS] GitHub accepted the credential rewritten by the host CONNECT proxy (HTTP 200)" + } else { + $errorText = if ($null -eq $github.Error) { "none" } else { $github.Error } + $checks += "[FAIL] authenticated GitHub request failed (http=$($github.HttpCode), error=$errorText)" + } + + $stage = "github.com endpoint-mismatch request" + $mismatch = Invoke-CurlProbe ` + -CurlPath $curlPath ` + -Url "https://github.com/" ` + -BodyPath (Join-Path $OutputDir "credential-mismatch-response.json") ` + -CaBundle $caBundle ` + -Token $token + if ($null -eq $mismatch.Error -and $mismatch.HttpCode -eq "403" -and $mismatch.Body.Contains("credential_endpoint_mismatch")) { + $checks += "[PASS] proxy rejected placeholder use outside the GitHub binding (HTTP 403 credential_endpoint_mismatch)" + } else { + $errorText = if ($null -eq $mismatch.Error) { "none" } else { $mismatch.Error } + $checks += "[FAIL] endpoint-mismatch request was not rejected as expected (http=$($mismatch.HttpCode), error=$errorText)" + } +} catch { + # Report only the stage and exception type. Exception messages can echo + # native command arguments, and this probe must never persist credentials. + $checks += "[FAIL] probe encountered an unexpected error during $stage ($($_.Exception.GetType().Name))" +} + +Complete-Probe $checks diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml new file mode 100644 index 0000000000..65cd4a21c1 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway configuration for run-provider-credential-test.ps1. The runner writes +# a disposable copy with the requested wxc-exec path. + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" + +# The split mapper gives MXC a loopback redirect and the driver starts the +# per-sandbox host CONNECT proxy that enforces L4/L7 policy and rewrites +# provider credential placeholders. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" + +debug = false diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs new file mode 100644 index 0000000000..dbd63f1426 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs @@ -0,0 +1,622 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket demo agent for `OpenShell` MXC `ProcessContainer`. +//! +//! **Active role: `server`** — a plain WebSocket echo server on port 22000, +//! used by `run-ws-agent-test.ps1` as the target application. It is launched +//! directly as `agent_command` and wrapped by `openshell-supervisor-relay.exe` +//! (see `mxc-ws-gateway.toml`'s `pc_relay_spawner_path`/`pc_relay_target_port`) +//! for connectivity, exactly like the `OpenClaw` scenario wraps `node.exe` -- +//! `openshell forward service --target-port 22000` opens an on-demand relay +//! for a host client to reach it. `server` has no relay awareness at all. +//! +//! **Legacy roles: `spawner` and `proxy-for`** — implement an older, +//! *removed* static-relay protocol (`pc_relay_port` config field + a +//! `reverse-relay-addr.txt` file the driver would write into `share_dir` +//! before sandbox creation). The driver no longer supports this: `mxc.rs`/ +//! `driver.rs` have no code path that binds `pc_relay_port` or writes that +//! file, so these modes cannot work against the current driver -- kept in +//! this file only as a historical reference for the pre-dynamic-forward +//! design, not exercised by any current test. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use futures::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; + +const WS_PORT: u16 = 22000; + +// ── Entry point ─────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mode = std::env::args().nth(1).unwrap_or_default(); + match mode.as_str() { + "spawner" => spawner().await, + "server" => server().await, + "proxy-for" => { + // proxy-for : start the command in agent-cmd.txt as a child + // process, wait for it to bind , connect outward to the + // gateway relay, and bridge bidirectionally. Used to expose an + // arbitrary WebSocket server (e.g. openclaw gateway) to host + // clients via the OpenShell relay without modifying that server. + let port = std::env::args() + .nth(2) + .and_then(|s| s.parse::().ok()) + .expect("Usage: mxc-ws-agent proxy-for "); + proxy_for(port).await + } + other => { + eprintln!("mxc-ws-agent: unknown mode {other:?}. Use 'spawner' or 'server'."); + std::process::exit(2); + } + } +} + +// ── AppContainer SID (Windows only) ────────────────────────────────────────── + +/// Returns the `AppContainer` SID string of the current process, or `None` if +/// not running in an `AppContainer`. Written to `appcontainer-sid.txt` in the +/// share dir as a diagnostic aid. +#[cfg(windows)] +#[allow(unsafe_code)] // Windows token-query FFI is confined to this diagnostic helper. +fn appcontainer_sid() -> Option { + use std::ptr; + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32; + fn GetTokenInformation( + token: isize, + class: i32, + info: *mut u8, + len: u32, + ret_len: *mut u32, + ) -> i32; + fn ConvertSidToStringSidW(sid: *const u8, str_sid: *mut *mut u16) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> isize; + fn LocalFree(mem: *mut u8) -> *mut u8; + fn CloseHandle(handle: isize) -> i32; + } + + // SAFETY: output buffers remain alive through each call; successful token + // information contains a SID pointer into that buffer. The SID conversion + // returns a NUL-terminated allocation freed with LocalFree, and the owned + // token handle is closed on every return path. + unsafe { + let proc = GetCurrentProcess(); + let mut token: isize = 0; + if OpenProcessToken(proc, 0x0008, &raw mut token) == 0 { + return None; + } + let mut buf = [0u8; 256]; + let mut ret_len: u32 = 0; + let ok = GetTokenInformation(token, 31, buf.as_mut_ptr(), 256, &raw mut ret_len); + if ok == 0 { + CloseHandle(token); + return None; + } + let sid_ptr = ptr::read_unaligned(buf.as_ptr().cast::<*const u8>()); + if sid_ptr.is_null() { + CloseHandle(token); + return None; + } + let mut wide_ptr: *mut u16 = ptr::null_mut(); + if ConvertSidToStringSidW(sid_ptr, &raw mut wide_ptr) == 0 || wide_ptr.is_null() { + CloseHandle(token); + return None; + } + let mut len = 0; + while *wide_ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(wide_ptr, len); + let result = String::from_utf16_lossy(slice); + LocalFree(wide_ptr.cast()); + CloseHandle(token); + if result.is_empty() { + None + } else { + Some(result) + } + } +} + +#[cfg(not(windows))] +fn appcontainer_sid() -> Option { + None +} + +// ── Signal / relay address helpers ──────────────────────────────────────────── + +fn exe_dir() -> anyhow::Result { + Ok(std::env::current_exe()? + .parent() + .ok_or_else(|| anyhow::anyhow!("exe has no parent dir"))? + .to_path_buf()) +} + +fn signal_file_path() -> anyhow::Result { + Ok(exe_dir()?.join("openshell-shutdown.signal")) +} + +/// Read a file written by the host (ASCII, possibly with UTF-8 BOM from +/// `PowerShell` Set-Content -Encoding UTF8) and return the trimmed string. +fn read_host_file(path: &std::path::Path) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|s| s.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|s| !s.is_empty()) +} + +// ── Spawner ─────────────────────────────────────────────────────────────────── + +/// Process #1 — the sandbox `agent_command`. +/// +/// Responsibilities: +/// 1. Spawns the server subprocess and holds its stdin pipe. +/// 2. Waits for the server to bind its port (ws-server-started.txt). +/// 3. Reads reverse-relay-addr.txt and starts the relay proxy bridge. +/// 4. Polls for the shutdown signal file; on detection kills the server and exits. +/// +/// The server is a plain WebSocket application with no relay knowledge. +async fn spawner() -> anyhow::Result<()> { + let exe = std::env::current_exe()?; + let dir = exe_dir()?; + let signal = signal_file_path()?; + + // Remove stale files from a previous run. ws-server-started.txt in + // particular must go too: it encodes the server's port, and a stale copy + // would let the readiness wait below observe an old run's port instead + // of actually waiting for this run's server to (re)bind. + let _ = std::fs::remove_file(&signal); + if let Ok(dir) = exe_dir() { + let _ = std::fs::remove_file(dir.join("relay-ready.txt")); + let _ = std::fs::remove_file(dir.join("ws-server-started.txt")); + } + + // Write AppContainer SID for diagnostic use. + if let Some(sid) = appcontainer_sid() { + let _ = std::fs::write(dir.join("appcontainer-sid.txt"), &sid); + eprintln!("[spawner] AppContainer SID: {sid}"); + } else { + eprintln!("[spawner] not running in an AppContainer (no SID)"); + } + + // Spawn the server. + let mut cmd = tokio::process::Command::new(&exe); + cmd.arg("server") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + let mut child = cmd.spawn()?; + let _pipe = child.stdin.take(); // keep write-end alive + eprintln!("[spawner] server started (pid {:?})", child.id()); + + // Wait up to 30 s for the server to write its startup marker, then start + // the relay proxy. We launch the proxy in a background task so the main + // lifecycle loop can still react to shutdown signals and server exit. + let relay_addr_file = dir.join("reverse-relay-addr.txt"); + let server_marker = dir.join("ws-server-started.txt"); + + let server_ready_deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + if server_marker.exists() { + break; + } + if tokio::time::Instant::now() >= server_ready_deadline { + eprintln!("[spawner] server did not start within 30 s; relay proxy skipped"); + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + // Read the local server port from ws-server-started.txt ("port="). + let local_ws_port = read_host_file(&server_marker) + .and_then(|s| s.strip_prefix("port=").and_then(|n| n.parse::().ok())) + .unwrap_or(WS_PORT); + + // Start the relay proxy bridge if the gateway relay address is available. + let mut relay_task = read_host_file(&relay_addr_file).map_or_else( + || { + eprintln!("[spawner] no reverse-relay-addr.txt; relay proxy disabled"); + None + }, + |relay_addr| { + let local_url = format!("ws://127.0.0.1:{local_ws_port}"); + let relay_url = format!("ws://{relay_addr}"); + eprintln!("[spawner] starting relay proxy: {relay_url} <-> {local_url}"); + + let (bridge_stop_tx, bridge_stop_rx) = oneshot::channel::<()>(); + tokio::spawn(run_relay_proxy(relay_url, local_url, bridge_stop_rx)); + Some(bridge_stop_tx) + }, + ); + + // Main lifecycle loop: server exit or shutdown signal. + loop { + tokio::select! { + status = child.wait() => { + let code = status.map_or(1, |s| s.code().unwrap_or(1)); + eprintln!("[spawner] server exited with code {code}"); + let _ = std::fs::remove_file(&signal); + std::process::exit(code); + } + () = tokio::time::sleep(Duration::from_millis(500)) => { + if signal.exists() { + eprintln!("[spawner] shutdown signal -- killing server"); + // Stop the relay proxy first so the relay connection closes + // cleanly before we tear down the server. + if let Some(tx) = relay_task.take() { let _ = tx.send(()); } + let _ = child.kill().await; + let _ = child.wait().await; + let _ = std::fs::remove_file(&signal); + eprintln!("[spawner] done"); + std::process::exit(0); + } + } + } + } +} + +// ── Relay proxy bridge ──────────────────────────────────────────────────────── + +/// Connect outward to the gateway relay and inward to the local server, then +/// bridge WebSocket messages bidirectionally until either side closes or the +/// shutdown signal fires. +/// +/// The local server is a plain WebSocket application. The spawner acts as a +/// transparent proxy between the gateway relay and the local server, so the +/// server needs no knowledge of the relay. +async fn run_relay_proxy(relay_url: String, local_url: String, mut stop_rx: oneshot::Receiver<()>) { + const LOCAL_CONNECT_ATTEMPTS: u32 = 15; + const LOCAL_CONNECT_TIMEOUT: Duration = Duration::from_millis(500); + const LOCAL_CONNECT_BACKOFF: Duration = Duration::from_millis(300); + // Connect to the gateway relay (outbound via egress_proxy). + let relay_ws = match tokio_tungstenite::connect_async(&relay_url).await { + Ok((ws, _)) => { + eprintln!("[spawner] relay connected: {relay_url}"); + ws + } + Err(e) => { + let msg = format!("relay connect failed: {e}"); + eprintln!("[spawner] {msg}"); + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-debug.txt"), &msg); + } + return; + } + }; + + // Connect to the local server (AppContainer-internal loopback), with + // retries. The in-sandbox server and the spawner's own AppContainer + // network-permission state can still be settling when this fires, so the + // first attempt can race the server's listen() call or a brief + // AppContainer network-policy warmup window. Without retry, a lost race + // manifests as a ~20s OS-level connect timeout (os error 10060) rather + // than an instant refusal, because the SYN is silently dropped, not + // rejected -- so each retry attempt uses a short timeout instead of + // waiting out that OS timeout on every try. + let mut local_ws = None; + let mut last_err = String::new(); + for attempt in 1..=LOCAL_CONNECT_ATTEMPTS { + match tokio::time::timeout( + LOCAL_CONNECT_TIMEOUT, + tokio_tungstenite::connect_async(&local_url), + ) + .await + { + Ok(Ok((ws, _))) => { + eprintln!( + "[spawner] local server connected: {local_url} (attempt {attempt}/{LOCAL_CONNECT_ATTEMPTS})" + ); + local_ws = Some(ws); + break; + } + Ok(Err(e)) => last_err = e.to_string(), + Err(_) => last_err = format!("timed out after {LOCAL_CONNECT_TIMEOUT:?}"), + } + eprintln!( + "[spawner] local server connect attempt {attempt}/{LOCAL_CONNECT_ATTEMPTS} failed ({last_err}); retrying" + ); + if attempt < LOCAL_CONNECT_ATTEMPTS { + tokio::time::sleep(LOCAL_CONNECT_BACKOFF).await; + } + } + let Some(local_ws) = local_ws else { + let msg = format!( + "local server connect failed after {LOCAL_CONNECT_ATTEMPTS} attempts ({local_url}): {last_err}" + ); + eprintln!("[spawner] {msg}"); + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-debug.txt"), &msg); + } + return; + }; + + let (mut relay_write, mut relay_read) = relay_ws.split(); + let (mut local_write, mut local_read) = local_ws.split(); + + eprintln!("[spawner] relay proxy bridge active"); + + // Write a marker so the host can wait until the bridge is fully connected + // before sending the first message. + if let Ok(dir) = exe_dir() { + let _ = std::fs::write(dir.join("relay-ready.txt"), b"ok"); + } + + loop { + tokio::select! { + // Relay -> local server + msg = relay_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if local_write.send(Message::Text(t)).await.is_err() { break; } + } + Some(Ok(Message::Binary(b))) => { + if local_write.send(Message::Binary(b)).await.is_err() { break; } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[spawner] relay closed"); + break; + } + Some(Ok(_)) => {} // ping/pong + Some(Err(e)) => { + eprintln!("[spawner] relay read error: {e}"); + break; + } + }, + // Local server -> relay + msg = local_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if relay_write.send(Message::Text(t)).await.is_err() { break; } + } + Some(Ok(Message::Binary(b))) => { + if relay_write.send(Message::Binary(b)).await.is_err() { break; } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[spawner] local server closed"); + break; + } + Some(Ok(_)) => {} + Some(Err(e)) => { + eprintln!("[spawner] local server read error: {e}"); + break; + } + }, + _ = &mut stop_rx => { + eprintln!("[spawner] relay proxy stopped by shutdown"); + break; + } + } + } + + eprintln!("[spawner] relay proxy bridge exited"); +} + +// ── Server ──────────────────────────────────────────────────────────────────── + +/// Process #2 — a plain WebSocket echo server. +/// +/// This is a stand-in for any real application. It has no knowledge of any +/// relay -- launched directly as the `agent_command` `openshell-supervisor- +/// relay.exe` wraps (see mxc-ws-gateway.toml's `pc_relay_spawner_path`/ +/// `pc_relay_target_port`), the same way `OpenClaw`'s gateway is. Runs until +/// killed; there is no cooperative shutdown protocol to implement (the +/// generic spawner just kills its target on shutdown, same as any other +/// wrapped process), so this loops on `listener.accept()` alone. +async fn server() -> anyhow::Result<()> { + let listener = TcpListener::bind(("0.0.0.0", WS_PORT)).await?; + eprintln!("[server] WebSocket listening on 0.0.0.0:{WS_PORT}"); + + let active = Arc::new(AtomicUsize::new(0)); + + loop { + let (stream, addr) = listener.accept().await?; + let active2 = active.clone(); + active2.fetch_add(1, Ordering::Relaxed); + tokio::spawn(async move { + handle_connection(stream, addr).await; + active2.fetch_sub(1, Ordering::Relaxed); + }); + } +} + +// ── WebSocket connection handler ────────────────────────────────────────────── + +async fn handle_connection(stream: TcpStream, addr: SocketAddr) { + eprintln!("[server] new connection from {addr}"); + + let ws = match tokio_tungstenite::accept_async(stream).await { + Ok(ws) => ws, + Err(e) => { + eprintln!("[server] handshake failed from {addr}: {e}"); + return; + } + }; + + let (mut write, mut read) = ws.split(); + + while let Some(msg) = read.next().await { + match msg { + Ok(Message::Text(text)) => { + eprintln!("[server] {addr} recv: {text}"); + if write.send(Message::Text(text)).await.is_err() { + break; + } + } + Ok(Message::Binary(bin)) => { + if write.send(Message::Binary(bin)).await.is_err() { + break; + } + } + Ok(Message::Close(_)) => break, + Ok(_) => {} + Err(e) => { + eprintln!("[server] {addr} error: {e}"); + break; + } + } + } + + eprintln!("[server] connection from {addr} ended"); +} + +// ── proxy-for mode ──────────────────────────────────────────────────────────── + +/// Start the command listed in `agent-cmd.txt` in the share dir as a child +/// process, wait for it to accept TCP on `port`, then connect outward to the +/// gateway relay and bridge all WebSocket traffic to/from the local server. +/// +/// This lets any WebSocket server (e.g. openclaw gateway) be exposed to host +/// clients via the `OpenShell` relay without any changes to that server. +async fn proxy_for(port: u16) -> anyhow::Result<()> { + // Early sentinel: write to exe_dir so it works in any container directory. + if let Ok(exe) = std::env::current_exe() + && let Some(d) = exe.parent() + { + let _ = std::fs::write( + d.join("proxy-for-started.txt"), + format!("port={port} exe={}", exe.display()), + ); + } + + let dir = match exe_dir() { + Ok(d) => d, + Err(e) => { + let _ = std::fs::write( + "C:\\work\\openshell-mxc-openclaw\\proxy-for-error.txt", + format!("exe_dir failed: {e}"), + ); + return Err(e); + } + }; + let signal = signal_file_path()?; + let _ = std::fs::remove_file(&signal); + if let Ok(d) = exe_dir() { + let _ = std::fs::remove_file(d.join("relay-ready.txt")); + } + + // Read the command to launch from agent-cmd.txt in the share dir. + // Each line is one argument; the first line is the executable. + let cmd_file = dir.join("agent-cmd.txt"); + let mut child = if cmd_file.exists() { + let lines: Vec = std::fs::read_to_string(&cmd_file)? + .lines() + .map(|l| l.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + if lines.is_empty() { + anyhow::bail!("agent-cmd.txt is empty"); + } + let stdout_file = std::fs::File::create(dir.join("agent-stdout.txt")).ok(); + let stderr_file = std::fs::File::create(dir.join("agent-stderr.txt")).ok(); + // If agent-env.txt exists in the share dir, set the child process env + // explicitly (clear parent env, then set only those vars). This allows + // running runtimes like node.js that fail with STATUS_DLL_INIT_FAILED + // when presented with the full host env, while mxc-ws-agent itself + // (the parent) still runs with the full env it needs. + // agent-env.txt format: one KEY=VALUE per line. + let env_file = dir.join("agent-env.txt"); + let mut cmd = tokio::process::Command::new(&lines[0]); + cmd.args(&lines[1..]); + if env_file.exists() + && let Ok(content) = std::fs::read_to_string(&env_file) + { + let child_env: Vec<(String, String)> = content + .lines() + .map(|l| l.trim_start_matches('\u{FEFF}').trim().to_string()) + .filter(|l| !l.is_empty() && l.contains('=')) + .filter_map(|l| { + let pos = l.find('=')?; + Some((l[..pos].to_string(), l[pos + 1..].to_string())) + }) + .collect(); + eprintln!( + "[proxy-for] using {} child env vars from agent-env.txt", + child_env.len() + ); + cmd.env_clear().envs(child_env); + } + cmd.stdout( + stdout_file.map_or_else(std::process::Stdio::inherit, std::process::Stdio::from), + ) + .stderr(stderr_file.map_or_else(std::process::Stdio::inherit, std::process::Stdio::from)); + eprintln!("[proxy-for] starting: {}", lines.join(" ")); + Some(cmd.spawn()?) + } else { + eprintln!("[proxy-for] no agent-cmd.txt; assuming server already running on port {port}"); + None + }; + + // Wait up to 60 s for the server to accept TCP on `port`. + eprintln!("[proxy-for] waiting for server on 127.0.0.1:{port} ..."); + let deadline = tokio::time::Instant::now() + Duration::from_mins(1); + loop { + if TcpStream::connect(format!("127.0.0.1:{port}")) + .await + .is_ok() + { + eprintln!("[proxy-for] server is up on port {port}"); + break; + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!("[proxy-for] timeout waiting for server on port {port}"); + } + tokio::time::sleep(Duration::from_millis(500)).await; + // Also check for early child exit + if let Some(ref mut c) = child + && let Ok(Some(status)) = c.try_wait() + { + anyhow::bail!("[proxy-for] child exited early: {status}"); + } + } + + // Connect relay and run bridge. + let relay_addr_file = dir.join("reverse-relay-addr.txt"); + if let Some(relay_addr) = read_host_file(&relay_addr_file) { + let relay_url = format!("ws://{relay_addr}"); + let local_url = format!("ws://127.0.0.1:{port}"); + eprintln!("[proxy-for] relay bridge: {relay_url} <-> {local_url}"); + let (bridge_stop_tx, bridge_stop_rx) = oneshot::channel::<()>(); + tokio::spawn(run_relay_proxy(relay_url, local_url, bridge_stop_rx)); + + // Main lifecycle: child exit or shutdown signal. + loop { + tokio::select! { + status = async { + if let Some(ref mut c) = child { c.wait().await.ok() } else { std::future::pending().await } + } => { + eprintln!("[proxy-for] child exited: {status:?}"); + let _ = std::fs::remove_file(&signal); + break; + } + () = tokio::time::sleep(Duration::from_millis(500)) => { + if signal.exists() { + eprintln!("[proxy-for] shutdown signal -- stopping"); + let _ = bridge_stop_tx.send(()); + if let Some(ref mut c) = child { let _ = c.kill().await; let _ = c.wait().await; } + let _ = std::fs::remove_file(&signal); + break; + } + } + } + } + } else { + eprintln!("[proxy-for] no reverse-relay-addr.txt; relay bridge disabled"); + // Still manage child lifecycle. + if let Some(mut c) = child { + let _ = c.wait().await; + } + } + Ok(()) +} diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml new file mode 100644 index 0000000000..afd04a44cd --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# mxc-ws-gateway.toml - Gateway config for the WebSocket agent test. +# +# Used by run-ws-agent-test.ps1. Fields that contain host-specific paths +# (wxc_exec_path and pc_relay_spawner_path) +# are patched at test runtime by the script; the values below are +# safe-to-commit placeholders. +# +# Layout: +# sandbox command = mxc-ws-agent.exe server +# Binds WebSocket on 0.0.0.0:22000, echoes messages back. +# No relay awareness -- launched the same way OpenClaw's +# gateway is in mxc-openclaw-gateway.toml. +# +# pc_relay_spawner_path / pc_relay_target_port +# Wrap the sandbox command in openshell-supervisor-relay.exe +# instead of launching it directly (see driver.rs's +# launch handshake). This is what gives the driver a +# control channel into the sandbox, which dynamic +# forwarding depends on. +# +# Connectivity: entirely on-demand via `openshell forward service +# --target-port 22000` (ForwardSink::open_dynamic_forward / the control +# channel's "forward" op) -- there is no static/always-on bridge and no +# port pre-declared in this config beyond pc_relay_target_port's own +# startup liveness check. +# +# Shutdown sequence on `sandbox delete`: +# driver sends "shutdown" over the control channel (see driver.rs) +# openshell-supervisor-relay kills the server directly and exits +# driver also kills wxc-exec as a backstop regardless + +[openshell.drivers.mxc] +# Path to wxc-exec.exe. Patched at runtime by run-ws-agent-test.ps1. +# Leave commented for mock-mode smoke tests (pass -Mock to the script). +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" + +# One-shot AppContainer backend: genuinely default-deny at the OS level. +backend = "process_container" + +# AppContainer capability required for the server to bind a TCP socket on +# 0.0.0.0:22000. "privateNetworkClientServer" allows the sandbox to act as +# both a client and a server on private (home/work/loopback) networks. +pc_capabilities = ["privateNetworkClientServer"] + +# process_container only: keep standard privilege level (not LPA). +pc_least_privilege = false + +# Egress proxy for outbound TCP connectivity -- required for +# openshell-supervisor-relay to dial out to the driver's on-demand relay +# (see mxc-openclaw-gateway.toml, which uses the same pattern). +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" + +# The workload command and cwd are supplied per sandbox by +# run-ws-agent-test.ps1 through `sandbox create --driver-config-json`. + +# Launch the per-sandbox command via the generic openshell-supervisor-relay binary. +# Patched at runtime by run-ws-agent-test.ps1. +pc_relay_spawner_path = "C:/work/openshell-mxc-ws/openshell-supervisor-relay.exe" +pc_relay_target_port = 22000 + +# Enable for verbose wxc-exec output during debugging. +debug = true diff --git a/crates/openshell-driver-mxc/examples/openclaw-capture.mjs b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs new file mode 100644 index 0000000000..eb32f624c0 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/openclaw-capture.mjs @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// openclaw-capture.mjs - generic launcher/log-capture shim for running an +// arbitrary Node.js entry point as the per-sandbox command inside an MXC +// ProcessContainer sandbox. +// +// This is OpenShell's own adapter code, not part of OpenClaw -- it contains +// no OpenClaw-specific logic. It exists because the sandboxed process's own +// stdout/stderr are piped (not inherited) by openshell-supervisor-relay (see +// pc_relay_spawner_path), which forwards them to the gateway log tagged +// "[target stdout]"/"[target stderr]" -- but a durable on-disk log inside +// share_dir is also useful for post-hoc debugging without re-running. +// +// Required env vars (set via `openshell sandbox create --env`): +// NEMOCLAW_MXC_CAPTURE_ENTRY absolute path to the real entry .mjs to run +// (e.g. /openclaw.mjs) +// NEMOCLAW_MXC_CAPTURE_LOG absolute path to append captured output to +// +// Usage: node openclaw-capture.mjs +// Equivalent to: node , except stdout +// and stderr are also appended to NEMOCLAW_MXC_CAPTURE_LOG as they're written. + +import fs, { appendFileSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { createConnection } from "node:net"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +// Node's promises realpath implementation uses a native Windows binding that +// requests privileges unavailable to AppContainer tokens. The callback +// implementation has the same realpath contract without those privileges. +// Patch before importing OpenClaw so node:fs/promises consumers see it too. +if (process.platform === "win32") { + fs.promises.realpath = promisify(fs.realpath); + syncBuiltinESMExports(); +} + +const required = (name) => { + const value = process.env[name]; + if (!value) throw new Error(name + " is required"); + return value; +}; + +const entry = required("NEMOCLAW_MXC_CAPTURE_ENTRY"); +const logPath = required("NEMOCLAW_MXC_CAPTURE_LOG"); +const selfProbePort = process.env.NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT; + +const append = (label, value) => { + try { + appendFileSync(logPath, "[" + label + "] " + String(value), "utf8"); + } catch { + // best-effort; never let logging failure take down the wrapped process + } +}; + +const connectProbe = (host, port, timeoutMs = 5000) => + new Promise((resolve) => { + let settled = false; + const socket = createConnection({ host, port: Number(port) }); + const finish = (connected, detail) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + socket.destroy(); + resolve({ connected, detail }); + }; + const deadline = setTimeout(() => finish(false, "timeout"), timeoutMs); + socket.once("connect", () => finish(true, "connected")); + socket.once("error", (error) => + finish(false, String(error?.code || error?.message || error)), + ); + }); + +const fetchProbe = async (url, timeoutMs = 15000) => { + try { + const response = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + await response.body?.cancel(); + return { connected: true, detail: "status=" + response.status }; + } catch (error) { + return { + connected: false, + detail: String(error?.cause?.code || error?.code || error?.message || error), + }; + } +}; + +const runEgressProof = async () => { + if (process.env.NEMOCLAW_MXC_EGRESS_PROOF !== "1") return; + const result = { + proxyConfigured: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy), + allowedViaProxy: await fetchProbe(required("NEMOCLAW_MXC_EGRESS_ALLOWED_URL")), + deniedViaProxy: await fetchProbe(required("NEMOCLAW_MXC_EGRESS_DENIED_URL")), + directInternetBypass: await connectProbe( + required("NEMOCLAW_MXC_EGRESS_DIRECT_HOST"), + 443, + ), + unrelatedHostLoopback: await connectProbe( + "127.0.0.1", + required("NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT"), + ), + }; + append("egress-proof", JSON.stringify(result) + "\n"); + console.log("[egress-proof] " + JSON.stringify(result)); +}; + +let selfProbeStarted = false; +let readinessWindow = ""; + +const startSelfProbe = () => { + if (selfProbeStarted || !selfProbePort) return; + selfProbeStarted = true; + append("self-probe-attempt", "started\n"); + + const port = Number(selfProbePort); + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + append("self-probe", "invalid_port\n"); + return; + } + + const maxAttempts = 6; + let attempt = 0; + const probe = () => { + attempt += 1; + let responseBytes = 0; + let settled = false; + const socket = createConnection({ host: "127.0.0.1", port }); + const finish = (outcome, errorCode = "none") => { + if (settled) return; + settled = true; + clearTimeout(deadline); + socket.destroy(); + if (responseBytes > 0 || attempt >= maxAttempts) { + append( + "self-probe", + `outcome=${responseBytes > 0 ? "response" : outcome} response_bytes=${responseBytes} attempts=${attempt} error_code=${errorCode}\n`, + ); + } else { + setTimeout(probe, 1000); + } + }; + const deadline = setTimeout(() => finish("timeout"), 2000); + + socket.once("connect", () => { + // This is the same protocol boundary exercised by the host-side health + // client, but it intentionally sends no token or other credential. Any + // HTTP or WebSocket response proves the target can service a connection + // from inside the ProcessContainer; payload content is never recorded. + socket.write( + `GET / HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: b3BlbnNoZWxsLW14YyE=\r\nSec-WebSocket-Version: 13\r\n\r\n`, + ); + }); + socket.on("data", (chunk) => { + responseBytes += chunk.length; + finish("response"); + }); + socket.once("error", (error) => + finish("error", String(error?.code || "unknown").replace(/[^A-Z0-9_-]/gi, "_")), + ); + socket.once("close", () => { + if (!settled) finish(responseBytes > 0 ? "response" : "closed"); + }); + }; + probe(); +}; + +const wrap = (stream, label) => { + const original = stream.write.bind(stream); + stream.write = (chunk, encoding, callback) => { + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + append(label, text); + // Logging libraries may split one rendered line across multiple writes + // (and may insert ANSI sequences), so detect readiness across chunk + // boundaries rather than requiring one exact write call. + readinessWindow = (readinessWindow + text).slice(-512); + if (/\[gateway\][\s\S]{0,256}ready/.test(readinessWindow)) startSelfProbe(); + return original(chunk, encoding, callback); + }; +}; + +wrap(process.stdout, "stdout"); +wrap(process.stderr, "stderr"); +append("self-probe", selfProbePort ? "configured\n" : "disabled\n"); +if (selfProbePort) { + // Readiness normally triggers the probe immediately. Keep a delayed + // fallback because some logging stacks bypass or split stdout writes in a + // way the wrapper cannot observe reliably. + setTimeout(startSelfProbe, 10000); +} +process.on("uncaughtExceptionMonitor", (error) => + append("uncaught", String(error?.stack || error) + "\n"), +); +process.on("unhandledRejection", (error) => + append("rejection", String(error?.stack || error) + "\n"), +); + +process.argv = [process.execPath, entry, ...process.argv.slice(2)]; +await runEgressProof(); +await import(pathToFileURL(entry).href); diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 46536ff4a3..0cefca119b 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -146,6 +146,7 @@ $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 @@ -515,6 +516,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" @@ -547,7 +561,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)" @@ -563,7 +578,7 @@ try { try { $createResult = Invoke-NativeCaptured $cli @( "sandbox", "create", "--name", $sandboxName, - "--policy", [string]$sc.PolicyFile, + "--policy", [string]$policyUsed, "--driver-config-json", $driverConfig, "--no-tty" ) diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 new file mode 100644 index 0000000000..6935b68754 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -0,0 +1,722 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-openclaw-forward-test.ps1 - OpenClaw-over-MXC test (both backends). +# +# Proves the full path this package exists to demonstrate: +# gateway -> MXC driver -> ProcessContainer OR isolation_session sandbox +# (neither has an in-sandbox supervisor process; ProcessContainer also +# has no inbound network capability at all) +# -> openshell-supervisor-relay launches OpenClaw's gateway inside it +# -> `openshell forward service --target-port 18889` opens a per-request, +# on-demand WebSocket relay (bound fresh for this call, torn down when +# it ends -- there is no always-on bridge) +# -> a real OpenClaw client (`openclaw gateway health`) on the HOST, +# talking through that forwarded port, authenticates and gets a real +# response. +# +# -Backend selects which MXC backend to exercise (default: process_container). +# Both go through the exact same dynamic-forward/control-channel code path in +# the driver -- spawner wrapping is computed before the backend branch, so +# nothing about openshell-supervisor-relay or the relay protocol differs. +# What DOES differ is the config: isolation_session merges the sandbox env onto the +# full host environment (no pc_minimal_env / LOCALAPPDATA workaround needed) +# and ignores ProcessContainer-only fields like pc_capabilities entirely -- +# see mxc-openclaw-isolation.toml's own comments. +# +# This test brings its OWN OpenClaw install (node.exe + the openclaw npm +# package) rather than shipping one: point -NodeExePath and +# -OpenClawInstallDir at your existing install. The AppContainer here can +# only read paths under share_dir, so this script STAGES (copies) your +# node.exe, the openclaw package, and this package's own +# openclaw-capture.mjs / openshell-supervisor-relay.exe into share_dir before +# creating the sandbox -- see the "Stage artifacts" step below. The OpenClaw +# package can be large (native-addon plugins etc.); the copy uses robocopy +# and only re-copies changed files on a rerun. +# +# Run from inside the package folder (gateway + cli + openshell-supervisor- +# relay.exe + mxc-openclaw-gateway.toml + openclaw-gateway.yaml + +# openclaw-capture.mjs + this script all sit together): +# +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-openclaw-forward-test.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe ` +# -NodeExePath C:\path\to\node.exe ` +# -OpenClawInstallDir C:\path\to\node_modules\openclaw + +[CmdletBinding()] +param( + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + # Your existing Node.js binary. Copied (not run in place) into share_dir -- + # the AppContainer cannot read paths outside it. + [Parameter(Mandatory = $true)] + [string] $NodeExePath, + # Root directory of your OpenClaw npm package install -- the directory that + # directly contains openclaw.mjs and its own node_modules. Copied + # (recursively, via robocopy) into share_dir\runtime\node_modules\openclaw. + [Parameter(Mandatory = $true)] + [string] $OpenClawInstallDir, + # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw, not + # C:\work\openshell-openclaw). Node's CommonJS module resolver calls + # fs.realpathSync while resolving the entry script, which lstat()s every + # parent directory up the chain -- including ones OUTSIDE share_dir. The + # AppContainer only grants share_dir itself, so an intermediate parent like + # C:\work fails with EPERM (confirmed empirically: this exact test failed + # with "EPERM: operation not permitted, lstat 'C:\work'" until the share + # dir was moved to the drive root). The drive root itself (C:\) apparently + # doesn't need an explicit grant to lstat successfully, so a one-level path + # sidesteps the problem entirely. + [string] $ShareDir = "C:\openshell-openclaw", + [int] $TargetPort = 18889, + [int] $ForwardLocalPort = 28889, + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-openclaw", + [string] $GatewayToken = "openshell-mxc-test-token", + # Sandbox name. Default is UNIQUE per run (openclaw-$PID): ProcessContainer + # sandboxes are one-shot, but a leftover from a killed prior run can still + # collide with `sandbox create` by name. Not backend-suffixed: sandbox + # names are capped at 19 chars (observed: "name exceeds maximum length (20 + # > 19)"), and "openclaw-$PID" alone is already close to that budget. + [string] $SandboxName = "", + [switch] $KeepRunning, + # Which MXC backend to exercise. process_container: one-shot AppContainer, + # no inbound network capability, needs pc_minimal_env's curated sandbox env + # (mxc-openclaw-gateway.toml). isolation_session: persistent + # provision/start/exec session, merges the sandbox env onto the full host env, + # ignores ProcessContainer-only fields (mxc-openclaw-isolation.toml). + [ValidateSet("process_container", "isolation_session")] + [string] $Backend = "process_container", + # Use mxc-openclaw-localnet.toml (pc_allow_local_network=true) instead of + # mxc-openclaw-gateway.toml (egress_proxy=true), to test whether traffic + # through the egress_proxy shim was responsible for a data-plane failure + # seen on one corp-managed machine (clean TCP connect + WS handshake, then + # silently dropped bytes). VERIFIED BROKEN as an escape hatch on the + # currently-used wxc-exec build, though: pc_allow_local_network does not + # actually let the sandbox reach the gateway's relay at all here -- + # `relay connect failed: ... actively refused it (os error 10061)` on + # every attempt, a hard connectivity failure, not the subtler data-drop + # this switch was meant to test around. Left in for whoever investigates + # next (a different wxc-exec build may behave differently), but don't + # expect it to work today. + [switch] $UseLocalNetwork +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +# The OpenShell CLI emits UTF-8 (status glyphs like Ok/× and checkmarks). PowerShell +# decodes captured native-command output using [Console]::OutputEncoding; if that is a +# legacy OEM code page the glyphs render as mojibake. Force UTF-8. +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +if ([string]::IsNullOrWhiteSpace($SandboxName)) { + $SandboxName = "openclaw-$PID" +} + +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-openclaw-forward-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } + +# ProcessContainer teardown on this box has been observed to leave the +# sandboxed node.exe (and occasionally openshell-supervisor-relay.exe) +# running for a few seconds after `sandbox delete` returns success -- long +# enough to still hold a lock on share_dir\node.exe when the NEXT run tries +# to re-stage it. Retry with backoff rather than failing outright, since +# "run this script again right after the last run" is a completely normal +# thing to do. +function Copy-ItemRetry([string]$src, [string]$dst, [int]$attempts = 10, [int]$delayMs = 1000) { + for ($i = 1; $i -le $attempts; $i++) { + try { Copy-Item $src $dst -Force; return } catch { + if ($i -eq $attempts) { throw } + Info "copy '$dst' locked (attempt $i/$attempts): $($_.Exception.Message) -- retrying in $($delayMs)ms" + Start-Sleep -Milliseconds $delayMs + } + } +} +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +function Grant-AppContainerWritableDirectory([string]$Path) { + # AppContainer access is a dual check: the generated package SID grant from + # MXC is necessary, but OpenClaw's SQLite staging also needs the two built-in + # application-package group SIDs. Scope inherited Modify access to the + # disposable writable data directories; never grant it to staged binaries. + & "$env:SystemRoot\System32\icacls.exe" $Path /grant ` + '*S-1-15-2-1:(OI)(CI)(M)' ` + '*S-1-15-2-2:(OI)(CI)(M)' /T /C /Q | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "failed to prepare AppContainer DACL for '$Path'" + } +} + +# Present the EXPECTED-on-MXC `sandbox create` outcomes as information rather +# than raw CLI error text -- see run-ollama-test.ps1 for the same pattern and +# rationale (ProcessContainer has no in-sandbox shell to attach to; a +# leftover sandbox from a prior run is cleared and recreated). + +# Returns $true when $out's content matches one of the known-benign +# MXC `sandbox create` patterns (post-create attach skipped / stale sandbox +# recreated), $false when it contains anything else -- the caller uses this +# plus the exit code to decide whether to stop instead of silently sailing +# into a 90s readiness wait that can only time out uninformatively. +function Show-SandboxCreate([object]$out, [string]$name) { + $lines = @($out | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($lines.Count -eq 0) { return $true } + $attachPat = '(?i)cannot exec in sandbox|no in-sandbox supervisor|has no interactive shell|supervisor session not connected|ssh exited with status' + $existsPat = '(?i)already exists|delete it first' + $joined = $lines -join "`n" + $hasOther = @($lines | Where-Object { $_ -notmatch $attachPat -and $_ -notmatch $existsPat }).Count -gt 0 + if ($joined -match $attachPat -and -not $hasOther) { + Info "sandbox '$name' created; agent ran in-driver. ProcessContainer has no in-sandbox shell, so the post-create attach was skipped (expected, not an error)." + return $true + } elseif ($joined -match $existsPat -and -not $hasOther) { + Info "sandbox '$name': a leftover from a prior run was cleared and recreated (expected, not an error)." + return $true + } else { + $lines | ForEach-Object { Info $_ } + return -not $hasOther + } +} + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$relayExe = Join-Path $here "openshell-supervisor-relay.exe" +$policy = Join-Path $here "e2e-policies\openclaw-gateway.yaml" +if ($UseLocalNetwork -and $Backend -eq "isolation_session") { + throw "-UseLocalNetwork only applies to -Backend process_container (it swaps in pc_allow_local_network, a ProcessContainer-only field; isolation_session already has default-allow egress and needs no such override)." +} +$tomlName = switch ($Backend) { + "isolation_session" { "mxc-openclaw-isolation.toml" } + default { if ($UseLocalNetwork) { "mxc-openclaw-localnet.toml" } else { "mxc-openclaw-gateway.toml" } } +} +$tomlBaseName = [System.IO.Path]::GetFileNameWithoutExtension($tomlName) +$toml = Join-Path $here $tomlName +$captureScript = Join-Path $here "openclaw-capture.mjs" + +$shareDirNorm = $ShareDir.TrimEnd('\','/').Replace('/', '\') +# Must be a direct child of a drive root (see the -ShareDir param doc for +# why: Node's module resolver lstat()s ungranted parent dirs otherwise). +# Enforced here too so a bad path fails fast instead of silently breaking +# node's resolver deep into the run, or -- worse -- widening the stale- +# process prefix match below to something unexpectedly shallow. +if ($shareDirNorm -notmatch '^[A-Za-z]:\\[^\\]+$') { + throw "ShareDir must be a direct child of a drive root (e.g. C:\openshell-openclaw), got '$shareDirNorm'" +} +$openClawStageDir = Join-Path $shareDirNorm "runtime\node_modules\openclaw" + +$gw = $null +$gwLog = Join-Path $resultDir "gateway.log" +$gwErrLog = Join-Path $resultDir "gateway.err.log" +$fwdProc = $null +$fwdLog = Join-Path $resultDir "forward.log" +$fwdErrLog = Join-Path $resultDir "forward.err.log" +$passed = $false +$healthJson = $null +$selfProbeOutcome = "not-recorded" +$selfProbeResponseBytes = 0 + +try { + # 1. Validate package artifacts + caller-supplied paths. + Step "Validate artifacts" + Info "backend: $Backend -- network mode: $(if ($UseLocalNetwork) { 'pc_allow_local_network (bypasses egress_proxy for the relay hop)' } else { 'default' }) -- config: $tomlName" + foreach ($f in @($gateway, $cli, $relayExe, $policy, $toml, $captureScript)) { + if (-not (Test-Path $f)) { throw "missing artifact: $f (run from inside the package folder)" } + Info "found $(Split-Path $f -Leaf)" + } + if (-not (Test-Path $WxcExecPath)) { throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath." } + if (-not (Test-Path $NodeExePath)) { throw "node.exe not found at '$NodeExePath'. Pass -NodeExePath." } + if (-not (Test-Path $OpenClawInstallDir)) { throw "OpenClaw install dir not found at '$OpenClawInstallDir'. Pass -OpenClawInstallDir." } + $openClawEntry = Join-Path $OpenClawInstallDir "openclaw.mjs" + if (-not (Test-Path $openClawEntry)) { throw "expected an OpenClaw entry point at '$openClawEntry' -- is -OpenClawInstallDir the package root (the dir containing openclaw.mjs)?" } + Info "machine : $env:COMPUTERNAME user: $env:USERNAME PS: $($PSVersionTable.PSVersion)" + + # 2. Patch a DISPOSABLE copy of the TOML in the results dir (never mutate the + # tracked source config in place). + Step "Patch gateway config (disposable copy)" + $tomlText = Get-Content $toml -Raw + $escaped = $WxcExecPath.Replace('\', '\\') + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', "wxc_exec_path = `"$escaped`"") + # The shipped TOMLs hardcode the default share dir only in the relay spawner + # path. Workload command/cwd/env are supplied per sandbox below. + $defaultShareDirToml = "C:/openshell-openclaw" + $shareDirToml = $shareDirNorm.Replace('\', '/') + if ($shareDirToml -ne $defaultShareDirToml) { + $tomlText = $tomlText.Replace($defaultShareDirToml, $shareDirToml) + } + $tomlUsed = Join-Path $resultDir "${tomlBaseName}.used.toml" + Set-Content $tomlUsed -Value $tomlText -Encoding UTF8 + # The policy's read_write grant is the only source of filesystem access + # now (the driver no longer adds share_dir automatically) -- it hardcodes + # the same default share dir literal as the TOML, so it needs the same + # -ShareDir substitution, or an overridden share_dir loses its grant + # entirely and every sandboxed file access fails closed. + $policyText = Get-Content $policy -Raw + if ($shareDirToml -ne $defaultShareDirToml) { + $policyText = $policyText.Replace($defaultShareDirToml, $shareDirToml) + } + if ($Backend -eq "isolation_session") { + # isolation_session advertises no UI-policy support, so the gateway + # rejects an explicit `ui:` section before provisioning even starts + # (see README.md's Capability Matrix). Strip it from this backend's + # disposable copy -- process_container is the only backend that needs + # it (Node.js touches user32/gdi32 at startup even though it never + # opens a window). + $policyText = [regex]::Replace($policyText, '(?ms)^ui:\r?\n(?:^[ \t].*\r?\n?)*', '') + } + $policyUsed = Join-Path $resultDir "openclaw-gateway.used.yaml" + Set-Content $policyUsed -Value $policyText -Encoding UTF8 + + # 3. Port free (auto-clear our own stale gateway). + Step "Check gateway port $Port is free" + $busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue + if ($busy) { + $owner = Get-Process -Id $busy.OwningProcess -ErrorAction SilentlyContinue + if ($owner -and $owner.Name -eq "openshell-gateway") { + Info "stopping stale gateway pid $($owner.Id)"; Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue; Start-Sleep 2 + } else { throw "port $Port in use by '$($owner.Name)' (pid $($busy.OwningProcess))" } + } + Ok "port $Port free" + + # 4. Stage artifacts into share_dir. The AppContainer here has a read-write + # grant on share_dir ONLY (see openclaw-gateway.yaml) -- no read-only + # grants on arbitrary host paths -- so node.exe, this package's + # openclaw-capture.mjs and openshell-supervisor-relay.exe, and your + # OpenClaw install must all physically live under share_dir. + Step "Stage artifacts into share_dir ($shareDirNorm)" + # A prior run's sandboxed processes can outlive `sandbox delete` by more + # than a few seconds -- sometimes indefinitely, if that run's own teardown + # hit a transport error talking to an already-stopped gateway. Rather than + # retry a locked copy indefinitely, find and kill anything still running + # out of share_dir before touching it. Copy-ItemRetry (below) remains as a + # short-window fallback for the ordinary "just exited, handle not released + # yet" case. + # Trailing separator anchors the match to "inside $shareDirNorm", not just + # "starts with the same characters" -- without it, a sibling directory like + # C:\openshell-openclaw-old would also match C:\openshell-openclaw. + $shareDirPrefix = $shareDirNorm.TrimEnd('\') + '\' + $stale = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($shareDirPrefix, [System.StringComparison]::OrdinalIgnoreCase) } + foreach ($p in $stale) { + Info "killing stale process from a prior run: $($p.ProcessName) (pid $($p.Id), $($p.Path))" + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + if ($stale) { Start-Sleep -Seconds 1 } + + New-Item -ItemType Directory -Force $shareDirNorm | Out-Null + New-Item -ItemType Directory -Force (Join-Path $shareDirNorm "home") | Out-Null + New-Item -ItemType Directory -Force (Join-Path $shareDirNorm "temp") | Out-Null + New-Item -ItemType Directory -Force (Join-Path $shareDirNorm "local") | Out-Null + Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm "home") + Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm "temp") + Remove-Item (Join-Path $shareDirNorm "openclaw-capture.log") -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $shareDirNorm "openshell-shutdown.signal") -Force -ErrorAction SilentlyContinue + + Copy-ItemRetry $NodeExePath (Join-Path $shareDirNorm "node.exe") + Info "staged node.exe" + Copy-ItemRetry $captureScript (Join-Path $shareDirNorm "openclaw-capture.mjs") + Info "staged openclaw-capture.mjs" + Copy-ItemRetry $relayExe (Join-Path $shareDirNorm "openshell-supervisor-relay.exe") + Info "staged openshell-supervisor-relay.exe" + + New-Item -ItemType Directory -Force $openClawStageDir | Out-Null + # /MIR deletes files in the destination not present in the source, which + # is what we want on a rerun after an OpenClaw upgrade/rollback -- without + # it, /E alone can leave a mixed tree from multiple versions, making + # failures hard to reproduce. Safe here because $openClawStageDir is + # computed from $shareDirNorm, already validated above (a direct child of + # a drive root, not user-arbitrary), not a path this script accepts raw. + $roboArgs = @($OpenClawInstallDir, $openClawStageDir, "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NP", "/R:2", "/W:1") + $roboOut = & robocopy.exe @roboArgs 2>&1 + # robocopy exit codes 0-7 are all "success" (bit flags for copied/skipped/ + # mismatched files); only >= 8 indicates a real failure. + if ($LASTEXITCODE -ge 8) { throw "robocopy failed staging OpenClaw install (exit $LASTEXITCODE): $($roboOut -join ' ')" } + Info "staged OpenClaw install ($OpenClawInstallDir -> $openClawStageDir, robocopy exit $LASTEXITCODE)" + Ok "share_dir staged" + + # 5. Gateway env: config path via env var (clap: OPENSHELL_GATEWAY_CONFIG), + # NOT a --config token -- Start-Process -ArgumentList does not quote + # array elements, so a config path containing a space gets split and the + # gateway's arg parser rejects it. OPENCLAW_GATEWAY_TOKEN is passed with + # sandbox create --env-from below, so setting it here gives the sandboxed + # OpenClaw a stable, known token without placing it in argv. + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENCLAW_GATEWAY_TOKEN = $GatewayToken + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + + # 6. Start gateway. + Step "Start gateway" + $gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--log-level", "info", "--port", "$Port") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + Info "gateway pid $($gw.Id)" + $deadline = (Get-Date).AddSeconds(30); $ready = $false + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ }; throw "gateway exited early (code $($gw.ExitCode))" } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { $ready = $true; break } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "gateway did not start listening on $Port within 30s" } + Ok "gateway listening on 127.0.0.1:$Port" + + # 7. Register CLI -> gateway. See run-ollama-test.ps1 for why EAP is + # dropped to 'Continue' around these calls (the CLI writes success + # banners to stderr too, which $ErrorActionPreference='Stop' would + # otherwise turn into terminating errors on Windows PowerShell 5.1). + Step "Register CLI -> gateway" + Remove-Item Env:OPENSHELL_GATEWAY -ErrorAction SilentlyContinue + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $expectedEndpoint = "http://127.0.0.1:$Port" + & $cli gateway add $expectedEndpoint --local --name $GatewayName 2>&1 | ForEach-Object { Info "$_" } + if ($LASTEXITCODE -ne 0) { + # Most likely "$GatewayName already registered" from a prior run. Don't + # just continue and select it blindly -- if it points at a stale URL + # (e.g. a different port from an earlier run), the rest of this script + # would create sandboxes and forward against the wrong gateway process. + # Verify the existing registration's endpoint actually matches this + # run's port; re-point the alias if it doesn't. + Info "gateway add exit $LASTEXITCODE -- '$GatewayName' likely already registered; verifying its endpoint matches this run" + $existingEndpoint = $null + try { + $listJson = & $cli gateway list -o json 2>&1 + # Split from the filter below (rather than one chained pipeline) -- + # piping ConvertFrom-Json's array output directly into Where-Object + # in the same pipeline expression does not filter correctly here. + $gateways = $listJson | ConvertFrom-Json + $existingEndpoint = ($gateways | Where-Object { $_.name -eq $GatewayName } | Select-Object -First 1).endpoint + } catch { + Info "could not parse 'gateway list -o json' output ($($_.Exception.Message)); treating as a mismatch" + } + if ($existingEndpoint -ne $expectedEndpoint) { + Info "'$GatewayName' is missing or points at '$existingEndpoint' (expected '$expectedEndpoint') -- removing and re-adding" + & $cli gateway remove $GatewayName 2>&1 | ForEach-Object { Info "$_" } + & $cli gateway add $expectedEndpoint --local --name $GatewayName 2>&1 | ForEach-Object { Info "$_" } + if ($LASTEXITCODE -ne 0) { throw "gateway add failed after removing stale alias '$GatewayName' (exit $LASTEXITCODE)" } + } else { + Info "'$GatewayName' already points at '$expectedEndpoint' -- reusing" + } + } + & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info "$_" } + $selExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $prevEAP + } + if ($selExit -ne 0) { throw "gateway select failed (exit $selExit); cannot guarantee the correct gateway context." } + + # 8. Create sandbox. Best-effort clear of any leftover sandbox of this name + # first (see run-ollama-test.ps1 for the same defensive pattern). + Step "Create sandbox '$SandboxName' (runs OpenClaw's gateway inside a ProcessContainer)" + $delOut = ""; $delCode = 0 + try { $delOut = (& $cli sandbox delete $SandboxName 2>&1 | Out-String).Trim(); $delCode = $LASTEXITCODE } + catch { $delOut = "$($_.Exception.Message)"; $delCode = 1 } + if ($delCode -ne 0) { + if ($delOut -match '(?i)not found') { Info "no leftover sandbox '$SandboxName' to remove (expected on a clean run)" } + elseif ($delOut) { Info "sandbox pre-delete '$SandboxName': $delOut (continuing)" } + else { Info "sandbox pre-delete '$SandboxName': delete exited $delCode (continuing)" } + } + $driverConfigJson = @{ + mxc = @{ + command = @( + "$shareDirToml/node.exe", + "$shareDirToml/openclaw-capture.mjs", + "gateway", "run", "--dev", "--allow-unconfigured", + "--auth", "token", "--bind", "loopback", "--port", "$TargetPort" + ) + cwd = $shareDirToml + } + } | ConvertTo-Json -Compress -Depth 5 + $createArgs = @( + "sandbox", "create", "--name", $SandboxName, "--policy", $policyUsed, + "--driver-config-json", $driverConfigJson, + "--env-from", "SYSTEMROOT", "--env-from", "WINDIR", + "--env-from", "PATH", "--env-from", "COMSPEC", + "--env-from", "OPENCLAW_GATEWAY_TOKEN", + "--env", "OPENCLAW_NO_UPDATE_CHECK=1", + "--env", "NO_UPDATE_NOTIFIER=1", + "--env", "LOCALAPPDATA=$shareDirToml/local", + "--env", "HOME=$shareDirToml/home", + "--env", "USERPROFILE=$shareDirToml/home", + "--env", "TEMP=$shareDirToml/temp", "--env", "TMP=$shareDirToml/temp", + "--env", "NEMOCLAW_MXC_CAPTURE_ENTRY=$shareDirToml/runtime/node_modules/openclaw/openclaw.mjs", + "--env", "NEMOCLAW_MXC_CAPTURE_LOG=$shareDirToml/openclaw-capture.log", + "--env", "NEMOCLAW_MXC_CAPTURE_SELF_PROBE_PORT=$TargetPort", + "--env", "NODE_OPTIONS=--use-env-proxy", + "--env", "NEMOCLAW_MXC_EGRESS_PROOF=1", + "--env", "NEMOCLAW_MXC_EGRESS_ALLOWED_URL=https://example.com/", + "--env", "NEMOCLAW_MXC_EGRESS_DENIED_URL=https://example.org/", + "--env", "NEMOCLAW_MXC_EGRESS_DIRECT_HOST=1.1.1.1", + "--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999", + "--no-tty", "--", "exit" + ) + try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE } + catch { $createOut = $_.Exception.Message; $createCode = 1 } + $createBenign = Show-SandboxCreate $createOut $SandboxName + if ($createCode -ne 0 -and -not $createBenign) { + throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)" + } + + # 9. Wait for OpenClaw's gateway to report ready, by tailing the gateway's + # own log for the line it prints on successful startup (forwarded from + # the sandbox's stdout via "wxc-exec stdout:"). Generous timeout: Node + # startup + AppContainer/UAC elevation + plugin warmup can take a while + # on a cold run. + Step "Wait for OpenClaw gateway readiness" + $readyDeadline = (Get-Date).AddSeconds(90) + $openclawReady = $false + while ((Get-Date) -lt $readyDeadline) { + if (Test-Path $gwLog) { + # `.*` (not `\s+`) between "[gateway]" and "ready": OpenClaw wraps its + # log lines in ANSI color codes whenever it inherits enough of the host + # env to detect a color-capable terminal -- which happens with + # mxc-openclaw-localnet.toml (-UseLocalNetwork), since that config + # doesn't set pc_minimal_env and so inherits the full host env, unlike + # mxc-openclaw-gateway.toml's curated minimal set. A strict \s+ match + # missed this entirely and timed out waiting for a line that had + # already printed. Those codes render in this log as LITERAL backslash- + # escaped text (e.g. "...\x1b[36mready..."), not real ESC bytes -- so + # "m" from "36m" directly abuts "ready" with no word boundary, which is + # why a \bready\b tightening (tried once) also failed to match; a bare + # substring check is what actually works here. The resulting collision + # risk with "already" is theoretical -- no such line has been observed + # on this "[gateway]"-tagged forwarded-stdout path in practice. + if (Select-String -Path $gwLog -Pattern '\[gateway\].*ready' -Quiet -ErrorAction SilentlyContinue) { $openclawReady = $true; break } + } + Start-Sleep -Seconds 2 + } + if (-not $openclawReady) { throw "OpenClaw did not report ready within 90s (see gateway.log in the results bundle)" } + Ok "OpenClaw gateway ready" + + # 10. openshell forward service: opens a fresh, on-demand relay for this + # one call and bridges TargetPort (inside the sandbox) to + # ForwardLocalPort (on this host). No port needs to be pre-declared + # anywhere except pc_relay_target_port's startup liveness check. + Step "openshell forward service --target-port $TargetPort --local $ForwardLocalPort" + $fwdProc = Start-Process -FilePath $cli ` + -ArgumentList @("forward", "service", "--target-port", "$TargetPort", "--local", "$ForwardLocalPort", $SandboxName) ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $fwdLog -RedirectStandardError $fwdErrLog + Info "forward pid $($fwdProc.Id)" + $fwdDeadline = (Get-Date).AddSeconds(20); $fwdUp = $false + while ((Get-Date) -lt $fwdDeadline) { + if ($fwdProc.HasExited) { throw "forward process exited early (code $($fwdProc.ExitCode)); see forward.log/forward.err.log" } + if ((Test-Path $fwdLog) -and (Select-String -Path $fwdLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + if ((Test-Path $fwdErrLog) -and (Select-String -Path $fwdErrLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + Start-Sleep -Milliseconds 500 + } + if (-not $fwdUp) { throw "forward did not report 'Forwarding ...' within 20s; see forward.log/forward.err.log" } + Ok "forward active: 127.0.0.1:$ForwardLocalPort -> sandbox:$TargetPort" + + # 11. Real OpenClaw client, on the HOST, through the forwarded port. This + # is the actual end-to-end proof: authenticate + get a real response + # from the sandboxed gateway via the relay, exactly as an external + # client would use `openshell forward service` in practice. + # + # Retried: OpenClaw's own log declares a startup-grace window ("[health- + # monitor] started (interval: 300s, startup-grace: 60s, ...)") after + # printing "[gateway] ready" -- on a slower/more heavily-loaded machine + # (observed on a domain-joined box with corporate AV/EDR) it can still + # be settling internally for longer than that, and a request landing in + # that window gets silently dropped with ZERO trace in any log (not a + # WS close, not an error -- the client's own 10s timeout just fires). + # Each attempt already blocks for up to 10s on failure, so a handful of + # attempts comfortably covers the declared 60s grace without a fixed + # sleep that would either undershoot on a slow box or waste time on a + # fast one. + Step "OpenClaw client: gateway health via the forwarded port" + $healthArgs = @($openClawEntry, "gateway", "health", "--port", "$ForwardLocalPort", "--token", $GatewayToken, "--json") + # Isolate the 2026.7.1 host client from any newer ~/.openclaw schema/state. + $savedOpenClawConfigPath = $env:OPENCLAW_CONFIG_PATH + $savedOpenClawStateDir = $env:OPENCLAW_STATE_DIR + $cleanOpenClawStateDir = Join-Path $ShareDir "home\.openclaw" + try { + $env:OPENCLAW_CONFIG_PATH = Join-Path $cleanOpenClawStateDir "openclaw.json" + $env:OPENCLAW_STATE_DIR = $cleanOpenClawStateDir + # Bumped from 6 -> 14 (2026-09-10): on this box OpenClaw's actual startup + # (port bind -> SQLite agent-db open -> HTTP server listening -> "ready") + # measured ~90s wall clock, longer than 6 attempts' ~60s budget covers -- + # the sandbox was torn down mid-startup before the health check could ever + # succeed. 14 attempts at up to 10s each comfortably covers 90s+ without + # a fixed sleep that would undershoot on a slower box. + $healthAttempts = 14 + for ($attempt = 1; $attempt -le $healthAttempts; $attempt++) { + $healthRaw = & $NodeExePath @healthArgs 2>&1 + $healthRaw | Out-File (Join-Path $resultDir "openclaw-health-raw.txt") -Encoding UTF8 + # --json output is PRETTY-PRINTED (multi-line), not compact -- extract from + # the first '{' to the last '}' across the whole output rather than + # assuming any single line is a complete JSON document. + $rawJoined = ($healthRaw | ForEach-Object { [string]$_ }) -join "`n" + $startIdx = $rawJoined.IndexOf('{') + $endIdx = $rawJoined.LastIndexOf('}') + $healthJson = $null + if ($startIdx -ge 0 -and $endIdx -gt $startIdx) { + $jsonText = $rawJoined.Substring($startIdx, $endIdx - $startIdx + 1) + try { $healthJson = $jsonText | ConvertFrom-Json } catch { Info "could not parse health JSON: $($_.Exception.Message)" } + } + if ($healthJson -and $healthJson.ok -eq $true) { + $passed = $true + Ok "gateway health: ok=true (attempt $attempt/$healthAttempts)" + break + } else { + Info "attempt $attempt/${healthAttempts}: no ok=true response yet$(if ($attempt -lt $healthAttempts) { ' -- retrying (still inside OpenClaws own startup-grace window)' })" + } + } + if (-not $passed) { + Bad "gateway health did not report ok=true after $healthAttempts attempts" + $healthRaw | ForEach-Object { Info "$_" } + } + + # Treat egress as a qualification gate, not just diagnostic output. The + # capture script runs these probes before importing OpenClaw, so the record + # is available by the time gateway health succeeds. + Step "Verify governed egress evidence" + $capturePath = Join-Path $ShareDir "openclaw-capture.log" + $proofMatch = Select-String -Path $capturePath -Pattern '^\[egress-proof\] (?\{.*\})$' -ErrorAction SilentlyContinue | Select-Object -Last 1 + $proof = $null + if ($proofMatch) { + try { $proof = $proofMatch.Matches[0].Groups['json'].Value | ConvertFrom-Json } + catch { Info "could not parse egress proof JSON: $($_.Exception.Message)" } + } + $proofPassed = $proof -and + $proof.proxyConfigured -eq $true -and + $proof.allowedViaProxy.connected -eq $true -and + $proof.deniedViaProxy.connected -eq $false -and + $proof.directInternetBypass.connected -eq $false + if ($proofPassed) { + Ok "allowed host passed proxy; denied host and direct Internet bypass were blocked" + Info "unrelated host loopback reachable: $($proof.unrelatedHostLoopback.connected) (known limitation)" + } else { + $passed = $false + Bad "governed egress proof failed or was not recorded" + } + } finally { + if ($null -eq $savedOpenClawConfigPath) { + Remove-Item Env:OPENCLAW_CONFIG_PATH -ErrorAction SilentlyContinue + } else { + $env:OPENCLAW_CONFIG_PATH = $savedOpenClawConfigPath + } + if ($null -eq $savedOpenClawStateDir) { + Remove-Item Env:OPENCLAW_STATE_DIR -ErrorAction SilentlyContinue + } else { + $env:OPENCLAW_STATE_DIR = $savedOpenClawStateDir + } + } +} +catch { + Bad $_.Exception.Message +} +finally { + # Stop the forward before the sandbox so its relay tears down cleanly. + if ($fwdProc -and -not $fwdProc.HasExited) { + try { Stop-Process -Id $fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + + # Tear down the sandbox while the gateway is still up (delete needs it). + if ($cli -and $SandboxName) { + try { & $cli sandbox delete $SandboxName 2>&1 | Out-Null } + catch { Info "sandbox teardown '$SandboxName': $($_.Exception.Message) (continuing)" } + } + + if ($KeepRunning -and $gw -and -not $gw.HasExited) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop with: Stop-Process -Id $($gw.Id) -Force" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup"; Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + try { $gw.WaitForExit(5000) | Out-Null } catch {} + Info "stopped gateway pid $($gw.Id)" + } + + Step "Gateway log (tail)" + if (Test-Path $gwLog) { + Get-Content $gwLog -Tail 30 -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + } + + # Copy the OpenClaw capture log (if it made it far enough to write one) for + # post-hoc debugging, then extract only the credential-free target-side + # self-probe outcome. The probe records a byte count, never response data. + # It is diagnostic and cannot make the end-to-end verdict pass: only the + # authenticated host-side OpenClaw client above owns that verdict. + $captureLog = Join-Path $shareDirNorm "openclaw-capture.log" + if (Test-Path $captureLog) { + Copy-Item $captureLog (Join-Path $resultDir "openclaw-capture.log") -Force -ErrorAction SilentlyContinue + $selfProbeLine = Select-String -Path $captureLog -Pattern '\[self-probe\] outcome=([^ ]+) response_bytes=([0-9]+)' -AllMatches -ErrorAction SilentlyContinue | Select-Object -Last 1 + if ($selfProbeLine -and $selfProbeLine.Matches.Count -gt 0) { + $selfProbeOutcome = $selfProbeLine.Matches[0].Groups[1].Value + $selfProbeResponseBytes = [int64]$selfProbeLine.Matches[0].Groups[2].Value + } elseif (Select-String -Path $captureLog -Pattern '\[self-probe\] invalid_port' -Quiet -ErrorAction SilentlyContinue) { + $selfProbeOutcome = "invalid-port" + } elseif (Select-String -Path $captureLog -Pattern '\[self-probe-attempt\] started' -Quiet -ErrorAction SilentlyContinue) { + $selfProbeOutcome = "started-no-completion" + } + } + if ($selfProbeOutcome -eq "response" -and $selfProbeResponseBytes -gt 0) { + Info "target-side self-probe: response ($selfProbeResponseBytes bytes); OpenClaw serviced a local sandbox connection" + } else { + Info "target-side self-probe: $selfProbeOutcome ($selfProbeResponseBytes bytes); inspect the sandboxed OpenClaw target/event loop" + } + + Step "RESULT" + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $summary = @" +OpenShell MXC OpenClaw + dynamic forward test +===================================================================== +timestamp : $stamp +machine : $env:COMPUTERNAME +verdict : $verdict +sandbox : $SandboxName +backend : $Backend +config : $tomlName +target_port : $TargetPort (inside sandbox) +forward_local_port : $ForwardLocalPort (on this host) +wxc_exec : $WxcExecPath +node_exe : $NodeExePath +openclaw_install : $OpenClawInstallDir +target_self_probe : $selfProbeOutcome ($selfProbeResponseBytes response bytes; diagnostic only) + +What PASS means: the gateway created a sandbox on the $Backend backend (no +in-sandbox supervisor process; ProcessContainer also has no inbound network +capability at all); openshell-supervisor-relay launched OpenClaw's gateway +inside it via the driver's control channel; +`openshell forward service` opened a fresh, on-demand WebSocket relay for +this one call (nothing pre-declared beyond the startup liveness port); and a +REAL OpenClaw client running on this host, talking only through that +forwarded port, authenticated with a token and got back a real 'ok: true' +health response. The egress proof also required an allowed HTTPS request to +pass through the OpenShell proxy while a denied host and direct Internet +bypass were blocked. Host loopback remains broadly reachable because dynamic +forwarding uses ephemeral loopback ports. + +Files in this bundle: + transcript.txt full console transcript + gateway.log/.err.log gateway stdout/stderr (includes + forwarded sandbox stdout/stderr, tagged + "wxc-exec stdout:"/"wxc-exec stderr:") + forward.log/.err.log `openshell forward service` stdout/stderr + openclaw-health-raw.txt raw output of the OpenClaw health client + openclaw-capture.log OpenClaw's own captured stdout/stderr + plus credential-free target self-probe + outcome/byte count (no response payload) + (if the sandbox got far enough to write it) + ${tomlBaseName}.used.toml exact config used (wxc_exec_path patched) + openclaw-gateway.used.yaml exact policy used +"@ + Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) + + try { Stop-Transcript | Out-Null } catch {} + try { + $zip = Join-Path $here "results-openclaw-forward-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow + Write-Host "Hand that zip back for evaluation." -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 new file mode 100644 index 0000000000..daffcbe424 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -0,0 +1,461 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# End-to-end MXC provider credential scenario. +# +# The sandbox receives a revision-scoped GITHUB_TOKEN placeholder, never the +# raw token. The MXC host CONNECT proxy resolves it for api.github.com and +# rejects the same placeholder at policy-allowed github.com because that host is +# outside this test profile's sole api.github.com credential binding. +# +# Prerequisites: +# $env:GITHUB_TOKEN = "github_pat_..." +# mise run --skip-tools windows:build:x64 +# +# Run from a local demo-package folder containing openshell-gateway.exe, +# openshell.exe, the PowerShell probe, and the three configuration fixtures +# beside this script, or pass explicit local gateway and CLI paths. When the +# script is copied to a network share, keep the executables on a local volume; +# Windows Application Control commonly rejects unsigned development binaries +# launched from UNC or mapped network paths. +# +# powershell -NoProfile -ExecutionPolicy Bypass ` +# -File .\run-provider-credential-test.ps1 ` +# -GatewayPath .\target\x86_64-pc-windows-msvc\release\openshell-gateway.exe ` +# -CliPath .\target\x86_64-pc-windows-msvc\release\openshell.exe +# +# PowerShell 5.1-compatible. The script never prints GITHUB_TOKEN and scans all +# result artifacts for accidental raw-token leakage before creating the bundle. + +[CmdletBinding()] +param( + [string] $ShareDir = "C:\work\openshell-mxc-provider", + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + [string] $GatewayPath, + [string] $CliPath, + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-provider-e2e" +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-provider-credential-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null + +function Step([string]$message) { + $script:failureStage = $message + Write-Host "`n=== $message ===" -ForegroundColor Cyan +} +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Artifact([string]$explicit, [string]$leaf) { + if (-not [string]::IsNullOrWhiteSpace($explicit)) { + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($explicit) + } + return (Join-Path $here $leaf) +} + +function Escape-Toml([string]$value) { return $value.Replace('\', '\\') } + +function Test-NetworkPath([string]$value) { + if ($value.StartsWith('\\')) { return $true } + if ($value -notmatch '^([A-Za-z]):[\\/]') { return $false } + + $drive = Get-PSDrive -Name $Matches[1] -PSProvider FileSystem -ErrorAction SilentlyContinue + return $null -ne $drive -and + -not [string]::IsNullOrWhiteSpace($drive.DisplayRoot) -and + $drive.DisplayRoot.StartsWith('\\') +} + +function Assert-LocalExecutable([string]$label, [string]$path) { + if (-not (Test-NetworkPath $path)) { return } + + throw "$label executable resolves to network path '$path'. Windows Application Control can block unsigned development binaries launched from network locations. Pass -GatewayPath and -CliPath pointing to local build outputs (for example, the repository's target\x86_64-pc-windows-msvc\release directory); the script and result artifacts may remain on the network share." +} + +function Get-LaunchFailureMessage([string]$label, [string]$path, [System.Exception]$exception) { + $messages = New-Object System.Collections.Generic.List[string] + $currentException = $exception + while ($null -ne $currentException) { + if (-not [string]::IsNullOrWhiteSpace($currentException.Message)) { + [void]$messages.Add($currentException.Message) + } + $currentException = $currentException.InnerException + } + $message = ($messages -join ' | ') + + if ($message -match '(?i)Application Control policy has blocked this file') { + $sha256 = try { (Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash } catch { "unavailable" } + $signature = try { (Get-AuthenticodeSignature -LiteralPath $path -ErrorAction Stop).Status } catch { "unavailable" } + return "Application Control blocked $label launch '$path' (SHA256=$sha256; Authenticode=$signature). Use a local, policy-approved binary and review the applicable App Control event log if the local launch is also blocked. Original error: $message" + } + + return "failed to launch $label '$path': $message" +} + +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 removes embedded quotes from JSON passed to native commands through the +# call operator, which corrupts --driver-config-json before the CLI parses it. +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-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($CommandArgs | 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 $cli" } + } catch { + throw (Get-LaunchFailureMessage "CLI" $cli $_.Exception) + } + + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $exitCode = $process.ExitCode + $text = (@($stdout.Result, $stderr.Result) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $exitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $exitCode): $text" + } + return @{ ExitCode = $exitCode; Text = $text } +} + +function Wait-ForProbeResult([string]$path, [string]$sandbox, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline -and -not (Test-Path $path)) { + $status = Invoke-Cli @("sandbox", "get", $sandbox, "--output", "json") -AllowFailure + if ($status.ExitCode -eq 0) { + $details = $null + try { $details = $status.Text | ConvertFrom-Json } catch {} + if ($details -and $details.phase -eq "Error") { + throw "sandbox $sandbox entered Error before producing the probe result; inspect $gwLog and $gwErrLog" + } + } + Start-Sleep -Milliseconds 500 + } + return (Test-Path $path) +} + +function Copy-ProbeArtifacts { + $artifacts = @( + @{ Source = $resultFile; Destination = "mxc-provider-credential-result.txt" }, + @{ Source = (Join-Path $ShareDir "github-user-response.json"); Destination = "github-user-response.json" }, + @{ Source = (Join-Path $ShareDir "credential-mismatch-response.json"); Destination = "credential-mismatch-response.json" } + ) + foreach ($artifact in $artifacts) { + if (Test-Path $artifact.Source) { + Copy-Item $artifact.Source (Join-Path $resultDir $artifact.Destination) -Force + } + } +} + +$gateway = Resolve-Artifact $GatewayPath "openshell-gateway.exe" +$cli = Resolve-Artifact $CliPath "openshell.exe" +$powerShellExe = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" +$probeTemplate = Join-Path $here "mxc-provider-credential-probe.ps1" +$tomlTemplate = Join-Path $here "mxc-provider-credential.toml" +$policyTemplate = Join-Path $here "mxc-provider-credential-policy.yaml" +$profileTemplate = Join-Path $here "mxc-github-provider-profile.yml" +$tomlUsed = Join-Path $resultDir "mxc-provider-credential.used.toml" +$policyUsed = Join-Path $resultDir "mxc-provider-credential-policy.used.yaml" +$profileUsed = Join-Path $resultDir "mxc-github-provider-profile.used.yml" +$resultFile = Join-Path $ShareDir "mxc-provider-credential-result.txt" +$wxcProbeFile = Join-Path $resultDir "wxc-probe.json" +$gwLog = Join-Path $resultDir "gateway.log" +$gwErrLog = Join-Path $resultDir "gateway.err.log" +$gw = $null +$sandboxName = "mxc-gh-$(Get-Date -Format 'MMddHHmmss')" +$providerName = "mxc-github-e2e" +$passed = $false +$rawTokenLeak = $false +$artifactScanFailed = $false +$failureReason = "" +$failureStage = "initialization" +$githubToken = $env:GITHUB_TOKEN + +try { + Step "Validate prerequisites" + if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + throw "GITHUB_TOKEN is not set. Set it in this PowerShell session; do not pass it on the command line." + } + foreach ($file in @($gateway, $cli, $powerShellExe, $probeTemplate, $tomlTemplate, $policyTemplate, $profileTemplate, $WxcExecPath)) { + if (-not (Test-Path $file)) { throw "missing artifact: $file" } + Info "found $file" + } + Assert-LocalExecutable "gateway" $gateway + Assert-LocalExecutable "CLI" $cli + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + throw "gateway port $Port is already in use" + } + $previous = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $wxcProbeLines = & $WxcExecPath --probe 2>&1 + $wxcProbeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + $wxcProbeText = (($wxcProbeLines | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.Exception.Message + } else { + $_.ToString() + } + }) -join [Environment]::NewLine).Trim() + [System.IO.File]::WriteAllText($wxcProbeFile, $wxcProbeText, $utf8NoBom) + if ($wxcProbeExit -ne 0) { + throw "wxc-exec --probe failed (exit $wxcProbeExit); inspect $wxcProbeFile" + } + Ok "prerequisites available; token value was not printed" + + Step "Render disposable config and stage probe" + $shareFwd = $ShareDir.Replace('\', '/') + $powerShellFwd = $powerShellExe.Replace('\', '/') + New-Item -ItemType Directory -Force $ShareDir | Out-Null + $stagedProbe = Join-Path $ShareDir "mxc-provider-credential-probe.ps1" + Copy-Item $probeTemplate $stagedProbe -Force + Remove-Item ` + $resultFile, ` + (Join-Path $ShareDir "github-user-response.json"), ` + (Join-Path $ShareDir "credential-mismatch-response.json"), ` + (Join-Path $ShareDir "github-user-response.json.stderr"), ` + (Join-Path $ShareDir "credential-mismatch-response.json.stderr") ` + -Force -ErrorAction SilentlyContinue + + $tomlText = [System.IO.File]::ReadAllText($tomlTemplate, [System.Text.Encoding]::UTF8) + $tomlText = [regex]::Replace( + $tomlText, + '(?m)^wxc_exec_path\s*=.*$', + "wxc_exec_path = `"$(Escape-Toml $WxcExecPath)`"" + ) + [System.IO.File]::WriteAllText($tomlUsed, $tomlText, $utf8NoBom) + + $policyText = [System.IO.File]::ReadAllText($policyTemplate, [System.Text.Encoding]::UTF8).Replace("C:/work/openshell-mxc-provider", $shareFwd) + $policyText = $policyText.Replace("C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", $powerShellFwd) + [System.IO.File]::WriteAllText($policyUsed, $policyText, $utf8NoBom) + $profileText = [System.IO.File]::ReadAllText($profileTemplate, [System.Text.Encoding]::UTF8) + $profileText = $profileText.Replace("C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", $powerShellFwd) + [System.IO.File]::WriteAllText($profileUsed, $profileText, $utf8NoBom) + $driverConfig = @{ + mxc = @{ + command = @( + $powerShellFwd, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "$shareFwd/mxc-provider-credential-probe.ps1", + $shareFwd + ) + cwd = $shareFwd + } + } | ConvertTo-Json -Compress -Depth 4 + Ok "staged probe and rendered config without credential material" + + Step "Start gateway" + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + # The CLI, not the gateway process environment, supplies the provider + # credential. Temporarily remove GITHUB_TOKEN while spawning the gateway so + # a successful test cannot be attributed to gateway environment inheritance. + Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue + try { + try { + $gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + } catch { + throw (Get-LaunchFailureMessage "gateway" $gateway $_.Exception) + } + } finally { + $env:GITHUB_TOKEN = $githubToken + } + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { throw "gateway exited early (code $($gw.ExitCode))" } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { break } + Start-Sleep -Milliseconds 400 + } + if (-not (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)) { + throw "gateway did not listen on port $Port within 30 seconds" + } + Ok "gateway listening on 127.0.0.1:$Port" + + Step "Configure provider and effective policy" + $env:OPENSHELL_GATEWAY = "" + $gatewayAdd = Invoke-Cli @( + "gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName + ) -AllowFailure + if ($gatewayAdd.ExitCode -ne 0 -and $gatewayAdd.Text -notmatch '(?i)already exists') { + throw "gateway registration failed (exit $($gatewayAdd.ExitCode)): $($gatewayAdd.Text)" + } + Invoke-Cli @("gateway", "select", $GatewayName) | Out-Null + Invoke-Cli @("provider", "profile", "lint", "--file", $profileUsed) | Out-Null + Invoke-Cli @("provider", "profile", "import", "--file", $profileUsed) | Out-Null + Invoke-Cli @("provider", "create", "--name", $providerName, "--type", "mxc-github-e2e", "--credential", "GITHUB_TOKEN") | Out-Null + Ok "created attached-provider inputs without adding GITHUB_TOKEN to the sandbox environment" + + Step "Create MXC sandbox and run credential probe" + # MXC launches the per-sandbox command itself and exposes no supervisor/SSH + # relay. Structured output makes the CLI return after the sandbox reaches + # Ready instead of trying to connect or exec a command. + $createArgs = @( + "sandbox", "create", + "--name", $sandboxName, + "--provider", $providerName, + "--policy", $policyUsed, + "--driver-config-json", $driverConfig, + # PowerShell uses SystemRoot to locate inbox curl.exe and PATHEXT to + # recognize the fully qualified path as an executable command. + "--env", "SystemRoot=$env:SystemRoot", + "--env", "PATHEXT=$env:PATHEXT", + "--env", "USERPROFILE=$shareFwd", + "--env", "LOCALAPPDATA=$shareFwd", + "--env", "TEMP=$shareFwd", + "--env", "TMP=$shareFwd", + "--output", "json" + ) + $create = Invoke-Cli $createArgs + if ($create.Text) { Info $create.Text } + if (-not (Wait-ForProbeResult $resultFile $sandboxName 150)) { + throw "probe did not produce $resultFile within 150 seconds" + } + $resultText = [System.IO.File]::ReadAllText($resultFile, [System.Text.Encoding]::UTF8) + if (-not [string]::IsNullOrWhiteSpace($githubToken) -and $resultText.Contains($githubToken)) { + $rawTokenLeak = $true + $resultText = $resultText.Replace($githubToken, "***REDACTED***") + [System.IO.File]::WriteAllText($resultFile, $resultText, $utf8NoBom) + } + Write-Host $resultText + if ($resultText -notmatch 'OVERALL: PASS') { + throw "in-sandbox provider credential checks failed" + } + + $effective = Invoke-Cli @("policy", "get", $sandboxName, "--full", "--output", "json") + [System.IO.File]::WriteAllText((Join-Path $resultDir "effective-policy.json"), $effective.Text, $utf8NoBom) + if ($effective.Text -notmatch '_provider_mxc_github_e2e' -or $effective.Text -notmatch 'api\.github\.com') { + throw "effective policy did not contain the attached provider's GitHub rule" + } + $passed = $true + Ok "placeholder isolation, authorized rewrite, and endpoint mismatch all passed" +} +catch { + $failureReason = ($_.Exception.Message -replace '\r?\n', ' | ').Trim() + if (-not [string]::IsNullOrWhiteSpace($githubToken)) { + $failureReason = $failureReason.Replace($githubToken, "***REDACTED***") + } + Bad $failureReason +} +finally { + if ($cli -and $sandboxName -and $gw -and -not $gw.HasExited) { + try { Invoke-Cli @("sandbox", "delete", $sandboxName) -AllowFailure | Out-Null } catch {} + } + + if ($gw -and -not $gw.HasExited) { + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + try { $gw.WaitForExit(5000) | Out-Null } catch {} + } + + # Preserve probe output on both success and failure. These files contain + # response bodies and redacted diagnostics, never the raw provider token. + try { Copy-ProbeArtifacts } catch { Info "could not collect probe artifacts: $($_.Exception.GetType().Name)" } + + # Scan after the gateway exits so redirected log handles are flushed and + # closed. Any match is redacted and turns the scenario into a failure. + if (-not [string]::IsNullOrWhiteSpace($githubToken)) { + Get-ChildItem $resultDir -File -ErrorAction SilentlyContinue | ForEach-Object { + $artifact = $_ + try { + $contents = [System.IO.File]::ReadAllText($artifact.FullName, [System.Text.Encoding]::UTF8) + if ($contents.Contains($githubToken)) { + $rawTokenLeak = $true + [System.IO.File]::WriteAllText($artifact.FullName, $contents.Replace($githubToken, "***REDACTED***"), $utf8NoBom) + } + } catch { + $artifactScanFailed = $true + $passed = $false + Bad "could not inspect result artifact $($artifact.FullName): $($_.Exception.GetType().Name)" + } + } + } + if ($rawTokenLeak) { + $passed = $false + Bad "raw GITHUB_TOKEN appeared in a result artifact; it was redacted" + } + + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $summary = @" +OpenShell MXC provider credential example +========================================= +verdict : $verdict +sandbox : $sandboxName +backend : process_container +provider : $providerName +share_path : $ShareDir +stage : $failureStage +failure : $(if ([string]::IsNullOrWhiteSpace($failureReason)) { "none" } else { $failureReason }) + +PASS proves: + - MXC received a revision-scoped GITHUB_TOKEN placeholder, not the token. + - api.github.com accepted the credential after host-proxy substitution. + - policy-allowed github.com could not resolve the api.github.com-bound placeholder. + - the raw token did not appear in collected result artifacts. +"@ + [System.IO.File]::WriteAllText((Join-Path $resultDir "summary.txt"), $summary, $utf8NoBom) + Write-Host "`n$summary" -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) + + if ($artifactScanFailed) { + Info "result bundle was not created because one or more artifacts could not be scanned" + } else { + try { + $zip = Join-Path $here "results-provider-credential-$stamp.zip" + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "BUNDLE: $zip" -ForegroundColor Yellow + } catch { Info "could not create result bundle: $($_.Exception.Message)" } + } +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 new file mode 100644 index 0000000000..0564d17fc5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -0,0 +1,747 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-ws-agent-test.ps1 - WebSocket agent lifecycle test for OpenShell MXC ProcessContainer. +# +# Tests the full lifecycle of a WebSocket sandbox reached via dynamic +# `openshell forward service` bridging (the same mechanism +# run-openclaw-forward-test.ps1 exercises against a real OpenClaw gateway -- +# this test uses a small built-in WS echo server instead): +# +# 1. Start openshell-gateway configured for ProcessContainer, with +# mxc-ws-agent.exe (server mode) wrapped by openshell-supervisor-relay.exe +# (pc_relay_spawner_path / pc_relay_target_port). +# 2. Create a sandbox using ws-agent.yaml policy. The gateway launches +# openshell-supervisor-relay.exe inside the AppContainer, which spawns +# the WebSocket echo server on port 22000 once the driver's "launch" +# handshake completes. +# 3. Wait for port 22000 to become available (server is ready). +# 4. `openshell forward service --target-port 22000` opens a fresh, +# on-demand relay; connect a WebSocket client through it, send a +# message, verify the echo. +# 5. Delete the sandbox. The driver sends a "shutdown" control-channel +# request (and kills wxc-exec as a backstop regardless) -> the spawner +# kills the server directly -> AppContainer tears down -> port 22000 +# freed. +# 6. Verify port 22000 is freed within the drain timeout. +# +# In -Mock mode: steps 3-4 and 6 are skipped because wxc-exec is not invoked +# and the server never starts. The test validates gateway startup, sandbox +# create, and sandbox delete only. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). ASCII only. +# +# Usage (from the directory containing openshell-gateway.exe / openshell.exe): +# +# # Real run against a live MXC backend: +# .\run-ws-agent-test.ps1 -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe +# +# # Wiring-only smoke test (no wxc-exec required): +# .\run-ws-agent-test.ps1 -Mock +# +# # Override the agent directory (default: C:\work\openshell-mxc-ws): +# .\run-ws-agent-test.ps1 -WxcExecPath ... -AgentDir C:\work\openshell-mxc-ws +# +# Exit code: 0 = PASS, 1 = FAIL. + +[CmdletBinding()] +param( + # Path to wxc-exec.exe. Required for real runs; ignored in mock mode. + [string] $WxcExecPath = "", + + # Working directory the AppContainer can read/write. mxc-ws-agent.exe is + # expected alongside this script; + # the script copies it here if needed. + [string] $AgentDir = "C:\work\openshell-mxc-ws", + + # Gateway gRPC port (matches the openshell-gateway default). + [int] $Port = 17670, + + # Gateway name registered with the CLI. + [string] $GatewayName = "openshell-mxc-ws-test", + + # Port the WebSocket server binds inside the AppContainer. NOT actually + # overridable today -- it's a compile-time const in mxc-ws-agent.rs; any + # other value is rejected below rather than silently ignored. + [int] $WsPort = 22000, + + # Local host port `openshell forward service` binds for this run's + # on-demand relay. Host clients connect here; the CLI bridges them to the + # in-sandbox server via the driver's dynamic forward (ForwardSink:: + # open_dynamic_forward). Freely overridable -- unlike -WsPort, this one + # actually is wired through end to end. + [int] $RelayPort = 22001, + + # WebSocket echo message sent during the connectivity check. + [string] $WsMessage = "hello-ws", + + # Skip wxc-exec invocation and AppContainer enforcement; validates gateway + # startup, sandbox create/delete lifecycle only. + [switch] $Mock, + + # Keep the gateway running after the test (useful for manual inspection). + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +# --- Results bundle ----------------------------------------------------------- + +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-ws-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +$transcriptStarted = $false + +# --- Helpers ------------------------------------------------------------------ + +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 } +function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } + +# Escape backslashes for TOML basic strings. +function Esc([string]$p) { return $p.Replace('\', '\\') } + +# Convert Windows path to forward-slash form (TOML values). +function Fwd([string]$p) { return $p.Replace('\', '/') } + +# -WsPort is NOT actually wired through end to end: the in-sandbox server's +# port is a compile-time const (WS_PORT = 22000 in mxc-ws-agent.rs) -- the +# TOML generation below doesn't patch it. Rather than silently accept an +# override that has no effect, reject it explicitly so a caller doesn't waste +# time debugging a "port already in use" against a port this test never +# actually uses. -RelayPort has no such restriction: it's just the local +# port passed to `openshell forward service --local`, freely chosen per run. +if ($WsPort -ne 22000) { + throw "-WsPort is not wired through to the sandboxed server (compile-time const in mxc-ws-agent.rs); only the default 22000 is supported." +} + +# --- Path variables ----------------------------------------------------------- + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$tomlSrc = Join-Path $here "mxc-ws-gateway.toml" +$toml = Join-Path $resultDir "mxc-ws-gateway.toml" +$policyFile = Join-Path $here "e2e-policies\ws-agent.yaml" +$policyUsed = Join-Path $resultDir "ws-agent.yaml" + +$agentExeSrc = Join-Path $here "mxc-ws-agent.exe" +$agentExe = Join-Path $AgentDir "mxc-ws-agent.exe" + +# openshell-supervisor-relay.exe wraps the per-sandbox command (see mxc-ws-gateway.toml's +# pc_relay_spawner_path) so the driver has a control channel into the sandbox, +# which dynamic forwarding depends on. +$relayExeSrc = Join-Path $here "openshell-supervisor-relay.exe" +$relayExe = Join-Path $AgentDir "openshell-supervisor-relay.exe" + +$gwLog = Join-Path $resultDir "gateway.log" +$gwErrLog = Join-Path $resultDir "gateway.err.log" +$fwdLog = Join-Path $resultDir "forward.log" +$fwdErrLog = Join-Path $resultDir "forward.err.log" + +$script:gwProc = $null +$script:fwdProc = $null +$runId = Get-Date -Format 'MMddHHmmss' +$sandboxName = "mxc-ws-$runId" + +# --- Gateway management ------------------------------------------------------- + +function Start-Gw { + Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + $env:OPENSHELL_GATEWAY_CONFIG = $toml + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_MXC_SHARE_DIR = $AgentDir + $p = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info", "--port", $Port) ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ($p.HasExited) { + 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) { + return $p + } + Start-Sleep -Milliseconds 400 + } + if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + throw "gateway did not start within 30 s." +} + +function Stop-Gw($p) { + if ($p -and -not $p.HasExited) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Milliseconds 700 +} + +# --- CLI registration --------------------------------------------------------- + +function Register-Cli { + $env:OPENSHELL_GATEWAY = "" + $addMsg = "" + try { + & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | + ForEach-Object { $addMsg += "$_`n"; Info $_ } + } catch { + $addMsg = $_.Exception.Message + Info "gateway add: $addMsg" + } + if ($addMsg -match 'different endpoint') { + # Registered at a stale port; remove and re-add. + Info "removing stale gateway registration and re-adding at port $Port" + try { & $cli gateway remove $GatewayName 2>&1 | Out-Null } catch {} + try { + & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | + ForEach-Object { Info $_ } + } catch { Info "gateway add retry: $($_.Exception.Message) (continuing)" } + } + try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway select: $($_.Exception.Message) (continuing)" } +} + +# --- Port polling ------------------------------------------------------------- + +function Wait-PortOpen([int]$port, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline) { + if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { + return $true + } + Start-Sleep -Milliseconds 500 + } + return $false +} + +function Wait-PortClosed([int]$port, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline) { + if (-not (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue)) { + return $true + } + Start-Sleep -Milliseconds 500 + } + return $false +} + +# --- WebSocket echo test ------------------------------------------------------ +# +# Uses System.Net.WebSockets.ClientWebSocket (.NET 4.5+ / PS 5.1). +# Sends $msg over WebSocket and checks the server echoes it back unchanged. + +function Test-WsEcho([string]$wsHost, [int]$port, [string]$msg) { + $uri = [Uri]("ws://" + $wsHost + ":" + $port) + $ws = New-Object System.Net.WebSockets.ClientWebSocket + $cts = New-Object System.Threading.CancellationTokenSource(10000) + + try { + Info "connecting to $uri ..." + $ws.ConnectAsync($uri, $cts.Token).Wait() + if ($ws.State -ne [System.Net.WebSockets.WebSocketState]::Open) { + throw ("WebSocket did not open (state: " + $ws.State + ")") + } + Info "connected" + + # Send a text frame. + $sendBytes = [System.Text.Encoding]::UTF8.GetBytes($msg) + $segment = New-Object System.ArraySegment[byte] (,$sendBytes) + $ws.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Text, + $true, $cts.Token).Wait() + Info "sent: $msg" + + # Receive the echo. + $recvBuf = New-Object byte[] 4096 + $recvSeg = New-Object System.ArraySegment[byte] (,$recvBuf) + $result = $ws.ReceiveAsync($recvSeg, $cts.Token).Result + $echo = [System.Text.Encoding]::UTF8.GetString($recvBuf, 0, $result.Count) + Info "received: $echo" + + $echoMatched = ($echo -eq $msg) + + # Graceful close -- best-effort. The relay may not complete the WS + # Close handshake, so ignore close errors when the echo already matched. + try { + $ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, + "test done", $cts.Token).Wait() + } catch {} + + return $echoMatched + } catch { + Warn ("WebSocket test error: " + $_.Exception.GetBaseException().Message) + return $false + } finally { + $cts.Dispose() + $ws.Dispose() + } +} + +# --- Render gateway TOML ------------------------------------------------------ + +function Render-Toml { + if (-not (Test-Path $tomlSrc)) { + throw "base TOML not found at $tomlSrc" + } + $t = Get-Content $tomlSrc -Raw + + if (-not $Mock) { + $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', + "wxc_exec_path = `"$(Esc $WxcExecPath)`"") + } + + $relayExeFwd = Fwd $relayExe + + $t = [regex]::Replace($t, '(?m)^\s*#?\s*pc_relay_spawner_path\s*=.*$', + "pc_relay_spawner_path = `"$relayExeFwd`"") + + Set-Content $toml -Value $t -Encoding UTF8 +} + +# --- Render policy (disposable copy) ------------------------------------------ + +# The policy's read_write grant is the only source of filesystem access now +# (the driver never adds a workload directory automatically) -- it hardcodes +# the default AgentDir, so it needs the same -AgentDir substitution or an override loses its grant +# entirely and the wrapped server can't even read its own binary/DLLs. +function Render-Policy { + if (-not (Test-Path $policyFile)) { + throw "policy not found at $policyFile" + } + $p = Get-Content $policyFile -Raw + $defaultAgentDirPolicy = "C:/work/openshell-mxc-ws" + $agentDirPolicy = (Fwd $AgentDir) + if ($agentDirPolicy -ne $defaultAgentDirPolicy) { + $p = $p.Replace($defaultAgentDirPolicy, $agentDirPolicy) + } + Set-Content $policyUsed -Value $p -Encoding UTF8 +} + +# --- Results tracking --------------------------------------------------------- + +$checks = New-Object System.Collections.ArrayList +$harnessError = $null + +function Record([string]$name, [bool]$pass, [string]$detail) { + $resultStr = if ($pass) { "PASS" } else { "FAIL" } + $r = [pscustomobject]@{ Check = $name; Result = $resultStr; Detail = $detail } + [void]$checks.Add($r) + if ($pass) { Ok ($name + ": " + $detail) } else { Bad ($name + ": " + $detail) } +} + +# ============================================================================= +# MAIN +# ============================================================================= + +try { + Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + $transcriptStarted = $true + + # --- Pre-flight ----------------------------------------------------------- + + Step "Pre-flight" + + if ($Mock) { + Info "mock mode: OPENSHELL_MXC_MOCK_WXC=1 -- wxc-exec not invoked, WS connectivity skipped" + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if ([string]::IsNullOrWhiteSpace($WxcExecPath) -or -not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." + } + Ok "wxc-exec: $WxcExecPath" + + # A real run exercises process_container with egress_proxy = true + # (mxc-ws-gateway.toml). MXC schema 0.8.0-alpha's network_json() + # (mxc.rs) now emits a direct egress.allow rule for 127.0.0.0/8 + # instead of runtimeConfig.networkProxy when a proxy is configured, + # so the driver no longer calls the elevation-only + # NetworkIsolationSetAppContainerConfig -- process_container + + # egress_proxy selects the BaseContainer/PSEC tier and runs + # non-elevated. Elevation is therefore no longer required here; keep + # logging the elevation state for diagnostics only. + $wid = [Security.Principal.WindowsIdentity]::GetCurrent() + $wp = New-Object Security.Principal.WindowsPrincipal($wid) + $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + Info "elevated=$admin (not required for this build)" + } + + foreach ($f in @($gateway, $cli, $tomlSrc, $policyFile)) { + if (-not (Test-Path $f)) { + throw "Missing artifact: $f -- Build first or run from a release package folder." + } + } + Ok "gateway, CLI, TOML template, policy file: present" + + # Prepare the agent directory and copy the built binaries. + New-Item -ItemType Directory -Force $AgentDir | Out-Null + # Remove stale files from previous runs. + Remove-Item (Join-Path $AgentDir "openshell-shutdown.signal") -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $AgentDir "appcontainer-sid.txt") -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $AgentDir "outbound-probe.txt") -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $AgentDir "outbound-probe-addr.txt") -Force -ErrorAction SilentlyContinue + if (Test-Path $agentExeSrc) { + try { + Copy-Item $agentExeSrc $agentExe -Force + Ok "mxc-ws-agent.exe copied from release build" + } catch { + # File is locked by a stale process from a previous run. + # If an existing copy is present it is safe to proceed - the lock + # just means an AppContainer is still holding the old image. + if (Test-Path $agentExe) { + Warn ("Could not overwrite mxc-ws-agent.exe (file in use): " + $_.Exception.Message) + Warn "Proceeding with the existing copy -- it may be an older build." + } else { + throw + } + } + } elseif (-not (Test-Path $agentExe)) { + throw ("mxc-ws-agent.exe not found at " + $agentExeSrc + " or " + $agentExe + ". " + + "Run from the package folder (mxc-ws-agent.exe should sit alongside this script), " + + "or build with: cargo build --release --target x86_64-pc-windows-msvc -p openshell-driver-mxc --example mxc-ws-agent") + } else { + Info "mxc-ws-agent.exe already in $AgentDir (using existing)" + } + + if (Test-Path $relayExeSrc) { + try { + Copy-Item $relayExeSrc $relayExe -Force + Ok "openshell-supervisor-relay.exe copied from release build" + } catch { + if (Test-Path $relayExe) { + Warn ("Could not overwrite openshell-supervisor-relay.exe (file in use): " + $_.Exception.Message) + Warn "Proceeding with the existing copy -- it may be an older build." + } else { + throw + } + } + } elseif (-not (Test-Path $relayExe)) { + throw ("openshell-supervisor-relay.exe not found at " + $relayExeSrc + " or " + $relayExe + ". " + + "Run from the package folder (it should sit alongside this script), " + + "or build with: cargo build --release --target x86_64-pc-windows-msvc -p openshell-supervisor-relay") + } else { + Info "openshell-supervisor-relay.exe already in $AgentDir (using existing)" + } + + # --- Port availability ---------------------------------------------------- + + Step "Check ports" + $busyGw = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue + if ($busyGw) { + throw "gateway port $Port already in use (pid $($busyGw.OwningProcess)). Stop stale process first." + } + Ok "gateway port $Port is free" + + $busyWs = Get-NetTCPConnection -State Listen -LocalPort $WsPort -ErrorAction SilentlyContinue + if ($busyWs) { + throw "WebSocket port $WsPort already in use (pid $($busyWs.OwningProcess)). Free it before running this test." + } + Ok "WebSocket port $WsPort is free" + + $busyRelay = Get-NetTCPConnection -State Listen -LocalPort $RelayPort -ErrorAction SilentlyContinue + if ($busyRelay) { + throw "relay port $RelayPort already in use (pid $($busyRelay.OwningProcess)). Free it before running this test." + } + Ok "relay port $RelayPort is free" + + # --- Render TOML + start gateway ------------------------------------------ + + Step "Render gateway TOML" + Render-Toml + Render-Policy + Copy-Item $toml (Join-Path $resultDir "mxc-ws-gateway.rendered.toml") -Force -ErrorAction SilentlyContinue + Info "rendered TOML: $toml" + Info "policy: $policyUsed" + + Step "Start gateway (port $Port)" + $script:gwProc = Start-Gw + Info "gateway pid $($script:gwProc.Id)" + Record "gateway-start" $true "pid $($script:gwProc.Id), port $Port" + + # --- Register CLI --------------------------------------------------------- + + Step "Register CLI" + Register-Cli + Ok "gateway '$GatewayName' registered" + + # --- Create sandbox ------------------------------------------------------- + + Step "Create sandbox '$sandboxName'" + $createOut = $null; $createExitCode = 0 + $driverConfigJson = @{ + mxc = @{ + command = @((Fwd $agentExe), "server") + cwd = (Fwd $AgentDir) + } + } | ConvertTo-Json -Compress -Depth 4 + try { + # MXC exec-in-driver has no SSH server, so any `sandbox create` invocation + # that attempts SSH will fail with connection-refused and exit non-zero. + # Use the same pattern as run-mxc-e2e.ps1: pass --no-tty with a no-op + # command so the CLI fires the SSH attempt, fails quickly (connection + # refused), and returns. Do NOT gate on exit code here. + $createOut = & $cli sandbox create ` + --name $sandboxName ` + --policy $policyUsed ` + --driver-config-json $driverConfigJson ` + --no-tty ` + -- cmd.exe /c exit 0 ` + 2>&1 + $createExitCode = $LASTEXITCODE + } catch { + $createOut = $_.Exception.Message; $createExitCode = 1 + } + $createStr = ($createOut -join "`n") + Info "create exit: $createExitCode (non-zero expected for MXC -- no SSH server)" + + # Verify the sandbox actually exists by fetching it. + Start-Sleep -Milliseconds 500 + $getOut = $null; $getExitCode = 0 + try { + $getOut = & $cli sandbox get $sandboxName 2>&1 + $getExitCode = $LASTEXITCODE + } catch { + $getOut = $_.Exception.Message; $getExitCode = 1 + } + $getStr = ($getOut -join "`n") + # `sandbox get`'s text output prints "Phase: " (see run.rs); + # require Ready, not just presence -- a sandbox that exists but is stuck + # Provisioning/Error is not actually usable for the WebSocket check below. + $createOk = ($getExitCode -eq 0) -and ($getStr -notmatch 'not found|does not exist') -and ($getStr -match '(?m)^\s*Phase:\s*Ready\s*$') + Record "sandbox-create" $createOk "sandbox $sandboxName $(if ($createOk) {'exists and is Ready'} else {'not found or not Ready after create'})" + + if (-not $createOk) { + Info "create output: $createStr" + Info "get output: $getStr" + throw "sandbox create failed: sandbox does not exist or is not Ready after create" + } + + # --- WebSocket connectivity (real mode only) ------------------------------ + + if (-not $Mock) { + + Step "Wait for WebSocket server on port $WsPort" + $serverUp = Wait-PortOpen -port $WsPort -seconds 30 + if ($serverUp) { + Record "server-port-open" $true "port $WsPort is listening" + } else { + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + $detail = "port $WsPort did NOT open within 30 s" + if ($gwText -match 'CreateProcessW failed') { + $detail = $detail + " (gateway log: agent launch failure)" + } + Record "server-port-open" $false $detail + } + + # Cross-check server-port-open against openshell-supervisor-relay's own + # confirmation, from inside the sandbox: it detects target-port + # readiness itself (wait_for_port_ready, gated by the "launch" + # handshake) and logs it, forwarded into the gateway log the same way + # as every other wxc-exec stdout/stderr line. No share_dir file + # needed -- this is the same information the marker file used to + # carry, just sourced from the spawner's own diagnostic instead. + if ($serverUp) { + Step "Verify spawner's own port-ready confirmation (gateway log)" + # The spawner's own polling (wait_for_port_ready, 300ms interval) + # runs independently of this script's Wait-PortOpen above -- its + # log line can land a couple of seconds after the raw TCP connect + # already succeeded (observed up to ~2.3s). Poll for it rather + # than checking once immediately, or this races and fails spuriously. + $readyPattern = "port $WsPort ready after" + $readyDeadline = (Get-Date).AddSeconds(15) + $readyOk = $false + while ((Get-Date) -lt $readyDeadline -and -not $readyOk) { + $readyOk = (Test-Path $gwLog -PathType Leaf) -and (Select-String -Path $gwLog -Pattern $readyPattern -Quiet -ErrorAction SilentlyContinue) + if (-not $readyOk) { + $readyOk = (Test-Path $gwErrLog -PathType Leaf) -and (Select-String -Path $gwErrLog -Pattern $readyPattern -Quiet -ErrorAction SilentlyContinue) + } + if (-not $readyOk) { Start-Sleep -Milliseconds 300 } + } + if ($readyOk) { + Record "ws-server-marker" $true "spawner logged '$readyPattern' in the gateway log" + } else { + Record "ws-server-marker" $false "spawner's port-ready log line not found in gateway.log/gateway.err.log within 15 s" + } + } else { + Warn "skipping ws-server-marker: server did not start" + } + + # `openshell forward service` opens a fresh, on-demand relay for this + # one call, bridging $WsPort (inside the sandbox) to $RelayPort (on + # this host). No port needs to be pre-declared anywhere except + # pc_relay_target_port's own startup liveness check. Mirrors + # run-openclaw-forward-test.ps1's step 10. + if ($serverUp) { + Step "openshell forward service --target-port $WsPort --local $RelayPort" + $script:fwdProc = Start-Process -FilePath $cli ` + -ArgumentList @("forward", "service", "--target-port", "$WsPort", "--local", "$RelayPort", $sandboxName) ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $fwdLog -RedirectStandardError $fwdErrLog + Info "forward pid $($script:fwdProc.Id)" + $fwdDeadline = (Get-Date).AddSeconds(20); $fwdUp = $false + while ((Get-Date) -lt $fwdDeadline) { + if ($script:fwdProc.HasExited) { break } + if ((Test-Path $fwdLog) -and (Select-String -Path $fwdLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + if ((Test-Path $fwdErrLog) -and (Select-String -Path $fwdErrLog -Pattern 'Forwarding' -Quiet -ErrorAction SilentlyContinue)) { $fwdUp = $true; break } + Start-Sleep -Milliseconds 500 + } + + if ($fwdUp) { + Ok "forward active: 127.0.0.1:$RelayPort -> sandbox:$WsPort" + + Step "WebSocket echo test via forwarded port (ws://127.0.0.1:$RelayPort)" + $echoOk = Test-WsEcho -wsHost "127.0.0.1" -port $RelayPort -msg $WsMessage + if ($echoOk) { + Record "ws-echo" $true ("'" + $WsMessage + "' echoed via forwarded port $RelayPort") + } else { + Record "ws-echo" $false "echo failed via forwarded port $RelayPort -- see transcript" + } + } else { + $exitDetail = if ($script:fwdProc.HasExited) { " (forward process exited early, code $($script:fwdProc.ExitCode))" } else { "" } + Record "ws-echo" $false "forward did not report 'Forwarding ...' within 20 s$exitDetail -- see forward.log/forward.err.log" + } + } else { + Warn "skipping ws-echo: server did not start" + } + + } else { + Info "[mock] skipping server-port-open and ws-echo" + } + + # Stop the forward before deleting the sandbox it points at. + if ($script:fwdProc -and -not $script:fwdProc.HasExited) { + try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + + # --- Delete sandbox ------------------------------------------------------- + + Step "Delete sandbox '$sandboxName'" + $deleteOut = $null; $deleteExitCode = 0 + try { + $deleteOut = & $cli sandbox delete $sandboxName 2>&1 + $deleteExitCode = $LASTEXITCODE + } catch { + $deleteOut = $_.Exception.Message; $deleteExitCode = 1 + } + $deleteStr = ($deleteOut -join "`n") + Info "delete exit: $deleteExitCode" + if ($deleteExitCode -ne 0) { Info "output: $deleteStr" } + Record "sandbox-delete" ($deleteExitCode -eq 0) "exit $deleteExitCode" + + # --- Port freed (real mode only) ------------------------------------------ + + if (-not $Mock) { + Step "Verify port $WsPort is released after delete" + # The driver sends a "shutdown" control-channel request (openshell- + # supervisor-relay kills the server directly) and kills wxc-exec as a + # backstop regardless. Allow 30 s for that plus the OS to release the + # port. + $portClosed = Wait-PortClosed -port $WsPort -seconds 30 + if ($portClosed) { + Record "port-freed" $true "port $WsPort released within 30 s" + } else { + Record "port-freed" $false "port $WsPort still bound after 30 s" + } + } else { + Info "[mock] skipping port-freed check" + } + +} catch { + $harnessError = $_.Exception.Message + Bad "harness error: $harnessError" +} finally { + # --- Teardown ------------------------------------------------------------- + + # Best-effort forward/sandbox cleanup in case the test failed mid-run. + if ($script:fwdProc -and -not $script:fwdProc.HasExited) { + try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } + try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + + if (-not $KeepRunning) { + Stop-Gw $script:gwProc + $script:gwProc = $null + } elseif ($script:gwProc) { + Info "gateway pid $($script:gwProc.Id) left running (-KeepRunning)" + } + + # --- Summary -------------------------------------------------------------- + + Step "Summary" + $checks | Format-Table -AutoSize + + $failCount = @($checks | Where-Object { $_.Result -eq "FAIL" }).Count + $passCount = @($checks | Where-Object { $_.Result -eq "PASS" }).Count + Write-Host "PASS=$passCount FAIL=$failCount" + + $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } + $checkLines = ($checks | ForEach-Object { " " + $_.Result + " " + $_.Check + ": " + $_.Detail }) -join "`n" + $modeStr = if ($Mock) { "MOCK (no wxc-exec, no WS connectivity)" } else { "REAL" } + $wxcStr = if ($Mock) { "(mock)" } else { $WxcExecPath } + $errStr = if ($harnessError) { "harness_error: $harnessError" } else { "" } + + $summary = "OpenShell MXC WebSocket agent test`n" + + "====================================`n" + + "timestamp : $stamp`n" + + "machine : $env:COMPUTERNAME`n" + + "verdict : $verdict`n" + + "mode : $modeStr`n" + + "gateway : $gateway (port $Port)`n" + + "agent_dir : $AgentDir`n" + + "agent_exe : $agentExe`n" + + "relay_exe : $relayExe`n" + + "policy : $policyUsed`n" + + "sandbox : $sandboxName`n" + + "ws_port : $WsPort`n" + + "relay_port : $RelayPort (on this host)`n" + + "ws_message : $WsMessage`n" + + "wxc_exec : $wxcStr`n" + + "totals : PASS=$passCount FAIL=$failCount`n" + + "$errStr`n" + + "`nChecks:`n$checkLines`n" + + "`nFiles in this bundle ($resultDir):`n" + + " transcript.txt full console transcript`n" + + " gateway.log / gateway.err.log gateway stdout / stderr`n" + + " forward.log / forward.err.log 'openshell forward service' stdout / stderr`n" + + " mxc-ws-gateway.rendered.toml exact gateway config used`n" + + " ws-agent.yaml sandbox policy used`n" + + "`nWhat PASS means:`n" + + " gateway-start gateway bound port $Port within 30 s`n" + + " sandbox-create sandbox reached Ready after create (CLI exit may be non-zero on MXC without SSH)`n" + + " server-port-open WS server bound port $WsPort within 30 s`n" + + " ws-server-marker spawner logged its own port-ready confirmation in the gateway log`n" + + " ws-echo '$WsMessage' echoed via a dynamic 'openshell forward service' relay at 127.0.0.1:$RelayPort`n" + + " (fresh, on-demand relay for this one call -- no static bridge, nothing pre-declared)`n" + + " sandbox-delete CLI returned exit 0 for sandbox delete`n" + + " port-freed port $WsPort released within 30 s of sandbox delete`n" + + Set-Content (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + $color = if ($verdict -eq "PASS") { "Green" } else { "Red" } + Write-Host $summary -ForegroundColor $color + + if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch {} } + + # Zip the bundle. + try { + $zip = Join-Path $here "results-ws-$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 ($harnessError -or (@($checks | Where-Object { $_.Result -eq "FAIL" }).Count -gt 0)) { + Write-Host "`nTEST FAILED" -ForegroundColor Red + exit 1 +} else { + Write-Host "`nTEST PASSED" -ForegroundColor Green + exit 0 +} diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs new file mode 100644 index 0000000000..673b82177e --- /dev/null +++ b/crates/openshell-driver-mxc/src/control_channel.rs @@ -0,0 +1,410 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Request/response JSON control channel to a sandboxed process, riding +//! `wxc-exec`'s inherited stdin/stdout (STDIO passthrough) — see the +//! `openshell-supervisor-relay` crate's module docs for the protocol +//! and why this needs no `AppContainer` network capability at all: it's +//! inherited process handles, not network traffic. + +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::process::ChildStdin; +use tokio::sync::{Mutex, oneshot}; + +#[derive(Debug, thiserror::Error)] +pub enum ControlChannelError { + #[error("control channel write failed: {0}")] + Write(#[source] std::io::Error), + #[error("control channel response sender dropped")] + Dropped, + #[error("control channel request timed out after {0:?}")] + Timeout(Duration), + #[error("control channel serialize failed: {0}")] + Serialize(#[from] serde_json::Error), +} + +type PendingMap = Mutex>>; +/// Slot for one of the spawner's one-time, unsolicited events -- startup- +/// ready (see `try_route_ready`) and target-ready (see +/// `try_route_target_ready`) each get their own instance of this type. +/// Not part of `PendingMap`: neither has a correlation id or is a reply to +/// anything the driver sent. The payload is `Ok(())` for a normal fire, or +/// `Err(reason)` when the event fired but something about it was rejected +/// (currently only the "ready" event's protocol version check uses this; +/// `"target_ready"` always sends `Ok(())`). +pub type ReadySlot = Mutex>>>; + +/// Wire protocol version this driver requires from +/// `openshell-supervisor-relay`'s startup `"ready"` event (see +/// `try_route_ready`). Must match that crate's own `PROTOCOL_VERSION` +/// constant -- duplicated rather than shared via a common crate, matching +/// how the rest of this wire protocol (event/op names, the auth nonce +/// encoding, etc.) is already duplicated across the two sides. Bump both +/// together whenever the control-channel protocol changes in a way an +/// out-of-sync peer can't safely ignore (e.g. the "nonce" field added to +/// "forward", or the `"target_ready"` event itself) -- an independently +/// staged, stale relay binary then fails fast with a clear error instead of +/// hanging or misbehaving against fields/events it doesn't understand. +const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 2; + +/// One control channel per sandboxed process. `request()` is safe to call +/// concurrently — each call gets its own correlation id and awaits only its +/// own response, so multiple in-flight requests (e.g. concurrent `forward` +/// calls) don't interfere with each other. +pub struct ControlChannel { + stdin: Mutex, + next_id: AtomicU64, + pending: Arc, +} + +impl ControlChannel { + pub fn new(stdin: ChildStdin) -> Self { + Self { + stdin: Mutex::new(stdin), + next_id: AtomicU64::new(1), + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// A clonable handle to the pending-requests map, for the stdout-reader + /// task (which owns the read side) to route responses into. + pub fn pending_handle(&self) -> Arc { + self.pending.clone() + } + + /// Try to parse `line` as a control-channel response and complete the + /// matching pending request. Returns `true` if `line` was consumed this + /// way; `false` means the caller should treat it as plain log text + /// instead (covers wxc-exec's own banner/config-dump lines, which are + /// never `{"id":...}`-shaped). + pub async fn try_route_response(pending: &PendingMap, line: &str) -> bool { + let Ok(value) = serde_json::from_str::(line) else { + return false; + }; + let Some(id) = value.get("id").and_then(Value::as_u64) else { + return false; + }; + let mut map = pending.lock().await; + map.remove(&id).is_some_and(|tx| { + let _ = tx.send(value); + true + }) + } + + /// Try to recognize `line` as the spawner's unsolicited startup-ready + /// event (`{"event":"ready","protocol_version":N}`) -- sent once, + /// before it's spawned anything, so the driver knows when to send the + /// `"launch"` request carrying the real command/env (see driver.rs's + /// launch handshake and the `openshell-supervisor-relay` crate's module + /// docs). Unlike a query response this has no correlation id, so it + /// can't go through `try_route_response`. Returns `true` if `line` was + /// consumed this way (regardless of whether the version check passed -- + /// the caller distinguishes that via the channel payload). + /// + /// Validates `protocol_version` against + /// `REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION` so an independently + /// staged, out-of-sync relay binary (e.g. left over from an older + /// package drop in a shared `share_dir`) fails the sandbox immediately + /// with a clear "wrong version" error instead of hanging or misbehaving + /// later against a "launch"/"forward" field or a `"target_ready"` event it + /// doesn't understand. A missing field means a pre-versioning binary -- + /// also rejected, since there's no version to compare. + pub async fn try_route_ready(ready: &ReadySlot, line: &str) -> bool { + Self::try_route_named_event(ready, line, "ready", |value| { + match value.get("protocol_version").and_then(Value::as_u64) { + Some(v) if v == REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION => Ok(()), + Some(v) => Err(format!( + "openshell-supervisor-relay reports protocol_version {v}, this driver requires {REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION} -- restage a matching build" + )), + None => Err(format!( + "openshell-supervisor-relay's ready event has no protocol_version field (pre-versioning binary); this driver requires {REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION} -- restage a matching build" + )), + } + }) + .await + } + + /// Try to recognize `line` as the spawner's unsolicited target-ready + /// event (`{"event":"target_ready"}`) -- sent once the spawner has + /// actually spawned the target and confirmed its configured port is + /// accepting connections (see `wait_for_port_ready` in + /// `openshell-supervisor-relay`). Distinct from the `"launch"` + /// control-channel *response*, which only confirms the command/env + /// arrived, not that the target is running: driver.rs awaits this event + /// too before publishing the sandbox `Ready=True`, so a caller acting on + /// `Ready` can't race a target that hasn't bound its port yet. Returns + /// `true` if `line` was consumed this way. No version gate here -- the + /// startup "ready" handshake above already rejected an incompatible + /// peer long before this could fire. + pub async fn try_route_target_ready(target_ready: &ReadySlot, line: &str) -> bool { + Self::try_route_named_event(target_ready, line, "target_ready", |_| Ok(())).await + } + + async fn try_route_named_event( + slot: &ReadySlot, + line: &str, + event_name: &str, + validate: impl FnOnce(&Value) -> Result<(), String>, + ) -> bool { + let Ok(value) = serde_json::from_str::(line) else { + return false; + }; + if value.get("event").and_then(|v| v.as_str()) != Some(event_name) { + return false; + } + let sender = slot.lock().await.take(); + if let Some(tx) = sender { + let _ = tx.send(validate(&value)); + } + true + } + + /// Fail every currently pending request with `Dropped`, e.g. when the + /// stdout-reader task observes EOF/error on the child's stdout: once the + /// reader is gone, no response will ever arrive for these ids, so let + /// callers fail fast instead of sitting out their individual timeouts. + /// Dropping each sender (rather than sending a value) is what makes the + /// waiting `request()` call observe `ControlChannelError::Dropped`. + pub async fn fail_all_pending(pending: &PendingMap) { + let mut map = pending.lock().await; + map.clear(); + } + + /// Send `{"id":N,"op":op,"data":data}` and await the correlated + /// response, or an error on write failure, timeout, or a dropped sender + /// (the reader task exited, e.g. the process died). + pub async fn request( + &self, + op: &str, + data: Value, + timeout: Duration, + ) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(id, tx); + + let req = serde_json::json!({"id": id, "op": op, "data": data}); + let mut line = serde_json::to_string(&req)?; + line.push('\n'); + + let write_result = { + let mut stdin = self.stdin.lock().await; + match stdin.write_all(line.as_bytes()).await { + Ok(()) => stdin.flush().await, + Err(e) => Err(e), + } + }; + if let Err(e) = write_result { + self.pending.lock().await.remove(&id); + return Err(ControlChannelError::Write(e)); + } + + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(_)) => Err(ControlChannelError::Dropped), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(ControlChannelError::Timeout(timeout)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_ready_slot() -> ReadySlot { + Mutex::new(None) + } + + fn armed_ready_slot() -> (ReadySlot, oneshot::Receiver>) { + let (tx, rx) = oneshot::channel(); + (Mutex::new(Some(tx)), rx) + } + + // ── try_route_response ────────────────────────────────────────────── + + #[tokio::test] + async fn try_route_response_completes_matching_pending_id() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx, rx) = oneshot::channel(); + pending.lock().await.insert(7, tx); + + let consumed = + ControlChannel::try_route_response(&pending, r#"{"id":7,"ok":true,"data":42}"#).await; + + assert!(consumed); + let value = rx.await.unwrap(); + assert_eq!(value["data"], 42); + assert!(pending.lock().await.is_empty()); + } + + #[tokio::test] + async fn try_route_response_ignores_unknown_id() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx, _rx) = oneshot::channel(); + pending.lock().await.insert(1, tx); + + let consumed = ControlChannel::try_route_response(&pending, r#"{"id":99,"ok":true}"#).await; + + assert!( + !consumed, + "an id with no pending sender must not be consumed" + ); + assert_eq!( + pending.lock().await.len(), + 1, + "the real pending entry survives" + ); + } + + #[tokio::test] + async fn try_route_response_ignores_non_json_and_id_less_lines() { + let pending: PendingMap = Mutex::new(HashMap::new()); + + assert!(!ControlChannel::try_route_response(&pending, "not json at all").await); + assert!(!ControlChannel::try_route_response(&pending, r#"{"event":"ready"}"#).await); + } + + // ── try_route_ready (protocol-version handshake) ──────────────────── + + #[tokio::test] + async fn try_route_ready_accepts_matching_protocol_version() { + let (slot, rx) = armed_ready_slot(); + + let consumed = + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":2}"#) + .await; + + assert!(consumed); + assert_eq!(rx.await.unwrap(), Ok(())); + } + + #[tokio::test] + async fn try_route_ready_rejects_mismatched_protocol_version() { + let (slot, rx) = armed_ready_slot(); + + let consumed = + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":1}"#) + .await; + + assert!( + consumed, + "a recognized ready event is consumed even when rejected" + ); + let err = rx.await.unwrap().expect_err("version 2 must be rejected"); + assert!( + err.contains('2'), + "error should name the offending version: {err}" + ); + assert!( + err.contains('1'), + "error should name the required version: {err}" + ); + } + + #[tokio::test] + async fn try_route_ready_rejects_missing_protocol_version_field() { + let (slot, rx) = armed_ready_slot(); + + let consumed = ControlChannel::try_route_ready(&slot, r#"{"event":"ready"}"#).await; + + assert!(consumed); + let err = rx + .await + .unwrap() + .expect_err("a missing field must be rejected"); + assert!( + err.contains("pre-versioning"), + "error should call out the pre-versioning case: {err}" + ); + } + + #[tokio::test] + async fn try_route_ready_ignores_other_events_and_non_json() { + let slot = empty_ready_slot(); + + assert!(!ControlChannel::try_route_ready(&slot, r#"{"event":"target_ready"}"#).await); + assert!(!ControlChannel::try_route_ready(&slot, "garbage").await); + } + + // ── try_route_target_ready ─────────────────────────────────────────── + + #[tokio::test] + async fn try_route_target_ready_fires_ok_with_no_version_gate() { + let (slot, rx) = armed_ready_slot(); + + // No protocol_version field at all -- unlike "ready", "target_ready" + // must not be gated on one (see the doc comment on + // try_route_target_ready). + let consumed = + ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + + assert!(consumed); + assert_eq!(rx.await.unwrap(), Ok(())); + } + + #[tokio::test] + async fn try_route_target_ready_ignores_ready_event() { + let slot = empty_ready_slot(); + + // "ready" and "target_ready" must not be cross-routed into each + // other's slot. + let consumed = ControlChannel::try_route_target_ready( + &slot, + r#"{"event":"ready","protocol_version":2}"#, + ) + .await; + + assert!(!consumed); + } + + #[tokio::test] + async fn try_route_named_event_is_a_safe_no_op_once_the_slot_is_already_empty() { + let (slot, rx) = armed_ready_slot(); + + assert!(ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await); + // The slot's sender was taken (and used) on the first fire. A + // repeat of the same event on the wire is still recognized as a + // "target_ready" line (so the caller doesn't mistake it for plain + // log text) but must not panic just because the slot is now empty. + let consumed_again = + ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + + assert!( + consumed_again, + "still recognized as the event, even as a no-op" + ); + assert_eq!( + rx.await.unwrap(), + Ok(()), + "only the first fire's Ok(()) was ever sent" + ); + } + + // ── fail_all_pending ────────────────────────────────────────────────── + + #[tokio::test] + async fn fail_all_pending_drops_every_sender() { + let pending: PendingMap = Mutex::new(HashMap::new()); + let (tx1, rx1) = oneshot::channel(); + let (tx2, rx2) = oneshot::channel(); + pending.lock().await.insert(1, tx1); + pending.lock().await.insert(2, tx2); + + ControlChannel::fail_all_pending(&pending).await; + + assert!(pending.lock().await.is_empty()); + assert!( + rx1.await.is_err(), + "dropped sender must surface as a recv error" + ); + assert!(rx2.await.is_err()); + } +} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 60f824516f..ce728cbeec 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -4,8 +4,11 @@ //! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, //! and self-reported readiness. +use crate::control_channel::ControlChannel; use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use crate::relay; +use base64::Engine as _; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::SandboxPolicy; @@ -15,14 +18,16 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; use openshell_core::proto_struct::struct_to_json_value; +use openshell_core::provider_credentials::ProviderCredentialState; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::pin::Pin; -use std::sync::Arc; -use tokio::sync::{Mutex, broadcast, mpsc, watch}; -use tokio::task::JoinHandle; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Child; +use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; @@ -49,12 +54,22 @@ pub enum MxcBackend { ProcessContainer, } +impl MxcBackend { + const fn containment(self) -> &'static str { + match self { + Self::IsolationSession => "isolation_session", + Self::ProcessContainer => "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. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] +#[allow(clippy::struct_excessive_bools)] // Independent, existing gateway TOML options. pub struct MxcComputeConfig { /// Path to `wxc-exec.exe`. Required for live runs. pub wxc_exec_path: String, @@ -64,12 +79,53 @@ pub struct MxcComputeConfig { pub pc_least_privilege: bool, /// `processContainer` only: `AppContainer` capabilities to grant. pub pc_capabilities: Vec, + /// `processContainer` only: inject a network section with + /// `defaultPolicy: "allow"` so the `AppContainer` has unrestricted outbound + /// TCP access. Required when `pc_capabilities` alone is insufficient to + /// enable network access in the target wxc-exec build. + pub pc_network_allow: bool, + /// `processContainer` only: include `"allowLocalNetwork": true` in the + /// MXC network section. Required for node.js (and other runtimes that + /// need loopback during DLL initialization) to start inside a + /// processcontainer. + pub pc_allow_local_network: bool, + /// `processContainer` only: when `true`, start with an EMPTY process env + /// (not even `MINIMAL_WINDOWS_BOOTSTRAP_ENV`) instead of the safe + /// default -- only the sandbox's explicit per-request environment is + /// passed to the process. + /// Use for agents like Node.js that fail with `STATUS_DLL_INIT_FAILED` + /// when unrecognised host env vars are present; the caller is then + /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`COMSPEC`/ + /// `LOCALAPPDATA` through `sandbox create --env/--env-from` if needed + /// (`CreateProcessW` itself won't succeed without `LOCALAPPDATA` at + /// least -- see `MINIMAL_WINDOWS_BOOTSTRAP_ENV`). + /// + /// The sandbox's explicit per-request environment is layered on top. + pub pc_minimal_env: bool, + /// `processContainer` only: path to a generic spawn+relay-bridge binary + /// (see the `openshell-supervisor-relay` crate). When non-empty (and + /// `pc_relay_target_port != 0`), the driver launches this binary instead + /// of the per-sandbox workload command directly, sending the command/env + /// over the control channel once the spawner announces readiness (the + /// "launch" handshake) rather than writing them to the workload directory. This + /// decouples the relay-bridging logic from the target application (e.g. + /// `OpenClaw`) entirely — the target needs no awareness of the relay + /// protocol. It's also what gives the driver a control channel into the + /// sandbox at all, which `ForwardSink::open_dynamic_forward` (dynamic + /// `openshell forward service` bridging) depends on regardless of any + /// particular port being pre-declared. + pub pc_relay_spawner_path: String, + /// `processContainer` only: the TCP port the workload's target process + /// binds, which `pc_relay_spawner_path` bridges to the gateway relay. + /// Ignored unless `pc_relay_spawner_path` is set. `0` disables spawner + /// wrapping (default) — the per-sandbox command runs directly. + pub pc_relay_target_port: u16, /// 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. + /// Enable Pattern-C governed egress. When true, MXC permits loopback-only + /// egress, the driver injects proxy environment variables, and the host + /// CONNECT proxy receives the full network policy. 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 @@ -91,6 +147,11 @@ impl Default for MxcComputeConfig { backend: MxcBackend::default(), pc_least_privilege: false, pc_capabilities: Vec::new(), + pc_network_allow: false, + pc_relay_spawner_path: String::new(), + pc_relay_target_port: 0, + pc_allow_local_network: false, + pc_minimal_env: false, default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), egress_proxy: false, egress_proxy_addr: String::new(), @@ -126,13 +187,49 @@ struct SandboxEntry { iso_sandbox_id: Option, isolation_stopped: bool, phase_state: PhaseState, - /// Serializes stop/delete with provisioning and process launch. + /// Serializes stop/delete with provisioning and process launch: taken as + /// an owned guard (`startup_guard`) in `create_sandbox` before the entry + /// is published, and only released once `run_lifecycle` has installed + /// `exec_child`/`shutdown_tx`/`terminated_rx`/`control_channel` (or + /// failed). `stop_sandbox`/`delete_sandbox` block on this same gate + /// before touching any of those fields, so a stop/delete arriving while + /// a sandbox is still starting can't race a launch that hasn't finished + /// wiring the kill/shutdown machinery yet. lifecycle_gate: Arc>, - monitor_cancel: Option>, - monitor_task: Option>, + exec_child: Option, + /// Fires when `delete_sandbox` is called on a `ProcessContainer` sandbox so + /// `monitor_exec` can kill the `wxc-exec` child and release all resources + /// (including ports bound inside the `AppContainer`) before the entry is + /// removed from the registry. + shutdown_tx: Option>, + /// Set to `true` (from `monitor_exec`) once the `wxc-exec` child has + /// genuinely exited -- whether that's a natural exit or the forced kill + /// triggered via `shutdown_tx` above. Lets `stop_sandbox`/`delete_sandbox` + /// await *confirmed* termination (bounded by a timeout) instead of firing + /// the kill signal and immediately reporting success regardless of + /// whether the process actually died. + /// + /// A `watch::Receiver` rather than a `oneshot::Receiver` deliberately: + /// it's `.clone()`d (never `.take()`n) by callers, so it survives a + /// caller that times out and retries -- unlike a consumed oneshot, the + /// retry can still observe the same underlying completion instead of + /// silently skipping the wait because the field looks empty. + terminated_rx: Option>, + /// Path to the shutdown signal file written by `delete_sandbox` so + /// `mxc-ws-agent.rs` (set directly as the sandbox command, no control + /// channel) can detect a deletion and exit cleanly. Only set for that + /// case -- when spawner wrapping is active, `delete_sandbox` sends a + /// `"shutdown"` control-channel request to `openshell-supervisor-relay` + /// instead, so this stays `None`. + signal_file: Option, trimmed_policy: Option, proxy_addr: Option, host_proxy: Option, + /// JSON request/response control channel over the spawner's inherited + /// stdin/stdout (see `control_channel.rs`). Only present when spawner + /// wrapping is active (`pc_relay_spawner_path` configured); dropped on + /// delete, which closes the child's stdin. + control_channel: Option>, } impl std::fmt::Debug for SandboxEntry { @@ -197,6 +294,10 @@ pub struct MxcComputeBackend { registry: Arc>>, watch_tx: Arc>, policy_mapper: Arc, + /// Provider resolver snapshots staged by the gateway immediately before + /// create. The driver consumes each entry exactly once; only placeholder + /// child environment values cross into MXC. + pending_provider_credentials: 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 @@ -241,24 +342,8 @@ fn sandbox_config(sandbox: &DriverSandbox) -> Result 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::>(); + let mut environment = HashMap::new(); if let Some(spec) = sandbox.spec.as_ref() { if let Some(template) = spec.template.as_ref() { environment.extend(template.environment.clone()); @@ -273,13 +358,88 @@ fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { environment } +/// Merge provider-owned child environment values into MXC `process.env`. +/// +/// Provider entries win case-insensitively, matching Windows environment +/// semantics. Secret values have already been replaced by revision-scoped +/// placeholders; explicitly classified GCP configuration is resolved by the +/// shared credential state because SDKs consume it before making a request. +fn append_provider_child_env( + env: &mut Vec, + provider_credentials: Option<&ProviderCredentialState>, +) { + let Some(provider_credentials) = provider_credentials else { + return; + }; + let mut provider_env = provider_credentials + .child_env_with_gcp_resolved() + .into_iter() + .collect::>(); + provider_env.sort_by(|(left, _), (right, _)| left.cmp(right)); + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !provider_env + .iter() + .any(|(provider_key, _)| key.eq_ignore_ascii_case(provider_key)) + }); + env.extend( + provider_env + .into_iter() + .map(|(key, value)| format!("{key}={value}")), + ); +} + +/// Rejects provider credential environment keys that would collide once +/// injected into the sandbox process, before any staging or launch happens. +/// +/// Windows environment variables are case-insensitive, so two provider keys +/// that differ only by case (or shadow one of the reserved TLS trust keys +/// `append_tls_env_vars` injects later) would otherwise merge or get silently +/// overwritten with no diagnostic, leaving the sandbox with an ambiguous, +/// wrong, or missing credential. +fn validate_provider_child_env_keys( + provider_credentials: Option<&ProviderCredentialState>, +) -> Result<(), tonic::Status> { + let Some(provider_credentials) = provider_credentials else { + return Ok(()); + }; + let mut seen: HashMap = HashMap::new(); + let mut collisions: Vec = Vec::new(); + let mut keys = provider_credentials + .child_env_with_gcp_resolved() + .into_keys() + .collect::>(); + keys.sort_unstable(); + for key in keys { + let folded = key.to_ascii_uppercase(); + if TLS_ENV_KEYS.iter().any(|reserved| folded == *reserved) { + collisions.push(format!("{key} (reserved for TLS trust configuration)")); + continue; + } + if let Some(existing) = seen.insert(folded, key.clone()) + && existing != key + { + collisions.push(format!("{key} (collides with {existing})")); + } + } + if collisions.is_empty() { + Ok(()) + } else { + Err(tonic::Status::failed_precondition(format!( + "provider credential environment keys are ambiguous on Windows (case-insensitive) \ + or reserved: {}", + collisions.join(", ") + ))) + } +} + 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", + "mxc governed egress requires process_container; isolation_session cannot enforce the loopback-only proxy path", )); } let raw = config.egress_proxy_addr.trim(); @@ -295,7 +455,7 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result 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(); - } +/// Minimum Windows environment variables required just for `CreateProcessW` +/// / `AppContainer`-DACL process creation to succeed at all -- independent of +/// whatever per-sandbox runtime command is selected. Confirmed empirically: +/// without `LOCALAPPDATA` specifically, `CreateProcessW` itself fails with +/// `ERROR_ENVVAR_NOT_FOUND` (Win32 203) under the appcontainer-dacl fallback +/// tier, before the agent binary is ever reached -- a Windows `AppContainer` +/// requirement, not specific to Node.js or any other agent. None of these +/// are secrets, so resolving them from the gateway host is safe; this is +/// the per-sandbox environment layers on top of. See `pc_minimal_env` on +/// `MxcComputeConfig` for the explicit empty-baseline option. +const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; - 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); - } - } - } - quoted.push_str(&"\\".repeat(backslashes * 2)); - quoted.push('"'); - quoted -} fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { config .command @@ -360,18 +500,19 @@ const TLS_ENV_KEYS: [&str; 6] = [ "GIT_SSL_CAINFO", ]; +/// Replace client trust overrides with the proxy's public CA paths. +/// Curated `ProcessContainers` receive copies staged under the authorized share, +/// rather than paths inside the host proxy's private temporary directory. 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([ @@ -384,6 +525,173 @@ fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBu ]); } +fn stage_tls_ca_files( + ca_paths: Option<&(PathBuf, PathBuf)>, + workload_dir: &str, + sandbox_id: &str, +) -> std::io::Result> { + let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { + return Ok(None); + }; + if workload_dir.trim().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "mxc driver_config.cwd must be set when staging proxy CA files", + )); + } + if sandbox_id.is_empty() + || !sandbox_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "sandbox_id must be a non-empty alphanumeric, hyphen or underscore component", + )); + } + let target_dir = PathBuf::from(workload_dir) + .join(".openshell-proxy") + .join(sandbox_id); + std::fs::create_dir_all(&target_dir)?; + let staged_ca = target_dir.join("openshell-ca.pem"); + let staged_bundle = target_dir.join("ca-bundle.pem"); + std::fs::copy(ca_cert_path, &staged_ca)?; + std::fs::copy(combined_bundle_path, &staged_bundle)?; + Ok(Some((staged_ca, staged_bundle))) +} + +/// PROTOTYPE (2026-09-10): env-var-based governed egress, as an alternative +/// to MXC's own `network.proxy`/`runtimeConfig.networkProxy` transparent +/// redirect (both confirmed broken for this driver's use case -- see +/// `network_json()` in mxc.rs for the elevation/loopback-block history). +/// `HTTP_PROXY`/`HTTPS_PROXY` are honored voluntarily by well-behaved HTTP +/// clients (curl, most language HTTP libraries, Node fetch, git, etc.), not +/// enforced by the OS -- but paired with the sandbox's own default-deny +/// egress (only 127.0.0.1 allowed, see `network_json()`), that's actually +/// sufficient: compliant agents route through the host CONNECT proxy this +/// way, and anything that ignores these vars and tries to connect directly +/// just hits the WFP deny-by-default wall instead of silently bypassing +/// governance. Lowercase forms included too since some tools (e.g. curl) +/// prefer them, and both are common in the wild. +const PROXY_ENV_KEYS: [&str; 6] = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", +]; + +const SANDBOX_PROXY_USERNAME: &str = "openshell"; + +struct SandboxProxyAuth { + password: String, +} + +impl SandboxProxyAuth { + fn generate() -> Self { + let random: [u8; 32] = rand::random(); + Self { + password: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random), + } + } + + fn proxy_url(&self, addr: SocketAddr) -> String { + format!("http://{SANDBOX_PROXY_USERNAME}:{}@{addr}", self.password) + } + + fn host_client_auth(&self) -> openshell_supervisor_network::host::HostProxyClientAuth { + openshell_supervisor_network::host::HostProxyClientAuth::basic( + SANDBOX_PROXY_USERNAME, + &self.password, + ) + } +} + +fn append_proxy_env_vars( + env: &mut Vec, + proxy_addr: Option, + proxy_auth: Option<&SandboxProxyAuth>, +) { + let (Some(addr), Some(proxy_auth)) = (proxy_addr, proxy_auth) else { + return; + }; + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !PROXY_ENV_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + }); + let proxy_url = proxy_auth.proxy_url(addr); + env.extend([ + format!("HTTP_PROXY={proxy_url}"), + format!("http_proxy={proxy_url}"), + format!("HTTPS_PROXY={proxy_url}"), + format!("https_proxy={proxy_url}"), + "NO_PROXY=".to_string(), + "no_proxy=".to_string(), + ]); +} + +/// Not called: the release wxc-exec (`BaseContainer` dispatcher) requires +/// write-DAC permission on every path in `readonlyPaths` to set up +/// `AppContainer` ACLs, and adding the TLS CA cert temp directory here +/// causes it to fail with a DACL error (empirically confirmed) -- the CA +/// cert paths are available to the agent via TLS env vars instead (see +/// `append_tls_env_vars`). Kept for a future build where that DACL +/// requirement no longer applies. +#[allow(dead_code)] +fn append_tls_readonly_grant( + readonly_paths: &mut Vec, + ca_paths: Option<&(PathBuf, PathBuf)>, +) { + let Some((ca_cert_path, _)) = ca_paths else { + return; + }; + let Some(dir) = ca_cert_path.parent().map(Path::to_path_buf) else { + return; + }; + let dir = dir.display().to_string(); + if !readonly_paths + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&dir)) + { + readonly_paths.push(dir); + } +} +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); + } + } + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} fn append_tls_readwrite_grant( readwrite_paths: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>, @@ -435,11 +743,31 @@ impl MxcComputeBackend { // Production policy translation is always handled by the embedded // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), + pending_provider_credentials: Arc::new(StdMutex::new(HashMap::new())), etw_session, attribution, } } + /// Returns a cheap, cloneable handle exposing MXC's dynamic port-forward + /// capability, so the gateway's `ComputeRuntime` can grab it (before + /// `self` is consumed into `Arc`) and call it directly + /// from `handle_forward_tcp` for sandboxes with no `ConnectSupervisor` + /// session -- MXC has no supervisor at all, so that path is otherwise + /// permanently dead for it. + pub fn forward_sink(&self) -> ForwardSink { + ForwardSink { + registry: self.registry.clone(), + } + } + + /// Return the in-process create-time provider credential side channel. + pub fn provider_credentials_sink( + &self, + ) -> Arc>> { + self.pending_provider_credentials.clone() + } + /// Test-only constructor wiring the in-process mock `wxc-exec` shim. #[cfg(test)] pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { @@ -459,10 +787,11 @@ impl MxcComputeBackend { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: self.config.backend == MxcBackend::ProcessContainer, } } - fn validate_sandbox_fields(sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { if let Some(spec) = &sandbox.spec { if effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), @@ -498,13 +827,14 @@ impl MxcComputeBackend { &MapCtx { sandbox_id: sandbox_id.to_string(), egress, + 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::validate_sandbox_fields(sandbox)?; + self.validate_sandbox_fields(sandbox)?; let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let egress_addr = configured_egress_addr(&self.config)?; self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; @@ -526,7 +856,16 @@ impl MxcComputeBackend { pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { let sandbox_id = sandbox.id.clone(); - Self::validate_sandbox_fields(sandbox)?; + // Consume before any fallible validation so rejected creates cannot + // retain real provider material in the staging map. + let provider_credentials = self + .pending_provider_credentials + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&sandbox_id); + validate_provider_child_env_keys(provider_credentials.as_ref())?; + + 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) => { @@ -547,6 +886,15 @@ impl MxcComputeBackend { // synchronously at the CreateSandbox boundary. let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; + if provider_credentials + .as_ref() + .is_some_and(ProviderCredentialState::requires_proxy_resolution) + && egress_addr.is_none() + { + return Err(tonic::Status::failed_precondition( + "mxc provider credentials require governed egress; enable egress_proxy so placeholders can be resolved by the host proxy", + )); + } if sandbox .spec @@ -592,11 +940,14 @@ impl MxcComputeBackend { isolation_stopped: false, phase_state: PhaseState::Starting, lifecycle_gate, - monitor_cancel: None, - monitor_task: None, - trimmed_policy: None, - proxy_addr: None, + exec_child: None, + shutdown_tx: None, + terminated_rx: None, + signal_file: None, + trimmed_policy: mapped.trimmed_policy.clone(), + proxy_addr: mapped.proxy_addr, host_proxy: None, + control_channel: None, }, ); } @@ -617,6 +968,7 @@ impl MxcComputeBackend { sandbox, sandbox_config, mapped, + provider_credentials, reserved_proxy_listener, startup_guard, ) @@ -637,8 +989,19 @@ impl MxcComputeBackend { (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) }; + // Blocks until any in-flight create_sandbox/run_lifecycle has either + // finished wiring shutdown_tx/control_channel or failed -- closes the + // race where a stop arriving mid-startup would otherwise find both + // `None` and silently no-op (see the `lifecycle_gate` field doc). let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let ( + iso_id, + mut isolation_stopped, + shutdown_tx, + terminated_rx, + control_channel, + host_proxy, + ) = { 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")) @@ -646,25 +1009,76 @@ impl MxcComputeBackend { ( entry.iso_sandbox_id.clone(), entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), + // Only ProcessContainer entries have these; isolation_session + // relies on invoker.stop() below instead. .take() the kill + // signal so a concurrent stop can't double-fire it, but + // .clone() terminated_rx (a watch::Receiver, not a oneshot) + // and the control channel (an Arc) -- both need to survive a + // caller that times out below and retries: a fresh clone of + // the same watch::Receiver still observes the SAME + // underlying completion, whereas .take()-ing it would make a + // retry silently skip the wait (see the matching fix in + // delete_sandbox and MR !98's review thread on this). + entry.shutdown_tx.take(), + entry.terminated_rx.clone(), + entry.control_channel.clone(), + entry.host_proxy.take(), ) }; - 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(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; + drop(host_proxy); + + 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}")) + })?; + isolation_stopped = true; + } + } else { + // ProcessContainer has no persistent iso id -- the sandbox IS + // the one-shot wxc-exec process, so without this block stop had + // nothing to act on and just relabeled the sandbox Stopped while + // wxc-exec (and everything inside the AppContainer) kept + // running. Ask nicely first over the control channel (bounded + // by request()'s own 3s timeout, same as delete_sandbox), then + // trigger the shutdown_tx kill backstop. + if let Some(channel) = control_channel { + match channel + .request( + "shutdown", + serde_json::Value::Null, + std::time::Duration::from_secs(3), + ) + .await + { + Ok(_) => { + info!(sandbox = %sandbox_name, "control-channel shutdown acknowledged"); + } + Err(e) => { + warn!(sandbox = %sandbox_name, "control-channel shutdown failed: {e}"); + } + } + } + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + // Await *confirmed* termination via terminated_rx -- not just + // firing the kill signal and reporting success regardless -- + // before this returns Ok. This runs whenever terminated_rx is + // present, independent of whether THIS call sent the kill + // signal above: shutdown_tx is None either because the process + // already exited naturally, or because an earlier (possibly + // timed-out) stop/delete attempt already sent it -- either way, + // this call still needs to observe genuine completion, not + // assume it. + if let Some(mut rx) = terminated_rx + && !wait_for_termination(&mut rx).await + { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination on stop"); + return Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the stop timeout" + ))); + } } let mut registry = self.registry.lock().await; @@ -707,8 +1121,17 @@ impl MxcComputeBackend { entry.lifecycle_gate.clone() }; + // See stop_sandbox's matching comment on lifecycle_gate. let _lifecycle_guard = lifecycle_gate.lock().await; - let (iso_id, isolation_stopped, cancel, monitor_task) = { + let ( + iso_id, + isolation_stopped, + shutdown_tx, + terminated_rx, + signal_file, + control_channel, + host_proxy, + ) = { let mut registry = self.registry.lock().await; let Some(entry) = registry.get_mut(sandbox_id) else { return Ok(false); @@ -716,18 +1139,17 @@ impl MxcComputeBackend { ( entry.iso_sandbox_id.clone(), entry.isolation_stopped, - entry.monitor_cancel.take(), - entry.monitor_task.take(), + entry.shutdown_tx.take(), + // .clone(), not .take() -- see stop_sandbox's matching + // comment: a watch::Receiver survives a caller that times + // out and retries, unlike a consumed oneshot. + entry.terminated_rx.clone(), + entry.signal_file.take(), + entry.control_channel.take(), + entry.host_proxy.take(), ) }; - 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}")) - })?; - } + drop(host_proxy); if let Some(ref iso_id) = iso_id { if !isolation_stopped { self.invoker.stop(iso_id).await.map_err(|error| { @@ -743,6 +1165,75 @@ impl MxcComputeBackend { self.invoker.deprovision(iso_id).await.map_err(|error| { tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) })?; + } else { + // Prefer telling the spawner directly over the control channel + // (a "shutdown" request -- see the launch handshake) so it can + // proactively kill its target and exit before the AppContainer + // teardown below, since that teardown alone can leave sandboxed + // processes running well past this call returning. Awaited + // (bounded by request()'s own 3s timeout) rather than + // fire-and-forget: a detached task races the shutdown_tx + // backstop below instead of being superseded by it, so the + // graceful path can lose to its own fallback. The backstop + // still always runs afterward regardless of outcome here -- + // this only orders "ask nicely" before "force it". Only + // present when the driver launched openshell-supervisor-relay + // (spawner wrapping); mxc-ws-agent.rs (no control channel) + // still uses the older signal-file mechanism. + if let Some(channel) = control_channel { + match channel + .request( + "shutdown", + serde_json::Value::Null, + std::time::Duration::from_secs(3), + ) + .await + { + Ok(_) => { + info!(sandbox = %sandbox_name, "control-channel shutdown acknowledged"); + } + Err(e) => { + warn!(sandbox = %sandbox_name, "control-channel shutdown failed: {e}"); + } + } + } + // Write the shutdown signal file so the spawner inside the + // AppContainer detects deletion and exits cleanly, freeing ports + // and child processes even if MXC does not cascade-kill them when + // wxc-exec is terminated. Only set for mxc-ws-agent.rs (no + // control channel) -- see above. + if let Some(ref path) = signal_file + && let Err(e) = std::fs::write(path, b"") + { + warn!(sandbox = %sandbox_name, path = %path.display(), error = %e, + "failed to write ProcessContainer shutdown signal file"); + } + // Signal monitor_exec to kill wxc-exec as a backstop. + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + // Await *confirmed* termination via terminated_rx -- not just + // firing the signal and reporting success regardless -- before + // this removes the registry entry and returns Ok(true). Without + // this, delete_sandbox could report success while the + // ProcessContainer (and whatever it launched) is still alive, + // retaining ports and file locks (see stop_sandbox's matching + // comment). Runs whenever terminated_rx is present, independent + // of whether THIS call sent the kill signal above -- shutdown_tx + // is None either because the process already exited naturally, + // or because an earlier (possibly timed-out) stop/delete attempt + // already sent it. A retry must still confirm genuine + // completion via the persisted watch value rather than assuming + // it, or it can remove the registry entry and report success + // while the process is still alive (MR !98 review thread). + if let Some(mut rx) = terminated_rx + && !wait_for_termination(&mut rx).await + { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination on delete"); + return Err(tonic::Status::deadline_exceeded(format!( + "sandbox {sandbox_name} did not terminate within the delete timeout" + ))); + } } let mut registry = self.registry.lock().await; @@ -803,6 +1294,91 @@ impl MxcComputeBackend { } } +// ── Dynamic port forwarding ─────────────────────────────────────────────────── +// +// Closes the `openshell forward service` gap for MXC: `handle_forward_tcp` +// normally requires a live `ConnectSupervisor` session, which MXC's +// exec-in-driver design never registers (no in-sandbox supervisor process +// exists). This gives the gateway an alternate path straight into a running +// sandbox's control channel instead, bypassing that requirement entirely. + +#[derive(Debug, thiserror::Error)] +pub enum OpenDynamicForwardError { + #[error("sandbox {0} not found")] + SandboxNotFound(String), + #[error( + "sandbox {0} has no control channel (not launched via a relay spawner, or not yet Ready)" + )] + NoControlChannel(String), + #[error("failed to bind ephemeral relay listener: {0}")] + RelayBind(#[source] std::io::Error), + #[error("control channel request failed: {0}")] + ControlChannel(#[from] crate::control_channel::ControlChannelError), + #[error("sandbox rejected forward request: {0}")] + Rejected(String), +} + +/// Cheap, cloneable handle exposing MXC's dynamic port-forward capability — +/// see `MxcComputeBackend::forward_sink`. +#[derive(Clone)] +pub struct ForwardSink { + registry: Arc>>, +} + +impl ForwardSink { + /// Open a new, independent relay bridge to `target_port` inside the + /// given sandbox's `AppContainer`, on demand (not pre-declared in the + /// gateway TOML). Returns the ephemeral relay's address — reachable + /// directly by the gateway process itself, no `AppContainer` boundary on + /// that leg — a per-forward auth nonce the caller MUST send as the first + /// bytes on its own connection to that address (see `relay.rs` module + /// docs: the relay is host-interface-bound, so another reachable process + /// could otherwise race to connect first and hijack the forward), and a + /// [`relay::RelayHandle`] the caller must hold for as long as the + /// forward should stay open, then `.stop()` (or just drop) to tear it + /// down. + /// + /// Target host is always `127.0.0.1` inside the `AppContainer` (matching + /// `TcpRelayTarget`'s existing loopback-only restriction at the gRPC + /// layer), so there's no separate `target_host` parameter to thread + /// through — the sandbox-side `forward` op only ever dials loopback. + pub async fn open_dynamic_forward( + &self, + sandbox_id: &str, + target_port: u16, + ) -> Result<(SocketAddr, [u8; relay::NONCE_LEN], relay::RelayHandle), OpenDynamicForwardError> + { + let (control_channel, sandbox_name) = { + let reg = self.registry.lock().await; + let entry = reg + .get(sandbox_id) + .ok_or_else(|| OpenDynamicForwardError::SandboxNotFound(sandbox_id.to_string()))?; + let channel = entry + .control_channel + .clone() + .ok_or_else(|| OpenDynamicForwardError::NoControlChannel(sandbox_id.to_string()))?; + (channel, entry.sandbox.name.clone()) + }; + + // Fresh per forward -- see relay.rs module docs for why this matters + // on a host-interface listener. + let nonce: [u8; relay::NONCE_LEN] = rand::random(); + + let bind_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let (relay_handle, relay_addr) = relay::start_control_channel_relay( + bind_addr, + sandbox_name, + nonce, + control_channel, + target_port, + ) + .await + .map_err(OpenDynamicForwardError::RelayBind)?; + + Ok((relay_addr, nonce, relay_handle)) + } +} + // ── Lifecycle task ──────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] @@ -815,15 +1391,18 @@ async fn run_lifecycle( sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, mapped: MappedConfig, + provider_credentials: Option, mut reserved_proxy_listener: Option, - _startup_guard: tokio::sync::OwnedMutexGuard<()>, + 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 proxy_auth = proxy_addr.map(|_| SandboxProxyAuth::generate()); + let trimmed_policy = mapped.trimmed_policy.clone(); let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) + && let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = + (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) { drop(reserved_proxy_listener.take()); match openshell_supervisor_network::host::start_host_proxy( @@ -831,10 +1410,11 @@ async fn run_lifecycle( bind_addr: addr, policy: proxy_policy, binary_path: host_proxy_binary_path(&sandbox_config), + client_auth: proxy_auth.host_client_auth(), sandbox_id: Some(sandbox_id.clone()), sandbox_name: Some(sandbox_name.clone()), openshell_endpoint: None, - provider_credentials: None, + provider_credentials: provider_credentials.clone(), agent_proposals: openshell_core::proposals::AgentProposals::default(), denial_tx: None, activity_tx: None, @@ -858,8 +1438,35 @@ async fn run_lifecycle( } else { None }; - let host_proxy_ca_paths = host_proxy.as_ref().and_then(|proxy| proxy.ca_file_paths()); - drop(reserved_proxy_listener.take()); + let host_proxy_ca_paths = host_proxy + .as_ref() + .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); + // A curated ProcessContainer cannot read the host's private temp folder. + // Stage only the public CA material beneath the per-sandbox working directory, + // DACL is already granted by the policy, so HTTPS clients can authenticate + // the OpenShell inspection proxy without broadening filesystem access. + let agent_proxy_ca_paths = if config.pc_minimal_env && host_proxy_ca_paths.is_some() { + match stage_tls_ca_files( + host_proxy_ca_paths.as_ref(), + &sandbox_config.cwd, + &sandbox_id, + ) { + Ok(paths) => paths, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to stage MXC egress proxy CA files: {error}"), + ) + .await; + return; + } + } + } else { + host_proxy_ca_paths.clone() + }; if let Some(addr) = proxy_addr { { let mut registry = registry.lock().await; @@ -876,15 +1483,12 @@ async fn run_lifecycle( )); } - // 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()); + if !config.pc_minimal_env { + append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); + } let readonly_paths = mapped.readonly_paths; + let ui = mapped.ui; let filesystem = MxcFilesystem { readwrite_paths, readonly_paths, @@ -893,18 +1497,79 @@ async fn run_lifecycle( 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"); + // ProcessContainer starts with a completely blank environment — no PATH, + // no SystemRoot, nothing. Start with either an empty environment or the + // safe Windows bootstrap set, then layer the per-request environment from + // the CreateSandbox spec and the TLS/proxy variables required by governed + // egress. + let mut env_map: HashMap = if config.pc_minimal_env { + HashMap::new() + } else { + MINIMAL_WINDOWS_BOOTSTRAP_ENV + .iter() + .filter_map(|&key| std::env::var(key).ok().map(|v| (key.to_string(), v))) + .collect() + }; + + for entry in sandbox_environment(&sandbox) { + if let Some(pos) = entry.find('=') { + env_map.insert(entry[..pos].to_string(), entry[pos + 1..].to_string()); + } + } + + let mut env: Vec = env_map + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + append_provider_child_env(&mut env, provider_credentials.as_ref()); + // Layer proxy configuration for every env tier. Curated ProcessContainers + // use the staged CA copies above; other tiers use the original paths. + append_tls_env_vars(&mut env, agent_proxy_ca_paths.as_ref()); + append_proxy_env_vars(&mut env, proxy_addr, proxy_auth.as_ref()); + env.sort(); // deterministic order for logging / debugging + info!(sandbox = %sandbox_name, count = env.len(), "MXC process env vars"); + + // When spawner wrapping is configured, launch openshell-supervisor-relay + // instead of the per-sandbox command directly. The real command/env are sent over + // the control channel once the spawner announces readiness (see the + // "launch" handshake below) rather than written to the working directory as + // agent-cmd.txt/agent-env.txt -- this keeps command/env (which can carry + // secrets, e.g. OPENCLAW_GATEWAY_TOKEN) off disk entirely and eliminates + // the file-staleness/namespace-mismatch bug class that existed when they + // were file-based. The target application (e.g. OpenClaw) + // stays entirely unaware of the relay protocol either way. + let spawner_wrapping_active = + !config.pc_relay_spawner_path.is_empty() && config.pc_relay_target_port != 0; + let effective_command_line = if spawner_wrapping_active { + // Quoted: pc_relay_spawner_path is a filesystem path and may contain + // spaces (e.g. under "Program Files"); unquoted, wxc-exec would + // parse the executable path incorrectly and the launch would fail + // before the control-channel handshake ever starts. + format!( + "\"{}\" {}", + config.pc_relay_spawner_path, config.pc_relay_target_port + ) + } else { + command_line.clone() + }; + + // Downstream logging/ETW attribution should reflect what's actually + // launched (openshell-supervisor-relay, when wrapping is active), not + // the original workload command -- shadow command_line with the effective + // value. + let command_line = effective_command_line; let process = MxcProcess { command_line: command_line.clone(), - cwd: sandbox_config.cwd, - env: environment, + cwd: sandbox_config.cwd.clone(), + // Cloned: the launch handshake below (spawner_wrapping_active case) + // needs its own copy of `env` to send over the control channel. + env: env.clone(), timeout: 0, }; let network = proxy_addr.map(|addr| MxcNetwork { default_policy: "block".into(), proxy: Some(addr), + allow_local_network: false, }); let child = match config.backend { @@ -967,11 +1632,52 @@ async fn run_lifecycle( least_privilege: config.pc_least_privilege, capabilities: config.pc_capabilities.clone(), }; - match invoker - .run_oneshot(&sandbox_id, filesystem, process_container, process, network) - .await + // Build the effective network config: + // - egress_proxy: use the proxy-based network (already in `network`) + // - pc_network_allow: inject allow-all (fallback for builds without capability support) + // - pc_allow_local_network: block-default but with allowLocalNetwork=true so + // intra-container loopback works and the spawner can reach the relay on the + // host's route-selected private interface without a full egress proxy. + let effective_network = if network.is_none() + && (config.pc_allow_local_network || config.pc_network_allow) { - Ok(child) => child, + // Both flags apply to the same no-proxy startup case and + // aren't mutually exclusive -- honor both instead of letting + // pc_allow_local_network's branch silently force + // default_policy back to "block" and drop pc_network_allow's + // unrestricted-egress intent. + Some(MxcNetwork { + default_policy: if config.pc_network_allow { + "allow".into() + } else { + "block".into() + }, + proxy: None, + allow_local_network: config.pc_allow_local_network, + }) + } else { + // `network` is Some here (egress_proxy configured). Preserve + // config.pc_allow_local_network instead of unconditionally + // clearing it -- MxcNetwork already carries both `proxy` and + // `allow_local_network` together, so a proxy and local-network + // access aren't mutually exclusive. + network.map(|mut n| { + n.allow_local_network = config.pc_allow_local_network; + n + }) + }; + match invoker + .run_oneshot( + &sandbox_id, + filesystem, + process_container, + process, + effective_network, + ui, + ) + .await + { + Ok(child) => child, Err(error) => { set_failed( ®istry, @@ -988,28 +1694,162 @@ async fn run_lifecycle( }; 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}"), - last_transition_time: String::new(), - }, - false, - ); - let (cancel_tx, cancel_rx) = watch::channel(false); - { - // 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. + // Stream wxc-exec's stdout/stderr into the gateway log as it runs, so the + // agent's live output is visible in the gateway console instead of sitting + // unread in the OS pipe until the process exits. + let mut child = child; + + // Control channel: correlate JSON responses in the stdout stream with + // pending requests sent over stdin (see control_channel.rs). Only + // meaningful when the process on the other end is + // openshell-supervisor-relay (spawner wrapping active) -- an arbitrary + // workload target wouldn't understand this protocol, so stdin is + // left untouched (and unpiped expectations unaffected) otherwise. + let control_channel: Option> = if spawner_wrapping_active { + if let Some(stdin) = child.stdin.take() { + Some(Arc::new(ControlChannel::new(stdin))) + } else { + // wxc-exec didn't give us a piped stdin even though spawner + // wrapping was requested. Without a control channel, + // openshell-supervisor-relay would wait forever for a + // "launch" request that can never arrive -- a silent hang, + // not a failure. Fail the sandbox now instead. + let err = "wxc-exec stdin is not piped; control-channel launch cannot proceed"; + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, err).await; + let _ = child.kill().await; + let _ = child.wait().await; + return; + } + } else { + // No control channel on the direct-agent path. Both spawn_exec and + // run_oneshot now always pipe stdin (needed for the control-channel + // case above), so without this the write end stays open inside + // `child` for the sandbox's full lifetime -- any workload that + // reads stdin until EOF would then block forever, since EOF never + // arrives. Drop it so stdin readers see EOF immediately instead. + drop(child.stdin.take()); + None + }; + let pending_responses = control_channel.as_ref().map(|c| c.pending_handle()); + // Startup-ready signal from the spawner (see control_channel.rs's + // try_route_ready). Fired once, before the "launch" handshake below. + // Carries Err(reason) instead of firing at all when the spawner's + // reported protocol_version doesn't match what this driver requires -- + // see try_route_ready's doc comment. + let (ready_slot, ready_rx) = if spawner_wrapping_active { + let (tx, rx) = oneshot::channel::>(); + (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) + } else { + (None, None) + }; + // Target-ready signal from the spawner (see control_channel.rs's + // try_route_target_ready) -- fired once the target is actually running + // and its configured port is accepting connections, distinct from the + // "launch" response below (which only confirms the command/env + // arrived). Awaited after "launch" succeeds and before publishing + // Ready=True, so Ready can't be reported while the target is still + // unreachable. Always Ok(()) when it fires (no version gate on this + // event -- see try_route_target_ready). + let (target_ready_slot, target_ready_rx) = if spawner_wrapping_active { + let (tx, rx) = oneshot::channel::>(); + (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) + } else { + (None, None) + }; + + if let Some(stdout) = child.stdout.take() { + let sandbox_name_out = sandbox_name.clone(); + let ready_slot = ready_slot.clone(); + let target_ready_slot = target_ready_slot.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + let routed_ready = match &ready_slot { + Some(slot) => ControlChannel::try_route_ready(slot, &line).await, + None => false, + }; + let routed_target_ready = match &target_ready_slot { + Some(slot) => ControlChannel::try_route_target_ready(slot, &line).await, + None => false, + }; + let routed = routed_ready + || routed_target_ready + || match &pending_responses { + Some(pending) => { + ControlChannel::try_route_response(pending, &line).await + } + None => false, + }; + if !routed { + info!(sandbox = %sandbox_name_out, "wxc-exec stdout: {line}"); + } + } + Ok(None) => break, + Err(e) => { + warn!(sandbox = %sandbox_name_out, "wxc-exec stdout read error: {e}"); + break; + } + } + } + // Stdout is gone (EOF or read error): no control-channel response + // will ever arrive again. Fail any still-pending requests now + // instead of leaving them to time out individually. + if let Some(pending) = &pending_responses { + ControlChannel::fail_all_pending(pending).await; + } + }); + } + // Drop this scope's Arc clones now that the stdout task holds its own: + // if the spawner exits before ever sending "ready"/"target_ready", the + // stdout task's clone is the only thing keeping the + // Mutex> alive, so its loop ending (EOF) drops the last + // reference -- which drops the still-`Some` Sender and makes + // ready_rx/target_ready_rx below observe a dropped sender immediately + // instead of waiting out the full timeout. + drop(ready_slot); + drop(target_ready_slot); + if let Some(stderr) = child.stderr.take() { + let sandbox_name_err = sandbox_name.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => warn!(sandbox = %sandbox_name_err, "wxc-exec stderr: {line}"), + Ok(None) => break, + Err(e) => { + warn!(sandbox = %sandbox_name_err, "wxc-exec stderr read error: {e}"); + break; + } + } + } + }); + } + // Publish a cancellable handle (exec_child, and for ProcessContainer + // shutdown_tx/terminated_rx too) and release the startup gate now, + // rather than holding it until the target-readiness wait below (up to + // ~430s worst case: 120s ready + 310s target_ready) completes or times + // out. stop_sandbox/delete_sandbox block on lifecycle_gate before doing + // anything else, so holding it this long meant a stop/delete arriving + // while a target is slow to (or never does) come up had no way to + // interrupt that wait -- it just queued up behind it. See also imp.rs's + // matching fix: openshell-supervisor-relay now races its own + // port-readiness wait against a "shutdown" request instead of only + // observing shutdown once that wait finishes. + let shutdown_rx = { + let mut reg = registry.lock().await; + let Some(entry) = reg.get_mut(&sandbox_id) else { + // The sandbox was deleted between agent launch and now. `delete` + // already tore down the *previous* process entry, but `child` + // here was spawned after that -- it was never registered, so + // nothing else will kill it. `tokio::process::Child` does not + // kill-on-drop, so without this the wxc-exec process (and its + // AppContainer) would keep running past `delete` reporting + // success. + drop(reg); + let _ = child.kill().await; + let _ = child.wait().await; return; }; @@ -1017,8 +1857,8 @@ async fn run_lifecycle( // `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. + // `sandbox_id` while the exact process generation is alive. Command + // text is never an attribution key. if config.etw_audit && let Some(pid) = child.id() { @@ -1029,72 +1869,337 @@ async fn run_lifecycle( } } Err(error) => { - warn!( - sandbox = %sandbox_name, - pid, - error, - "failed to obtain wxc-exec process generation key; PID-based ETW attribution disabled for this launch" - ); + warn!(sandbox = %sandbox_name, pid, error, + "failed to obtain wxc-exec process generation key; ETW attribution disabled for this launch"); + } + } + } + + entry.exec_child = Some(child); + entry.control_channel.clone_from(&control_channel); + + // For ProcessContainer, wire a kill channel so stop_sandbox/ + // delete_sandbox can terminate the wxc-exec process and cause the + // AppContainer (and all in-sandbox processes, including long-lived + // servers) to be torn down, as a backstop regardless of how shutdown + // is signaled below. `terminated_rx` is the other half of the pair + // `monitor_exec` uses to report back once the process has actually + // exited, so callers can await confirmed termination instead of + // just firing the kill and hoping. + if matches!(config.backend, MxcBackend::ProcessContainer) { + let (tx, rx) = oneshot::channel::<()>(); + let (done_tx, done_rx) = watch::channel(false); + entry.shutdown_tx = Some(tx); + entry.terminated_rx = Some(done_rx); + // The generic spawner (openshell-supervisor-relay) gets its + // shutdown notice over the control channel (see delete_sandbox's + // "shutdown" request) -- no file needed. Only mxc-ws-agent.rs + // (set directly as the sandbox command, not spawner-wrapped, no + // control channel) still polls a signal file for it. + if !spawner_wrapping_active && !sandbox_config.cwd.is_empty() { + entry.signal_file = + Some(PathBuf::from(&sandbox_config.cwd).join("openshell-shutdown.signal")); + } + Some((rx, done_tx)) + } else { + None + } + }; + + // 6. Monitor exec completion in background. + let registry2 = registry.clone(); + let watch_tx2 = watch_tx.clone(); + let sandbox2 = sandbox.clone(); + let sandbox_id2 = sandbox_id.clone(); + tokio::spawn(async move { + monitor_exec( + registry2, + watch_tx2, + attribution, + sandbox2, + sandbox_id2, + shutdown_rx, + ) + .await; + }); + + // A cancellable handle now exists in the registry (exec_child, plus + // shutdown_tx/terminated_rx for ProcessContainer) -- stop_sandbox/ + // delete_sandbox arriving from here on can act immediately instead of + // waiting out the target-readiness wait below. + drop(startup_guard); + + // When spawner wrapping is active, openshell-supervisor-relay.rs hasn't + // spawned the real target yet -- it waits for a "launch" request over + // the control channel instead of reading agent-cmd.txt/agent-env.txt + // from files in the workload directory (see its module docs). Wait for its startup-ready + // event, then send the real command/env directly; this keeps them off + // disk (they can carry secrets, e.g. OPENCLAW_GATEWAY_TOKEN) and also + // proves the correlated request/response path works end to end -- the + // old unconditional "ping" this replaces only logged a warning on + // failure, but failure here is fatal: nothing was ever spawned. + if let (Some(channel), Some(ready_rx), Some(target_ready_rx)) = + (control_channel.clone(), ready_rx, target_ready_rx) + { + // Generous timeout: this fires right after spawn, so it's racing UAC + // elevation + AppContainer creation (observed up to several + // seconds), not just the control channel itself. The `forward` path + // won't have this constraint -- it only runs once the sandbox is + // already Ready, long past this window. + let ready_timeout = std::time::Duration::from_mins(2); + let ready_err = match tokio::time::timeout(ready_timeout, ready_rx).await { + Ok(Ok(Ok(()))) => None, + // Protocol version mismatch (see try_route_ready) -- an + // independently staged, out-of-sync relay binary. Reject fast + // and clearly instead of proceeding into a "launch" handshake + // it may not understand. + Ok(Ok(Err(version_err))) => Some(version_err), + Ok(Err(_)) => Some("spawner exited before sending its ready event".to_string()), + Err(_) => Some(format!( + "timed out after {ready_timeout:?} waiting for spawner ready event" + )), + }; + let launch_err = if let Some(e) = ready_err { + Some(e) + } else { + let launch_data = serde_json::json!({ + "command": sandbox_config.command, + "env": env, + }); + match channel + .request("launch", launch_data, std::time::Duration::from_mins(2)) + .await + { + Ok(resp) if resp.get("ok").and_then(serde_json::Value::as_bool) == Some(true) => { + info!(sandbox = %sandbox_name, "control-channel launch acknowledged"); + // The "launch" response above only confirms the + // command/env reached the spawner -- it still needs + // to spawn the target and confirm its configured + // port is accepting connections + // (openshell-supervisor-relay's own + // wait_for_port_ready, up to ~300s worst case across + // its own retries). Await that distinct + // "target_ready" event before treating launch as + // successful, so a caller acting on Ready=True below + // can never race a target that hasn't bound its port + // yet. + let target_ready_timeout = std::time::Duration::from_secs(310); + match tokio::time::timeout(target_ready_timeout, target_ready_rx).await { + Ok(Ok(Ok(()))) => { + info!(sandbox = %sandbox_name, "control-channel target ready"); + None + } + // No version gate on this event, so this arm + // never actually fires today -- see + // try_route_target_ready -- but match it + // explicitly rather than unreachable!(), in case + // that ever changes. + Ok(Ok(Err(target_err))) => Some(target_err), + Ok(Err(_)) => { + Some("spawner exited before its target became ready".to_string()) + } + Err(_) => Some(format!( + "timed out after {target_ready_timeout:?} waiting for target to become ready" + )), + } } + Ok(resp) => Some( + resp.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("launch rejected") + .to_string(), + ), + Err(e) => Some(e.to_string()), } + }; + if let Some(err) = launch_err { + warn!(sandbox = %sandbox_name, "control-channel launch failed: {err}"); + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &err).await; + // `child` was already moved into the registry (and possibly + // already claimed by monitor_exec, spawned above) once a + // cancellable handle was published. ProcessContainer has + // shutdown_tx/terminated_rx for exactly this: signal it and + // await confirmed termination, the same way stop_sandbox/ + // delete_sandbox would. Otherwise fall back to reclaiming + // exec_child directly and killing it (a monitor_exec race that + // hasn't claimed exec_child yet -- rare, but possible), or, for + // isolation_session (which has neither shutdown_tx/terminated_rx + // nor -- by this point -- a leftover exec_child, since + // monitor_exec almost always already claimed it), an explicit + // invoker.stop() on the isolation session. + let (shutdown_tx, terminated_rx, leftover_child, iso_id, isolation_stopped) = { + let mut reg = registry.lock().await; + match reg.get_mut(&sandbox_id) { + Some(entry) => ( + entry.shutdown_tx.take(), + // .clone(), not .take(): see stop_sandbox's matching + // comment on terminated_rx. + entry.terminated_rx.clone(), + entry.exec_child.take(), + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + ), + None => (None, None, None, None, false), + } + }; + if let Some(tx) = shutdown_tx { + let _ = tx.send(()); + } + if let Some(mut child) = leftover_child { + // We raced monitor_exec for exec_child and this branch's + // earlier `entry.exec_child.take()` won: monitor_exec will + // find the registry slot already empty and return before + // ever reaching its `done_tx.send(true)` (MR !98 review + // thread). Waiting on terminated_rx here would therefore + // wait out the full timeout for a signal that never comes, + // while `child` -- which does not kill-on-drop -- leaks. + // Kill and reap it directly instead. + let _ = child.kill().await; + let _ = child.wait().await; + } else if let Some(mut rx) = terminated_rx { + // leftover_child was None, so monitor_exec already claimed + // exec_child and is the one racing shutdown_tx against + // child.wait(); await its confirmed termination. + if !wait_for_termination(&mut rx).await { + warn!(sandbox = %sandbox_name, "timed out waiting for ProcessContainer termination after launch failure"); + } + } else if let Some(iso_id) = iso_id { + // isolation_session: no shutdown_tx/terminated_rx (those are + // ProcessContainer-only -- see the wiring site above) and no + // leftover_child either at this point, so without this the + // MXC session (and the wxc-exec `exec` child monitor_exec's + // own child.wait() is blocked on) would keep running + // indefinitely after a launch/target-ready failure here + // (MR !98 review thread). Best-effort: log rather than + // propagate, since this cleanup runs inside an + // already-failing path. + if !isolation_stopped { + if let Err(error) = invoker.stop(&iso_id).await { + warn!(sandbox = %sandbox_name, %error, "failed to stop isolation session after launch failure"); + } else { + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(&sandbox_id) { + entry.isolation_stopped = true; + } + } + } + } + return; } + } + // 5. Self-report Ready=True. The cancellable handle (exec_child, and for + // ProcessContainer shutdown_tx/terminated_rx) was already published to + // the registry and monitor_exec already spawned, above -- this only + // updates the sandbox's condition now that the target is confirmed + // reachable. + 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}"), + last_transition_time: String::new(), + }, + false, + ); + { + let mut reg = registry.lock().await; + let Some(entry) = reg.get_mut(&sandbox_id) else { + return; + }; + // Only Starting -> Running is valid here. This publish runs after + // run_lifecycle released its startup gate (see drop(startup_guard) + // above) specifically so a stop/delete arriving during the + // target-readiness wait isn't blocked behind it -- but that means a + // stop/delete may have already moved this sandbox past Starting by + // the time this runs. Overwriting that final state with a stale + // Running, or emitting a Ready event for an already-Stopped + // sandbox, would be wrong (MR !98 review thread; same reasoning as + // set_failed's matching guard). + if entry.phase_state != PhaseState::Starting { + return; + } 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, - ))); } let _ = watch_tx.send(sandbox_event(ready_sandbox)); } +async fn wait_for_termination(rx: &mut watch::Receiver) -> bool { + matches!( + tokio::time::timeout( + std::time::Duration::from_secs(10), + rx.wait_for(|done| *done) + ) + .await, + Ok(Ok(_)) + ) +} + 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, + shutdown: Option<(oneshot::Receiver<()>, watch::Sender)>, ) { + let child = { + let mut reg = registry.lock().await; + reg.get_mut(&sandbox_id).and_then(|e| e.exec_child.take()) + }; + let Some(mut child) = child else { + return; + }; 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 { + + // For ProcessContainer sandboxes a stop/delete can arrive while the + // agent is still running. Race child exit against the kill signal so + // that the wxc-exec process — and therefore the entire AppContainer + // (including any long-lived servers bound to ports) — is terminated + // promptly. `done_tx`, when present, is signaled once the process has + // genuinely exited (natural exit or the forced kill below) so + // stop_sandbox/delete_sandbox can await *confirmed* termination instead + // of firing the kill signal and immediately reporting success. + let (wait_result, done_tx) = if let Some((rx, done_tx)) = shutdown { + tokio::select! { + res = child.wait() => (res, Some(done_tx)), + _ = rx => { if let Err(error) = child.kill().await { - warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); + warn!(sandbox = %sandbox.name, %error, "failed to terminate MXC process"); + return; } - // `kill` waits on current Tokio releases, but an explicit wait is - // harmless and guarantees the OS process handle is reaped. - let _ = child.wait().await; + if let Err(error) = child.wait().await { + warn!(sandbox = %sandbox.name, %error, "failed to confirm MXC process termination"); + return; + } + if let Some(pid) = wxc_pid + && let Ok(mut idx) = attribution.lock() + { + idx.retire_launch(&sandbox_id, pid); + } + info!(sandbox = %sandbox.name, "MXC ProcessContainer terminated for sandbox stop/delete"); + let _ = done_tx.send(true); + return; } - None } + } else { + (child.wait().await, None) }; - - // 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() - { - idx.retire_launch(&sandbox_id, pid); + if wait_result.is_ok() { + if let Some(pid) = wxc_pid + && let Ok(mut idx) = attribution.lock() + { + idx.retire_launch(&sandbox_id, pid); + } + if let Some(done_tx) = done_tx { + let _ = done_tx.send(true); + } } - let Some(status) = status else { - return; - }; - - match status { + match wait_result { Ok(status) if status.success() => { info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); let done = make_sandbox_with_condition( @@ -1170,11 +2275,24 @@ async fn set_failed( 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()); + let Some(entry) = reg.get_mut(sandbox_id) else { + return; + }; + // Only Starting -> Failed is valid here. Every call site before + // run_lifecycle releases its startup gate is safe by construction + // (phase_state is provably still Starting -- stop_sandbox/delete_sandbox + // can't touch the entry until the gate opens). The one call site after + // the gate is released (the control-channel launch-failure path) is not: + // a concurrent stop/delete may have already moved this sandbox past + // Starting while this lifecycle was still waiting on target-readiness, + // and overwriting that final state with a stale Failed -- or emitting a + // Failed event for an already-Stopped sandbox -- would be wrong + // (MR !98 review thread). + if entry.phase_state != PhaseState::Starting { + return; } + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(message.to_string()); drop(reg); let _ = watch_tx.send(sandbox_event(failed)); } @@ -1216,7 +2334,9 @@ mod lifecycle_tests { use futures::StreamExt; use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; use openshell_core::proto::{ - FilesystemPolicy, MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy, + FilesystemPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, + NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, + StaticCredentialEndpointBinding, UiClipboardAccess, UiPolicy, }; use std::time::Duration; @@ -1224,6 +2344,33 @@ mod lifecycle_tests { driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) } + #[tokio::test] + async fn termination_confirmation_rejects_closed_unconfirmed_channel() { + let (tx, mut rx) = watch::channel(false); + drop(tx); + assert!(!wait_for_termination(&mut rx).await); + } + + #[tokio::test] + async fn termination_confirmation_accepts_confirmed_exit() { + let (tx, mut rx) = watch::channel(false); + tx.send(true).unwrap(); + drop(tx); + assert!(wait_for_termination(&mut rx).await); + } + + #[test] + fn ui_policy_capability_tracks_configured_backend() { + let process_container = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + assert!(process_container.capabilities().supports_ui_policy); + + let isolation_session = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::IsolationSession, + ..Default::default() + }); + assert!(!isolation_session.capabilities().supports_ui_policy); + } + fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { let serde_json::Value::Object(driver_config) = serde_json::json!({ "command": command, @@ -1260,6 +2407,32 @@ mod lifecycle_tests { } } + fn github_provider_credentials() -> ProviderCredentialState { + ProviderCredentialState::from_bound_environment( + 42, + HashMap::from([( + "GITHUB_TOKEN".to_string(), + "raw-test-token-must-not-enter-mxc".to_string(), + )]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "GITHUB_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.github.com".to_string(), + port: 443, + path: "/**".to_string(), + }], + credential_identity: "provider-github:GITHUB_TOKEN".to_string(), + workload_credential_handle: String::new(), + }, + )]), + Vec::new(), + ) + .expect("valid GitHub provider credential state") + } + fn with_policy(mut sandbox: DriverSandbox, policy: SandboxPolicy) -> DriverSandbox { sandbox.spec.as_mut().unwrap().policy = Some(policy); sandbox @@ -1302,6 +2475,17 @@ mod lifecycle_tests { assert!(config.egress_proxy_addr.is_empty()); } + #[test] + fn gateway_config_rejects_per_sandbox_workload_fields() { + for field in ["agent_command", "agent_cwd", "agent_env", "share_dir"] { + let mut config = serde_json::Map::new(); + config.insert(field.to_string(), serde_json::json!([])); + let error = serde_json::from_value::(config.into()) + .expect_err("workload fields must not be accepted in gateway config"); + assert!(error.to_string().contains(field)); + } + } + #[test] fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { let configured = "127.0.0.1:18080".parse().unwrap(); @@ -1311,7 +2495,349 @@ mod lifecycle_tests { } #[test] - fn sandbox_environment_inherits_host_with_spec_precedence() { + fn governed_egress_rejects_non_loopback_and_isolation_session() { + let mut config = MxcComputeConfig { + egress_proxy: true, + egress_proxy_addr: "10.0.0.1:18080".into(), + ..Default::default() + }; + assert!( + configured_egress_addr(&config) + .unwrap_err() + .message() + .contains("127.0.0.1") + ); + + config.egress_proxy_addr = "127.0.0.1:18080".into(); + config.backend = MxcBackend::IsolationSession; + let error = configured_egress_addr(&config).unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("requires process_container")); + } + + #[test] + fn provider_child_environment_overrides_agent_values_with_placeholders() { + let credentials = github_provider_credentials(); + let placeholder = credentials + .snapshot() + .child_env + .get("GITHUB_TOKEN") + .cloned() + .expect("GitHub placeholder"); + let mut env = vec![ + "github_token=agent-value".to_string(), + "UNCHANGED=value".to_string(), + ]; + + append_provider_child_env(&mut env, Some(&credentials)); + + assert!(env.contains(&"UNCHANGED=value".to_string())); + assert!(!env.iter().any(|entry| entry == "github_token=agent-value")); + assert!(env.contains(&format!("GITHUB_TOKEN={placeholder}"))); + assert!(!env.iter().any(|entry| entry.contains("raw-test-token"))); + } + + #[test] + fn provider_child_env_keys_reject_case_insensitive_collision() { + let credentials = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([ + ("github_token".to_string(), "a".to_string()), + ("GITHUB_TOKEN".to_string(), "b".to_string()), + ]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec!["github_token".to_string(), "GITHUB_TOKEN".to_string()], + ) + .expect("valid provider credential state"); + + let error = validate_provider_child_env_keys(Some(&credentials)) + .expect_err("case-colliding provider keys must fail closed"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("collides")); + } + + #[test] + fn provider_child_env_keys_reject_tls_reserved_name() { + let credentials = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("SSL_CERT_FILE".to_string(), "not-a-ca-bundle".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec!["SSL_CERT_FILE".to_string()], + ) + .expect("valid provider credential state"); + + let error = validate_provider_child_env_keys(Some(&credentials)) + .expect_err("TLS-reserved provider keys must fail closed"); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("reserved for TLS")); + } + + #[test] + fn provider_child_env_keys_allow_distinct_names() { + let credentials = github_provider_credentials(); + validate_provider_child_env_keys(Some(&credentials)) + .expect("non-colliding, non-reserved provider keys are allowed"); + } + + #[tokio::test] + async fn provider_state_requiring_resolution_fails_closed_without_governed_egress() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let sink = backend.provider_credentials_sink(); + sink.lock() + .expect("provider credential staging lock poisoned") + .insert( + "sb-provider-no-proxy".to_string(), + github_provider_credentials(), + ); + + let error = backend + .create_sandbox(&with_policy( + driver_sandbox("sb-provider-no-proxy"), + fs_policy(&[]), + )) + .await + .expect_err("provider credentials must require governed egress"); + + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("enable egress_proxy")); + assert!( + sink.lock() + .expect("provider credential staging lock poisoned") + .is_empty(), + "the driver must consume staged credential material on every create attempt" + ); + } + + #[tokio::test] + async fn governed_egress_puts_only_provider_placeholder_in_mxc_process_env() { + let mut config = MxcComputeConfig { + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".to_string(), + ..Default::default() + }; + config.backend = MxcBackend::ProcessContainer; + let backend = MxcComputeBackend::new_mocked(config); + let credentials = github_provider_credentials(); + let placeholder = credentials + .snapshot() + .child_env + .get("GITHUB_TOKEN") + .cloned() + .expect("GitHub placeholder"); + backend + .provider_credentials_sink() + .lock() + .expect("provider credential staging lock poisoned") + .insert("sb-provider-env".to_string(), credentials); + + let mut policy = fs_policy(&[]); + policy.network_policies.insert( + "github".to_string(), + NetworkPolicyRule { + name: "github".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + protocol: "rest".to_string(), + provider_credentialed: true, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "cmd".to_string(), + ..Default::default() + }], + }, + ); + let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); + sandbox + .spec + .as_mut() + .expect("sandbox spec") + .environment + .extend([ + ( + "GITHUB_TOKEN".to_string(), + "raw-agent-env-token".to_string(), + ), + ("UNCHANGED".to_string(), "value".to_string()), + ]); + + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-provider-env", |_| { + crate::mxc::mock_recorded_config("sb-provider-env").is_some() + }) + .await + .expect("MXC config should be recorded"); + + let recorded = + crate::mxc::mock_recorded_config("sb-provider-env").expect("mock recorded config"); + let env = recorded["process"]["env"] + .as_array() + .expect("MXC process env") + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + assert!(env.contains(&"UNCHANGED=value")); + assert!(env.contains(&format!("GITHUB_TOKEN={placeholder}").as_str())); + let encoded = recorded.to_string(); + assert!(!encoded.contains("raw-agent-env-token")); + assert!(!encoded.contains("raw-test-token-must-not-enter-mxc")); + } + + #[test] + fn tls_state_replaces_user_trust_overrides_and_grants_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 mut env = vec![ + "FOO=bar".to_string(), + "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), + ]; + append_tls_env_vars(&mut env, Some(&(ca_cert.clone(), bundle.clone()))); + assert!(env.contains(&"FOO=bar".to_string())); + assert!( + !env.iter() + .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") + ); + assert!(env.contains(&format!("SSL_CERT_FILE={}", bundle.display()))); + + let existing = tls_dir.display().to_string().to_ascii_lowercase(); + let mut readonly = vec![existing.clone()]; + append_tls_readonly_grant(&mut readonly, Some(&(ca_cert, bundle))); + assert_eq!(readonly, vec![existing]); + } + + #[test] + fn tls_ca_files_are_staged_under_the_authorized_share() { + let source = tempfile::tempdir().unwrap(); + let share = tempfile::tempdir().unwrap(); + let ca = source.path().join("source-ca.pem"); + let bundle = source.path().join("source-bundle.pem"); + std::fs::write(&ca, b"ca").unwrap(); + std::fs::write(&bundle, b"bundle").unwrap(); + + let staged = stage_tls_ca_files( + Some(&(ca, bundle)), + share.path().to_str().expect("UTF-8 test path"), + "sandbox-a", + ) + .unwrap() + .expect("staged paths"); + + assert_eq!( + staged.0.parent().unwrap(), + share.path().join(".openshell-proxy").join("sandbox-a") + ); + assert_eq!(std::fs::read(staged.0).unwrap(), b"ca"); + assert_eq!(std::fs::read(staged.1).unwrap(), b"bundle"); + } + + #[test] + fn tls_ca_staging_keeps_sandboxes_in_the_same_share_independent() { + let source = tempfile::tempdir().unwrap(); + let share = tempfile::tempdir().unwrap(); + let paths = ( + source.path().join("ca.pem"), + source.path().join("bundle.pem"), + ); + std::fs::write(&paths.0, b"first-ca").unwrap(); + std::fs::write(&paths.1, b"first-bundle").unwrap(); + let first = stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), "sandbox-a") + .unwrap() + .unwrap(); + std::fs::write(&paths.0, b"second-ca").unwrap(); + std::fs::write(&paths.1, b"second-bundle").unwrap(); + let second = stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), "sandbox-b") + .unwrap() + .unwrap(); + + assert_ne!(first, second); + assert_eq!(std::fs::read(first.0).unwrap(), b"first-ca"); + assert_eq!(std::fs::read(first.1).unwrap(), b"first-bundle"); + assert_eq!(std::fs::read(second.0).unwrap(), b"second-ca"); + assert_eq!(std::fs::read(second.1).unwrap(), b"second-bundle"); + } + + #[test] + fn tls_ca_staging_rejects_empty_shares_and_unsafe_sandbox_components() { + let share = tempfile::tempdir().unwrap(); + let paths = (PathBuf::from("unused-ca"), PathBuf::from("unused-bundle")); + for empty in ["", " ", "\t"] { + assert_eq!( + stage_tls_ca_files(Some(&paths), empty, "sandbox-a") + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + for id in [ + "", + ".", + "..", + "../outside", + "..\\outside", + "C:\\outside", + "file:stream", + ] { + assert_eq!( + stage_tls_ca_files(Some(&paths), share.path().to_str().unwrap(), id) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + assert_eq!(std::fs::read_dir(share.path()).unwrap().count(), 0); + assert_eq!(stage_tls_ca_files(None, "", "").unwrap(), None); + } + + #[test] + fn proxy_env_replaces_inherited_values_and_clears_bypass_rules() { + let mut env = vec![ + "PATH=C:\\Windows".to_owned(), + "HTTP_PROXY=http://stale.invalid:1".to_owned(), + "https_proxy=http://stale.invalid:2".to_owned(), + "NO_PROXY=example.com".to_owned(), + "no_proxy=example.org".to_owned(), + ]; + + let proxy_auth = SandboxProxyAuth { + password: "sandbox-secret".to_owned(), + }; + append_proxy_env_vars( + &mut env, + Some("127.0.0.1:18080".parse().unwrap()), + Some(&proxy_auth), + ); + + assert!(env.contains(&"PATH=C:\\Windows".to_owned())); + for key in PROXY_ENV_KEYS { + assert_eq!( + env.iter() + .filter(|entry| entry.starts_with(&format!("{key}="))) + .count(), + 1, + "{key} must be emitted exactly once" + ); + } + assert!( + env.contains(&"HTTP_PROXY=http://openshell:sandbox-secret@127.0.0.1:18080".to_owned()) + ); + assert!( + env.contains(&"HTTPS_PROXY=http://openshell:sandbox-secret@127.0.0.1:18080".to_owned()) + ); + assert!(env.contains(&"NO_PROXY=".to_owned())); + assert!(env.contains(&"no_proxy=".to_owned())); + } + + #[test] + fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { let mut sandbox = driver_sandbox("sb-env"); let spec = sandbox.spec.as_mut().unwrap(); spec.template @@ -1324,14 +2850,9 @@ mod lifecycle_tests { 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) + key == "SHARED" || key == "TOKEN" })); } @@ -1424,7 +2945,7 @@ mod lifecycle_tests { 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 host_path = tmp.path().join("hello.txt"); let mut found = false; for _ in 0..100 { if host_path.exists() { @@ -1490,8 +3011,11 @@ mod lifecycle_tests { recorded.get("network").is_none(), "coarse path must not emit an MXC network block" ); + assert_eq!(recorded["ui"]["disable"], true); + assert_eq!(recorded["ui"]["clipboard"], "none"); + assert_eq!(recorded["ui"]["injection"], false); - let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let host_path = tmp.path().join("hello.txt"); let mut found = false; for _ in 0..100 { if host_path.exists() { @@ -1519,10 +3043,12 @@ mod lifecycle_tests { "-Command".into(), format!("Set-Content -LiteralPath {hello} -Value hi"), ]; - let mut config = MxcComputeConfig::default(); - config.backend = MxcBackend::ProcessContainer; - config.egress_proxy = true; - config.egress_proxy_addr = "127.0.0.1:18080".into(); + let config = MxcComputeConfig { + backend: MxcBackend::ProcessContainer, + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }; let backend = MxcComputeBackend::new_mocked(config); let mut stream = backend.watch_sandboxes().await; @@ -1547,6 +3073,7 @@ mod lifecycle_tests { driver_sandbox_with_command("sb-egress", &share, cmd), policy.clone(), ); + backend .create_sandbox(&sandbox) .await @@ -1562,23 +3089,16 @@ mod lifecycle_tests { ); let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["network"]["defaultPolicy"], "block"); + assert_eq!(recorded["version"], "0.8.0-alpha"); + assert_eq!(recorded["network"]["egress"]["default"], "deny"); + assert_eq!( + recorded["network"]["egress"]["allow"], + serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) + ); assert!(recorded["network"].get("allowedHosts").is_none()); assert!(recorded["network"].get("blockedHosts").is_none()); - // 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!(proxy_port <= u64::from(u16::MAX)); - 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" - ); + assert!(recorded["network"].get("proxy").is_none()); + assert!(recorded.get("networkProxy").is_none()); let reg = backend.registry.lock().await; let entry = reg.get("sb-egress").expect("registry entry"); @@ -1587,7 +3107,19 @@ mod lifecycle_tests { entry_proxy_addr.ip(), std::net::IpAddr::from([127, 0, 0, 1]) ); - assert_eq!(u64::from(entry_proxy_addr.port()), proxy_port); + assert_ne!(entry_proxy_addr.port(), 0); + let child_env = recorded["process"]["env"].as_array().expect("child env"); + let proxy_env = child_env + .iter() + .filter_map(serde_json::Value::as_str) + .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) + .expect("HTTP_PROXY must direct clients to the authenticated proxy"); + let (credentials, address) = proxy_env + .strip_prefix("http://openshell:") + .and_then(|value| value.split_once('@')) + .expect("proxy URL must contain the per-sandbox credential"); + assert!(!credentials.is_empty()); + assert_eq!(address, entry_proxy_addr.to_string()); assert_eq!( entry.trimmed_policy.as_ref().unwrap().network_policies, policy.network_policies @@ -1610,12 +3142,58 @@ mod lifecycle_tests { } } Ok(_) => break, - Err(_) => continue, + Err(_) => {} } } assert!(saw_redirect, "expected EgressRedirect platform event"); } + #[tokio::test] + async fn processcontainer_live_config_carries_explicit_ui_policy() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let mut policy = fs_policy(&[]); + policy.ui = Some(UiPolicy { + allow_graphical_ui: true, + clipboard: UiClipboardAccess::All as i32, + allow_input_injection: true, + }); + let sandbox = with_policy(driver_sandbox("sb-pc-ui"), policy); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + let _ = wait_for(&backend, "sb-pc-ui", |_| { + crate::mxc::mock_recorded_config("sb-pc-ui").is_some() + }) + .await; + let recorded = crate::mxc::mock_recorded_config("sb-pc-ui") + .expect("mock recorded processContainer config"); + assert_eq!(recorded["ui"]["disable"], false); + assert_eq!(recorded["ui"]["clipboard"], "all"); + assert_eq!(recorded["ui"]["injection"], true); + } + + #[tokio::test] + async fn isolation_session_rejects_ui_before_lifecycle_side_effects() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::IsolationSession, + ..Default::default() + }); + let policy = SandboxPolicy { + ui: Some(UiPolicy::default()), + ..Default::default() + }; + let sandbox = with_policy(driver_sandbox("sb-iso-ui"), policy); + let error = backend + .create_sandbox(&sandbox) + .await + .expect_err("isolation UI must be rejected synchronously"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("ui")); + assert!(backend.list_sandboxes().await.is_empty()); + assert!(crate::mxc::mock_recorded_config("sb-iso-ui").is_none()); + } + #[tokio::test] async fn negative_out_of_policy_write_is_denied_with_event() { let share_tmp = tempfile::tempdir().unwrap(); @@ -1669,7 +3247,7 @@ mod lifecycle_tests { ); // 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"); + let out_fs = 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. @@ -1714,6 +3292,144 @@ mod lifecycle_tests { assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); } + /// `delete_sandbox` must await *confirmed* `ProcessContainer` termination + /// before removing the registry entry and reporting success -- not just + /// fire the kill signal and report success regardless (which could + /// leave the process retaining ports and file locks past a successful + /// delete). Bounded well under the child's own sleep duration: if + /// delete stopped awaiting `terminated_rx`, this would still return + /// quickly (the bug was reporting success *too early*, not hanging), so + /// the meaningful assertion is that the sandbox is confirmed gone from + /// the registry immediately after -- a second delete finds nothing. + #[tokio::test] + async fn delete_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(MxcComputeConfig::default()); + let policy = fs_policy(&[&share]); + let sandbox = with_policy( + driver_sandbox_with_command("sb-delete", "", command), + policy, + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-delete", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + tokio::time::sleep(Duration::from_millis(250)).await; + + let deleted = tokio::time::timeout( + Duration::from_secs(5), + backend.delete_sandbox(&sandbox.id, "sb-delete"), + ) + .await + .expect("delete should not wait for the child sleep") + .expect("delete should terminate and reap the child"); + assert!(deleted, "delete should report the sandbox as removed"); + assert!( + backend.get_sandbox("sb-delete").await.is_none(), + "sandbox should be gone from the registry after delete" + ); + } + + /// `delete_sandbox` must confirm genuine termination via `terminated_rx` + /// even when `shutdown_tx` was already consumed by an earlier attempt (a + /// timed-out delete/stop, or -- as constructed directly here -- any + /// other caller that got to the field first). `terminated_rx` must + /// therefore be `.clone()`d, not `.take()`n, from the registry entry: + /// taking it would make this call's own None-shutdown_tx branch skip + /// the wait entirely and report success (and remove the registry entry) + /// before termination was ever confirmed -- exactly the MR !98 review + /// thread this regression-tests ("delete timeout loses termination + /// state"). + #[tokio::test] + async fn delete_confirms_termination_even_when_shutdown_tx_already_taken() { + 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(MxcComputeConfig::default()); + let policy = fs_policy(&[&share]); + let sandbox = with_policy( + driver_sandbox_with_command("sb-delete-retry", "", command), + policy, + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, "sb-delete-retry", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + tokio::time::sleep(Duration::from_millis(250)).await; + + // Simulate an earlier delete/stop attempt that already consumed and + // fired shutdown_tx (e.g. one that then timed out before + // confirming termination) -- take a fresh watch subscription first + // so this test can observe the SAME completion delete_sandbox + // itself must wait for. + let terminated = { + let registry = backend.registry.lock().await; + registry + .get(&sandbox.id) + .expect("sandbox should be registered") + .terminated_rx + .clone() + .expect("ProcessContainer entry should have terminated_rx wired") + }; + { + let mut registry = backend.registry.lock().await; + let entry = registry + .get_mut(&sandbox.id) + .expect("sandbox should be registered"); + let tx = entry + .shutdown_tx + .take() + .expect("ProcessContainer entry should have shutdown_tx wired"); + let _ = tx.send(()); + } + + let deleted = tokio::time::timeout( + Duration::from_secs(5), + backend.delete_sandbox(&sandbox.id, "sb-delete-retry"), + ) + .await + .expect("delete should still confirm termination within the timeout, not hang") + .expect("delete should succeed even with shutdown_tx already taken"); + assert!(deleted, "delete should report the sandbox as removed"); + + // The regression this guards against: delete_sandbox used to + // .take() terminated_rx too, so finding shutdown_tx already None + // made it skip waiting entirely and return success immediately -- + // well before the real OS process had actually been killed and + // reaped. If that bug were back, this watch value could still be + // false right here. + assert!( + *terminated.borrow(), + "delete_sandbox must not report success before terminated_rx confirms the process died" + ); + assert!( + backend.get_sandbox("sb-delete-retry").await.is_none(), + "sandbox should be gone from the registry after delete" + ); + } + #[tokio::test] async fn unmappable_network_policy_fails_create_lifecycle() { use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; @@ -1745,9 +3461,11 @@ mod lifecycle_tests { #[tokio::test] async fn governed_egress_rejects_network_middleware_before_lifecycle() { - let mut config = MxcComputeConfig::default(); - config.egress_proxy = true; - config.egress_proxy_addr = "127.0.0.1:18080".into(); + let config = MxcComputeConfig { + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }; let backend = MxcComputeBackend::new_mocked(config); let mut policy = fs_policy(&[]); diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 4b9d328e9f..dce22b3b49 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -15,6 +15,8 @@ #![allow(clippy::result_large_err)] +#[cfg(target_os = "windows")] +mod control_channel; #[cfg(target_os = "windows")] mod driver; #[cfg(target_os = "windows")] @@ -32,11 +34,17 @@ mod policy_map; // Windows-only. #[cfg(target_os = "windows")] mod etw_consumer; +#[cfg(target_os = "windows")] +mod relay; #[cfg(target_os = "windows")] -pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; +pub use driver::{ + ForwardSink, MxcBackend, MxcComputeBackend, MxcComputeConfig, OpenDynamicForwardError, +}; #[cfg(target_os = "windows")] pub use grpc::ComputeDriverService; +#[cfg(target_os = "windows")] +pub use relay::RelayHandle; // Re-export the embedded mapper API so the windows-only example and integration // test can reach it without making `policy_map` a public module. #[cfg(target_os = "windows")] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 0a77565f83..eecc1f7155 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -15,10 +15,11 @@ 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"; +/// 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"; /// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; @@ -69,6 +70,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,20 +133,74 @@ pub struct MxcProcess { pub timeout: u64, } +/// 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 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() }); + // 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 in practice blocks loopback connects from the relay to + // its target process (127.0.0.1:port) even with networkLoopback capability + // in the PSEC spec. Direct allow for 127.0.0.1/32 (not the broader 127.0.0.0/8 + // range -- openshell-supervisor-relay only ever dials the literal + // 127.0.0.1, see imp.rs) lets the relay reach: + // - the target it spawns (loopback inside AppContainer) + // - the host relay listener (also 127.0.0.1 via egress allow) + // 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: `openshell forward service`'s + // dynamic bridge (imp.rs's "forward" control-channel op) connects the + // relay out to a fresh, per-request ephemeral host port chosen at + // forward-call time (data.relay_addr), not a port known when this + // config is generated -- confirmed 2026-09-10 that scoping `ports` to + // just [proxy.port(), relay_target_port] breaks that dynamic forward + // (ws-echo failed with a "forbidden by access permissions" / 10013 + // relay-connect error). Any-port-on-127.0.0.1 is the correct scope + // here, not a narrower static list. + 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" }); } value } @@ -142,6 +237,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,6 +278,15 @@ 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 } @@ -335,7 +440,11 @@ impl WxcExecInvoker { cmd.arg("--debug"); } - debug!(config = %json, "wxc-exec phase"); + // `config` here never carries `process.env` today (provision/start/ + // stop/deprovision have no `process` field at all -- see run_phase's + // doc comment), but redact defensively rather than relying on that + // staying true. + debug!(config = %redact_env_for_debug(config), "wxc-exec phase"); let output = cmd.output().await?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -406,7 +515,13 @@ impl WxcExecInvoker { cmd.arg("--debug"); } - debug!(config = %json, "wxc-exec provision"); + let redacted = redact_env_for_debug(&config); + if self.debug { + let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| json.clone()); + info!("generated wxc-config (provision):\n{pretty}"); + } else { + debug!(config = %redacted, "wxc-exec provision"); + } let output = cmd.output().await?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -494,15 +609,26 @@ impl WxcExecInvoker { 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); + // Piped (not null): mirrors the ProcessContainer one-shot spawn + // below -- with STDIO passthrough, wxc-exec forwards this handle + // down to the exec'd child, giving the driver a control channel + // into the isolation_session sandbox with no network capability + // required. Without this, pc_relay_spawner_path's control channel + // (and therefore dynamic `openshell forward service`) silently + // has nothing to attach to on this backend. + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); if self.debug { cmd.arg("--debug"); } - debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); + if self.debug { + let redacted = redact_env_for_debug(&config); + let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| json.clone()); + info!(sandbox_id = %iso_sandbox_id, "generated wxc-config (exec):\n{pretty}"); + } + info!(sandbox_id = %iso_sandbox_id, "wxc-exec exec spawn"); let child = cmd.spawn()?; Ok(child) } @@ -575,9 +701,16 @@ 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 @@ -593,20 +726,49 @@ impl WxcExecInvoker { } 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); + // Piped (not null): with STDIO passthrough, wxc-exec forwards this + // handle down to the sandboxed child, giving the driver a write + // channel into the AppContainer with no network capability + // required at all -- see openshell-supervisor-relay's stdin/stdout + // JSON control protocol. + .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) } @@ -745,7 +907,7 @@ mod tests { } #[test] - fn provision_config_json_includes_network_proxy_when_supplied() { + fn provision_config_json_includes_network_loopback_when_proxy_supplied() { let filesystem = MxcFilesystem { readwrite_paths: vec!["C:\\work\\demo".into()], readonly_paths: Vec::new(), @@ -754,39 +916,47 @@ mod tests { let network = MxcNetwork { default_policy: "block".into(), proxy: Some("127.0.0.1:18080".parse().unwrap()), + allow_local_network: false, }; 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" + // With proxy: use direct loopback egress (127.0.0.1/32 allow) instead of + // runtimeConfig.networkProxy proxy mode, so relay can reach its spawned + // target process via loopback without processmodel.dll proxy-redirect WFP + // interference. PSEC tier still selected via requires_psec_networking(). + assert_eq!(config["network"]["egress"]["default"], "deny"); + assert_eq!( + config["network"]["egress"]["allow"][0]["to"][0]["cidr"], + "127.0.0.1/32" ); + assert_eq!(config["network"]["ingress"]["default"], "allow"); + assert_eq!(config["network"]["ingress"]["hostLoopback"], "allow"); + assert!(config["network"].get("defaultPolicy").is_none()); + assert!(config["network"].get("proxy").is_none()); + // No runtimeConfig.networkProxy — using direct loopback egress instead. + assert!(config.get("runtimeConfig").is_none()); } #[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 relay to reach both the spawned target (intra-container loopback) + // and the host relay listener (host loopback) without proxy-mode WFP issues. + 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,9 +973,39 @@ 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 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 ui = MxcUi { + disable: false, + clipboard: MxcClipboardAccess::Write, + injection: true, + }; + let config = oneshot_config_json("sb-ui", &filesystem, &pc, &process, None, Some(&ui)); + + assert_eq!(config["ui"]["disable"], false); + assert_eq!(config["ui"]["clipboard"], "write"); + assert_eq!(config["ui"]["injection"], true); + } + + #[test] + fn isolation_provision_config_never_synthesizes_ui() { + let config = + provision_config_json(DEFAULT_CONFIGURATION_ID, &MxcFilesystem::default(), None); + assert!(config.get("ui").is_none()); } #[test] diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 30a0ca56da..2a5c9883aa 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(); @@ -313,6 +436,7 @@ 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(); 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 4bfcc316a8..4af3290715 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,12 @@ 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_policies` verbatim. Network middleware remains rejected until the +/// host proxy can receive the gateway middleware service registry. /// /// 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 +153,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() { @@ -185,34 +187,34 @@ fn build_split_mxc_config( ); } - // 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 +224,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 +275,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/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs new file mode 100644 index 0000000000..da0a000188 --- /dev/null +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -0,0 +1,686 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket relay embedded in the gateway for MXC `ProcessContainer` sandboxes. +//! +//! When `egress_proxy = true` the `AppContainer` has outbound TCP via the +//! `OpenShell` host CONNECT proxy. The driver binds a relay listener on demand +//! (`start_relay`, e.g. from `ForwardSink::open_dynamic_forward`) and tells +//! the in-sandbox spawner its address over the stdin/stdout control channel; +//! the spawner connects outward to it as a WebSocket CLIENT (Phase A). Host +//! clients connect as raw TCP (Phase B); the relay tunnels their bytes +//! through Phase A so the in-sandbox agent can pipe them directly to the +//! target service. Each relay is per-request and short-lived — bound fresh +//! for each `openshell forward service` call, torn down when that forward +//! ends. +//! +//! ```text +//! host TCP client -> relay (gateway, raw TCP accept) +//! | tunnel via Phase A WS +//! sandbox agent -> local service (openclaw:18889) +//! ``` +//! +//! The listener is bound to the host's route-selected IPv4 interface rather +//! than loopback. `AppContainer` fallback does not map its `127.0.0.1` to the +//! host, so a loopback listener is unreachable unless traffic is sent through +//! the CONNECT proxy; that proxy can also capture the bridge's separate +//! sandbox-local target connection. Binding one concrete host interface keeps +//! the target hop on sandbox loopback and lets `allowLocalNetwork` authorize +//! only the host callback. In principle another host or local process could +//! race to connect before the real Phase A/B peer does and +//! hijack or inject traffic into the forward. Both phases are authenticated +//! against a fresh, unguessable per-forward nonce (`ForwardSink:: +//! open_dynamic_forward` generates it) instead of trusting connection order: +//! +//! Phase A (sandbox spawner, WS client) must send `TEXT "AUTH:"` as its first message, before anything else is +//! accepted from that connection -- see openshell-supervisor- +//! relay's `run_relay_bridge`, which sends this immediately +//! after connecting. +//! Phase B (host client, raw TCP -- normally the gateway process itself, +//! connecting right after `open_dynamic_forward` returns) must +//! write the raw nonce bytes as the first bytes on the +//! connection, before any tunneled application data -- see +//! openshell-server's `ForwardTcp` handler. +//! +//! A connection that fails or times out on this check is closed and the +//! relay keeps waiting for the real peer, rather than treating the first +//! comer as authoritative or tearing the whole relay down (a wrong guess +//! shouldn't be a viable way to deny service to the real caller either). +//! +//! Protocol over Phase A (WS connection from sandbox to relay), after auth: +//! TEXT "`SESSION_START`" — relay opened a Phase B TCP connection +//! BINARY — bytes from Phase B TCP stream +//! TEXT "`SESSION_END`" — Phase B TCP connection closed +//! WS Close — relay shutting down (`delete_sandbox` or error) +//! +//! Phase B (host client), after the nonce prefix, is a plain byte stream — +//! no WS handshake — so the client's full byte stream (including any WS +//! upgrade request and frames) is tunneled transparently to the in-sandbox +//! service from that point on. + +use crate::control_channel::ControlChannel; +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use openshell_core::net::set_tcp_nodelay_best_effort; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::{accept_async, tungstenite::Message}; +use tracing::{info, warn}; + +/// Length in bytes of the per-forward auth nonce (see module docs). Must +/// match `NONCE_LEN` in `driver.rs` (which generates it) and +/// `openshell-supervisor-relay`'s copy (which echoes it back on Phase A) -- +/// duplicated rather than shared via a common crate, matching how the rest +/// of this wire protocol (e.g. the "`SESSION_START`"/"`SESSION_END`" literals) +/// is already duplicated across the two sides. +pub const NONCE_LEN: usize = 32; + +/// How long to wait for a freshly-accepted connection to present its auth +/// nonce before giving up on it and going back to waiting for the real peer. +/// Generous: this is a local host round-trip, but a slow/malicious +/// connector shouldn't be able to stall the relay for the legitimate peer +/// for long either. +const AUTH_TIMEOUT: Duration = Duration::from_secs(5); + +/// Select the concrete host IPv4 address used to reach the machine's default +/// route. UDP `connect` performs route selection without sending a packet, so +/// this does not depend on the probe endpoint being reachable. Binding the +/// relay to that exact interface avoids exposing it on every interface while +/// still making it reachable from an `AppContainer` whose loopback is isolated +/// from the host's loopback. +/// Fixed-time byte comparison so a wrong guess doesn't leak how many +/// leading bytes it got right via response timing. The nonce is one-shot +/// (a fresh relay per forward) so this is defense in depth rather than +/// closing a practically exploitable channel, but it's free. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Hex-encode `bytes` (lowercase, unpadded). `pub` (crate-visible in +/// practice, since `relay` isn't a `pub mod`) so `ForwardSink:: +/// open_dynamic_forward` in `driver.rs` can use the exact same encoding to +/// build the "forward" control-channel request's `nonce` field that this +/// module expects back from the sandbox on Phase A. +pub fn encode_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// Render the first `n` bytes of `data` as a printable-ASCII preview +/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. +/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an +/// HTTP/WS handshake looks intact versus corrupted or empty. +/// +/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or +/// other sensitive payload, and this relay's own logs are gateway logs, so no +/// byte preview is ever logged, at any level. Kept only so a future opt-in +/// diagnostic mode has a ready-made (still-redaction-worthy) formatter to +/// start from. +#[allow(dead_code)] +fn byte_preview(data: &[u8]) -> String { + const MAX: usize = 120; + let n = data.len().min(MAX); + let mut s: String = data[..n] + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + if data.len() > MAX { + s.push_str("..."); + } + s +} + +// ── Public handle ───────────────────────────────────────────────────────────── + +/// Owned handle returned by [`start_relay`]. Drop or call [`stop`] to +/// shut down the relay task and release the listener port. +pub struct RelayHandle { + shutdown_tx: oneshot::Sender<()>, +} + +impl RelayHandle { + pub fn stop(self) { + let _ = self.shutdown_tx.send(()); + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +/// Bind a TCP listener on `bind_addr` and spawn the relay task. The caller +/// communicates the returned address (and `nonce`) to the sandbox directly +/// (over the control channel) — this doesn't touch the filesystem at all. +/// `nonce` must be freshly generated per call (see module docs) — it's what +/// lets this host-interface relay tell the real Phase A/B peers apart from +/// any other local process that might race to connect first. +pub async fn start_relay( + bind_addr: SocketAddr, + sandbox_name: String, + nonce: [u8; NONCE_LEN], +) -> std::io::Result<(RelayHandle, SocketAddr)> { + let listener = TcpListener::bind(bind_addr).await?; + let actual = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(relay_task( + listener, + sandbox_name.clone(), + nonce, + shutdown_rx, + )); + info!(sandbox = %sandbox_name, relay = %actual, "MXC relay started"); + Ok((RelayHandle { shutdown_tx }, actual)) +} + +/// Start a host-loopback listener whose sandbox leg is multiplexed over the +/// inherited stdin/stdout control channel. Unlike [`start_relay`], this path +/// never asks the `AppContainer` to connect back to a host network address. +pub async fn start_control_channel_relay( + bind_addr: SocketAddr, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + control_channel: Arc, + target_port: u16, +) -> std::io::Result<(RelayHandle, SocketAddr)> { + let listener = TcpListener::bind(bind_addr).await?; + let actual = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(control_channel_relay_task( + listener, + sandbox_name.clone(), + nonce, + control_channel, + target_port, + shutdown_rx, + )); + info!(sandbox = %sandbox_name, relay = %actual, "MXC control-channel relay started"); + Ok((RelayHandle { shutdown_tx }, actual)) +} + +async fn control_channel_relay_task( + listener: TcpListener, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + control_channel: Arc, + target_port: u16, + mut shutdown_rx: oneshot::Receiver<()>, +) { + loop { + let mut host_stream = tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + info!(sandbox = %sandbox_name, %addr, "MXC control-channel relay: host client connected"); + set_tcp_nodelay_best_effort(&stream); + stream + } + Err(error) => { + warn!(sandbox = %sandbox_name, "MXC control-channel relay listener error: {error}"); + return; + } + }, + _ = &mut shutdown_rx => return, + }; + + let mut auth_buf = [0_u8; NONCE_LEN]; + match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { + Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} + Ok(Ok(_) | Err(_)) | Err(_) => continue, + } + + let session_id = encode_hex(&rand::random::<[u8; NONCE_LEN]>()); + let open = control_channel + .request( + "forward_open", + serde_json::json!({"session_id": session_id, "target_port": target_port}), + Duration::from_secs(10), + ) + .await; + if !control_response_ok(&open) { + warn!(sandbox = %sandbox_name, "MXC control-channel relay: sandbox rejected session open"); + continue; + } + + let (mut host_read, mut host_write) = host_stream.into_split(); + let mut host_buf = vec![0_u8; 8192]; + let mut host_to_sandbox_bytes = 0_u64; + let mut sandbox_to_host_bytes = 0_u64; + let mut shutting_down = false; + loop { + tokio::select! { + result = host_read.read(&mut host_buf) => match result { + Ok(0) | Err(_) => break, + Ok(n) => { + let bytes = base64::engine::general_purpose::STANDARD.encode(&host_buf[..n]); + let response = control_channel.request( + "forward_write", + serde_json::json!({"session_id": session_id, "bytes": bytes}), + Duration::from_secs(10), + ).await; + if !control_response_ok(&response) { + break; + } + host_to_sandbox_bytes += n as u64; + } + }, + response = control_channel.request( + "forward_read", + serde_json::json!({"session_id": session_id}), + Duration::from_secs(10), + ) => { + let Ok(response) = response else { break }; + if response.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + break; + } + let data = response.get("data").unwrap_or(&serde_json::Value::Null); + if data.get("eof").and_then(serde_json::Value::as_bool) == Some(true) { + break; + } + let Some(encoded) = data.get("bytes").and_then(serde_json::Value::as_str) else { + break; + }; + if !encoded.is_empty() { + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else { + break; + }; + if host_write.write_all(&bytes).await.is_err() { + break; + } + sandbox_to_host_bytes += bytes.len() as u64; + } + }, + _ = &mut shutdown_rx => { + shutting_down = true; + break; + }, + } + } + let _ = control_channel + .request( + "forward_close", + serde_json::json!({"session_id": session_id}), + Duration::from_secs(5), + ) + .await; + if shutting_down { + return; + } + info!( + sandbox = %sandbox_name, + host_to_sandbox_bytes, + sandbox_to_host_bytes, + "MXC control-channel relay: host client disconnected" + ); + } +} + +fn control_response_ok( + response: &Result, +) -> bool { + matches!( + response, + Ok(value) if value.get("ok").and_then(serde_json::Value::as_bool) == Some(true) + ) +} + +async fn relay_task( + listener: TcpListener, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + mut shutdown_rx: oneshot::Receiver<()>, +) { + let expected_auth = format!("AUTH:{}", encode_hex(&nonce)); + + // Phase A: wait for the sandbox agent's outbound WS connection, and + // require it to prove it's the real peer (see module docs) before + // trusting anything else from it. A connection that fails or times out + // on this is closed; the relay keeps waiting rather than accepting the + // first comer or giving up entirely. + let mut sandbox_ws = loop { + tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + // Latency-sensitive request/response tunnel, including on + // a same-host interface -- small WS frames can otherwise + // stall behind delayed ACK behavior. Best-effort, before + // the WS upgrade so it applies to the whole connection. + set_tcp_nodelay_best_effort(&stream); + match tokio::time::timeout(AUTH_TIMEOUT, accept_async(stream)).await { + Ok(Ok(mut ws)) => { + match tokio::time::timeout(AUTH_TIMEOUT, ws.next()).await { + Ok(Some(Ok(Message::Text(t)))) + if constant_time_eq(t.as_bytes(), expected_auth.as_bytes()) => + { + info!(sandbox = %sandbox_name, %addr, "MXC relay: sandbox connected (authenticated)"); + break ws; + } + Ok(Some(Ok(_))) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS auth message did not match; closing and continuing to wait"); + let _ = ws.close(None).await; + } + Ok(_) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS closed/errored before authenticating; continuing to wait"); + } + Err(_) => { + warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS auth timed out; closing and continuing to wait"); + let _ = ws.close(None).await; + } + } + } + Ok(Err(e)) => warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS handshake failed: {e}"), + Err(_) => warn!(sandbox = %sandbox_name, %addr, + "MXC relay: sandbox WS handshake timed out; continuing to wait"), + } + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: listener error: {e}"); + return; + } + }, + _ = &mut shutdown_rx => { + info!(sandbox = %sandbox_name, "MXC relay: shutdown before sandbox connected"); + return; + } + } + }; + + // Phase B: accept raw TCP host clients one at a time and tunnel their + // byte stream through the Phase A WS connection. + loop { + let mut host_stream = tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + info!(sandbox = %sandbox_name, %addr, "MXC relay: host client connected"); + // See the matching comment on the Phase A accept above. + set_tcp_nodelay_best_effort(&stream); + stream + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: listener error: {e}"); + return; + } + }, + _ = &mut shutdown_rx => { + info!(sandbox = %sandbox_name, "MXC relay: shutdown"); + // Send a proper WS Close frame instead of just dropping the + // connection -- otherwise the sandbox sees an abrupt TCP + // reset ("Connection reset without closing handshake") + // instead of a clean close, even though nothing actually + // went wrong. + let _ = sandbox_ws.close(None).await; + return; + } + }; + + // Authenticate before treating this as the real host client (see + // module docs): the raw nonce bytes must arrive first, ahead of any + // tunneled application data. A mismatch or timeout closes this + // connection and goes back to waiting for the next accept -- it + // must never fall through to tunneling a stranger's traffic. + let mut auth_buf = [0u8; NONCE_LEN]; + match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { + Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} + Ok(Ok(_)) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client auth bytes did not match; closing and continuing to wait"); + continue; + } + Ok(Err(e)) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client closed/errored before authenticating: {e}"); + continue; + } + Err(_) => { + warn!(sandbox = %sandbox_name, + "MXC relay: host client auth timed out; closing and continuing to wait"); + continue; + } + } + + let (mut host_read, mut host_write) = host_stream.into_split(); + + // Notify in-sandbox agent that a new session is starting. + if sandbox_ws + .send(Message::Text("SESSION_START".into())) + .await + .is_err() + { + info!(sandbox = %sandbox_name, "MXC relay: sandbox gone at session start"); + return; + } + + // Bridge until Phase B or Phase A closes. Byte counters + first-chunk + // size events exist purely for diagnosing WHERE in the hop chain + // (host <-> this relay <-> Phase A WS <-> sandbox <-> target) bytes + // stop flowing, since a silent drop anywhere looks identical from the + // outside (client just times out) without this instrumentation. No + // payload content is ever logged -- see the module-level note on + // `byte_preview`. + let mut buf = vec![0u8; 8192]; + let mut host_to_sandbox_bytes: u64 = 0; + let mut sandbox_to_host_bytes: u64 = 0; + let mut host_to_sandbox_chunks: u64 = 0; + let mut sandbox_to_host_chunks: u64 = 0; + let sandbox_gone = loop { + tokio::select! { + // Phase B → Phase A: TCP bytes wrapped as WS Binary. + result = host_read.read(&mut buf) => match result { + Ok(0) => break false, // Phase B EOF + Ok(n) => { + host_to_sandbox_chunks += 1; + host_to_sandbox_bytes += n as u64; + if host_to_sandbox_chunks == 1 { + info!(sandbox = %sandbox_name, bytes = n, "MXC relay: first host->sandbox chunk"); + } + if sandbox_ws + .send(Message::Binary(buf[..n].to_vec().into())) + .await + .is_err() + { + break true; // Phase A gone + } + } + Err(e) => { + warn!(sandbox = %sandbox_name, "MXC relay: host read error: {e}"); + break false; + } + }, + // Phase A → Phase B: WS Binary bytes written to TCP. + msg = sandbox_ws.next() => match msg { + Some(Ok(Message::Binary(b))) => { + sandbox_to_host_chunks += 1; + sandbox_to_host_bytes += b.len() as u64; + if sandbox_to_host_chunks == 1 { + info!(sandbox = %sandbox_name, bytes = b.len(), "MXC relay: first sandbox->host chunk"); + } + if host_write.write_all(&b).await.is_err() { + break false; // Phase B gone + } + } + Some(Ok(Message::Text(t))) => { + if let Some(reason) = t.strip_prefix("SESSION_FAILED:") { + // Sandbox couldn't reach the target port. Close + // Phase B now instead of leaving the host client + // hanging until its own timeout. + warn!(sandbox = %sandbox_name, reason, + "MXC relay: sandbox failed to connect to target"); + break false; + } else if t == "SESSION_END" { + // Target closed its end (EOF) -- see + // openshell-supervisor-relay's matching send on + // Ok(0). Close Phase B now instead of leaving + // the host client waiting for bytes that will + // never arrive until its own timeout. + info!(sandbox = %sandbox_name, "MXC relay: target closed its end of the session"); + break false; + } + // otherwise ignore (shouldn't arrive from sandbox) + } + Some(Ok(Message::Close(_))) | None => { + info!(sandbox = %sandbox_name, "MXC relay: sandbox WS closed"); + break true; + } + Some(Ok(_)) => {} // ping/pong handled by tungstenite + Some(Err(e)) => { + warn!(sandbox = %sandbox_name, "MXC relay: sandbox read error: {e}"); + break true; + } + }, + _ = &mut shutdown_rx => { + let _ = sandbox_ws.close(None).await; + return; + } + } + }; + + // Signal session end to the in-sandbox agent (if Phase A still alive). + if !sandbox_gone { + let _ = sandbox_ws.send(Message::Text("SESSION_END".into())).await; + } + + info!(sandbox = %sandbox_name, + host_to_sandbox_bytes, host_to_sandbox_chunks, + sandbox_to_host_bytes, sandbox_to_host_chunks, + "MXC relay: host client disconnected"); + if sandbox_gone { + info!(sandbox = %sandbox_name, "MXC relay: sandbox gone, stopping relay"); + return; + } + // Phase A still alive — wait for the next Phase B client. + } +} + +#[cfg(test)] +mod control_relay_cleanup_tests { + use super::*; + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::net::TcpStream; + + /// Exercise the real control-channel writer and relay with a child that + /// echoes request lines. The test supplies the sandbox's matching replies. + async fn check_forward_cleanup(interrupt_active: bool) { + let mut child = tokio::process::Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "while ($null -ne ($line = [Console]::ReadLine())) { [Console]::WriteLine($line) }", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let control = Arc::new(ControlChannel::new(child.stdin.take().unwrap())); + let pending = control.pending_handle(); + let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); + let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let responder = tokio::spawn(async move { + while let Some(line) = lines.next_line().await.unwrap() { + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + let op = request["op"].as_str().unwrap().to_string(); + let response = serde_json::json!({ + "id": request["id"], "ok": true, + "data": {"bytes": "", "eof": false}, + }); + ControlChannel::try_route_response(&pending, &response.to_string()).await; + observed_tx + .send((op.clone(), request["data"]["session_id"].clone())) + .unwrap(); + if op == "forward_close" { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let nonce = [7; NONCE_LEN]; + let relay = tokio::spawn(control_channel_relay_task( + listener, + "cleanup-test".into(), + nonce, + control, + 12345, + shutdown_rx, + )); + let mut client = TcpStream::connect(address).await.unwrap(); + client.write_all(&nonce).await.unwrap(); + let opened = tokio::time::timeout(Duration::from_secs(20), observed_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(opened.0, "forward_open"); + let mut shutdown_tx = Some(shutdown_tx); + if interrupt_active { + shutdown_tx.take().unwrap().send(()).unwrap(); + } else { + client.shutdown().await.unwrap(); + } + let closed_id = tokio::time::timeout(Duration::from_secs(10), async { + while let Some((op, id)) = observed_rx.recv().await { + if op == "forward_close" { + return id; + } + } + panic!("control channel ended without closing the forward"); + }) + .await + .expect("active forward must close on shutdown or EOF"); + assert_eq!(closed_id, opened.1); + if let Some(shutdown_tx) = shutdown_tx { + shutdown_tx.send(()).unwrap(); + } + tokio::time::timeout(Duration::from_secs(10), relay) + .await + .unwrap() + .unwrap(); + let mut byte = [0]; + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), client.read(&mut byte)) + .await + .unwrap() + .unwrap(), + 0 + ); + responder.await.unwrap(); + child.kill().await.unwrap(); + child.wait().await.unwrap(); + } + + #[tokio::test] + async fn active_forward_is_closed_before_relay_shutdown_returns() { + check_forward_cleanup(true).await; + } + + #[tokio::test] + async fn ordinary_client_eof_still_closes_the_forward() { + check_forward_cleanup(false).await; + } +} + +// ── Relay task ──────────────────────────────────────────────────────────────── diff --git a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs new file mode 100644 index 0000000000..3a3a16af59 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const CAPTURE: &str = include_str!("../examples/openclaw-capture.mjs"); +const RUNNER: &str = include_str!("../examples/run-openclaw-forward-test.ps1"); + +#[cfg(windows)] +#[test] +fn runner_restores_openclaw_environment_after_success_and_failure() { + let directory = tempfile::tempdir().unwrap(); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let output = std::process::Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"]) + .arg(root.join("tests/openclaw_environment_cleanup.ps1")) + .arg("-RunnerPath") + .arg(root.join("examples/run-openclaw-forward-test.ps1")) + .arg("-TestDirectory") + .arg(directory.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("PASS: 4 environment restoration cases") + ); +} + +#[test] +fn capture_preloads_appcontainer_safe_realpath_before_openclaw() { + let patch = CAPTURE + .find("fs.promises.realpath = promisify(fs.realpath)") + .expect("capture must install the callback realpath compatibility binding"); + let import = CAPTURE + .find("await import(pathToFileURL(entry).href)") + .expect("capture must import the OpenClaw entry point"); + + assert!( + patch < import, + "realpath compatibility must be installed before OpenClaw loads" + ); + assert!(CAPTURE.contains("syncBuiltinESMExports()")); +} + +#[test] +fn runner_limits_package_group_dacl_grants_to_writable_data_directories() { + assert!(RUNNER.contains("*S-1-15-2-1:(OI)(CI)(M)")); + assert!(RUNNER.contains("*S-1-15-2-2:(OI)(CI)(M)")); + assert!( + RUNNER.contains("Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm \"home\")") + ); + assert!( + RUNNER.contains("Grant-AppContainerWritableDirectory (Join-Path $shareDirNorm \"temp\")") + ); + assert!(!RUNNER.contains("Grant-AppContainerWritableDirectory $shareDirNorm")); +} diff --git a/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 b/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 new file mode 100644 index 0000000000..987ea5aed7 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/openclaw_environment_cleanup.ps1 @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +param([string]$RunnerPath, [string]$TestDirectory) +$ErrorActionPreference = 'Stop' + +# Execute the actual runner's override scope, not a copy of its cleanup logic. +$tokens = $null +$parseErrors = $null +$source = [System.IO.File]::ReadAllText($RunnerPath) +$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw 'Runner must parse without errors' } +$scope = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.TryStatementAst] -and + $node.Finally -and $node.Finally.Extent.Text.Contains('$savedOpenClawConfigPath') +}, $true) +if (!$scope) { throw 'OpenClaw environment restoration needs a finally scope' } +$save = $ast.Find({ param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$savedOpenClawConfigPath' +}, $true) +$exercise = [scriptblock]::Create($source.Substring($save.Extent.StartOffset, $scope.Extent.EndOffset - $save.Extent.StartOffset)) + +function Step { param($Message) } +function Info { param($Message) } +function Ok { param($Message) } +function Bad { param($Message) throw $Message } +$ShareDir = $TestDirectory +$resultDir = $TestDirectory +$expectedState = Join-Path $ShareDir 'home\.openclaw' +$expectedConfig = Join-Path $expectedState 'openclaw.json' +$healthArgs = @() +$proof = '[egress-proof] {"proxyConfigured":true,"allowedViaProxy":{"connected":true},"deniedViaProxy":{"connected":false},"directInternetBypass":{"connected":false},"unrelatedHostLoopback":{"connected":true}}' +[System.IO.File]::WriteAllText((Join-Path $ShareDir 'openclaw-capture.log'), $proof) +$originalConfig = $env:OPENCLAW_CONFIG_PATH +$originalState = $env:OPENCLAW_STATE_DIR +$cases = 0 +try { + foreach ($initial in @($null, 'original-value')) { + foreach ($injectFailure in @($false, $true)) { + $env:OPENCLAW_CONFIG_PATH = $initial + $env:OPENCLAW_STATE_DIR = $initial + $passed = $false + $NodeExePath = { + if ($env:OPENCLAW_CONFIG_PATH -ne $expectedConfig -or $env:OPENCLAW_STATE_DIR -ne $expectedState) { + throw 'Client did not receive isolated OpenClaw paths' + } + if ($injectFailure) { throw 'injected-client-failure' } + '{"ok":true}' + } + $caught = $false + try { . $exercise } catch { + if (!$injectFailure -or $_.Exception.Message -ne 'injected-client-failure') { throw } + $caught = $true + } + if ($caught -ne $injectFailure) { throw 'Unexpected execution outcome' } + if (!$injectFailure -and !$passed) { throw 'Successful health path did not pass' } + if ($env:OPENCLAW_CONFIG_PATH -cne $initial -or $env:OPENCLAW_STATE_DIR -cne $initial) { + throw 'Environment was not restored after the client scope' + } + $cases++ + } + } +} finally { + $env:OPENCLAW_CONFIG_PATH = $originalConfig + $env:OPENCLAW_STATE_DIR = $originalState +} +Write-Output "PASS: $cases environment restoration cases" diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index d8d6a37904..c7382afece 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -210,12 +210,15 @@ 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() @@ -295,29 +298,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 +399,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 +423,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 +436,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 +458,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 +471,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 a4ed71117a..3d2b8647f8 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 ────────────────────── @@ -930,6 +936,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!( @@ -1023,13 +1030,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 ───────────────────────────────────────────────────────────── @@ -1046,11 +1181,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", ]; @@ -1121,6 +1259,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 8ac2c7ade8..c7bb11d86b 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -36,7 +36,7 @@ use openshell_core::proto::{ FilesystemPolicy, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, }; use openshell_driver_mxc::{MxcComputeBackend, MxcComputeConfig}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -68,7 +68,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"); @@ -81,10 +81,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") @@ -97,6 +106,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 @@ -133,6 +168,103 @@ 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 base = serde_json::json!({ + "phase": "provision", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true, + }, + }); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, &["--experimental"]); + 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, &["--experimental"]); + 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"] @@ -272,7 +404,7 @@ 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` (native MXC 0.8 proxy redirect 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 — @@ -321,6 +453,13 @@ 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()); let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); assert_eq!( @@ -346,10 +485,7 @@ fn dryrun_accepts_split_policy_output() { fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { // Abort early if the mock env var is set — a stale OPENSHELL_MXC_MOCK_WXC // would silently turn this "real" run back into a mock run. - if std::env::var("OPENSHELL_MXC_MOCK_WXC") - .map(|v| v == "1") - .unwrap_or(false) - { + 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(), @@ -395,16 +531,17 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { || combined.contains("not enabled") { // Extract the message if possible for a more useful skip reason. - let reason = if let Ok(v) = + let reason = serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) - { - v["error"]["message"] - .as_str() - .unwrap_or("backend_error (E_NOTIMPL)") - .to_string() - } else { - "backend_error (velocity keys not enabled)".to_string() - }; + .map_or_else( + |_| "backend_error (velocity keys not enabled)".to_string(), + |v| { + v["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + }, + ); return Err(reason); } @@ -421,7 +558,7 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { } /// Probe the released binary's live `network.proxy` support separately from -/// ordinary ProcessContainer support. Some builds accept the proxy JSON during +/// 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(); @@ -472,15 +609,12 @@ fn probe_processcontainer_proxy(wxc: &PathBuf) -> Result<(), String> { )) } -/// Probe the isolation_session backend. +/// Probe the `isolation_session` backend. /// /// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or /// `Err(reason)` when the backend is unavailable (caller prints SKIP). fn probe_isolation_session(wxc: &PathBuf) -> Result { - if std::env::var("OPENSHELL_MXC_MOCK_WXC") - .map(|v| v == "1") - .unwrap_or(false) - { + 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(), @@ -667,9 +801,9 @@ fn pc_oneshot_in_policy_write_succeeds() { ); } -/// Run an HTTPS request through the real driver and ProcessContainer. The +/// 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. +/// that the driver's internal TLS share is reachable from the `AppContainer`. #[tokio::test] #[ignore = "requires real wxc-exec and outbound HTTPS"] async fn pc_https_egress_reads_injected_ca_bundle() { @@ -820,7 +954,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { } /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. -/// This is the genuine OS default-deny proof — the AppContainer blocks the write +/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write /// without requiring any host ACL lockdown. The mock can only fake this. #[test] #[ignore = "requires real wxc-exec"] @@ -890,7 +1024,7 @@ fn pc_oneshot_out_of_policy_write_denied() { // ── Isolation session enforcement tests ────────────────────────────────────── -/// Full isolation_session round trip: provision → start → exec → stop → +/// Full `isolation_session` round trip: provision → start → exec → stop → /// deprovision. `deprovision` runs in a drop-guard even on panic so the /// single-session backend is never left orphaned. #[test] diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 2852c43328..ae564cd4ab 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -528,6 +528,7 @@ impl PodmanComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }) } diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index d012ffb99e..b1b3073958 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -800,6 +800,7 @@ impl VmDriver { .to_string_lossy() .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), + supports_ui_policy: false, } } diff --git a/crates/openshell-gateway/Cargo.toml b/crates/openshell-gateway/Cargo.toml index 32dcaeb24c..3aa6aa24f0 100644 --- a/crates/openshell-gateway/Cargo.toml +++ b/crates/openshell-gateway/Cargo.toml @@ -73,6 +73,7 @@ telemetry = ["openshell-core/telemetry", "openshell-server/telemetry"] ## `rust:verify:defaults-without-telemetry`. defaults-without-telemetry = ["in-tree-compute-drivers"] bundled-z3 = ["openshell-server/bundled-z3"] +prebuilt-z3 = ["openshell-server/prebuilt-z3"] [lints] workspace = true diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index b67e93c102..30e1c83727 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -159,10 +159,39 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { ) -> openshell_core::Result { let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; let backend = openshell_driver_mxc::MxcComputeBackend::new(config); + context.set_forward_sink(std::sync::Arc::new(MxcForwardSink(backend.forward_sink()))); + let provider_credentials_sink = backend.provider_credentials_sink(); let driver = openshell_driver_mxc::ComputeDriverService::new(backend); - Ok(openshell_server::ComputeDriverInstance::InProcess( - std::sync::Arc::new(driver), - )) + Ok( + openshell_server::ComputeDriverInstance::InProcessWithProviderCredentials { + driver: std::sync::Arc::new(driver), + provider_credentials_sink, + }, + ) + } +} + +/// Adapts MXC's dynamic port-forward side channel (`handle_forward_tcp`'s +/// fallback for sandboxes with no in-sandbox supervisor) to the generic +/// `ComputeDriverForwardSink` capability the server crate consumes, so +/// `openshell-server` never has to depend on `openshell-driver-mxc` directly. +#[cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] +struct MxcForwardSink(openshell_driver_mxc::ForwardSink); + +#[cfg(all(target_os = "windows", feature = "compute-driver-mxc"))] +#[async_trait::async_trait] +impl openshell_server::ComputeDriverForwardSink for MxcForwardSink { + async fn open_dynamic_forward( + &self, + sandbox_id: &str, + target_port: u16, + ) -> Result<(std::net::SocketAddr, Vec, Box), String> { + let (addr, nonce, handle) = self + .0 + .open_dynamic_forward(sandbox_id, target_port) + .await + .map_err(|error| error.to_string())?; + Ok((addr, nonce.to_vec(), Box::new(handle))) } } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index d6c66ac597..2ff6dc90ed 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -29,7 +29,7 @@ use openshell_core::mcp::{DEFAULT_MCP_PROTOCOL_VERSION, McpProtocolVersion}; use openshell_core::proto::{ FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, LandlockPolicy, McpOptions, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProcessPolicy, - SandboxPolicy, + SandboxPolicy, UiClipboardAccess, UiPolicy, }; use serde::{Deserialize, Deserializer, Serialize}; @@ -63,6 +63,8 @@ struct PolicyFile { landlock: Option, #[serde(default, skip_serializing_if = "Option::is_none")] process: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ui: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] network_policies: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -104,6 +106,53 @@ struct ProcessDef { run_as_group: String, } +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct UiDef { + #[serde(default, skip_serializing_if = "is_false")] + allow_graphical_ui: bool, + #[serde(default, skip_serializing_if = "UiClipboardAccessDef::is_none")] + clipboard: UiClipboardAccessDef, + #[serde(default, skip_serializing_if = "is_false")] + allow_input_injection: bool, +} + +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum UiClipboardAccessDef { + #[default] + None, + Read, + Write, + All, +} + +impl UiClipboardAccessDef { + // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. + #[allow(clippy::trivially_copy_pass_by_ref)] + const fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + const fn to_proto(self) -> UiClipboardAccess { + match self { + Self::None => UiClipboardAccess::None, + Self::Read => UiClipboardAccess::Read, + Self::Write => UiClipboardAccess::Write, + Self::All => UiClipboardAccess::All, + } + } + + fn from_proto(value: i32) -> Self { + match UiClipboardAccess::try_from(value).unwrap_or(UiClipboardAccess::Unspecified) { + UiClipboardAccess::Unspecified | UiClipboardAccess::None => Self::None, + UiClipboardAccess::Read => Self::Read, + UiClipboardAccess::Write => Self::Write, + UiClipboardAccess::All => Self::All, + } + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct NetworkPolicyRuleDef { @@ -202,6 +251,12 @@ fn is_zero(v: &u16) -> bool { *v == 0 } +// Signature dictated by serde's `skip_serializing_if`, which requires `&T`. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_false(v: &bool) -> bool { + !*v +} + // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. #[allow(clippy::trivially_copy_pass_by_ref)] fn is_zero_u32(v: &u32) -> bool { @@ -928,6 +983,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() as i32, + allow_input_injection: ui.allow_input_injection, + }), network_policies, network_middlewares, }) @@ -971,6 +1031,12 @@ fn from_proto(policy: &SandboxPolicy) -> Result { } }); + let ui = policy.ui.as_ref().map(|ui| UiDef { + allow_graphical_ui: ui.allow_graphical_ui, + clipboard: UiClipboardAccessDef::from_proto(ui.clipboard), + allow_input_injection: ui.allow_input_injection, + }); + let network_policies = policy .network_policies .iter() @@ -1086,6 +1152,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { filesystem_policy, landlock, process, + ui, network_policies, network_middlewares, }) @@ -1284,6 +1351,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { compatibility: "best_effort".into(), }), process: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), } @@ -1319,6 +1387,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 `/`). @@ -1420,6 +1490,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}") } @@ -1626,6 +1702,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 `/`) @@ -1685,6 +1762,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, + }); + } + // Check filesystem paths if let Some(ref fs) = policy.filesystem { let total_paths = fs.read_only.len() + fs.read_write.len(); @@ -2129,6 +2214,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) @@ -2151,6 +2237,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" @@ -2158,6 +2245,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) { @@ -2277,6 +2378,98 @@ 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 { + 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 parse sandbox policy YAML") + ); + } + + #[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() { @@ -3834,6 +4027,7 @@ network_policies: process: None, filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4292,6 +4486,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4308,6 +4503,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; @@ -4380,6 +4576,7 @@ network_policies: }), filesystem: None, landlock: None, + ui: None, network_policies: HashMap::new(), network_middlewares: HashMap::default(), }; diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index c236742739..6d41a2278d 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -349,6 +349,7 @@ impl OpenShellClient { let request = proto::DeleteSandboxRequest { name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), + ..Default::default() }; async move { grpc.delete_sandbox(request).await } }) @@ -907,6 +908,7 @@ impl WorkspaceScopedClient { let request = proto::DeleteSandboxRequest { name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), + ..Default::default() }; async move { grpc.delete_sandbox(request).await } }) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index fb44a4984d..be57ca32dd 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -35,9 +35,11 @@ use openshell_core::proto::compute::v1::{ gateway_listener_requirement::Selector, watch_sandboxes_event, }; use openshell_core::proto::{ - PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxSpec, SandboxStatus, - SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, SshSession, + PlatformEvent, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, + SandboxSpec, SandboxStatus, SandboxTemplate, SandboxWorkloadTemplate, ServiceEndpoint, + SshSession, }; +use openshell_core::provider_credentials::ProviderCredentialState; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{ObjectLabels, ObjectWorkspace}; use prost::Message; @@ -63,6 +65,60 @@ pub type DriverWatchStream = Pin> + Send>>; pub type SharedComputeDriver = Arc + Send + Sync>; +pub type SandboxProviderCredentialsSink = Arc>>; + +/// Driver-specific values that must be delivered atomically with sandbox +/// creation without expanding the public compute-driver protobuf contract. +#[derive(Clone, Default)] +pub struct SandboxCreateRuntimeInputs { + pub effective_policy: Option, + pub provider_credentials: Option, +} + +impl SandboxCreateRuntimeInputs { + #[must_use] + pub(crate) fn new( + effective_policy: ProtoSandboxPolicy, + provider_credentials: Option, + ) -> Self { + Self { + effective_policy: Some(effective_policy), + provider_credentials, + } + } +} + +struct StagedProviderCredentials { + sink: SandboxProviderCredentialsSink, + sandbox_id: String, +} + +impl StagedProviderCredentials { + fn stage( + sink: SandboxProviderCredentialsSink, + sandbox_id: &str, + credentials: ProviderCredentialState, + ) -> Self { + let mut pending = sink + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.insert(sandbox_id.to_string(), credentials); + drop(pending); + Self { + sink, + sandbox_id: sandbox_id.to_string(), + } + } +} + +impl Drop for StagedProviderCredentials { + fn drop(&mut self) { + self.sink + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.sandbox_id); + } +} use traced_driver::TracedDriver; @@ -267,6 +323,16 @@ struct SandboxDeleteTarget { sandbox_name: String, } +/// Optional caller-owned preconditions for an identity-safe sandbox delete. +/// +/// These values are validated again while holding the sandbox lifecycle and +/// gateway-global locks, immediately before the durable `Deleting` mutation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SandboxDeletePreconditions { + pub expected_sandbox_id: Option, + pub expected_resource_version: Option, +} + /// Identity and driver result for a completed delete request. #[derive(Debug, Eq, PartialEq)] pub struct DeleteSandboxResult { @@ -293,6 +359,7 @@ enum BeginDelete { } #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct ComputeDriverInfoSnapshot { /// Gateway-selected driver name used for routing and `driver_config` keys. pub name: String, @@ -312,6 +379,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, } /// Interval between store-vs-backend reconciliation sweeps. @@ -602,6 +672,7 @@ pub struct ComputeRuntime { telemetry_compute_driver: TelemetryComputeDriver, driver_process: Option>, default_image: String, + provider_credentials_sink: Option, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, @@ -611,6 +682,9 @@ pub struct ComputeRuntime { lifecycle_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, + /// Dynamic TCP forward capability contributed by the active in-process + /// driver, if it has one. See `forward_sink`. + forward_sink: Option>, /// Gateway-issued staging slots for rootfs tar archives. Shared across /// clones: `ServerState` holds `ComputeRuntime` by value, so a per-clone /// table would make a token minted on one clone invisible to another. @@ -638,6 +712,7 @@ impl ComputeRuntime { driver_name: String, driver: SharedComputeDriver, driver_process: Option>, + provider_credentials_sink: Option, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, @@ -667,6 +742,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 gateway_listener_requirements = match driver @@ -734,6 +810,7 @@ impl ComputeRuntime { telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process, default_image, + provider_credentials_sink, store, sandbox_index, sandbox_watch_bus, @@ -743,6 +820,7 @@ impl ComputeRuntime { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements, replica_id: lease::replica_id(), + forward_sink: None, rootfs_tar_staging, }) } @@ -785,6 +863,7 @@ impl ComputeRuntime { endpoint.name, driver, endpoint.driver_process, + None, store, sandbox_index, sandbox_watch_bus, @@ -794,11 +873,33 @@ impl ComputeRuntime { .await } + /// Contributes a driver's dynamic TCP forward capability, if it has one. + /// Called at most once, right after `from_driver`, by the generic + /// `build_compute_runtime` construction path. + pub(crate) fn set_forward_sink(&mut self, sink: Arc) { + self.forward_sink = Some(sink); + } + + /// A driver-owned dynamic TCP forward capability, when the active driver + /// has one. `handle_forward_tcp` uses this as a fallback path for + /// sandboxes with no live `ConnectSupervisor` session (e.g. MXC, which + /// has no in-sandbox supervisor at all). `None` for every other driver. + #[must_use] + pub fn forward_sink(&self) -> Option<&Arc> { + self.forward_sink.as_ref() + } + #[must_use] pub fn default_image(&self) -> &str { &self.default_image } + /// Whether this in-process driver accepts create-time provider state. + #[must_use] + pub(crate) fn accepts_create_time_provider_credentials(&self) -> bool { + self.provider_credentials_sink.is_some() + } + #[must_use] pub fn driver_info_snapshots(&self) -> &[ComputeDriverInfoSnapshot] { std::slice::from_ref(&self.driver_info) @@ -904,8 +1005,26 @@ impl ComputeRuntime { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), Status> { + self.validate_sandbox_create_with_runtime_inputs( + sandbox, + &SandboxCreateRuntimeInputs::default(), + ) + .await + } + + pub(crate) async fn validate_sandbox_create_with_runtime_inputs( + &self, + sandbox: &Sandbox, + runtime_inputs: &SandboxCreateRuntimeInputs, + ) -> Result<(), Status> { + 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(effective_policy) = runtime_inputs.effective_policy.as_ref() + && let Some(spec) = driver_sandbox.spec.as_mut() + { + spec.policy = Some(effective_policy.clone()); + } // Peek, never consume: create runs the same path immediately after and // must still find the token. if let Some(token) = take_staging_token(&mut driver_sandbox) { @@ -928,12 +1047,55 @@ 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, sandbox_token: Option, await_main_process_attachment: bool, ) -> Result { + self.create_sandbox_with_runtime_inputs( + sandbox, + sandbox_token, + await_main_process_attachment, + SandboxCreateRuntimeInputs::default(), + ) + .await + } + + pub(crate) async fn create_sandbox_with_runtime_inputs( + &self, + sandbox: Sandbox, + sandbox_token: Option, + await_main_process_attachment: bool, + runtime_inputs: SandboxCreateRuntimeInputs, + ) -> Result { + // Defense in depth for internal callers that bypass the public create + // handler's ValidateSandboxCreate step. This check has no side effects. + self.validate_policy_capabilities(&sandbox, runtime_inputs.effective_policy.as_ref())?; + if runtime_inputs.provider_credentials.is_some() && self.provider_credentials_sink.is_none() + { + return Err(Status::internal( + "provider credentials supplied to a compute driver without a create-time sink", + )); + } let sandbox_id = sandbox.object_id().to_string(); let mut sandbox = sandbox; @@ -948,6 +1110,11 @@ impl ComputeRuntime { let mut driver_sandbox = driver_sandbox_from_public(&sandbox, &self.driver_info.name) .map_err(|status| *status)?; + if let Some(effective_policy) = runtime_inputs.effective_policy + && let Some(spec) = driver_sandbox.spec.as_mut() + { + spec.policy = Some(effective_policy); + } if let Some(staged) = staged.as_ref() { set_rootfs_tar_path(&mut driver_sandbox, staged.path()); } @@ -997,6 +1164,18 @@ impl ComputeRuntime { if let Some(spec) = driver_sandbox.spec.as_mut() { spec.await_main_process_attachment = await_main_process_attachment; } + let _staged_provider_credentials = match ( + self.provider_credentials_sink.clone(), + runtime_inputs.provider_credentials, + ) { + (Some(sink), Some(credentials)) => Some(StagedProviderCredentials::stage( + sink, + &sandbox_id, + credentials, + )), + (None, Some(_)) => unreachable!("provider credential sink checked before persistence"), + (_, None) => None, + }; match self .driver .call( @@ -1528,6 +1707,20 @@ impl ComputeRuntime { &self, workspace: &str, name: &str, + ) -> Result { + self.delete_sandbox_with_preconditions( + workspace, + name, + SandboxDeletePreconditions::default(), + ) + .await + } + + pub(crate) async fn delete_sandbox_with_preconditions( + &self, + workspace: &str, + name: &str, + preconditions: SandboxDeletePreconditions, ) -> Result { // Resolve and acquire both request-side locks before spawning the // owned worker. Cancellation while any of these awaits is pending is @@ -1538,6 +1731,15 @@ impl ComputeRuntime { .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + if preconditions + .expected_sandbox_id + .as_deref() + .is_some_and(|expected| expected != candidate.object_id()) + { + return Err(Status::aborted( + "sandbox identity does not match expected_sandbox_id", + )); + } let target = SandboxDeleteTarget { sandbox_id: candidate.object_id().to_string(), sandbox_name: candidate.object_name().to_string(), @@ -1555,7 +1757,7 @@ impl ComputeRuntime { tokio::spawn( async move { runtime - .delete_sandbox_inner(target, delete_guard, global_guard) + .delete_sandbox_inner(target, preconditions, delete_guard, global_guard) .await } .instrument(request_span), @@ -1571,6 +1773,7 @@ impl ComputeRuntime { async fn delete_sandbox_inner( &self, target: SandboxDeleteTarget, + preconditions: SandboxDeletePreconditions, delete_guard: SandboxLifecycleGuard, guard: tokio::sync::OwnedMutexGuard<()>, ) -> Result { @@ -1594,6 +1797,23 @@ impl ComputeRuntime { "sandbox name changed while the delete request was waiting; retry explicitly", )); } + if preconditions + .expected_sandbox_id + .as_deref() + .is_some_and(|expected| expected != current.object_id()) + { + return Err(Status::aborted( + "sandbox identity changed before delete mutation", + )); + } + if preconditions + .expected_resource_version + .is_some_and(|expected| expected != sandbox_resource_version(¤t)) + { + return Err(Status::aborted( + "sandbox resource version changed before delete mutation", + )); + } // `Started` carries both sides of the CAS transition: the durable // `Deleting` row used to fence recovery, and the prior row used only @@ -4780,6 +5000,7 @@ impl ComputeDriver for NoopTestDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, )) } @@ -4927,10 +5148,12 @@ pub async 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, default_image: "openshell/sandbox:test".to_string(), + provider_credentials_sink: None, store, sandbox_index: SandboxIndex::new(), sandbox_watch_bus: SandboxWatchBus::new(), @@ -4940,6 +5163,7 @@ pub async fn new_test_runtime_with_driver( lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + forward_sink: None, rootfs_tar_staging: Arc::new(rootfs_tar::RootfsTarStagingRegistry::disabled()), } } @@ -4953,6 +5177,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}; @@ -5210,6 +5435,11 @@ mod tests { listed_sandboxes: Vec, current_sandboxes: Vec, workspace_rpcs_unimplemented: bool, + validate_create_calls: AtomicUsize, + create_calls: AtomicUsize, + provider_credentials_sink: Option, + provider_env_at_create: TestMutex>>, + created_sandboxes: TestMutex>, } #[tonic::async_trait] @@ -5242,6 +5472,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, })) } @@ -5258,6 +5489,7 @@ mod tests { &self, _request: Request, ) -> Result, Status> { + self.validate_create_calls.fetch_add(1, Ordering::Relaxed); Ok(tonic::Response::new(ValidateSandboxCreateResponse {})) } @@ -5307,8 +5539,28 @@ mod tests { async fn create_sandbox( &self, - _request: Request, + request: Request, ) -> Result, Status> { + self.create_calls.fetch_add(1, Ordering::Relaxed); + if let Some(sandbox) = request.into_inner().sandbox { + if let Some(sink) = &self.provider_credentials_sink { + let credentials = sink + .lock() + .expect("provider credential staging lock poisoned") + .get(&sandbox.id) + .cloned() + .expect("provider credentials must be staged before driver create"); + *self + .provider_env_at_create + .lock() + .expect("provider env observation lock poisoned") = + Some(credentials.child_env_with_gcp_resolved()); + } + self.created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned") + .push(sandbox); + } Ok(tonic::Response::new(CreateSandboxResponse {})) } @@ -5586,6 +5838,7 @@ mod tests { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, })) } @@ -5799,10 +6052,12 @@ 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, default_image: "openshell/sandbox:test".to_string(), + provider_credentials_sink: None, store, sandbox_index: SandboxIndex::new(), sandbox_watch_bus: SandboxWatchBus::new(), @@ -5812,10 +6067,205 @@ mod tests { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + forward_sink: None, rootfs_tar_staging: Arc::new(rootfs_tar::RootfsTarStagingRegistry::disabled()), } } + 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() + }, + None, + ); + + 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 sink: SandboxProviderCredentialsSink = Arc::new(TestMutex::new(HashMap::new())); + let driver = Arc::new(TestDriver { + provider_credentials_sink: Some(sink.clone()), + ..Default::default() + }); + let mut runtime = test_runtime(driver.clone()).await; + runtime.provider_credentials_sink = Some(sink.clone()); + + let mut sandbox = sandbox_record( + "sb-provider-inputs", + "provider-inputs", + SandboxPhase::Provisioning, + ); + sandbox.spec = Some(SandboxSpec { + policy: Some(PublicSandboxPolicy { + version: 1, + ..Default::default() + }), + ..Default::default() + }); + let provider_credentials = ProviderCredentialState::from_environment( + 17, + HashMap::from([("GITHUB_TOKEN".to_string(), "raw-test-token".to_string())]), + HashMap::new(), + HashMap::new(), + ); + let runtime_inputs = SandboxCreateRuntimeInputs::new( + PublicSandboxPolicy { + version: 2, + ..Default::default() + }, + Some(provider_credentials), + ); + + runtime + .create_sandbox_with_runtime_inputs(sandbox, None, false, runtime_inputs) + .await + .expect("create should succeed"); + + let observed_env = driver + .provider_env_at_create + .lock() + .expect("provider env observation lock poisoned") + .clone() + .expect("driver should observe staged provider credentials"); + let observed_token = &observed_env["GITHUB_TOKEN"]; + assert_ne!(observed_token, "raw-test-token"); + assert!(observed_token.starts_with(openshell_core::secrets::PLACEHOLDER_PREFIX_PUBLIC)); + assert!( + sink.lock() + .expect("provider credential staging lock poisoned") + .is_empty(), + "create-time credentials must be removed after the driver call" + ); + + let observed_policy_version = { + let created = driver + .created_sandboxes + .lock() + .expect("created sandbox observation lock poisoned"); + created[0] + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version) + }; + assert_eq!( + observed_policy_version, + Some(2), + "the driver must receive the effective policy" + ); + + let persisted = runtime + .store + .get_message::("sb-provider-inputs") + .await + .expect("persisted sandbox lookup should succeed") + .expect("sandbox should be persisted"); + assert_eq!( + persisted + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .map(|policy| policy.version), + Some(1), + "the public sandbox must retain its base policy" + ); + } + async fn test_runtime_with_gateway_managed_lifecycle( driver: SharedComputeDriver, driver_name: &str, @@ -8438,6 +8888,133 @@ mod tests { ); } + #[tokio::test] + async fn identity_guarded_delete_rejects_a_different_sandbox_before_mutation() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-current", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + + let error = runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-stale".to_string()), + expected_resource_version: None, + }, + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), Code::Aborted); + assert_eq!(driver.delete_calls(), 0); + let current = runtime + .store + .get_message::("sb-current") + .await + .unwrap() + .unwrap(); + assert_eq!( + SandboxPhase::try_from(current.phase()).unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn identity_guarded_delete_revalidates_resource_version_under_lifecycle_lock() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + let expected_resource_version = sandbox_resource_version(¤t); + + let delete_gate = runtime.lifecycle_gates.gate_for(sandbox.object_id()); + let delete_guard = delete_gate.lock().await; + let delete_runtime = runtime.clone(); + let delete = tokio::spawn(async move { + delete_runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some("sb-1".to_string()), + expected_resource_version: Some(expected_resource_version), + }, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), async { + while Arc::strong_count(&delete_gate) < 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("guarded delete did not start waiting on the sandbox gate"); + + runtime + .store + .update_message_cas::( + sandbox.object_id(), + expected_resource_version, + |sandbox| sandbox.set_current_policy_version(9), + ) + .await + .unwrap(); + drop(delete_guard); + + let error = delete.await.unwrap().unwrap_err(); + assert_eq!(error.code(), Code::Aborted); + assert_eq!(driver.delete_calls(), 0); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(current.current_policy_version(), 9); + assert_eq!( + SandboxPhase::try_from(current.phase()).unwrap(), + SandboxPhase::Ready + ); + } + + #[tokio::test] + async fn identity_guarded_delete_accepts_the_exact_current_identity() { + let driver = ControlledDriver::new(); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Ready); + runtime.store.put_message(&sandbox).await.unwrap(); + let current = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + + let result = runtime + .delete_sandbox_with_preconditions( + "default", + "sandbox-a", + SandboxDeletePreconditions { + expected_sandbox_id: Some(current.object_id().to_string()), + expected_resource_version: Some(sandbox_resource_version(¤t)), + }, + ) + .await + .unwrap(); + + assert!(result.deleted); + assert_eq!(result.sandbox_id, "sb-1"); + assert_eq!(driver.delete_calls(), 1); + } + #[tokio::test] async fn request_cancellation_does_not_cancel_the_delete_worker() { let driver = ControlledDriver::new(); @@ -9946,6 +10523,7 @@ mod tests { }), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10117,6 +10695,7 @@ mod tests { })), workspace: "default".to_string(), }], + ..Default::default() })) .await; @@ -10912,6 +11491,7 @@ mod tests { "test-driver".to_string(), Arc::new(TestDriver::default()), None, + None, store, SandboxIndex::new(), SandboxWatchBus::new(), diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index e1ea5994cc..eadd747f86 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -107,6 +107,10 @@ pub trait CredentialDriver: std::fmt::Debug + Send + Sync { pub struct ResolvedProviderCredentials { pub values: HashMap, pub expires_at_ms: HashMap, + /// Keys returned by a credential driver whose effective expiration has + /// already passed. Values stay withheld, but create-time consumers need + /// the identities to fail closed instead of silently omitting credentials. + pub expired_keys: HashSet, } #[derive(Debug, Clone, Copy)] @@ -706,6 +710,7 @@ impl CredentialRuntime { effective_expires_at_ms, "skipping expired handle-backed credential" ); + resolved.expired_keys.insert(credential_key); continue; } if effective_expires_at_ms > 0 { diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 97f07164ba..77c6c1e7e1 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -236,6 +236,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 8abdfd36d0..cd71e8064f 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1716,6 +1716,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 @@ -1732,20 +1746,40 @@ async fn current_effective_policy_for_sandbox( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + &provider_names, + ) + .await?; + current_effective_policy_for_sandbox_with_records( + state, + catalog, + sandbox, + sandbox_id, + &provider_records, + ) + .await +} + +async fn current_effective_policy_for_sandbox_with_records( + state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, + sandbox: &Sandbox, + sandbox_id: &str, + provider_records: &[super::provider::ProviderEnvironmentRecord], +) -> Result { let global_settings = load_global_settings(state.store.as_ref()).await?; - if let Some(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. - return apply_effective_policy_context( - state, + if let Some(mut global_policy) = decode_policy_from_global_settings(&global_settings)? { + // A global policy replaces dynamic policy, but startup-only UI remains + // anchored to the sandbox spec so reads cannot misrepresent enforcement. + preserve_sandbox_startup_ui(&mut global_policy, sandbox); + return apply_effective_policy_context_from_records( catalog, - workspace, - &provider_names, + provider_records, global_policy, PolicySource::Global, - ) - .await; + ); } let policy = if let Some(record) = state @@ -1764,15 +1798,12 @@ async fn current_effective_policy_for_sandbox( } }; - apply_effective_policy_context( - state, + apply_effective_policy_context_from_records( catalog, - workspace, - &provider_names, + provider_records, policy, PolicySource::Sandbox, ) - .await } async fn effective_policy_for_source( @@ -1807,17 +1838,26 @@ async fn apply_effective_policy_context( catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], - mut policy: ProtoSandboxPolicy, + policy: ProtoSandboxPolicy, policy_source: PolicySource, ) -> Result { - clear_provider_credentialed_markers(&mut policy); - let mut provider_context = provider_policy_context_with_catalog( + let provider_records = super::provider::load_provider_environment_records( state.store.as_ref(), - catalog, workspace, provider_names, ) .await?; + apply_effective_policy_context_from_records(catalog, &provider_records, policy, policy_source) +} + +fn apply_effective_policy_context_from_records( + catalog: &EffectiveProviderProfileCatalog, + provider_records: &[super::provider::ProviderEnvironmentRecord], + mut policy: ProtoSandboxPolicy, + policy_source: PolicySource, +) -> Result { + clear_provider_credentialed_markers(&mut policy); + let mut provider_context = provider_policy_context_from_records(catalog, provider_records); if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() { policy = compose_effective_policy(&policy, &provider_context.layers); } @@ -2523,9 +2563,10 @@ pub(super) async fn handle_get_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)) @@ -2987,16 +3028,23 @@ async fn provider_policy_context_with_catalog( workspace: &str, provider_names: &[String], ) -> Result { + let records = + super::provider::load_provider_environment_records(store, workspace, provider_names) + .await?; + Ok(provider_policy_context_from_records(catalog, &records)) +} + +fn provider_policy_context_from_records( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], +) -> ProviderPolicyContext { let mut layers = Vec::new(); let mut credentialed_scopes = Vec::new(); let mut endpointless_provider_names = HashSet::new(); - for name in provider_names { - let provider = store - .get_message_by_name::(workspace, name) - .await - .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? - .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + for record in records { + let name = &record.name; + let provider = &record.provider; let provider_type = provider.r#type.trim(); let Some(profile) = super::provider::get_provider_type_profile_for_scope( @@ -3012,7 +3060,7 @@ async fn provider_policy_context_with_catalog( continue; }; - if !super::provider::provider_profile_endpoints_are_active(&profile, &provider) { + if !super::provider::provider_profile_endpoints_are_active(&profile, provider) { endpointless_provider_names.insert(name.clone()); continue; } @@ -3041,11 +3089,11 @@ async fn provider_policy_context_with_catalog( }); } - Ok(ProviderPolicyContext { + ProviderPolicyContext { layers, credentialed_scopes, endpointless_provider_names, - }) + } } fn endpoint_ports(endpoint: &NetworkEndpoint) -> Vec { @@ -3226,21 +3274,167 @@ pub(super) async fn handle_get_gateway_config( })) } +/// Resolve the effective policy and provider credential snapshot required by +/// an in-process compute driver at sandbox creation time. +/// +/// The policy, revision, endpoint bindings, and environment are all derived +/// from one immutable provider-record snapshot. Raw static credentials remain +/// in the returned resolver state; only revision-scoped placeholders are +/// exposed through its child environment. +pub(super) async fn resolve_sandbox_create_runtime_inputs( + state: &ServerState, + sandbox: &Sandbox, +) -> Result { + if !state.compute.accepts_create_time_provider_credentials() { + return Ok(crate::compute::SandboxCreateRuntimeInputs::default()); + } + + let sandbox_id = sandbox.object_id(); + let workspace = sandbox.object_workspace(); + let provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.as_slice()) + .unwrap_or_default(); + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + provider_names, + ) + .await?; + let effective_policy = current_effective_policy_for_sandbox_with_records( + state, + &provider_profile_catalog, + sandbox, + sandbox_id, + &provider_records, + ) + .await?; + let policy_credential_bindings = + policy_static_credential_endpoint_bindings(Some(&effective_policy))?; + validate_policy_credential_binding_context( + &provider_profile_catalog, + &provider_records, + &effective_policy, + &policy_credential_bindings, + )?; + let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + )?; + let mut provider_environment = + super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + state.store.as_ref(), + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + &state.credentials, + Some(sandbox_id), + ) + .await?; + + // MXC uses the binding-capable host proxy. Withhold any static value that + // has no endpoint binding instead of exposing it directly to the process. + let unbound_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + !provider_environment + .static_credential_bindings + .contains_key(*key) + }) + .cloned() + .collect::>(); + for key in unbound_static_keys { + warn!( + sandbox_id, + key = %key, + "withholding unbound static provider credential from MXC sandbox" + ); + provider_environment.environment.remove(&key); + provider_environment.credential_expires_at_ms.remove(&key); + provider_environment.static_credential_keys.remove(&key); + } + validate_create_time_provider_credential_lifetimes(sandbox_id, &provider_environment)?; + + let provider_credentials = if provider_records.is_empty() { + None + } else { + let non_secret_environment_keys = provider_environment + .environment + .keys() + .filter(|key| !provider_environment.static_credential_keys.contains(*key)) + .cloned() + .collect(); + Some( + openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( + provider_env_revision, + provider_environment.environment, + provider_environment.credential_expires_at_ms, + provider_environment.dynamic_credentials, + provider_environment.static_credential_bindings, + non_secret_environment_keys, + ) + .map_err(|error| { + Status::failed_precondition(format!( + "invalid provider credential binding for sandbox '{sandbox_id}': {error}" + )) + })?, + ) + }; + + Ok(crate::compute::SandboxCreateRuntimeInputs::new( + effective_policy, + provider_credentials, + )) +} + +fn validate_create_time_provider_credential_lifetimes( + sandbox_id: &str, + provider_environment: &super::provider::ProviderEnvironment, +) -> Result<(), Status> { + let mut expiring_static_keys = provider_environment + .static_credential_keys + .iter() + .filter(|key| { + provider_environment + .credential_expires_at_ms + .get(*key) + .is_some_and(|expires_at_ms| *expires_at_ms > 0) + }) + .chain(provider_environment.expired_static_keys.iter()) + .cloned() + .collect::>() + .into_iter() + .collect::>(); + expiring_static_keys.sort(); + + if expiring_static_keys.is_empty() { + Ok(()) + } else { + Err(Status::failed_precondition(format!( + "compute driver cannot refresh expiring or already-expired provider credentials for sandbox '{sandbox_id}'; recreate the sandbox with non-expiring, current credentials (affected keys: {})", + expiring_static_keys.join(", ") + ))) + } +} + pub(super) async fn handle_get_sandbox_provider_environment( state: &Arc, request: Request, ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings; - crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; + let principal = crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; drop(request); - let sandbox = state - .store - .get_message::(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox = + super::sandbox::fetch_and_authorize_sandbox(state, &principal, &sandbox_id).await?; let workspace = sandbox.object_workspace().to_string(); let spec = sandbox @@ -3259,12 +3453,12 @@ pub(super) async fn handle_get_sandbox_provider_environment( &provider_names, ) .await?; - let effective_policy = current_effective_policy_for_sandbox( + let effective_policy = current_effective_policy_for_sandbox_with_records( state.as_ref(), &provider_profile_catalog, - &workspace, &sandbox, &sandbox_id, + &provider_records, ) .await?; let policy_credential_bindings = @@ -3461,6 +3655,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?; @@ -3865,7 +4064,10 @@ async fn handle_update_config_inner( } let should_backfill_policy = if let Some(baseline_policy) = spec.policy.as_ref() { - let comparable_baseline = baseline_policy.clone(); + let comparable_baseline = validate_and_canonicalize_stored_policy( + baseline_policy.clone(), + STORED_POLICY_SOURCE_SPEC, + )?; validate_static_fields_unchanged(&comparable_baseline, &new_policy)?; false } else { @@ -6473,17 +6675,17 @@ async fn sandbox_policy_merge_validation_data_with_catalog( ) -> Result { let global_settings = load_global_settings(state.store.as_ref()).await?; let composition_enabled = provider_policy_composition_enabled_in(&global_settings)?; - let ProviderPolicyContext { - layers, - credentialed_scopes, - endpointless_provider_names, - } = provider_policy_context_with_catalog( + let records = super::provider::load_provider_environment_records( state.store.as_ref(), - catalog, workspace, provider_names, ) .await?; + let ProviderPolicyContext { + layers, + credentialed_scopes, + endpointless_provider_names, + } = provider_policy_context_from_records(catalog, &records); let provider_layers = if composition_enabled { layers } else { @@ -6494,12 +6696,6 @@ async fn sandbox_policy_merge_validation_data_with_catalog( provider_layer_count = provider_layers.len(), "Composed provider policy and credential context for merge validation" ); - let records = super::provider::load_provider_environment_records( - state.store.as_ref(), - workspace, - provider_names, - ) - .await?; Ok(SandboxPolicyMergeValidationData { provider_layers, catalog: catalog.clone(), @@ -7110,6 +7306,7 @@ mod tests { use crate::auth::principal::{ Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; + use crate::grpc::provider::ProviderEnvironment; use crate::grpc::test_support::{authed_request, test_server_state}; use crate::persistence::test_store; use std::collections::HashMap; @@ -8006,6 +8203,66 @@ 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 { + sandbox_id: sandbox_id.to_string(), + }), + 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; @@ -8103,6 +8360,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 { + name: 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() { @@ -11333,6 +11677,63 @@ mod tests { assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string())); } + #[test] + fn create_time_provider_credentials_reject_expiring_static_values() { + let provider_environment = ProviderEnvironment { + credential_expires_at_ms: HashMap::from([ + ("B_TOKEN".to_string(), 20_000), + ("A_TOKEN".to_string(), 10_000), + ("NON_SECRET".to_string(), 30_000), + ]), + static_credential_keys: HashSet::from(["A_TOKEN".to_string(), "B_TOKEN".to_string()]), + ..Default::default() + }; + + let error = validate_create_time_provider_credential_lifetimes( + "sandbox-expiring", + &provider_environment, + ) + .expect_err("expiring static credentials must fail closed"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("A_TOKEN, B_TOKEN")); + assert!(!error.message().contains("NON_SECRET")); + } + + #[test] + fn create_time_provider_credentials_allow_non_expiring_static_values() { + let provider_environment = ProviderEnvironment { + credential_expires_at_ms: HashMap::from([("STATIC_TOKEN".to_string(), 0)]), + static_credential_keys: HashSet::from(["STATIC_TOKEN".to_string()]), + ..Default::default() + }; + + validate_create_time_provider_credential_lifetimes("sandbox-static", &provider_environment) + .expect("non-expiring static credentials are supported"); + } + + #[test] + fn create_time_provider_credentials_reject_already_expired_static_values() { + // The shared resolver withholds already-expired static credentials + // entirely -- they never appear in `static_credential_keys` or + // `credential_expires_at_ms` -- so this check must consult + // `expired_static_keys` independently instead of silently allowing + // sandbox creation without the configured credential. + let provider_environment = ProviderEnvironment { + expired_static_keys: HashSet::from(["GITHUB_TOKEN".to_string()]), + ..Default::default() + }; + + let error = validate_create_time_provider_credential_lifetimes( + "sandbox-expired", + &provider_environment, + ) + .expect_err("already-expired static credentials must fail closed"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("GITHUB_TOKEN")); + } + #[tokio::test] async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; @@ -18534,6 +18935,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}; @@ -20931,6 +21361,22 @@ mod tests { "handle_get_sandbox_config must return NotFound, not PermissionDenied" ); + // --- handle_get_sandbox_provider_environment --- + let err = handle_get_sandbox_provider_environment( + &state, + non_member_request(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sandbox-other".into(), + supports_static_credential_bindings: true, + }), + ) + .await + .unwrap_err(); + assert_eq!( + err.code(), + Code::NotFound, + "handle_get_sandbox_provider_environment must hide cross-workspace sandboxes" + ); + // --- handle_get_sandbox_logs --- let err = handle_get_sandbox_logs( &state, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e3cb3f6b20..e0a83890c4 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -68,6 +68,13 @@ pub(super) struct ProviderEnvironment { pub dynamic_credentials: HashMap, pub static_credential_bindings: HashMap, pub static_credential_keys: HashSet, + /// Static credential keys withheld because they were already expired at + /// resolution time. Excluded from `environment`/`static_credential_keys` + /// like any other withheld key, but tracked separately so create-time + /// callers (see `validate_create_time_provider_credential_lifetimes`) can + /// fail closed instead of silently creating a sandbox without the + /// configured credential. + pub expired_static_keys: HashSet, } /// Immutable provider records used to build one provider-environment response. @@ -1117,6 +1124,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let mut expires = HashMap::new(); let mut static_credential_bindings = HashMap::new(); let mut static_credential_keys = HashSet::new(); + let mut expired_static_keys = HashSet::new(); let now_ms = crate::persistence::current_time_ms(); validate_provider_environment_records_unique_at(store, catalog, records, now_ms).await?; let registry = openshell_providers::ProviderRegistry::new(); @@ -1235,6 +1243,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin expires_at_ms, "skipping expired provider credential" ); + expired_static_keys.insert(key.clone()); continue; } if expires_at_ms > 0 { @@ -1338,6 +1347,23 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin } } + // The credential runtime withholds expired handle-backed values. Keep + // the identity of any otherwise injectable key so MXC create-time + // validation can reject the incomplete credential snapshot. + for key in resolved_refs.expired_keys { + if accepted_stored_credential_keys + .as_ref() + .is_some_and(|accepted| !accepted.contains(&key)) + || is_non_injectable_provider_credential(provider, &key) + || broker_only_credential_keys.contains(&key) + || has_no_usable_endpoint + || !is_valid_env_key(&key) + { + continue; + } + expired_static_keys.insert(key); + } + // Build each provider's emitted environment independently so another // provider's earlier output cannot change how this provider classifies // or populates its own keys. Cross-provider credential/config @@ -1354,6 +1380,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records), static_credential_bindings, static_credential_keys, + expired_static_keys, }) } @@ -10572,6 +10599,49 @@ mod tests { ); } + #[tokio::test] + async fn resolve_provider_env_preserves_expired_handle_key_for_create_time_rejection() { + let store = test_store().await; + let config = openshell_core::Config::new(None).with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + let mut provider = provider_with_credential_value( + "github-expired", + "github", + "GITHUB_TOKEN", + "github-token", + ); + provider.credential_expires_at_ms.insert( + "GITHUB_TOKEN".to_string(), + crate::persistence::current_time_ms() - 1, + ); + create_provider_record_validating( + &store, + "default", + &catalog, + provider, + Some(&credentials), + ) + .await + .unwrap(); + + let result = resolve_provider_environment_with_credentials( + &store, + &catalog, + "default", + &["github-expired".to_string()], + &credentials, + ) + .await + .unwrap(); + + assert!(!result.contains_key("GITHUB_TOKEN")); + assert!(result.expired_static_keys.contains("GITHUB_TOKEN")); + } + #[tokio::test] async fn resolve_provider_env_skips_expired_credentials_and_returns_expiry_metadata() { let store = test_store().await; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 83e6358df2..cc16a141db 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -14,12 +14,13 @@ use crate::auth::workspace_authz::{ AuthorizedWorkspaceScope, MinWorkspaceRole, authorize_list_workspace_selector, authorize_sandbox_workspace, authorize_workspace_selector, }; +use crate::compute::SandboxDeletePreconditions; use crate::pagination::Pagination; use crate::persistence::{ ObjectLabels, ObjectListQuery, ObjectType, WriteCondition, generate_name, }; use futures::future; -use openshell_core::net::set_tcp_nodelay_best_effort; +use openshell_core::net::{connect_tcp_nodelay_best_effort, set_tcp_nodelay_best_effort}; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateSandboxRequest, @@ -490,9 +491,12 @@ async fn handle_create_sandbox_inner( ) .await?; + let runtime_inputs = + super::policy::resolve_sandbox_create_runtime_inputs(state.as_ref(), &sandbox).await?; + state .compute - .validate_sandbox_create(&sandbox) + .validate_sandbox_create_with_runtime_inputs(&sandbox, &runtime_inputs) .await .map_err(|status| { warn!(error = %status, "Rejecting sandbox create request"); @@ -518,7 +522,12 @@ async fn handle_create_sandbox_inner( let mut sandbox = state .compute - .create_sandbox(sandbox, sandbox_token, await_main_process_attachment) + .create_sandbox_with_runtime_inputs( + sandbox, + sandbox_token, + await_main_process_attachment, + runtime_inputs, + ) .await?; state .supervisor_sessions @@ -1343,8 +1352,22 @@ async fn handle_delete_sandbox_inner( let workspace = super::workspace::resolve_workspace(state.store.as_ref(), &authz.workspace) .await? .name; + if req.expected_resource_version != 0 && req.expected_sandbox_id.is_empty() { + return Err(Status::invalid_argument( + "expected_resource_version requires expected_sandbox_id", + )); + } - let result = state.compute.delete_sandbox(&workspace, &name).await?; + let preconditions = SandboxDeletePreconditions { + expected_sandbox_id: (!req.expected_sandbox_id.is_empty()) + .then_some(req.expected_sandbox_id), + expected_resource_version: (req.expected_resource_version != 0) + .then_some(req.expected_resource_version), + }; + let result = state + .compute + .delete_sandbox_with_preconditions(&workspace, &name, preconditions) + .await?; if result.deleted { state.telemetry.end_sandbox_session(&result.sandbox_id); } @@ -1917,6 +1940,69 @@ pub(super) async fn handle_forward_tcp( } let connection_guard = acquire_forward_connection_guard(state, &init, &sandbox).await?; + let sandbox_id = sandbox.object_id().to_string(); + + // Drivers with no in-sandbox supervisor at all (MXC) never have a live + // ConnectSupervisor session -- `open_relay_with_target` below would just + // burn its 15s timeout and fail. When the active driver contributes a + // dynamic-forward capability, bridge through that instead (see + // `ComputeDriverForwardSink::open_dynamic_forward`). + if let Some(forward_sink) = state.compute.forward_sink() { + let target_port = match &target { + relay_open::Target::Tcp(t) => u16::try_from(t.port) + .map_err(|_| Status::invalid_argument("tcp target port out of range"))?, + relay_open::Target::Ssh(_) => { + return Err(Status::unimplemented( + "this driver has no SSH server to forward to", + )); + } + }; + + let (relay_addr, nonce, relay_handle) = forward_sink + .open_dynamic_forward(&sandbox_id, target_port) + .await + .map_err(|e| Status::unavailable(format!("driver dynamic forward failed: {e}")))?; + + // This is a latency-sensitive request/response tunnel, including on + // loopback -- small agent-protocol/WS frames can otherwise stall + // behind delayed ACK behavior, so disable Nagle on this leg too. + let mut relay_stream = connect_tcp_nodelay_best_effort(&[relay_addr]) + .await + .map_err(|e| { + Status::unavailable(format!( + "failed to connect to MXC relay at {relay_addr}: {e}" + )) + })?; + // Prove to the relay this is the real Phase B peer before any + // tunneled application data -- see openshell-driver-mxc's relay.rs + // module docs (the relay listens on loopback, so without this any + // other local process racing to connect first could otherwise + // hijack the forward). + tokio::io::AsyncWriteExt::write_all(&mut relay_stream, &nonce) + .await + .map_err(|e| { + Status::unavailable(format!("failed to authenticate to MXC relay: {e}")) + })?; + + let (tx, rx) = mpsc::channel::>(256); + let sandbox_id_bridge = sandbox_id.clone(); + tokio::spawn(async move { + let _connection_guard = connection_guard; + // Held for the bridge's lifetime; dropping it (bridge exits, + // this task ends) stops the ephemeral relay listener and closes + // Phase A, which is what tells the sandbox's dynamic bridge to + // stop too -- no separate teardown message needed. + let _relay_handle = relay_handle; + bridge_forward_tcp_stream(inbound, relay_stream, tx, &sandbox_id_bridge, "mxc-dynamic") + .await; + }); + + let stream: Pin< + Box> + Send + 'static>, + > = Box::pin(ReceiverStream::new(rx)); + return Ok(Response::new(stream)); + } + let (channel_id, relay_rx) = state .supervisor_sessions .open_relay_with_target( @@ -1928,7 +2014,6 @@ pub(super) async fn handle_forward_tcp( .await .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; - let sandbox_id = sandbox.object_id().to_string(); let (tx, rx) = mpsc::channel::>(256); tokio::spawn(async move { let _connection_guard = connection_guard; @@ -2114,13 +2199,15 @@ fn validate_tcp_target_parts(host: &str, _port: u32) -> Result { } } -async fn bridge_forward_tcp_stream( +async fn bridge_forward_tcp_stream( mut inbound: tonic::Streaming, - relay_stream: tokio::io::DuplexStream, + relay_stream: S, tx: mpsc::Sender>, sandbox_id: &str, channel_id: &str, -) { +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ let (mut relay_read, mut relay_write) = tokio::io::split(relay_stream); let sandbox_id_in = sandbox_id.to_string(); @@ -3649,6 +3736,7 @@ mod tests { workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), + ..Default::default() }), ) .await @@ -3687,6 +3775,66 @@ mod tests { ); } + #[tokio::test] + async fn delete_handler_rejects_expected_identity_drift_before_mutation() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("guarded-delete", Vec::new()); + sandbox.metadata.as_mut().unwrap().id = "sb-current".to_string(); + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_delete_sandbox_inner( + &state, + authed_request(DeleteSandboxRequest { + name: "guarded-delete".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + expected_sandbox_id: "sb-stale".to_string(), + expected_resource_version: 0, + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::Aborted); + assert!( + state + .store + .get_message::("sb-current") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn delete_handler_rejects_resource_version_without_immutable_identity() { + let state = test_server_state().await; + let mut sandbox = test_sandbox("guarded-delete", Vec::new()); + sandbox.metadata.as_mut().unwrap().id = "sb-current".to_string(); + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_delete_sandbox_inner( + &state, + authed_request(DeleteSandboxRequest { + name: "guarded-delete".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + expected_sandbox_id: String::new(), + expected_resource_version: 17, + }), + ) + .await + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!( + state + .store + .get_message::("sb-current") + .await + .unwrap() + .is_some() + ); + } + #[tokio::test] async fn attach_sandbox_provider_persists_current_provider_list() { let state = test_server_state().await; @@ -6785,6 +6933,7 @@ mod tests { non_member_request(DeleteSandboxRequest { workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), + ..Default::default() }), ) .await diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 85bf1371a8..2785b9b09b 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, - SandboxSpec, SandboxTemplate, + SandboxSpec, SandboxTemplate, UiClipboardAccess, }; use openshell_core::rpc_error::invalid_argument; use prost::Message; @@ -1018,7 +1018,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, @@ -1042,6 +1042,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(()) } @@ -2347,6 +2359,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/lib.rs b/crates/openshell-server/src/lib.rs index 7ea7711a97..6023532753 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1055,17 +1055,45 @@ async fn terminate_signal() { } pub use compute::{ - AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, SharedComputeDriver, + AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, + SandboxProviderCredentialsSink, SharedComputeDriver, }; /// Driver instance returned by a compiled compute-driver factory. pub enum ComputeDriverInstance { /// A driver hosted in the gateway process. InProcess(SharedComputeDriver), + /// An in-process driver with a create-time provider credential side channel. + InProcessWithProviderCredentials { + driver: SharedComputeDriver, + provider_credentials_sink: SandboxProviderCredentialsSink, + }, /// A driver process launched and owned by the gateway. ManagedRemote(AcquiredRemoteDriverEndpoint), } +/// Type-erased dynamic TCP forward capability. +/// +/// Optionally contributed by a +/// compiled in-process driver that has no in-sandbox supervisor of its own to +/// relay through (e.g. MXC: no live `ConnectSupervisor` session ever exists, +/// so `ForwardTcp` must bridge through the driver's own control channel +/// instead). Most drivers never call `ComputeDriverBuildContext::set_forward_sink` +/// and this stays `None`. +#[async_trait::async_trait] +pub trait ComputeDriverForwardSink: Send + Sync { + /// Opens a fresh, on-demand relay to `target_port` inside the sandbox. + /// Returns the relay's address, an auth nonce the caller must send as the + /// first bytes on its own connection to that address, and an opaque + /// handle the caller must hold for as long as the forward should stay + /// open (drop to tear it down). + async fn open_dynamic_forward( + &self, + sandbox_id: &str, + target_port: u16, + ) -> std::result::Result<(SocketAddr, Vec, Box), String>; +} + /// Factory for a compute driver linked into a gateway binary. #[async_trait::async_trait] pub trait ComputeDriverFactory: Send + Sync { @@ -1384,6 +1412,7 @@ impl ComputeDriverConfigContext<'_> { pub struct ComputeDriverBuildContext<'a> { config: ComputeDriverConfigContext<'a>, shutdown_rx: watch::Receiver, + forward_sink: Arc>>>, } impl ComputeDriverBuildContext<'_> { @@ -1451,6 +1480,13 @@ impl ComputeDriverBuildContext<'_> { .file .and_then(|file| file.openshell.gateway.otlp.as_ref()) } + + /// Contributes this driver's dynamic TCP forward capability, if it has + /// one, so `ComputeRuntime::forward_sink` can bridge `ForwardTcp` for + /// sandboxes with no live `ConnectSupervisor` session. + pub fn set_forward_sink(&self, sink: Arc) { + *self.forward_sink.lock().unwrap() = Some(sink); + } } #[allow(clippy::too_many_arguments)] @@ -1490,6 +1526,8 @@ async fn build_compute_runtime( let runtime = match driver { ConfiguredComputeDriver::Registered(registration) => { + let forward_sink: Arc>>> = + Arc::new(Mutex::new(None)); let build_context = ComputeDriverBuildContext { config: ComputeDriverConfigContext { driver_name: ®istration.name, @@ -1499,23 +1537,57 @@ async fn build_compute_runtime( driver_startup, }, shutdown_rx, + forward_sink: forward_sink.clone(), }; let instance = registration.factory.build(build_context).await?; match instance { - ComputeDriverInstance::InProcess(driver) => ComputeRuntime::from_driver( - registration.name, + ComputeDriverInstance::InProcess(driver) => { + let mut runtime = ComputeRuntime::from_driver( + registration.name, + driver, + None, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?; + let sink = forward_sink.lock().unwrap().take(); + if let Some(sink) = sink { + runtime.set_forward_sink(sink); + } + runtime + } + ComputeDriverInstance::InProcessWithProviderCredentials { driver, - None, - store, - sandbox_index, - sandbox_watch_bus, - tracing_log_bus, - supervisor_sessions, - ) - .await - .map_err(|error| { - Error::execution(format!("failed to create compute runtime: {error}")) - })?, + provider_credentials_sink, + } => { + let mut runtime = ComputeRuntime::from_driver( + registration.name, + driver, + None, + Some(provider_credentials_sink), + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await + .map_err(|error| { + Error::execution(format!("failed to create compute runtime: {error}")) + })?; + let sink = forward_sink.lock().unwrap().take(); + if let Some(sink) = sink { + runtime.set_forward_sink(sink); + } + runtime + } ComputeDriverInstance::ManagedRemote(mut endpoint) => { endpoint.name = registration.name; ComputeRuntime::new_remote_driver( diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index eb5b77d893..f92a9a40e2 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -119,11 +119,11 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "79c72615d957fc0653c672f61998bf7d8d21b757bc05d07b3fff92bd70fc8f52"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "26a2882f1fc82741f4821da90fb9674c9b5d66991760398a93a90354b7349d83"; + "800ab99d50213956aacd377a44ff98fbe0af5a948f0138c96106b9a12c90bcd7"; const DURABLE_SCHEMA_SHA256: &str = - "568ec5637c504726b40a616d286457f41b5be2f4761872c749313e1ee16b5c85"; + "c439724f77f71b794a68a76cec4040dde9eb22cbdb6e4f1517028683399c87ef"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "f96d841e67da5c3443fa0aca15936dd14ac30d2e150ddf0439b0d195c4a0cfd9"; + "ea546b1a1ee0ddf50ee74f2541dcf68541054c70a5ce29c93d3fd29e6af98820"; // A persisted Sandbox without endpoint status retains its lifecycle fields; // the absent repeated field decodes empty and needs no database rewrite. const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; @@ -490,13 +490,13 @@ mod tests { assert_eq!( (public_closure.messages.len(), public_closure.enums.len()), - (282, 13) + (283, 14) ); assert_eq!( (durable_closure.messages.len(), durable_closure.enums.len()), - (82, 9) + (83, 10) ); - assert_eq!((overlap_messages.len(), overlap_enums.len()), (72, 9)); + assert_eq!((overlap_messages.len(), overlap_enums.len()), (73, 10)); assert_eq!( public_inventory_hash, PUBLIC_RPC_SCHEMA_SHA256, @@ -575,6 +575,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 3bd5430ef1..62487d6bdb 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -100,6 +100,7 @@ impl FakeComputeDriver { resource_capabilities: None, rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, + supports_ui_policy: false, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index 631dc63292..9d6f5cfffa 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -4,15 +4,16 @@ //! 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 +//! MXC uses this on Windows: the driver injects proxy environment variables +//! pointing to a per-sandbox loopback listener in the gateway process. 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 base64::Engine as _; use miette::Result; use openshell_core::activity::ActivitySender; use openshell_core::denial::DenialEvent; @@ -33,7 +34,28 @@ use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::{ProxyHandle, ProxyIdentityMode}; -/// Configuration for a host-side OpenShell CONNECT proxy. +/// Ephemeral credential required from a sandbox before the host proxy will +/// evaluate or forward its request. +/// +/// The expected header value is intentionally private and this type does not +/// implement `Debug`, preventing accidental credential disclosure in logs. +#[derive(Clone)] +pub struct HostProxyClientAuth { + expected_proxy_authorization: Arc, +} + +impl HostProxyClientAuth { + #[must_use] + pub fn basic(username: &str, password: &str) -> Self { + let encoded = base64::engine::general_purpose::STANDARD + .encode(format!("{username}:{password}").as_bytes()); + Self { + expected_proxy_authorization: Arc::from(format!("Basic {encoded}")), + } + } +} + +/// 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, @@ -43,6 +65,9 @@ pub struct HostProxyConfig { /// socket-owning sandbox process. Policy binaries must match this path for /// L4/L7 allow rules to pass. pub binary_path: PathBuf, + /// Per-sandbox client authentication. Host-side MXC proxies must set this + /// so another sandbox cannot borrow this proxy's identity and policy. + pub client_auth: HostProxyClientAuth, pub sandbox_id: Option, pub sandbox_name: Option, pub openshell_endpoint: Option, @@ -190,11 +215,15 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result String { + let mut request = String::from( + "GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n", + ); + for header in headers { + request.push_str(header); + request.push_str("\r\n"); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + String::from_utf8(response).unwrap() + } + + async fn proxy_connect_request(addr: SocketAddr, headers: &[&str]) -> String { + let mut request = + String::from("CONNECT example.invalid:443 HTTP/1.1\r\nHost: example.invalid:443\r\n"); + for header in headers { + request.push_str(header); + request.push_str("\r\n"); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + String::from_utf8(response).unwrap() + } + #[tokio::test] async fn rejects_non_loopback_bind_addr() { let result = start_host_proxy(test_config( @@ -322,24 +391,9 @@ mod tests { .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(); + let auth = HostProxyClientAuth::basic("openshell", "test-secret"); + let header = format!("Proxy-Authorization: {}", auth.expected_proxy_authorization); + let response = proxy_request(addr, &[&header]).await; assert!( response.starts_with("HTTP/1.1 200 OK"), "unexpected response: {response}" @@ -355,4 +409,68 @@ mod tests { "unexpected policy payload: {body}" ); } + + #[tokio::test] + async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() { + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + + let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret"); + let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret"); + // Node's EnvHttpProxyAgent currently emits the field name in lower + // case; HTTP field names are case-insensitive. + let header_a = format!( + "proxy-authorization: {}", + auth_a.expected_proxy_authorization + ); + let header_b = format!( + "Proxy-Authorization: {}", + auth_b.expected_proxy_authorization + ); + + let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + config_a.client_auth = auth_a; + let proxy_a = start_host_proxy(config_a).await.unwrap(); + + let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + config_b.client_auth = auth_b; + let proxy_b = start_host_proxy(config_b).await.unwrap(); + + let addr_a = proxy_a.http_addr().unwrap(); + let addr_b = proxy_b.http_addr().unwrap(); + assert!(proxy_request(addr_a, &[]).await.starts_with("HTTP/1.1 407")); + assert!( + proxy_request(addr_a, &[&header_b]) + .await + .starts_with("HTTP/1.1 407"), + "sandbox B credential must not authenticate to sandbox A proxy" + ); + assert!( + proxy_request(addr_a, &[&header_a, &header_a]) + .await + .starts_with("HTTP/1.1 407"), + "duplicate credentials must fail closed" + ); + assert!( + proxy_request(addr_a, &[&header_a]) + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + proxy_request(addr_b, &[&header_b]) + .await + .starts_with("HTTP/1.1 200") + ); + assert!( + proxy_connect_request(addr_a, &[&header_b]) + .await + .starts_with("HTTP/1.1 407") + ); + assert!( + proxy_connect_request(addr_a, &[&header_a]) + .await + .starts_with("HTTP/1.1 403"), + "valid credentials must pass the auth gate and reach deny-by-default policy evaluation" + ); + } } diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index 643db06f5b..b824c58d26 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -57,7 +57,7 @@ impl FileFingerprint { fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { let duration = time.duration_since(std::time::UNIX_EPOCH).ok()?; Some(( - duration.as_secs() as i64, + duration.as_secs().cast_signed(), i64::from(duration.subsec_nanos()), )) } diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index fdc83cf9c9..31fc7f99ee 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -2723,6 +2723,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(), } @@ -3820,6 +3821,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"); @@ -4358,6 +4360,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4429,6 +4432,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4505,6 +4509,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -5990,6 +5995,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6047,6 +6053,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6105,6 +6112,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6165,6 +6173,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -6224,6 +6233,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -7616,6 +7626,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 { @@ -7686,6 +7697,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"); @@ -7916,6 +7928,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; let engine = OpaEngine::from_proto(&proto).unwrap(); // Port 443 @@ -8884,6 +8897,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 @@ -9520,6 +9534,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/) @@ -9595,6 +9610,7 @@ network_policies: }), network_policies, network_middlewares: std::collections::HashMap::default(), + ui: None, }; // Initial load at pid=0 — no symlink expansion diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index f57e9f3202..22bb8191c3 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -174,6 +174,7 @@ pub(crate) enum ProxyIdentityMode { Static { binary_path: PathBuf, binary_sha256: String, + required_proxy_authorization: Option>, }, } @@ -191,14 +192,35 @@ impl ProxyIdentityMode { #[cfg(any(not(target_os = "linux"), test))] pub(crate) fn static_binary(path: impl Into) -> Result { + Self::static_binary_with_client_auth(path, None) + } + + #[cfg(any(not(target_os = "linux"), test))] + pub(crate) fn static_binary_with_client_auth( + path: impl Into, + required_proxy_authorization: Option>, + ) -> Result { let binary_path = path.into(); let binary_sha256 = crate::procfs::file_sha256(&binary_path)?; Ok(Self::Static { binary_path, binary_sha256, + required_proxy_authorization, }) } + fn required_proxy_authorization(&self) -> Option<&str> { + match self { + #[cfg(target_os = "linux")] + Self::Procfs { .. } => None, + #[cfg(any(not(target_os = "linux"), test))] + Self::Static { + required_proxy_authorization, + .. + } => required_proxy_authorization.as_deref(), + } + } + fn entrypoint_pid(&self) -> u32 { match self { #[cfg(target_os = "linux")] @@ -983,11 +1005,15 @@ enum AcceptAction { }, } +// The resource-pressure counter is used only by the Unix errno classifier. +#[cfg_attr(not(unix), allow(clippy::needless_pass_by_ref_mut))] fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, consecutive_unknown_errors: &mut u32, ) -> AcceptAction { + #[cfg(not(unix))] + let _ = (err, &*consecutive_resource_errors); #[cfg(unix)] if matches!( err.raw_os_error(), @@ -1696,6 +1722,40 @@ async fn deny_forward_destination( .await } +fn constant_time_bytes_eq(left: &[u8], right: &[u8]) -> bool { + let max_len = left.len().max(right.len()); + let mut difference = left.len() ^ right.len(); + for index in 0..max_len { + let left_byte = left.get(index).copied().unwrap_or_default(); + let right_byte = right.get(index).copied().unwrap_or_default(); + difference |= usize::from(left_byte ^ right_byte); + } + difference == 0 +} + +fn has_valid_proxy_authorization(request: &str, expected: &str) -> bool { + let mut provided = None; + for line in request.split("\r\n").skip(1) { + if line.is_empty() { + break; + } + let Some((name, value)) = line.split_once(':') else { + return false; + }; + if name.eq_ignore_ascii_case("proxy-authorization") { + // Reject duplicates even when both values are correct. Accepting + // ambiguous credentials can produce parser differentials between + // this proxy and downstream HTTP implementations. + if provided.is_some() { + return false; + } + provided = Some(value.trim()); + } + } + + provided.is_some_and(|value| constant_time_bytes_eq(value.as_bytes(), expected.as_bytes())) +} + // Many distinct, non-related context parameters are required for a CONNECT // dispatch; bundling them into a struct would just shift the noise into call // sites. @@ -1766,6 +1826,20 @@ async fn handle_tcp_connection( respond(&mut client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); } + if let Some(expected) = identity_mode.required_proxy_authorization() + && !has_valid_proxy_authorization(request, expected) + { + warn!("Rejected host proxy request with missing or invalid per-sandbox credentials"); + respond( + &mut client, + b"HTTP/1.1 407 Proxy Authentication Required\r\n\ + Proxy-Authenticate: Basic realm=\"OpenShell\"\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n", + ) + .await?; + return Ok(()); + } let mut lines = request.split("\r\n"); let request_line = lines.next().unwrap_or(""); let mut parts = request_line.split_whitespace(); @@ -2867,6 +2941,7 @@ fn authorize_egress_intent( ProxyIdentityMode::Static { binary_path, binary_sha256, + .. } => { let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), @@ -7464,6 +7539,7 @@ network_policies: ProxyIdentityMode::Static { binary_path, binary_sha256, + .. } => { assert_eq!(binary_path, tmp.path()); assert_eq!( diff --git a/crates/openshell-supervisor-relay/Cargo.toml b/crates/openshell-supervisor-relay/Cargo.toml new file mode 100644 index 0000000000..678f08acb4 --- /dev/null +++ b/crates/openshell-supervisor-relay/Cargo.toml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-supervisor-relay" +description = "Generic process spawner + WebSocket relay bridge for OpenShell MXC ProcessContainer sandboxes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-supervisor-relay" +path = "src/main.rs" + +# This is an MXC/AppContainer helper: it only does anything useful on +# Windows. The implementation is technically portable (no Windows-specific +# APIs), but per the openshell-driver-mxc platform pattern, its real deps +# stay Windows-only so `cargo build --workspace` on Linux/macOS doesn't pull +# in the full relay implementation for a binary those platforms never run — +# see src/main.rs for the corresponding cfg(target_os = "windows") gating. +[target.'cfg(target_os = "windows")'.dependencies] +tokio = { workspace = true } +futures = { workspace = true } +tokio-tungstenite = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +anyhow = { workspace = true } + +# Black-box integration tests (tests/control_channel_contract.rs) spawn the +# compiled binary above and drive it over real stdio/TCP/WS -- Windows-only, +# same as the binary itself (the whole test file is `#![cfg(windows)]`), so +# these stay out of the dependency graph everywhere else too. +[target.'cfg(target_os = "windows")'.dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } +futures = { workspace = true } +tokio-tungstenite = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs new file mode 100644 index 0000000000..ad09f7dcb8 --- /dev/null +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -0,0 +1,1090 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Generic process spawner + WebSocket relay bridge for `OpenShell` MXC +//! `ProcessContainer` sandboxes. +//! +//! Unlike `mxc-ws-agent.rs` (a self-contained echo-server test harness), this +//! binary has exactly one job: launch an arbitrary command inside the +//! `AppContainer` and, on request, expose one of its TCP ports to the host +//! through a gateway relay, without requiring any changes to the launched +//! command itself. It is the `agent_command` the mxc driver spawns in place +//! of the target application directly, whenever a sandbox config asks for +//! relay bridging (see `driver.rs`'s handling of `pc_relay_spawner_path` / +//! `pc_relay_target_port`). +//! +//! There is no bridge at startup — relay bridging is entirely on-demand, +//! driven by `forward` requests over the control channel below (used for +//! `openshell forward service`). Each bridge is independent, short-lived, and +//! parameterized per-request (relay address + target port supplied in the +//! request), not declared anywhere in the gateway TOML. +//! +//! ```text +//! Host TCP client (via `openshell forward service`) +//! | +//! v +//! Gateway relay (127.0.0.1:) <-- driver binds one per forward +//! ^ request, tells us the address +//! | outbound WS (via egress_proxy) over the control channel +//! | +//! openshell-supervisor-relay <-- dials out on "forward", proxies traffic +//! | for the lifetime of that one request +//! | loopback WS (AppContainer-internal) +//! v +//! <-- launched from the "launch" control-channel +//! request (see below), no relay knowledge required +//! ``` +//! +//! Usage: `openshell-supervisor-relay.exe ` -- `` +//! is the TCP port the launched command is expected to bind (an early +//! liveness check, `wait_for_port_ready`: if the target never binds it +//! within 60s, this process exits with an error instead of sitting around +//! with a target that will never work). This binary uses no `share_dir` files +//! at all -- command/env and shutdown both travel over the control channel. +//! +//! Shutdown: driver sends a `"shutdown"` request over the control channel +//! (see below) and separately kills the wxc-exec process (`AppContainer` +//! teardown) as a backstop regardless of whether that message gets through. +//! This binary's `run_lifecycle` reacts to the request by killing the +//! target cleanly if it's still running. +//! +//! ## Control channel (this process's own stdin/stdout) +//! +//! `wxc-exec` runs this process with STDIO passthrough, which forwards its +//! own stdin/stdout down from the driver -- the driver pipes them (see +//! `mxc.rs`'s `run_oneshot`) instead of the usual `null`/`piped`-for-logging +//! split, giving the gateway a write channel straight into the `AppContainer`. +//! This needs **no `AppContainer` network capability at all**: it's inherited +//! process handles, not network traffic, so none of `egress_proxy` / +//! `network.proxy` / `privateNetworkClientServer` are involved. +//! +//! Protocol: newline-delimited JSON, mirroring MXC's own `pipe_server` tool: +//! Request: `{"id": , "op": "", "data": }` +//! Response: `{"id": , "ok": true, "data": }` +//! or `{"id": , "ok": false, "error": ""}` +//! Event (unsolicited, no id): `{"event": ""}` +//! +//! Startup handshake: before spawning anything, this process emits +//! `{"event":"ready","protocol_version":N}` on stdout (`N` = `PROTOCOL_VERSION` +//! below -- the driver rejects a mismatched/missing version immediately, +//! so an independently staged, out-of-sync binary fails fast instead of +//! hanging or misbehaving later), then blocks waiting for a `"launch"` +//! request carrying `data: {"command": [...], "env": [...]}` (one arg per +//! `command` element, first is the executable; `env` is `"KEY=VALUE"` +//! strings, replacing the inherited environment entirely when non-empty -- +//! lets runtimes that choke on an unrecognized host env, e.g. node.js +//! `STATUS_DLL_INIT_FAILED`, get a curated one instead). The driver sends +//! this once its stdout-reader observes the ready event (see driver.rs). +//! Command/env travel over this channel rather than as `agent-cmd.txt`/ +//! `agent-env.txt` files in `share_dir` -- keeps them (which can carry +//! secrets) off disk, and avoids the file ever going stale. +//! +//! Ops: `launch` (see above), `shutdown` (no data; acked, then wakes +//! `run_lifecycle` to kill the target and exit -- see Shutdown above), +//! `ping`, `echo`, and `forward` -- `forward` opens a new, independent relay +//! bridge for the target port and relay address given in the request (see +//! `handle_control_request`'s doc comment for the full shape). +//! +//! Because this channel owns our stdout exclusively, the target process's own +//! stdout/stderr are piped (not inherited) and forwarded to *our* stderr with +//! a `[target stdout]`/`[target stderr]` tag instead, so they stay visible in +//! the gateway log without colliding with control-channel responses. + +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; + +/// Wire protocol version reported in the startup `"ready"` event (see +/// `run_control_channel`). Must match `openshell-driver-mxc`'s +/// `REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION` constant -- duplicated +/// rather than shared via a common crate, matching how the rest of this +/// wire protocol is already duplicated across the two sides. Bump both +/// together whenever the control-channel protocol changes in a way an +/// out-of-sync peer can't safely ignore, so an independently staged, stale +/// binary on either side fails fast with a clear version-mismatch error +/// instead of hanging or misbehaving against a field/event it predates. +const PROTOCOL_VERSION: u64 = 2; + +struct ForwardSession { + reader: tokio::sync::Mutex, + writer: tokio::sync::Mutex, +} + +type ForwardSessions = tokio::sync::Mutex>>; + +pub async fn run() -> anyhow::Result<()> { + let port: u16 = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: openshell-supervisor-relay "))? + .parse() + .map_err(|e| anyhow::anyhow!("invalid : {e}"))?; + + // Wait for the driver's "launch" request (see module docs' startup + // handshake) before spawning anything -- command/env arrive over the + // control channel, not as files read from share_dir. Shutdown notice + // arrives the same way (a later "shutdown" request) -- no share_dir + // files are used by this process at all. + let (launch_tx, launch_rx) = oneshot::channel::<(Vec, Vec)>(); + let launch_slot = Arc::new(tokio::sync::Mutex::new(Some(launch_tx))); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let shutdown_slot = Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); + let forward_sessions = Arc::new(ForwardSessions::new(HashMap::new())); + // Lets main() ask run_control_channel's task to announce "target_ready" + // on stdout once the target is actually up (see below) -- routed + // through that task rather than a second independent stdout handle + // here, since concurrent writers to tokio's stdout can interleave. + let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); + tokio::spawn(run_control_channel( + launch_slot, + shutdown_slot, + forward_sessions, + target_ready_rx, + )); + + eprintln!("[openshell-supervisor-relay] waiting for launch request from driver..."); + let (command, env) = launch_rx + .await + .map_err(|_| anyhow::anyhow!("control channel closed before a launch request arrived"))?; + + let mut child = spawn_target(command, env)?; + + eprintln!("[openshell-supervisor-relay] waiting for target on 127.0.0.1:{port} ..."); + // Race the (up to ~300s worst case) port-readiness wait against a + // "shutdown" control-channel request, rather than only observing + // shutdown once run_lifecycle's select starts below. Without this, a + // shutdown arriving while the target is still coming up (or never + // binds) gets acknowledged immediately by run_control_channel -- which + // just fires this oneshot, nothing more -- but nothing actually acts on + // it until wait_for_port_ready returns on its own, leaving this process + // (and the target it spawned) alive for up to the full port-readiness + // budget after a caller was told shutdown succeeded. + let mut shutdown_rx = shutdown_rx; + tokio::select! { + result = wait_for_port_ready(&mut child, port, Duration::from_mins(1)) => { + result?; + } + _ = &mut shutdown_rx => { + eprintln!( + "[openshell-supervisor-relay] shutdown request -- stopping before target became ready" + ); + let _ = child.kill().await; + let _ = child.wait().await; + eprintln!("[openshell-supervisor-relay] done"); + // Not `return Ok(())`: run_control_channel loops on + // stdin.next_line() for this process's entire lifetime and + // only sees EOF once the driver closes its end, which it has + // no reason to do before observing this process actually exit + // -- returning normally here would leave that task (and so + // this whole process) alive indefinitely, exactly the + // "acknowledged but still alive" symptom this fix exists to + // close. std::process::exit terminates unconditionally, + // matching run_lifecycle's own shutdown branch below. + std::process::exit(0); + } + } + eprintln!("[openshell-supervisor-relay] target is up on port {port}"); + // Unsolicited event, distinct from the "launch" control-channel + // response (which only confirmed the command/env arrived, not that the + // target is actually reachable) -- driver.rs awaits this before + // publishing the sandbox Ready=True. A send failure just means the + // control-channel task already exited; nothing to do about that here. + let _ = target_ready_tx.send(()); + + // No bridge at startup -- relay bridging is entirely on-demand via the + // control channel's "forward" op (see module docs). Just run the target + // process's lifecycle from here. + run_lifecycle(child, shutdown_rx).await; + + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Render the first `n` bytes of `data` as a printable-ASCII preview +/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. +/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an +/// HTTP/WS handshake looks intact versus corrupted or empty. +/// +/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or +/// other sensitive payload, and helper stderr is forwarded into gateway logs, +/// so no byte preview is ever logged. Kept only so a future opt-in diagnostic +/// mode has a ready-made (still-redaction-worthy) formatter to start from. +#[allow(dead_code)] +fn byte_preview(data: &[u8]) -> String { + const MAX: usize = 120; + let n = data.len().min(MAX); + let mut s: String = data[..n] + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + if data.len() > MAX { + s.push_str("..."); + } + s +} + +// ── Target process ──────────────────────────────────────────────────────────── + +/// Spawn `command` (first element is the executable, rest are args) with +/// `env` (`"KEY=VALUE"` strings) as its entire environment when non-empty -- +/// both arrive over the control channel's `"launch"` request (see module +/// docs), not read from `share_dir` files. The child's stdout/stderr are piped +/// and forwarded (tagged) to our own stderr — not inherited directly — +/// because our stdout is reserved exclusively for the control-channel +/// protocol with the driver. The child's stdin is closed; it isn't part of +/// this channel. +fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { + if command.is_empty() { + anyhow::bail!("launch command must not be empty"); + } + + let mut cmd = tokio::process::Command::new(&command[0]); + cmd.args(&command[1..]); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + if !env.is_empty() { + let child_env: Vec<(String, String)> = env + .iter() + .filter(|l| l.contains('=')) + .filter_map(|l| { + let pos = l.find('=')?; + Some((l[..pos].to_string(), l[pos + 1..].to_string())) + }) + .collect(); + eprintln!( + "[openshell-supervisor-relay] using {} child env vars from launch request", + child_env.len() + ); + cmd.env_clear().envs(child_env); + } + + // Not the full command line: it's a control-channel payload (see the + // "launch" handshake in this module's docs) and can carry secrets in + // its arguments (e.g. a token passed via CLI flag) -- log only the + // executable and an argument count, matching how the driver side + // avoids writing agent_command/env to disk for the same reason. + eprintln!( + "[openshell-supervisor-relay] starting program {:?} with {} arg(s)", + command[0], + command.len().saturating_sub(1) + ); + let mut child = cmd.spawn()?; + + if let Some(stdout) = child.stdout.take() { + tokio::spawn(forward_tagged_lines(stdout, "target stdout")); + } + if let Some(stderr) = child.stderr.take() { + tokio::spawn(forward_tagged_lines(stderr, "target stderr")); + } + + Ok(child) +} + +/// Read lines from `reader` and re-emit them on our own stderr, tagged, so +/// the target's output stays visible in the gateway log without touching our +/// stdout (reserved for the control channel). +async fn forward_tagged_lines(reader: impl tokio::io::AsyncRead + Unpin, label: &'static str) { + use tokio::io::{AsyncBufReadExt, BufReader}; + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + eprintln!("[{label}] {line}"); + } +} + +// ── Control channel ─────────────────────────────────────────────────────────── +// +// See module docs for the protocol and why this is safe to run with no +// AppContainer network capability. Runs for the lifetime of the process, +// independent of target/relay state. + +/// Holds the one-shot sender the `"launch"` op fires, carrying `(command, +/// env)` to `main()`. `None` after the first successful launch (or if +/// `main()` already gave up on it) -- a second `"launch"` is rejected. +type LaunchSlot = tokio::sync::Mutex, Vec)>>>; +/// Holds the one-shot sender the `"shutdown"` op fires, waking +/// `run_lifecycle`'s select so it can kill the target and exit. `None` +/// after the first shutdown request -- a second one is a no-op ack. +type ShutdownSlot = tokio::sync::Mutex>>; + +async fn run_control_channel( + launch: Arc, + shutdown: Arc, + forward_sessions: Arc, + target_ready_rx: oneshot::Receiver<()>, +) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let stdin = tokio::io::stdin(); + let mut lines = BufReader::new(stdin).lines(); + let mut stdout = tokio::io::stdout(); + + // Announce readiness before entering the request loop: this is the + // signal driver.rs's stdout-reader waits for to know it's safe to send + // "launch" (see module docs' startup handshake). Unsolicited -- no + // correlation id, since it isn't a reply to anything the driver sent. + let ready = serde_json::json!({"event": "ready", "protocol_version": PROTOCOL_VERSION}) + .to_string() + + "\n"; + if stdout.write_all(ready.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel: failed to announce ready"); + return; + } + eprintln!("[openshell-supervisor-relay] control channel ready (stdin/stdout)"); + + // `None` once fired (or once main()'s sender is dropped without firing, + // e.g. wait_for_port_ready failed) -- the `if` guard below then disables + // that select arm instead of it firing repeatedly on every subsequent + // poll of an already-resolved oneshot. + let mut target_ready_rx = Some(target_ready_rx); + + loop { + tokio::select! { + line_result = lines.next_line() => { + let line = match line_result { + Ok(Some(l)) => l, + Ok(None) => { + eprintln!("[openshell-supervisor-relay] control channel: stdin closed"); + break; + } + Err(e) => { + eprintln!("[openshell-supervisor-relay] control channel read error: {e}"); + break; + } + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Log only the operation and correlation id, never the raw request -- + // "launch" carries the target's complete environment (e.g. + // OPENCLAW_GATEWAY_TOKEN) and this process's stderr is forwarded + // verbatim into the gateway's own logs, so printing `trimmed` here + // would expose it even when MXC's own --debug flag is off. + eprintln!( + "[openshell-supervisor-relay] control request: {}", + describe_control_request(trimmed) + ); + let response = handle_control_request( + trimmed, + &launch, + &shutdown, + &forward_sessions, + ) + .await; + let mut out = response.to_string(); + out.push('\n'); + if stdout.write_all(out.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel write failed"); + break; + } + } + // See module docs' startup handshake -- distinct from the + // "launch" response, which only confirms the command/env + // arrived. Unsolicited, like "ready" above. + result = async { target_ready_rx.as_mut().unwrap().await }, if target_ready_rx.is_some() => { + target_ready_rx = None; + if result.is_ok() { + let event = serde_json::json!({"event": "target_ready"}).to_string() + "\n"; + if stdout.write_all(event.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel: failed to announce target_ready"); + } + } + // A dropped sender (main() bailed before the target ever + // came up, e.g. wait_for_port_ready's own error) means + // there's nothing to announce -- driver.rs's timeout on the + // corresponding event will surface that as a launch failure + // on its own. + } + } + } +} + +/// `forward` opens a new, independent relay bridge for a target port, e.g. +/// for `openshell forward service`. +/// +/// `data`: `{"relay_addr": "", "target_port": }` -- the +/// caller (the driver) has already started a fresh relay listener on the +/// gateway side for this one request and tells us its address here; we dial +/// out to it (Phase A). Replies once Phase A actually connects (or on +/// failure/timeout), so the caller knows whether the bridge is really usable +/// before it starts sending Phase B clients at the relay address it created. +/// +/// No explicit "stop" for this bridge: it runs until Phase A closes, which +/// happens when the caller drops its relay listener (the gRPC forward +/// stream ending) -- see the driver-side `ForwardSink::open_dynamic_forward`. +/// Summarizes an inbound control-channel request for logging as `op=... id=...` +/// -- deliberately never includes `data`, since `launch` (and, in principle, +/// `echo`) can carry secrets. Falls back to a fixed placeholder rather than +/// printing anything from `line` if it doesn't even parse, so a malformed +/// request can't smuggle sensitive-looking text into the log via a JSON +/// parse failure either. +fn describe_control_request(line: &str) -> String { + serde_json::from_str::(line).map_or_else( + |_| "".to_string(), + |v| { + let op = v.get("op").and_then(|x| x.as_str()).unwrap_or(""); + let id = v.get("id").cloned().unwrap_or(serde_json::Value::Null); + format!("op={op} id={id}") + }, + ) +} + +async fn handle_control_request( + line: &str, + launch: &LaunchSlot, + shutdown: &ShutdownSlot, + forward_sessions: &ForwardSessions, +) -> serde_json::Value { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let req: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + return serde_json::json!({"id": null, "ok": false, "error": format!("parse error: {e}")}); + } + }; + let id = req.get("id").cloned().unwrap_or(serde_json::Value::Null); + let op = req.get("op").and_then(|v| v.as_str()).unwrap_or(""); + + match op { + // Driver sends this on sandbox delete instead of writing a + // openshell-shutdown.signal file -- wakes run_lifecycle's select so + // it can kill the target and exit. Acked even on a repeat (the + // slot's already empty by then), since the driver's request has a + // short timeout and shouldn't be left hanging either way. + "shutdown" => { + let slot = shutdown.lock().await.take(); + if let Some(tx) = slot { + let _ = tx.send(()); + } + serde_json::json!({"id": id, "ok": true}) + } + // See module docs' startup handshake: sent once, right after the + // "ready" event, carrying the real command/env instead of them + // being written to share_dir as agent-cmd.txt/agent-env.txt. + "launch" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let command: Vec = data + .get("command") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + if command.is_empty() { + return serde_json::json!({ + "id": id, "ok": false, + "error": "launch requires data.command (non-empty array of strings)" + }); + } + let env: Vec = data + .get("env") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + + let slot = launch.lock().await.take(); + slot.map_or_else( + || serde_json::json!({"id": id, "ok": false, "error": "launch already requested"}), + |tx| { + let _ = tx.send((command, env)); + serde_json::json!({"id": id, "ok": true}) + }, + ) + } + "ping" => serde_json::json!({"id": id, "ok": true, "data": "pong"}), + "echo" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + serde_json::json!({"id": id, "ok": true, "data": data}) + } + "forward_open" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let session_id = data + .get("session_id") + .and_then(|v| v.as_str()) + .filter(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())) + .map(str::to_string); + let target_port = data + .get("target_port") + .and_then(serde_json::Value::as_u64) + .and_then(|n| u16::try_from(n).ok()); + let (Some(session_id), Some(target_port)) = (session_id, target_port) else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward_open requires a 64-character hexadecimal session_id and target_port (u16)" + }); + }; + if forward_sessions.lock().await.contains_key(&session_id) { + return serde_json::json!({ + "id": id, "ok": false, "error": "forward session already exists" + }); + } + let stream = match connect_forward_target(|| { + tokio::net::TcpStream::connect(("127.0.0.1", target_port)) + }) + .await + { + Ok(stream) => stream, + Err(error) => { + return serde_json::json!({ + "id": id, "ok": false, + "error": format!("target connect failed: {error}") + }); + } + }; + if let Err(error) = stream.set_nodelay(true) { + eprintln!( + "[openshell-supervisor-relay] failed to set TCP_NODELAY on target connection: {error}" + ); + } + let (reader, writer) = stream.into_split(); + forward_sessions.lock().await.insert( + session_id, + Arc::new(ForwardSession { + reader: tokio::sync::Mutex::new(reader), + writer: tokio::sync::Mutex::new(writer), + }), + ); + serde_json::json!({"id": id, "ok": true}) + } + "forward_write" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let session_id = data.get("session_id").and_then(|v| v.as_str()); + let encoded = data.get("bytes").and_then(|v| v.as_str()); + let (Some(session_id), Some(encoded)) = (session_id, encoded) else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward_write requires session_id and base64 bytes" + }); + }; + if encoded.len() > 16_384 { + return serde_json::json!({"id": id, "ok": false, "error": "forward_write chunk is too large"}); + } + let bytes = match base64::engine::general_purpose::STANDARD.decode(encoded) { + Ok(bytes) => bytes, + Err(error) => { + return serde_json::json!({ + "id": id, "ok": false, "error": format!("invalid forward bytes: {error}") + }); + } + }; + let session = forward_sessions.lock().await.get(session_id).cloned(); + let Some(session) = session else { + return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); + }; + match session.writer.lock().await.write_all(&bytes).await { + Ok(()) => serde_json::json!({"id": id, "ok": true}), + Err(error) => { + serde_json::json!({"id": id, "ok": false, "error": format!("target write failed: {error}")}) + } + } + } + "forward_read" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { + return serde_json::json!({"id": id, "ok": false, "error": "forward_read requires session_id"}); + }; + let session = forward_sessions.lock().await.get(session_id).cloned(); + let Some(session) = session else { + return serde_json::json!({"id": id, "ok": false, "error": "forward session not found"}); + }; + let mut bytes = vec![0_u8; 8192]; + match tokio::time::timeout( + Duration::from_millis(100), + session.reader.lock().await.read(&mut bytes), + ) + .await + { + Ok(Ok(0)) => { + serde_json::json!({"id": id, "ok": true, "data": {"bytes": "", "eof": true}}) + } + Ok(Ok(n)) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes[..n]); + serde_json::json!({"id": id, "ok": true, "data": {"bytes": encoded, "eof": false}}) + } + Ok(Err(error)) => { + serde_json::json!({"id": id, "ok": false, "error": format!("target read failed: {error}")}) + } + Err(_) => { + serde_json::json!({"id": id, "ok": true, "data": {"bytes": "", "eof": false}}) + } + } + } + "forward_close" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let Some(session_id) = data.get("session_id").and_then(|v| v.as_str()) else { + return serde_json::json!({"id": id, "ok": false, "error": "forward_close requires session_id"}); + }; + forward_sessions.lock().await.remove(session_id); + serde_json::json!({"id": id, "ok": true}) + } + "forward" => { + let data = req.get("data").cloned().unwrap_or(serde_json::Value::Null); + let relay_addr = data + .get("relay_addr") + .and_then(|v| v.as_str()) + .map(str::to_string); + let target_port = data + .get("target_port") + .and_then(serde_json::Value::as_u64) + .and_then(|n| u16::try_from(n).ok()); + // Proves to the relay (a loopback-bound listener any other local + // process could otherwise race to connect to first) that this + // is the real Phase A peer -- see openshell-driver-mxc's + // relay.rs module docs. Sent verbatim as the WS auth message + // below, not decoded here; the driver and the relay agree on + // the hex encoding independently. + let nonce = data + .get("nonce") + .and_then(|v| v.as_str()) + .map(str::to_string); + + let (Some(relay_addr), Some(target_port), Some(nonce)) = + (relay_addr, target_port, nonce) + else { + return serde_json::json!({ + "id": id, "ok": false, + "error": "forward requires data.relay_addr (string), data.target_port (u16), and data.nonce (string)" + }); + }; + + let (ready_tx, ready_rx) = oneshot::channel::>(); + + eprintln!( + "[openshell-supervisor-relay] forward: dynamic bridge ws://{relay_addr} <-> 127.0.0.1:{target_port}" + ); + tokio::spawn(run_relay_bridge(relay_addr, target_port, nonce, ready_tx)); + + match tokio::time::timeout(Duration::from_secs(5), ready_rx).await { + Ok(Ok(Ok(()))) => { + serde_json::json!({"id": id, "ok": true, "data": {"target_port": target_port}}) + } + Ok(Ok(Err(e))) => serde_json::json!({"id": id, "ok": false, "error": e}), + Ok(Err(_)) => { + serde_json::json!({"id": id, "ok": false, "error": "relay bridge task dropped"}) + } + Err(_) => serde_json::json!({ + "id": id, "ok": false, "error": "timed out waiting for relay connection" + }), + } + } + _ => serde_json::json!({"id": id, "ok": false, "error": format!("unknown op: {op}")}), + } +} + +/// Bound session opening below the driver's 10-second control-request timeout. +/// Five one-second attempts plus four 300ms retry delays take at most 6.2s +/// of timer budget. A stuck connect is cancelled before another is attempted. +async fn connect_forward_target(mut connect: F) -> std::io::Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut last_error = std::io::Error::from(std::io::ErrorKind::TimedOut); + for attempt in 1..=5 { + match tokio::time::timeout(Duration::from_secs(1), connect()).await { + Ok(Ok(stream)) => return Ok(stream), + Ok(Err(error)) => last_error = error, + Err(_) => last_error = std::io::Error::from(std::io::ErrorKind::TimedOut), + } + if attempt < 5 { + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + Err(last_error) +} + +/// Number of full-budget tries `wait_for_port_ready` makes -- each try gets +/// its own complete `per_try_timeout` window, not a slice of it. Worst case +/// total wait is `max_tries * per_try_timeout` (3 * 60s = 180s today). +const PORT_READY_MAX_TRIES: u32 = 3; + +/// Poll for the target port accepting TCP connections, bailing out early +/// (rather than waiting out all tries) if the child process exits first — a +/// dead child will never open the port, so there's no reason to wait. Makes +/// up to `PORT_READY_MAX_TRIES` tries, each given the full `per_try_timeout` +/// budget, logging the start of every try so a long cold-start wait (Node.js +/// first-run JIT/module resolution, first-touch AV scan of freshly staged +/// binaries, etc.) is visible rather than silent until success or final +/// timeout. +async fn wait_for_port_ready( + child: &mut tokio::process::Child, + port: u16, + per_try_timeout: Duration, +) -> anyhow::Result<()> { + // Bound each individual connect attempt: on at least one observed + // wxc-exec build, a connect() against a not-yet-listening loopback port + // inside the AppContainer never resolved at all (no fast ECONNREFUSED, + // no error) instead of failing quickly like an ordinary closed-port + // connect. Without this, a single early attempt can hang forever and + // this function -- and the whole readiness wait -- never returns even + // after the target's port genuinely opens, since nothing ever retries. + const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); + + let overall_start = tokio::time::Instant::now(); + for try_num in 1..=PORT_READY_MAX_TRIES { + eprintln!( + "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} (up to {per_try_timeout:?}) for port {port}" + ); + let try_deadline = tokio::time::Instant::now() + per_try_timeout; + loop { + let attempt = tokio::time::timeout( + ATTEMPT_TIMEOUT, + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await; + if let Ok(Ok(_)) = attempt { + eprintln!( + "[openshell-supervisor-relay] port {port} ready after {:?} (try {try_num}/{PORT_READY_MAX_TRIES})", + overall_start.elapsed() + ); + return Ok(()); + } + if let Ok(Some(status)) = child.try_wait() { + anyhow::bail!("target process exited before port {port} came up: {status}"); + } + if tokio::time::Instant::now() >= try_deadline { + eprintln!( + "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} timed out after {per_try_timeout:?} (elapsed {:?} total)", + overall_start.elapsed() + ); + break; + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + anyhow::bail!( + "timed out after {PORT_READY_MAX_TRIES} tries ({:?} total) waiting for port {port}", + overall_start.elapsed() + ); +} + +// ── Relay bridge ────────────────────────────────────────────────────────────── +// +// Implements the sandbox side of relay.rs's Phase A protocol exactly: +// +// TEXT "SESSION_START" — relay opened a new Phase B TCP connection; open a +// FRESH raw TCP connection to the target and start +// forwarding its bytes back as BINARY frames. +// BINARY — bytes from the Phase B TCP stream; write them +// as-is to the active target connection. +// TEXT "SESSION_END" — Phase B TCP connection closed; drop the target +// connection. +// WS Close — relay shutting down. +// +// This is a raw byte tunnel, not a WS-to-WS message bridge: each session gets +// its own genuine TCP connection to the target with bytes passed through +// untouched, so the host's own protocol (e.g. a real WS handshake it performs +// against the tunnel) reaches the target exactly as sent. A single persistent +// connection re-used across sessions, forwarding opaque message payloads, +// would not preserve that — the target would never see a valid handshake. + +async fn run_relay_bridge( + relay_addr: String, + port: u16, + nonce: String, + ready_tx: oneshot::Sender>, +) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let relay_url = format!("ws://{relay_addr}"); + + // The gateway already bound this listener before sending us the + // "forward" request, so it's expected to be immediately reachable -- + // single attempt, no retry needed here. + // + // Connect the raw TCP socket ourselves rather than letting + // tokio_tungstenite::connect_async do it internally, so TCP_NODELAY can + // be set before the WS handshake: this is a latency-sensitive + // request/response tunnel, including on loopback, and small WS frames + // can otherwise stall behind delayed ACK behavior. Best-effort -- + // failure to set it doesn't fail the connection, just costs a bit of + // latency. + let tcp = match tokio::net::TcpStream::connect(&relay_addr).await { + Ok(stream) => { + if let Err(e) = stream.set_nodelay(true) { + eprintln!( + "[openshell-supervisor-relay] failed to set TCP_NODELAY on relay connection: {e}" + ); + } + stream + } + Err(e) => { + let msg = format!("relay connect failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + }; + let relay_ws = match tokio_tungstenite::client_async(&relay_url, tcp).await { + Ok((ws, _)) => { + eprintln!("[openshell-supervisor-relay] relay connected: {relay_url}"); + ws + } + Err(e) => { + let msg = format!("relay handshake failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + }; + let (mut relay_write, mut relay_read) = relay_ws.split(); + + // Must be the very first message: the relay won't trust anything else + // from this connection (including SESSION_START/BINARY frames) until + // this matches -- see relay.rs module docs. + if let Err(e) = relay_write + .send(Message::Text(format!("AUTH:{nonce}").into())) + .await + { + let msg = format!("relay auth send failed: {e}"); + eprintln!("[openshell-supervisor-relay] {msg}"); + let _ = ready_tx.send(Err(msg)); + return; + } + + eprintln!("[openshell-supervisor-relay] relay bridge active"); + let _ = ready_tx.send(Ok(())); + + let mut session: Option = None; + let mut read_buf = vec![0u8; 8192]; + // Byte counters, reset per session -- mirror the instrumentation in + // relay.rs. Together the two sides let a hung request be localized to a + // specific hop instead of just "the client timed out": relay.rs's + // host_to_sandbox_* should match this session's phase_a_to_target_* + // (same bytes, different name each side of the WS tunnel), and + // target_to_phase_a_* should match relay.rs's sandbox_to_host_*. A + // mismatch or a stuck-at-zero counter on one side pinpoints exactly + // where bytes stop moving. No payload content is ever logged -- see the + // module-level note on `byte_preview`. + let mut phase_a_to_target_bytes: u64 = 0; + let mut phase_a_to_target_chunks: u64 = 0; + let mut target_to_phase_a_bytes: u64 = 0; + let mut target_to_phase_a_chunks: u64 = 0; + + loop { + let session_read = + futures::future::OptionFuture::from(session.as_mut().map(|s| s.read(&mut read_buf))); + + tokio::select! { + msg = relay_read.next() => match msg { + Some(Ok(Message::Text(t))) => { + if t == "SESSION_START" { + // Retry briefly: even though wait_for_port_ready() already + // confirmed a raw TCP accept succeeds once, that doesn't + // guarantee the target's listener stays continuously + // accept-ready under a freshly-started process (observed as + // a genuine, reproducible ~500ms startup race elsewhere in + // this codebase -- see mxc-ws-agent.rs's local-connect + // retry). A session-open failure here would otherwise + // silently drop the host's connection attempt. + match connect_forward_target(|| tokio::net::TcpStream::connect(("127.0.0.1", port))).await { + Ok(s) => { + // Latency-sensitive request/response tunnel -- + // see the matching comment on the relay + // connection above. Best-effort. + if let Err(e) = s.set_nodelay(true) { + eprintln!("[openshell-supervisor-relay] failed to set TCP_NODELAY on target connection: {e}"); + } + eprintln!("[openshell-supervisor-relay] session start -- connected to target 127.0.0.1:{port}"); + session = Some(s); + phase_a_to_target_bytes = 0; + phase_a_to_target_chunks = 0; + target_to_phase_a_bytes = 0; + target_to_phase_a_chunks = 0; + } + Err(last_err) => { + eprintln!("[openshell-supervisor-relay] session start -- target connect failed: {last_err}"); + // Tell the relay so it can close the host's + // TCP connection promptly instead of leaving + // it hanging until the client's own timeout. + let _ = relay_write + .send(Message::Text(format!("SESSION_FAILED:{last_err}").into())) + .await; + } + } + } else if t == "SESSION_END" { + eprintln!("[openshell-supervisor-relay] session end phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}"); + session = None; + } + } + Some(Ok(Message::Binary(b))) => { + if let Some(s) = session.as_mut() { + phase_a_to_target_chunks += 1; + phase_a_to_target_bytes += b.len() as u64; + if phase_a_to_target_chunks == 1 { + eprintln!("[openshell-supervisor-relay] first phase-A->target chunk: {} bytes", b.len()); + } + if s.write_all(&b).await.is_err() { + eprintln!("[openshell-supervisor-relay] target write failed"); + session = None; + } + } else { + eprintln!("[openshell-supervisor-relay] BINARY with no active session (dropped {} bytes)", b.len()); + } + } + Some(Ok(Message::Close(_))) | None => { + eprintln!("[openshell-supervisor-relay] relay closed"); + break; + } + Some(Ok(_)) => {} // ping/pong handled by tungstenite + Some(Err(e)) => { + eprintln!("[openshell-supervisor-relay] relay read error: {e}"); + break; + } + }, + Some(result) = session_read => { + match result { + Ok(0) => { + eprintln!("[openshell-supervisor-relay] target connection closed phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}"); + session = None; + // Tell the relay so it can close the host's Phase B + // TCP connection promptly (see relay.rs's handling + // of this message) instead of leaving the host + // client waiting for more bytes until its own + // timeout -- the target won't send any more. + let _ = relay_write.send(Message::Text("SESSION_END".into())).await; + } + Ok(n) => { + target_to_phase_a_chunks += 1; + target_to_phase_a_bytes += n as u64; + if target_to_phase_a_chunks == 1 { + eprintln!("[openshell-supervisor-relay] first target->phase-A chunk: {n} bytes"); + } + if relay_write.send(Message::Binary(read_buf[..n].to_vec().into())).await.is_err() { + break; + } + } + Err(e) => { + eprintln!("[openshell-supervisor-relay] target read error: {e}"); + session = None; + } + } + } + } + } + eprintln!( + "[openshell-supervisor-relay] relay bridge stopped phase_a_to_target_bytes={phase_a_to_target_bytes} phase_a_to_target_chunks={phase_a_to_target_chunks} target_to_phase_a_bytes={target_to_phase_a_bytes} target_to_phase_a_chunks={target_to_phase_a_chunks}" + ); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +/// Wait for the target process to exit or a "shutdown" control-channel +/// request to arrive (see `handle_control_request`), whichever comes first. +/// Any active dynamic relay bridges are tokio tasks in this same process, so +/// `std::process::exit` below tears them down too -- no separate stop signal +/// needed. +async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::Receiver<()>) { + tokio::select! { + status = child.wait() => { + let code = status.map_or(1, |s| s.code().unwrap_or(1)); + eprintln!("[openshell-supervisor-relay] target exited with code {code}"); + std::process::exit(code); + } + _ = shutdown_rx => { + eprintln!("[openshell-supervisor-relay] shutdown request -- stopping"); + let _ = child.kill().await; + let _ = child.wait().await; + eprintln!("[openshell-supervisor-relay] done"); + std::process::exit(0); + } + } +} + +#[cfg(test)] +mod forward_connect_tests { + use super::connect_forward_target; + use std::io::{Error, ErrorKind}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + #[tokio::test(start_paused = true)] + async fn forward_connect_returns_success_without_retry_delay() { + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| std::future::ready(Ok(42))).await; + assert_eq!(result.unwrap(), 42); + assert_eq!(start.elapsed(), Duration::ZERO); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_retries_refused_connections_and_preserves_last_error() { + let mut attempts = 0; + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + attempts += 1; + std::future::ready(Err::<(), _>(Error::new( + ErrorKind::ConnectionRefused, + attempts.to_string(), + ))) + }) + .await; + assert_eq!(attempts, 5); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ConnectionRefused); + assert_eq!(start.elapsed(), Duration::from_millis(1200)); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_cancels_stuck_attempts_before_driver_timeout() { + struct Cancelled(Arc); + impl Drop for Cancelled { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let cancelled = Arc::new(AtomicUsize::new(0)); + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + let guard = Cancelled(cancelled.clone()); + async move { + let _guard = guard; + std::future::pending::>().await + } + }) + .await; + assert_eq!(result.unwrap_err().kind(), ErrorKind::TimedOut); + assert_eq!(cancelled.load(Ordering::SeqCst), 5); + assert_eq!(start.elapsed(), Duration::from_millis(6200)); + assert!(start.elapsed() < Duration::from_secs(10)); + } + + #[tokio::test(start_paused = true)] + async fn forward_connect_recovers_after_a_stuck_attempt() { + let mut attempts = 0; + let start = tokio::time::Instant::now(); + let result = connect_forward_target(|| { + attempts += 1; + let attempt = attempts; + async move { + if attempt == 1 { + std::future::pending::<()>().await; + } + Ok(42) + } + }) + .await; + assert_eq!(result.unwrap(), 42); + assert_eq!(attempts, 2); + assert_eq!(start.elapsed(), Duration::from_millis(1300)); + } +} diff --git a/crates/openshell-supervisor-relay/src/main.rs b/crates/openshell-supervisor-relay/src/main.rs new file mode 100644 index 0000000000..c1117b248b --- /dev/null +++ b/crates/openshell-supervisor-relay/src/main.rs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Entry point for `openshell-supervisor-relay`. +//! +//! This is an MXC/AppContainer helper -- it only does anything useful on +//! Windows (see `imp.rs` for the real implementation and its module docs). +//! The implementation is technically portable (no Windows-specific APIs), +//! but per the `openshell-driver-mxc` platform pattern, it's gated behind +//! `cfg(target_os = "windows")` so a generic `cargo build --workspace` on +//! Linux/macOS doesn't compile the full relay implementation (and its +//! tokio/tungstenite dependency tree) for a binary those platforms never +//! run. Non-Windows builds get this minimal stub instead, purely so +//! workspace membership (`members = ["crates/*"]`) keeps working everywhere. + +#[cfg(target_os = "windows")] +mod imp; + +#[cfg(target_os = "windows")] +#[tokio::main] +async fn main() -> anyhow::Result<()> { + imp::run().await +} + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!( + "openshell-supervisor-relay is a Windows-only MXC ProcessContainer/AppContainer helper; \ + it is not usable on this platform." + ); + std::process::exit(1); +} diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs new file mode 100644 index 0000000000..1f3fb51464 --- /dev/null +++ b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs @@ -0,0 +1,666 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Black-box integration coverage for `openshell-supervisor-relay`'s +//! control-channel wire contract (see `src/imp.rs`'s module docs for the +//! protocol itself). +//! +//! Each test spawns the real compiled `openshell-supervisor-relay.exe` +//! (via `CARGO_BIN_EXE_...`) as an ordinary child process -- no `wxc-exec`, +//! no `AppContainer`, no MXC involved -- and drives it over its actual +//! stdin/stdout JSON protocol, exactly as `openshell-driver-mxc`'s +//! `driver.rs` and `control_channel.rs` do in production. This exercises +//! the real launch handshake, target-ready ordering, shutdown semantics, +//! and the relay-auth/forward bridging protocol end to end, without +//! requiring a live Windows `AppContainer` host. +//! +//! What this file deliberately does NOT cover: the `ProcessContainer` +//! stop/delete lifecycle as driven by `openshell-driver-mxc`'s +//! `driver.rs` (that needs a real `wxc-exec`/`AppContainer`, or a much +//! larger mock of the whole MXC invoker -- exercised today by +//! `run-openclaw-forward-test.ps1` / `run-ws-agent-test.ps1` against real +//! hardware instead) and the relay-listener half of the auth handshake +//! (`openshell-driver-mxc/src/relay.rs`'s `relay_task`, which has its own +//! unit-testable pieces but isn't exercised here). This file only tests +//! `openshell-supervisor-relay`'s side of the contract, standing in for +//! the relay listener with a small hand-rolled WS server per test. +//! +//! Windows-only, like the binary under test: gated on the whole file via +//! `#![cfg(windows)]` so nothing here (including the dev-dependencies +//! pulled in for it) affects non-Windows builds at all. + +#![cfg(windows)] + +use base64::Engine; +use futures::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use std::collections::HashSet; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// A running `openshell-supervisor-relay.exe`, with its stdin/stdout wired +/// up as a JSON control channel the same way the driver uses them. +struct RelayProcess { + child: Child, + stdin: ChildStdin, + lines: Lines>, + // `None` for `spawn()` (stderr is discarded there -- see its doc + // comment). `spawn_capturing_stderr()` populates this so a test can + // deterministically wait for a specific diagnostic line instead of + // guessing a sleep duration. + stderr_lines: Option>, +} + +impl RelayProcess { + /// Spawn the real binary. `target_port` is the CLI arg the binary + /// expects (its own liveness-check port for whatever `launch` later + /// starts) -- irrelevant to tests that never send `launch`. + async fn spawn(target_port: u16) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) + .arg(target_port.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Not read by these tests -- null rather than piped-and-ignored + // so the child can never block on a full stderr pipe buffer. + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("spawn openshell-supervisor-relay.exe (build it first: cargo build -p openshell-supervisor-relay)"); + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + Self { + child, + stdin, + lines: BufReader::new(stdout).lines(), + stderr_lines: None, + } + } + + /// Like `spawn`, but pipes stderr instead of discarding it, so a test + /// can wait for a specific diagnostic line via `wait_for_stderr_line`. + /// A background task drains it continuously for the process's entire + /// lifetime (forwarding every line over an unbounded channel) -- reading + /// only until the sought-after line arrives and then stopping (as an + /// earlier version of this helper did) leaves the pipe unread from then + /// on; this process's own ongoing diagnostic output (plus anything the + /// launched target itself prints, forwarded through it) then fills the + /// OS pipe buffer and makes its *next* `eprintln!` block synchronously + /// forever -- including ones on the exact shutdown path a test wants to + /// observe. Confirmed by hand: without continuous draining, this + /// deadlocks the relay process itself, not just this test. + async fn spawn_capturing_stderr(target_port: u16) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) + .arg(target_port.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("spawn openshell-supervisor-relay.exe (build it first: cargo build -p openshell-supervisor-relay)"); + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if tx.send(line).is_err() { + break; + } + } + }); + Self { + child, + stdin, + lines: BufReader::new(stdout).lines(), + stderr_lines: Some(rx), + } + } + + /// Consume forwarded stderr lines (discarding non-matching ones) until + /// one contains `pattern`, or `timeout` elapses. `timeout` is + /// deliberately a caller argument, not the shared `TIMEOUT` constant -- + /// spawning a real OS process for this to wait on can occasionally take + /// far longer than this file's other, purely protocol-level waits + /// (observed on this host: real-time AV scanning stalling + /// `CreateProcess` well past 10s, unrelated to anything this binary + /// controls). + async fn wait_for_stderr_line(&mut self, pattern: &str, timeout: Duration) { + let rx = self + .stderr_lines + .as_mut() + .expect("wait_for_stderr_line requires spawn_capturing_stderr"); + tokio::time::timeout(timeout, async { + loop { + match rx.recv().await { + Some(line) if line.contains(pattern) => return, + Some(_) => {} + None => { + panic!("relay stderr closed before printing a line containing {pattern:?}") + } + } + } + }) + .await + .unwrap_or_else(|_| { + panic!("timed out after {timeout:?} waiting for a stderr line containing {pattern:?}") + }); + } + + async fn next_line(&mut self) -> String { + tokio::time::timeout(TIMEOUT, self.lines.next_line()) + .await + .expect("timed out waiting for a control-channel line") + .expect("stdout read error") + .expect("relay exited before producing the expected line") + } + + async fn next_json(&mut self) -> Value { + let line = self.next_line().await; + serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("non-JSON control-channel line {line:?}: {e}")) + } + + async fn send(&mut self, value: Value) { + let mut line = serde_json::to_string(&value).expect("serialize request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .await + .expect("write control-channel request"); + self.stdin + .flush() + .await + .expect("flush control-channel request"); + } + + /// Consume and validate the startup handshake event -- see + /// `control_channel::try_route_ready` on the driver side, which this + /// mirrors. + async fn expect_ready(&mut self) { + let v = self.next_json().await; + assert_eq!(v["event"], "ready"); + assert_eq!(v["protocol_version"], 2); + } + + async fn launch(&mut self, id: u64, command: &[&str]) -> Value { + self.send(json!({ + "id": id, + "op": "launch", + "data": {"command": command, "env": []}, + })) + .await; + self.next_json().await + } +} + +/// Spawn an in-process TCP echo server (a stand-in for whatever real +/// `agent_command` target would be bound to a port in production) and +/// return the port it bound. Handles concurrent connections -- each +/// accepted connection gets its own task -- so it doubles as the shared +/// target for the concurrent-forwards test. +async fn spawn_echo_target() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + loop { + match sock.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if sock.write_all(&buf[..n]).await.is_err() { + break; + } + } + } + } + }); + } + }); + port +} + +/// Stand-in for `openshell-driver-mxc/src/relay.rs`'s Phase A listener: +/// accept one TCP connection, complete the WS upgrade, and require the +/// first message to be exactly `AUTH:` -- matching what +/// `run_relay_bridge` in `imp.rs` sends. Panics (failing the test) if +/// anything else arrives first, same as a real relay would just silently +/// distrust and drop the connection. +async fn accept_and_authenticate( + listener: &TcpListener, + expected_nonce: &str, +) -> WebSocketStream { + let (stream, _addr) = tokio::time::timeout(TIMEOUT, listener.accept()) + .await + .expect("timed out waiting for the relay's Phase A connection") + .expect("accept failed"); + let mut ws = tokio_tungstenite::accept_async(stream) + .await + .expect("WS upgrade failed"); + let msg = tokio::time::timeout(TIMEOUT, ws.next()) + .await + .expect("timed out waiting for the AUTH message") + .expect("relay closed before sending AUTH") + .expect("WS read error"); + let expected = format!("AUTH:{expected_nonce}"); + match msg { + Message::Text(t) if t == expected => {} + other => panic!("expected {expected:?} as the first message, got {other:?}"), + } + ws +} + +// ── Startup handshake ──────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn ready_event_reports_protocol_version() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; +} + +// ── ping / echo (protocol sanity, no launch required) ────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn ping_and_echo_round_trip() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + relay.send(json!({"id": 1, "op": "ping"})).await; + assert_eq!( + relay.next_json().await, + json!({"id": 1, "ok": true, "data": "pong"}) + ); + + relay + .send(json!({"id": 2, "op": "echo", "data": {"x": 1, "y": "two"}})) + .await; + let resp = relay.next_json().await; + assert_eq!(resp["id"], 2); + assert_eq!(resp["ok"], true); + assert_eq!(resp["data"], json!({"x": 1, "y": "two"})); +} + +// ── launch: success and failure ───────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn launch_fails_fast_when_command_is_empty() { + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + let resp = relay.launch(1, &[]).await; + assert_eq!(resp["id"], 1); + assert_eq!(resp["ok"], false); + assert!( + resp["error"].as_str().unwrap().contains("non-empty array"), + "unexpected error message: {resp}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn launch_success_then_target_ready_ordering() { + // Reserve a free port, then launch a target that binds exactly it -- + // small a-priori race (something else could steal the port between the + // bind-and-drop below and the launch), acceptable for a test. + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let script = format!( + "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ + $l.Start(); Start-Sleep -Seconds 30" + ); + + let mut relay = RelayProcess::spawn(port).await; + relay.expect_ready().await; + + let ack = relay + .launch( + 1, + &[ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + &script, + ], + ) + .await; + assert_eq!(ack["id"], 1); + assert_eq!(ack["ok"], true, "launch ack: {ack}"); + + // The "launch" response only confirms the command/env arrived -- the + // unsolicited "target_ready" event (no correlation id) is the actual + // liveness confirmation once the port readiness poll succeeds, and it + // must not have been sent already (it can't have been: nothing before + // this point in the protocol lets the spawner know the port bound). + // Reading it as the very next line asserts the ordering directly. + let target_ready = relay.next_json().await; + assert_eq!(target_ready["event"], "target_ready"); + assert!( + target_ready.get("id").is_none(), + "target_ready must not carry a correlation id" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_is_acked_and_the_process_exits() { + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let script = format!( + "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ + $l.Start(); Start-Sleep -Seconds 30" + ); + + let mut relay = RelayProcess::spawn(port).await; + relay.expect_ready().await; + let ack = relay + .launch( + 1, + &[ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + &script, + ], + ) + .await; + assert_eq!(ack["ok"], true); + let target_ready = relay.next_json().await; + assert_eq!(target_ready["event"], "target_ready"); + + relay.send(json!({"id": 2, "op": "shutdown"})).await; + let ack = relay.next_json().await; + assert_eq!(ack, json!({"id": 2, "ok": true})); + + let status = tokio::time::timeout(TIMEOUT, relay.child.wait()) + .await + .expect("relay did not exit within the timeout after shutdown") + .expect("wait() failed"); + assert!(status.success(), "expected a clean exit, got {status:?}"); +} + +/// Reproduces the reported race: a "shutdown" request arriving while the +/// target is still coming up (here, one that never binds the port at all) +/// must stop this process promptly, not leave it waiting out the full +/// port-readiness budget (~300s worst case, see `wait_for_port_ready`) +/// before ever observing the shutdown that `run_control_channel` already +/// acknowledged. Without racing that wait against shutdown, the final +/// `child.wait()` below would time out instead of completing within +/// `SHUTDOWN_TIMEOUT`. +/// +/// Waits for the relay's own "waiting for target on ..." stderr line +/// before sending shutdown, rather than sending it right after the launch +/// ack -- the ack fires as soon as the request is parsed, before this +/// process's own `spawn_target()` (a plain `CreateProcess` call) has +/// necessarily completed, and *that* call is a separate, pre-existing +/// source of multi-second-to-multi-minute stalls on this host (real-time +/// AV scanning a freshly-launched process) that this fix does not -- and is +/// not meant to -- address. Anchoring on that line instead isolates the +/// assertion to the one thing this fix actually changed: how promptly a +/// shutdown arriving *during the port-readiness poll itself* is observed +/// and acted on. +/// +/// Does not close `stdin` before waiting on process exit -- on purpose, +/// matching driver.rs, which never closes its end of the control channel +/// before observing the relay exit either. An earlier version of this +/// fix returned normally from `run()` on this path instead of calling +/// `std::process::exit`; `run_control_channel` loops on +/// `stdin.next_line()` for the process's entire lifetime, so with `stdin` +/// still open (as it always is against a real driver) that task -- and so +/// the whole process -- stayed alive indefinitely even after `run()` had +/// already returned. Confirmed by hand with internal timestamps: the fix +/// fired and `run()` returned within single-digit milliseconds while the +/// process, observed externally, never exited. This test would have +/// caught that. +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_during_port_wait_stops_promptly() { + // Generous: covers this host's observed CreateProcess stalls (up to + // ~60s) plus real margin, not just the fast path. + const SPAWN_TIMEOUT: Duration = Duration::from_mins(2); + // Well under the ~300s port-readiness budget this fix exists to avoid + // waiting out -- generous only relative to `TIMEOUT`, since a bare + // `std::process::exit` completes in well under a second. + const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(20); + + let port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + + let mut relay = RelayProcess::spawn_capturing_stderr(port).await; + relay.expect_ready().await; + // A real target that never binds `port` -- port-readiness polling never + // succeeds on its own. cmd.exe rather than powershell.exe: smaller, + // simpler, and this test's target just needs to run for a while and + // never bind `port`, not do anything powershell-specific. + let ack = relay + .launch(1, &["cmd", "/c", "timeout /t 300 /nobreak >nul"]) + .await; + assert_eq!(ack["ok"], true, "launch ack: {ack}"); + + // Confirms spawn_target() has returned and wait_for_port_ready has + // started -- only from this point on is the fix under test actually + // in play. + relay + .wait_for_stderr_line("waiting for target on", SPAWN_TIMEOUT) + .await; + + relay.send(json!({"id": 2, "op": "shutdown"})).await; + let ack = relay.next_json().await; + assert_eq!(ack, json!({"id": 2, "ok": true})); + + let status = tokio::time::timeout(SHUTDOWN_TIMEOUT, relay.child.wait()) + .await + .expect( + "relay did not exit promptly after shutdown during port-wait \ + (see imp.rs's port-wait/shutdown race)", + ) + .expect("wait() failed"); + assert!(status.success(), "expected a clean exit, got {status:?}"); +} + +// ── forward: authenticated relay association + byte bridging ─────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn control_channel_forward_round_trips_bytes_without_host_callback_networking() { + let target_port = spawn_echo_target().await; + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + let session_id = "a".repeat(64); + + relay + .send(json!({ + "id": 1, "op": "forward_open", + "data": {"session_id": session_id, "target_port": target_port}, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 1, "ok": true})); + + let payload = b"stdio-forward-round-trip"; + relay + .send(json!({ + "id": 2, "op": "forward_write", + "data": { + "session_id": session_id, + "bytes": base64::engine::general_purpose::STANDARD.encode(payload), + }, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 2, "ok": true})); + + let echoed = loop { + relay + .send(json!({ + "id": 3, "op": "forward_read", "data": {"session_id": session_id}, + })) + .await; + let response = relay.next_json().await; + let encoded = response["data"]["bytes"].as_str().unwrap(); + if !encoded.is_empty() { + break base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + } + }; + assert_eq!(echoed, payload); + + relay + .send(json!({ + "id": 4, "op": "forward_close", "data": {"session_id": session_id}, + })) + .await; + assert_eq!(relay.next_json().await, json!({"id": 4, "ok": true})); +} + +#[tokio::test(flavor = "multi_thread")] +async fn forward_with_correct_auth_bridges_bytes_both_directions() { + let target_port = spawn_echo_target().await; + let relay_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_addr = relay_listener.local_addr().unwrap(); + + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + let nonce = "test-nonce-abc123"; + relay + .send(json!({ + "id": 5, + "op": "forward", + "data": {"relay_addr": relay_addr.to_string(), "target_port": target_port, "nonce": nonce}, + })) + .await; + + // Both sides of the handshake only make progress if driven + // concurrently: the relay's forward ack doesn't arrive until its WS + // client connection to us completes, which needs us to actually + // accept it. + let (mut ws, ack) = tokio::join!( + accept_and_authenticate(&relay_listener, nonce), + relay.next_json() + ); + assert_eq!(ack["id"], 5); + assert_eq!(ack["ok"], true, "forward ack: {ack}"); + + ws.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + let payload = b"hello over the bridge".to_vec(); + ws.send(Message::Binary(payload.clone().into())) + .await + .unwrap(); + + let echoed = tokio::time::timeout(TIMEOUT, ws.next()) + .await + .expect("timed out waiting for the echoed bytes") + .expect("WS closed before echoing") + .expect("WS read error"); + match echoed { + Message::Binary(b) => assert_eq!(b.as_ref(), payload.as_slice()), + other => panic!("expected a Binary echo, got {other:?}"), + } + + ws.send(Message::Text("SESSION_END".into())).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_forwards_do_not_cross_talk() { + let target_port = spawn_echo_target().await; + let listener_a = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr_a = listener_a.local_addr().unwrap(); + let listener_b = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr_b = listener_b.local_addr().unwrap(); + + let mut relay = RelayProcess::spawn(0).await; + relay.expect_ready().await; + + relay + .send(json!({ + "id": 1, "op": "forward", + "data": {"relay_addr": addr_a.to_string(), "target_port": target_port, "nonce": "nonce-a"}, + })) + .await; + relay + .send(json!({ + "id": 2, "op": "forward", + "data": {"relay_addr": addr_b.to_string(), "target_port": target_port, "nonce": "nonce-b"}, + })) + .await; + + let (ws_a, ws_b) = tokio::join!( + accept_and_authenticate(&listener_a, "nonce-a"), + accept_and_authenticate(&listener_b, "nonce-b"), + ); + let (mut ws_a, mut ws_b) = (ws_a, ws_b); + + let ack1 = relay.next_json().await; + let ack2 = relay.next_json().await; + assert!( + ack1["ok"] == true && ack2["ok"] == true, + "acks: {ack1} / {ack2}" + ); + let ids: HashSet<_> = [ack1["id"].as_u64(), ack2["id"].as_u64()] + .into_iter() + .collect(); + assert_eq!( + ids, + HashSet::from([Some(1), Some(2)]), + "both forward requests must be acked exactly once" + ); + + ws_a.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + ws_b.send(Message::Text("SESSION_START".into())) + .await + .unwrap(); + ws_a.send(Message::Binary(b"payload-A".to_vec().into())) + .await + .unwrap(); + ws_b.send(Message::Binary(b"payload-B".to_vec().into())) + .await + .unwrap(); + + let (echo_a, echo_b) = tokio::join!( + tokio::time::timeout(TIMEOUT, ws_a.next()), + tokio::time::timeout(TIMEOUT, ws_b.next()), + ); + match echo_a + .expect("timeout on A") + .expect("closed on A") + .expect("read error on A") + { + Message::Binary(b) => assert_eq!( + b.as_ref(), + b"payload-A", + "session A must not see session B's bytes" + ), + other => panic!("unexpected message on A: {other:?}"), + } + match echo_b + .expect("timeout on B") + .expect("closed on B") + .expect("read error on B") + { + Message::Binary(b) => assert_eq!( + b.as_ref(), + b"payload-B", + "session B must not see session A's bytes" + ), + other => panic!("unexpected message on B: {other:?}"), + } +} diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 55fbcaff87..d9569b7970 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -817,6 +817,7 @@ async fn handle_sandbox_delete(app: &mut App, tx: mpsc::UnboundedSender) let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name, workspace_scope: Some(named_workspace_scope(app.selected_sandbox_workspace())), + ..Default::default() }; match app.client.delete_sandbox(req).await { Ok(_) => { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ab24fd26b6..44de908678 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -659,7 +659,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. @@ -920,6 +920,45 @@ OpenShell sends no override and Podman applies its runtime-selected profile. Set `Unconfined` explicitly only when the deployment requires the supervisor's mount setup to bypass that profile. +### MXC + +The MXC driver is Windows-only and opt-in. It links into the gateway, invokes Microsoft MXC through `wxc-exec.exe`, and runs each sandbox's configured command in-driver instead of using the Linux sandbox supervisor. + +```toml +[openshell] +version = 1 + +[openshell.gateway] +bind_address = "127.0.0.1:17670" +log_level = "info" +compute_drivers = ["mxc"] + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +# process_container (default) or isolation_session. +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +# Pattern C governed egress. MXC 0.8 denies direct Internet egress and permits +# host loopback; proxy-aware clients receive HTTP_PROXY/HTTPS_PROXY and the host +# CONNECT proxy enforces the trimmed network policy. Requires process_container. +egress_proxy = false +egress_proxy_addr = "" +debug = false +etw_audit = false +``` + +Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. + +MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. + +Supply the workload command and optional working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. + +Attached provider credentials require this governed-egress path. The gateway gives MXC only revision-scoped environment placeholders and keeps real values in the host proxy's endpoint-bound resolver. Provider keys override matching sandbox environment entries case-insensitively. Sandbox creation fails when attached provider material exists but `egress_proxy` is disabled. MXC takes its static provider snapshot at creation and rejects credentials with an expiration timestamp because it has no live refresh channel. Dynamic token grants continue to mint credentials per request in the host proxy. Recreate the sandbox after attaching, detaching, rotating, or revoking a non-expiring static provider credential. + +The driver assigns a distinct loopback listener port and proxy credentials to each MXC sandbox. The host proxy rejects missing, invalid, duplicate, or cross-sandbox proxy credentials before forwarding. This authenticates the sandbox's proxy access; it does not isolate unrelated host-loopback services or distinguish processes within the same sandbox. Treat the gateway host and processes that can read the sandbox credentials as trusted. + ### 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/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 0961a8d0b3..36bfa0778a 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-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 | Static | Controls which directories the agent can read and write. | | `landlock` | object | No | Static | Configures Landlock LSM enforcement behavior. | | `process` | object | No | Static | Sets the user and group the agent process runs as. | +| `ui` | object | No | Static | Controls graphical UI, directional clipboard access, and synthetic input on compute drivers that advertise complete enforcement. | | `network_policies` | map | No | Dynamic | Declares which binaries can reach which network endpoints. | | `network_middlewares` | map | No | Dynamic | Attaches ordered middleware by destination host; each implementation's manifest selects its supported HTTP and WebSocket operations. | @@ -142,6 +144,44 @@ process: run_as_group: "1500" ``` +## UI + +**Category:** Static + +Declares platform-neutral UI capabilities. Within an explicit section, every +field is deny by default, so `ui: {}` grants nothing on a supporting backend. +Omit the entire section when the workload does not need a UI surface. + +| Field | Type | Required | Values | Description | +|---|---|---|---|---| +| `allow_graphical_ui` | bool | No | `true`, `false` | Allows the workload to display graphical windows. Defaults to `false`. | +| `clipboard` | string | No | `none`, `read`, `write`, `all` | Controls host clipboard direction from the sandbox's perspective. Defaults to `none`. | +| `allow_input_injection` | bool | No | `true`, `false` | Allows synthetic keyboard or pointer input. Defaults to `false`. | + +MXC `process_container` enforces this section on Windows and advertises complete +support through the compute-driver capability contract. MXC's `disable` switch +suppresses the other UI fields, so a clipboard or input-injection grant on this +backend also requires `allow_graphical_ui: true`; OpenShell rejects a grant that +MXC would ignore. OpenShell rejects any explicit UI section, including `{}`, with +MXC `isolation_session` because current MXC rejects the top-level `ui` object +there. Docker, Podman, Kubernetes, VM, and older or partial extension drivers +also advertise no support, so the gateway rejects explicit UI policy before +validation or provisioning. Omitting the section preserves each runtime's +existing behavior. + +UI is a per-sandbox startup contract and cannot be set in a gateway-global +policy. A global policy changes dynamic policy fields while each sandbox keeps +the UI controls applied from its own policy at creation. + +Example: + +```yaml showLineNumbers={false} +ui: + allow_graphical_ui: true + clipboard: read + allow_input_injection: false +``` + ## Network Policies **Category:** Dynamic diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 730b7da794..602a0ff01b 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -256,7 +256,7 @@ Create a sandbox from a template: openshell sandbox create --template gpu-kata --provider github -- claude ``` -The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. +The `--template` flag cannot be combined with inline workload flags such as `--from`, `--cpu`, `--memory`, `--gpu`, `--env`, `--env-from`, or `--driver-config-json`. Put those values on the template instead. Create-time policy and provider attachments remain part of the sandbox request, so each sandbox can keep its own access boundary. Inspect and manage templates: @@ -373,6 +373,16 @@ openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent Variables set with `--env` are available to all processes in the sandbox, including interactive shells and exec commands. +Use `--env-from KEY[=ENVVAR]` when the value should come from the CLI process environment instead of appearing in the CLI process arguments. If `ENVVAR` is omitted, OpenShell reads `KEY`: + +```shell +export SESSION_TOKEN="..." +openshell sandbox create --env-from SESSION_TOKEN -- my-agent +openshell sandbox create --env-from AGENT_TOKEN=SESSION_TOKEN -- my-agent +``` + +`--env-from` changes only how the CLI receives the value. The resulting variable is still available to processes in the sandbox, just like `--env`. + When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. You can also set per-command environment variables with `sandbox exec`: @@ -750,6 +760,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/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index da2cb16eb4..1d3685bf39 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -12,7 +12,7 @@ Use this page to apply and iterate policy changes on running sandboxes. For a fu ## Policy Structure -A policy has static sections `filesystem_policy`, `landlock`, and `process` that are locked at sandbox creation, and dynamic `network_policies` and `network_middlewares` sections that are hot-reloadable on a running sandbox. +A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation. `network_policies` and `network_middlewares` are dynamic schema sections, but live updates work only on compute drivers that support runtime policy reload. MXC rejects live policy replacement and merge updates, so recreate the sandbox there. ```yaml wordWrap showLineNumbers={false} version: 1 @@ -32,6 +32,13 @@ landlock: # run_as_user: "1500" # run_as_group: "1500" +# Static, optional: portable UI capabilities. Within this explicit section, +# omitted values deny access. Only drivers advertising complete support accept it. +# ui: +# allow_graphical_ui: true +# clipboard: read +# allow_input_injection: false + # Dynamic: hot-reloadable. Named blocks of endpoints + binaries allowed to reach them. network_policies: my_api: @@ -69,6 +76,7 @@ When a hot reload changes rules, the supervisor publishes a new policy generatio | `filesystem_policy` | Static | Controls which directories the agent can access on disk. Paths are split into `read_only` and `read_write` lists. Any path not listed in either list is inaccessible. Set `include_workdir: true` to automatically add the agent's working directory to `read_write`. [Landlock LSM](https://docs.kernel.org/security/landlock.html) enforces these restrictions at the kernel level. | | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Optionally overrides the OS-level identity for the agent process. Explicit values must be `sandbox` or numeric UID/GID values from `1` through `4294967294`; root and the invalid identity sentinel are rejected. Docker and Podman may use named identities through per-field OCI `USER` fallback; Kubernetes uses its platform-selected numeric identity. The agent also runs with seccomp filters that block dangerous system calls. | +| `ui` | Static | Controls portable graphical UI, directional clipboard, and synthetic-input capabilities. Within an explicit section, every omitted value denies. MXC `process_container` advertises complete enforcement. MXC `isolation_session`, Docker, Podman, Kubernetes, VM, and partial extension drivers reject any explicit section before provisioning. Omission preserves existing runtime behavior. | | `network_policies` | Dynamic | Controls outbound traffic from the sandbox, including native model-provider endpoints. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection passes through the network supervisor, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block. Attached provider profiles can contribute endpoint and binary entries to the effective policy.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints with `protocol: tcp` allow ordinary DNS resolution and native TCP connections without inspecting payloads. Endpoints without `protocol` retain L4 passthrough through an explicit proxy.
If no endpoint matches, the connection is denied. | | `network_middlewares` | Dynamic | Declares keyed HTTP and WebSocket middleware configs. After network and L7 policy admit a request or upgrade, OpenShell matches each config's host selectors independently and runs matching entries by their unique ascending `order` before credential injection. WebSocket-capable entries continue on complete client text messages. | @@ -601,7 +609,12 @@ openshell policy set --global --policy ./global-policy.yaml When a global policy is configured: -- The global payload is applied in full for all sandboxes. +- The global payload replaces every sandbox's complete effective policy, + including static sections such as `filesystem_policy`, `landlock`, and + `process`, not only the dynamic `network_policies` and + `network_middlewares` fields. +- Each sandbox keeps the startup-only `ui` section from its own creation policy; + a global policy containing `ui` is rejected. - Sandbox-level policy updates are rejected until the global policy is removed. To restore sandbox-level policy control, delete the global policy setting: diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index f9a19589f9..1d781bd6ea 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -100,6 +100,10 @@ message GetCapabilitiesResponse { // Maximum rootfs tar file size in bytes accepted by the driver. Zero means // the driver does not support rootfs tar sources. uint64 rootfs_tar_max_bytes = 11; + // 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 = 12; } message AuthenticateSandboxRequest { diff --git a/proto/openshell.proto b/proto/openshell.proto index fd664a0f7b..458c87b026 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -864,6 +864,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. @@ -1308,6 +1312,16 @@ message DeleteSandboxRequest { string name = 1; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional immutable sandbox identity precondition. When non-empty, the + // gateway rejects the request with ABORTED unless the currently resolved + // sandbox has this exact metadata ID. The check is repeated under the + // lifecycle lock immediately before any delete mutation. + string expected_sandbox_id = 4; + // Optional optimistic-concurrency precondition. Requires + // expected_sandbox_id. When non-zero, the gateway rejects the request with + // ABORTED unless the sandbox's current resource version matches this value + // immediately before any delete mutation. + uint64 expected_resource_version = 5; } // Stop sandbox request. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 894e3b2754..5f207a5d16 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -31,6 +31,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. @@ -57,6 +61,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/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 59c14535b8..8c66201b9a 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 1a2673808a..8975f6c3d9 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -837,6 +837,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", @@ -874,6 +879,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"] @@ -886,19 +895,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 5103d4b1fc..2a7757bddd 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -162,6 +162,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 63ab8c3298..f2f873ae8e 100644 --- a/sdk/go/openshell/v1/internal/converter/health.go +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -48,6 +48,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 d0360a9b66..0a9cfb6a3f 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, }, }, { @@ -43,8 +44,10 @@ 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) + assert.False(t, info.ComputeDrivers[1].SupportsUIPolicy) } func TestGatewayInfoFromProto_NoDrivers(t *testing.T) { @@ -107,6 +110,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 8d68ca2f57..73cb9fc980 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 6935b3def0..c3606548a9 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 1db3ec3872..14a4e2544a 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -29,9 +29,10 @@ type GatewayInfo 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 fe5f82d6c1..0a9455d742 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1171,8 +1171,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() { @@ -1226,6 +1229,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 { @@ -3513,8 +3523,18 @@ type DeleteSandboxRequest struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional immutable sandbox identity precondition. When non-empty, the + // gateway rejects the request with ABORTED unless the currently resolved + // sandbox has this exact metadata ID. The check is repeated under the + // lifecycle lock immediately before any delete mutation. + ExpectedSandboxId string `protobuf:"bytes,4,opt,name=expected_sandbox_id,json=expectedSandboxId,proto3" json:"expected_sandbox_id,omitempty"` + // Optional optimistic-concurrency precondition. Requires + // expected_sandbox_id. When non-zero, the gateway rejects the request with + // ABORTED unless the sandbox's current resource version matches this value + // immediately before any delete mutation. + ExpectedResourceVersion uint64 `protobuf:"varint,5,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteSandboxRequest) Reset() { @@ -3561,6 +3581,20 @@ func (x *DeleteSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelecto return nil } +func (x *DeleteSandboxRequest) GetExpectedSandboxId() string { + if x != nil { + return x.ExpectedSandboxId + } + return "" +} + +func (x *DeleteSandboxRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + // Stop sandbox request. type StopSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14721,12 +14755,13 @@ const file_openshell_proto_rawDesc = "" + "\x0fcompute_drivers\x18\x03 \x03(\v2\x1f.openshell.v1.ComputeDriverInfoR\x0ecomputeDrivers\"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" + @@ -14911,10 +14946,12 @@ const file_openshell_proto_rawDesc = "" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x8f\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\xfb\x01\n" + "\x14DeleteSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12.\n" + + "\x13expected_sandbox_id\x18\x04 \x01(\tR\x11expectedSandboxId\x12:\n" + + "\x19expected_resource_version\x18\x05 \x01(\x04R\x17expectedResourceVersionJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + "\x12StopSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8e\x01\n" + diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 17d8909f83..7afffa4c7b 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -25,6 +25,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} +} + // Scope that currently controls a setting. type SettingScope int32 @@ -59,11 +120,11 @@ func (x SettingScope) String() string { } func (SettingScope) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[0].Descriptor() + return file_sandbox_proto_enumTypes[1].Descriptor() } func (SettingScope) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[0] + return &file_sandbox_proto_enumTypes[1] } func (x SettingScope) Number() protoreflect.EnumNumber { @@ -72,7 +133,7 @@ func (x SettingScope) Number() protoreflect.EnumNumber { // Deprecated: Use SettingScope.Descriptor instead. func (SettingScope) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{0} + return file_sandbox_proto_rawDescGZIP(), []int{1} } // Source used for the policy payload in GetSandboxConfigResponse. @@ -109,11 +170,11 @@ func (x PolicySource) String() string { } func (PolicySource) Descriptor() protoreflect.EnumDescriptor { - return file_sandbox_proto_enumTypes[1].Descriptor() + return file_sandbox_proto_enumTypes[2].Descriptor() } func (PolicySource) Type() protoreflect.EnumType { - return &file_sandbox_proto_enumTypes[1] + return &file_sandbox_proto_enumTypes[2] } func (x PolicySource) Number() protoreflect.EnumNumber { @@ -122,7 +183,7 @@ func (x PolicySource) Number() protoreflect.EnumNumber { // Deprecated: Use PolicySource.Descriptor instead. func (PolicySource) EnumDescriptor() ([]byte, []int) { - return file_sandbox_proto_rawDescGZIP(), []int{1} + return file_sandbox_proto_rawDescGZIP(), []int{2} } // Sandbox security policy configuration. @@ -142,8 +203,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() { @@ -218,6 +283,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"` @@ -383,6 +455,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"` @@ -398,7 +536,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) } @@ -410,7 +548,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 { @@ -423,7 +561,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 { @@ -468,7 +606,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) } @@ -480,7 +618,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 { @@ -493,7 +631,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 { @@ -553,7 +691,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) } @@ -565,7 +703,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 { @@ -578,7 +716,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 { @@ -607,7 +745,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) } @@ -619,7 +757,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 { @@ -632,7 +770,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 { @@ -744,7 +882,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) } @@ -756,7 +894,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 { @@ -769,7 +907,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 { @@ -995,7 +1133,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) } @@ -1007,7 +1145,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 { @@ -1020,7 +1158,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 { @@ -1059,7 +1197,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) } @@ -1071,7 +1209,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 { @@ -1084,7 +1222,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 { @@ -1139,7 +1277,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) } @@ -1151,7 +1289,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 { @@ -1164,7 +1302,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 { @@ -1233,7 +1371,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) } @@ -1245,7 +1383,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 { @@ -1258,7 +1396,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 { @@ -1298,7 +1436,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) } @@ -1310,7 +1448,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 { @@ -1323,7 +1461,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 { @@ -1395,7 +1533,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) } @@ -1407,7 +1545,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 { @@ -1420,7 +1558,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 { @@ -1447,7 +1585,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) } @@ -1459,7 +1597,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 { @@ -1472,7 +1610,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 { @@ -1493,7 +1631,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) } @@ -1505,7 +1643,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 { @@ -1518,7 +1656,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) GetSandboxId() string { @@ -1537,7 +1675,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) } @@ -1549,7 +1687,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 { @@ -1562,7 +1700,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. @@ -1579,7 +1717,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) } @@ -1591,7 +1729,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 { @@ -1604,7 +1742,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 { @@ -1637,7 +1775,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) } @@ -1649,7 +1787,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 { @@ -1662,7 +1800,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 { @@ -1747,7 +1885,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) } @@ -1759,7 +1897,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 { @@ -1772,7 +1910,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 { @@ -1831,7 +1969,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) } @@ -1843,7 +1981,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 { @@ -1856,7 +1994,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 { @@ -1976,7 +2114,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) } @@ -1988,7 +2126,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 { @@ -2001,7 +2139,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 { @@ -2057,7 +2195,7 @@ var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + "\n" + - "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xd8\x05\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2066,7 +2204,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" + @@ -2083,7 +2222,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" + @@ -2233,7 +2376,13 @@ const file_sandbox_proto_rawDesc = "" + "\atimeout\x18\x04 \x01(\tR\atimeout\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\x16allowInsecureTransport*b\n" + + "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransport*\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*b\n" + "\fSettingScope\x12\x1d\n" + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" + @@ -2255,86 +2404,90 @@ func file_sandbox_proto_rawDescGZIP() []byte { return file_sandbox_proto_rawDescData } -var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_sandbox_proto_goTypes = []any{ - (SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope - (PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource - (*SandboxPolicy)(nil), // 2: openshell.sandbox.v1.SandboxPolicy - (*FilesystemPolicy)(nil), // 3: openshell.sandbox.v1.FilesystemPolicy - (*LandlockPolicy)(nil), // 4: openshell.sandbox.v1.LandlockPolicy - (*ProcessPolicy)(nil), // 5: openshell.sandbox.v1.ProcessPolicy - (*NetworkPolicyRule)(nil), // 6: openshell.sandbox.v1.NetworkPolicyRule - (*NetworkMiddlewareConfig)(nil), // 7: openshell.sandbox.v1.NetworkMiddlewareConfig - (*MiddlewareEndpointSelector)(nil), // 8: openshell.sandbox.v1.MiddlewareEndpointSelector - (*NetworkCredentialBinding)(nil), // 9: openshell.sandbox.v1.NetworkCredentialBinding - (*NetworkEndpoint)(nil), // 10: openshell.sandbox.v1.NetworkEndpoint - (*McpOptions)(nil), // 11: openshell.sandbox.v1.McpOptions - (*GraphqlOperation)(nil), // 12: openshell.sandbox.v1.GraphqlOperation - (*L7DenyRule)(nil), // 13: openshell.sandbox.v1.L7DenyRule - (*L7Rule)(nil), // 14: openshell.sandbox.v1.L7Rule - (*L7Allow)(nil), // 15: openshell.sandbox.v1.L7Allow - (*L7QueryMatcher)(nil), // 16: openshell.sandbox.v1.L7QueryMatcher - (*NetworkBinary)(nil), // 17: openshell.sandbox.v1.NetworkBinary - (*GetSandboxConfigRequest)(nil), // 18: openshell.sandbox.v1.GetSandboxConfigRequest - (*GetGatewayConfigRequest)(nil), // 19: openshell.sandbox.v1.GetGatewayConfigRequest - (*GetGatewayConfigResponse)(nil), // 20: openshell.sandbox.v1.GetGatewayConfigResponse - (*SettingValue)(nil), // 21: openshell.sandbox.v1.SettingValue - (*EffectiveSetting)(nil), // 22: openshell.sandbox.v1.EffectiveSetting - (*GetSandboxConfigResponse)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigResponse - (*SupervisorMiddlewareService)(nil), // 24: openshell.sandbox.v1.SupervisorMiddlewareService - nil, // 25: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - nil, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - nil, // 27: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - nil, // 28: openshell.sandbox.v1.L7DenyRule.QueryEntry - nil, // 29: openshell.sandbox.v1.L7DenyRule.ParamsEntry - nil, // 30: openshell.sandbox.v1.L7Allow.QueryEntry - nil, // 31: openshell.sandbox.v1.L7Allow.ParamsEntry - nil, // 32: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - nil, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - (*structpb.Struct)(nil), // 34: google.protobuf.Struct + (UiClipboardAccess)(0), // 0: openshell.sandbox.v1.UiClipboardAccess + (SettingScope)(0), // 1: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 2: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 3: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 4: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 5: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 6: openshell.sandbox.v1.ProcessPolicy + (*UiPolicy)(nil), // 7: openshell.sandbox.v1.UiPolicy + (*NetworkPolicyRule)(nil), // 8: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkMiddlewareConfig)(nil), // 9: openshell.sandbox.v1.NetworkMiddlewareConfig + (*MiddlewareEndpointSelector)(nil), // 10: openshell.sandbox.v1.MiddlewareEndpointSelector + (*NetworkCredentialBinding)(nil), // 11: openshell.sandbox.v1.NetworkCredentialBinding + (*NetworkEndpoint)(nil), // 12: openshell.sandbox.v1.NetworkEndpoint + (*McpOptions)(nil), // 13: openshell.sandbox.v1.McpOptions + (*GraphqlOperation)(nil), // 14: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 15: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 16: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 17: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 18: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 19: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 20: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 21: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 22: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 23: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 24: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 25: openshell.sandbox.v1.GetSandboxConfigResponse + (*SupervisorMiddlewareService)(nil), // 26: openshell.sandbox.v1.SupervisorMiddlewareService + nil, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + nil, // 29: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 30: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 31: openshell.sandbox.v1.L7DenyRule.ParamsEntry + nil, // 32: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 33: openshell.sandbox.v1.L7Allow.ParamsEntry + nil, // 34: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + (*structpb.Struct)(nil), // 36: google.protobuf.Struct } var file_sandbox_proto_depIdxs = []int32{ - 3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy - 4, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy - 5, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy - 25, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry - 26, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry - 10, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 17, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 34, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct - 8, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector - 14, // 9: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule - 13, // 10: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 27, // 11: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry - 11, // 12: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions - 9, // 13: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding - 28, // 14: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry - 29, // 15: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry - 15, // 16: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow - 30, // 17: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry - 31, // 18: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry - 32, // 19: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry - 21, // 20: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue - 0, // 21: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope - 2, // 22: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 33, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry - 1, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource - 24, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 6, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 7, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 12, // 28: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 16, // 29: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 30: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 31: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 32: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 21, // 33: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 22, // 34: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 35, // [35:35] is the sub-list for method output_type - 35, // [35:35] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 4, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 5, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 6, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 27, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 28, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry + 7, // 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 + 12, // 7: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 19, // 8: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 36, // 9: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct + 10, // 10: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector + 16, // 11: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 15, // 12: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 29, // 13: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 13, // 14: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions + 11, // 15: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding + 30, // 16: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 31, // 17: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry + 17, // 18: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 32, // 19: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 33, // 20: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry + 34, // 21: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 23, // 22: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 1, // 23: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 3, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 35, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 2, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 26, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService + 8, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 9, // 29: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 14, // 30: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 18, // 31: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 32: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 33: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 18, // 34: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 23, // 35: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 24, // 36: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 37, // [37:37] is the sub-list for method output_type + 37, // [37:37] is the sub-list for method input_type + 37, // [37:37] is the sub-list for extension type_name + 37, // [37:37] is the sub-list for extension extendee + 0, // [0:37] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } @@ -2342,8 +2495,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), @@ -2354,8 +2507,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: 2, - NumMessages: 32, + NumEnums: 3, + NumMessages: 33, NumExtensions: 0, NumServices: 0, }, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 73c0863df7..2bc77b446e 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 @@ -168,6 +168,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). @@ -257,6 +258,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: @@ -381,6 +404,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] No fail-closed middleware selector can cover a `tls: skip` endpoint - [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages - [ ] 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) @@ -443,7 +467,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**: @@ -462,7 +486,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 2cbc21b6b6..a547ea5398 100644 --- a/skills/generate-sandbox-policy/examples.md +++ b/skills/generate-sandbox-policy/examples.md @@ -748,7 +748,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 0d30aacb50..b68cb3ca58 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -408,8 +408,17 @@ 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 ``` +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 @@ -434,7 +443,7 @@ the operation that removes retained state. 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 creation: `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 creation: `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. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. UI capabilities are enforced only by a configured driver/backend advertising complete UI-policy support. Today that is the MXC driver's OpenShell `process_container` backend, which emits MXC's `processcontainer` containment value. `isolation_session` and non-Windows drivers reject any explicit UI section before provisioning; omit it to preserve their existing behavior. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -504,7 +513,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP 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 parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 6d8a188efe..e76507032d 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)] @@ -53,12 +53,13 @@ $WindowsCargoMutex = [System.Threading.Mutex]::new($false, "Local\OpenShellWindo $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-supervisor-process --exclude openshell-vfio" $WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes $WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async" -$BundledZ3WorkspaceFeatures = "--features openshell-prover/bundled-z3" -$BundledZ3ServerFeatures = "--features openshell-server/bundled-z3,openshell-prover/bundled-z3" -$BundledZ3GatewayFeatures = "--features openshell-gateway/bundled-z3" -$Z3WorkspaceFeatures = $BundledZ3WorkspaceFeatures -$Z3ServerFeatures = $BundledZ3ServerFeatures -$Z3GatewayFeatures = $BundledZ3GatewayFeatures +$PrebuiltZ3WorkspaceFeatures = "--features openshell-prover/prebuilt-z3" +$PrebuiltZ3ServerFeatures = "--features openshell-server/prebuilt-z3,openshell-prover/prebuilt-z3" +$PrebuiltZ3GatewayFeatures = "--features openshell-gateway/prebuilt-z3" +$PrebuiltZ3Version = "4.16.0" +$Z3WorkspaceFeatures = $PrebuiltZ3WorkspaceFeatures +$Z3ServerFeatures = $PrebuiltZ3ServerFeatures +$Z3GatewayFeatures = $PrebuiltZ3GatewayFeatures function Get-VsInstallRoots { $programFiles = @( @@ -354,11 +355,13 @@ function Resolve-Z3HeaderPath([string] $HeaderPath) { function Configure-Z3 { if ([string]::IsNullOrWhiteSpace($env:Z3_LIBRARY_PATH_OVERRIDE)) { - Write-Host "==> Z3: bundled" + Write-Host "==> Z3: prebuilt release" + $env:Z3_SYS_Z3_VERSION = $PrebuiltZ3Version + Write-Host " Z3_SYS_Z3_VERSION=$env:Z3_SYS_Z3_VERSION" return [pscustomobject]@{ - WorkspaceFeatures = $BundledZ3WorkspaceFeatures - ServerFeatures = $BundledZ3ServerFeatures - GatewayFeatures = $BundledZ3GatewayFeatures + WorkspaceFeatures = $PrebuiltZ3WorkspaceFeatures + ServerFeatures = $PrebuiltZ3ServerFeatures + GatewayFeatures = $PrebuiltZ3GatewayFeatures } } @@ -513,7 +516,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 $Z3GatewayFeatures" ` + -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor-relay $Z3GatewayFeatures" ` -LogName "build-$RustTarget-release.log" } @@ -543,7 +546,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 $Z3GatewayFeatures" ` -LogName "test-$RustTarget-unsupported-$test.log" } @@ -557,6 +560,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 { @@ -574,7 +585,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-relay.exe")) { $path = Join-Path $TargetDir "$rustTarget\release\$binary" if (-not (Test-Path $path)) { continue @@ -601,13 +612,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 @@ -650,6 +661,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..7509a934c6 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -76,9 +76,14 @@ 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: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 tasks/scripts/windows-msvc.ps1 test-mxc-real aarch64-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)" From fb2980e077288b61ef03a2e6187e162d158526aa Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:31:55 -0700 Subject: [PATCH 02/39] fix(windows): restore MXC qualification and cold-start readiness (#3468) * fix(windows): restore MXC qualification gates Signed-off-by: Prekshi Vyas * fix(mxc): poll target for full readiness budget --------- Signed-off-by: Prekshi Vyas --- .../examples/mxc-gateway.toml | 3 ++ crates/openshell-driver-mxc/src/driver.rs | 34 +++++++++++++++++++ .../tests/wxc_exec_real.rs | 21 +++++++++--- .../openshell-supervisor-network/src/host.rs | 1 + .../openshell-supervisor-network/src/proxy.rs | 7 ++-- crates/openshell-supervisor-relay/src/imp.rs | 24 +++++++++---- tasks/scripts/windows-msvc.ps1 | 27 +++++++++++++-- 7 files changed, 102 insertions(+), 15 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 4fbd2c41f5..601b66bec6 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -7,6 +7,9 @@ # Supply command and cwd through --driver-config-json when creating a sandbox, # and environment variables through the standard sandbox --env option. +[openshell] +version = 2 + [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). diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index ce728cbeec..072b1debf1 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -660,6 +660,31 @@ fn append_tls_readonly_grant( } } fn encode_windows_command_line(args: &[String]) -> String { + if args + .first() + .and_then(|executable| executable.rsplit(['\\', '/']).next()) + .is_some_and(|executable| { + executable.eq_ignore_ascii_case("cmd") || executable.eq_ignore_ascii_case("cmd.exe") + }) + && let Some(command_index) = args + .iter() + .position(|arg| arg.eq_ignore_ascii_case("/c") || arg.eq_ignore_ascii_case("/k")) + { + let mut encoded = args[..=command_index] + .iter() + .map(|arg| quote_windows_argument(arg)) + .collect::>() + .join(" "); + if command_index + 1 < args.len() { + encoded.push(' '); + // cmd.exe parses the command tail with its own grammar. Escaping + // embedded quotes as C argv would leave literal backslashes in + // paths and redirections (for example `\"C:\\work file\"`). + encoded.push_str(&args[command_index + 1..].join(" ")); + } + return encoded; + } + args.iter() .map(|arg| quote_windows_argument(arg)) .collect::>() @@ -2919,6 +2944,15 @@ mod lifecycle_tests { quote_windows_argument("trailing slash\\ "), r#""trailing slash\ ""# ); + assert_eq!( + encode_windows_command_line(&[ + r"C:\Windows\System32\CMD.EXE".into(), + "/d".into(), + "/c".into(), + r#"echo hello > "C:\work dir\output.txt""#.into(), + ]), + r#"C:\Windows\System32\CMD.EXE /d /c echo hello > "C:\work dir\output.txt""# + ); } #[tokio::test] async fn positive_in_policy_write_reaches_ready_and_materializes_file() { diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index c7bb11d86b..36b6edb804 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -832,15 +832,24 @@ 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 diagnostic_path = output_dir.path().join("https-diagnostic.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 diagnostic_path_string = diagnostic_path.to_string_lossy().into_owned(); let cmd_string = cmd.to_string_lossy().into_owned(); + // Schannel's revocation lookup targets are intentionally outside this + // test's example.com-only policy. Disable that network lookup while still + // requiring curl to validate the proxy-issued certificate against the + // injected CA bundle. let script = format!( - "type \"%CURL_CA_BUNDLE%\" 1>NUL && \ - \"{}\" --fail --silent --show-error --cacert \"%CURL_CA_BUNDLE%\" \ + "echo CURL_CA_BUNDLE=%CURL_CA_BUNDLE% 1>\"{diagnostic_path_string}\" && \ + type \"%CURL_CA_BUNDLE%\" 1>NUL 2>>\"{diagnostic_path_string}\" && \ + \"{}\" --fail --silent --show-error --ssl-no-revoke \ + --cacert \"%CURL_CA_BUNDLE%\" \ https://example.com/ --output \"{output_path_string}\" \ - --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\"", + --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\" \ + 2>>\"{diagnostic_path_string}\"", curl.display() ); let command = vec![ @@ -932,10 +941,12 @@ async fn pc_https_egress_reads_injected_ca_bundle() { } let condition = terminal_condition.expect("HTTPS sandbox should reach a terminal condition"); + let diagnostic = std::fs::read_to_string(diagnostic_path) + .unwrap_or_else(|error| format!("failed to read HTTPS diagnostic: {error}")); assert_eq!( condition.reason, "AgentCompleted", - "HTTPS workload failed: {}", - condition.message + "HTTPS workload failed: {}; diagnostic: {diagnostic}", + condition.message, ); assert!(output_path.exists(), "curl should write the HTTPS response"); assert!( diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index 9d6f5cfffa..eb09634772 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -231,6 +231,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result` -- `` //! is the TCP port the launched command is expected to bind (an early //! liveness check, `wait_for_port_ready`: if the target never binds it -//! within 60s, this process exits with an error instead of sitting around -//! with a target that will never work). This binary uses no `share_dir` files +//! within the bounded five-minute cold-start budget, this process exits with +//! an error instead of sitting around with a target that will never work). +//! This binary uses no `share_dir` files //! at all -- command/env and shutdown both travel over the control channel. //! //! Shutdown: driver sends a `"shutdown"` request over the control channel @@ -164,7 +165,7 @@ pub async fn run() -> anyhow::Result<()> { // budget after a caller was told shutdown succeeded. let mut shutdown_rx = shutdown_rx; tokio::select! { - result = wait_for_port_ready(&mut child, port, Duration::from_mins(1)) => { + result = wait_for_port_ready(&mut child, port, PORT_READY_PER_TRY_TIMEOUT) => { result?; } _ = &mut shutdown_rx => { @@ -715,8 +716,11 @@ where /// Number of full-budget tries `wait_for_port_ready` makes -- each try gets /// its own complete `per_try_timeout` window, not a slice of it. Worst case -/// total wait is `max_tries * per_try_timeout` (3 * 60s = 180s today). -const PORT_READY_MAX_TRIES: u32 = 3; +/// total wait is `max_tries * per_try_timeout` (5 * 60s = 300s today). Keep +/// this aligned with the MXC driver's 310-second target-ready timeout so a +/// cold process remains actively probed for the full advertised budget. +const PORT_READY_MAX_TRIES: u32 = 5; +const PORT_READY_PER_TRY_TIMEOUT: Duration = Duration::from_mins(1); /// Poll for the target port accepting TCP connections, bailing out early /// (rather than waiting out all tries) if the child process exits first — a @@ -1013,12 +1017,20 @@ async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::R #[cfg(test)] mod forward_connect_tests { - use super::connect_forward_target; + use super::{PORT_READY_MAX_TRIES, PORT_READY_PER_TRY_TIMEOUT, connect_forward_target}; use std::io::{Error, ErrorKind}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; + #[test] + fn port_ready_budget_covers_five_minute_cold_start_contract() { + assert_eq!( + PORT_READY_PER_TRY_TIMEOUT * PORT_READY_MAX_TRIES, + Duration::from_mins(5), + ); + } + #[tokio::test(start_paused = true)] async fn forward_connect_returns_success_without_retry_delay() { let start = tokio::time::Instant::now(); diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index e76507032d..92569c57fd 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -518,6 +518,29 @@ function Invoke-Build([string] $RustTarget) { -RustTarget $RustTarget ` -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor-relay $Z3GatewayFeatures" ` -LogName "build-$RustTarget-release.log" + + $z3Runtime = if ([string]::IsNullOrWhiteSpace($env:Z3_LIBRARY_PATH_OVERRIDE)) { + $buildRoot = Join-Path $TargetDir "$RustTarget\release\build" + $candidates = @( + Get-ChildItem -Path $buildRoot -Filter "libz3.dll" -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like "*\z3-$PrebuiltZ3Version\bin\libz3.dll" } | + Sort-Object LastWriteTimeUtc -Descending + ) + if ($candidates.Count -eq 0) { + throw "The prebuilt Z3 runtime was not found under: $buildRoot" + } + $candidates[0].FullName + } else { + $path = Join-Path $env:Z3_LIBRARY_PATH_OVERRIDE "libz3.dll" + if (-not (Test-Path $path -PathType Leaf)) { + throw "Z3_LIBRARY_PATH_OVERRIDE is set but libz3.dll was not found at: $path" + } + $path + } + + $z3RuntimeDestination = Join-Path $TargetDir "$RustTarget\release\libz3.dll" + Copy-Item -LiteralPath $z3Runtime -Destination $z3RuntimeDestination -Force + Write-Host "==> Staged Z3 runtime: $z3RuntimeDestination" } function Invoke-Test([string] $RustTarget) { @@ -555,7 +578,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" } } @@ -585,7 +608,7 @@ function Get-Sha256([string] $Path) { function Show-Artifacts([string[]] $RustTargets) { $rows = @() foreach ($rustTarget in $RustTargets) { - foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe")) { + foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe", "libz3.dll")) { $path = Join-Path $TargetDir "$rustTarget\release\$binary" if (-not (Test-Path $path)) { continue From 4f06e23cc8e4bfb48f78f43645771683f674fe2d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:12:12 -0700 Subject: [PATCH 03/39] fix(mxc): harden governed proxy lifecycle (NVBug 6783325) (#3472) * fix(mxc): gate host proxy on explicit network policy Signed-off-by: Prekshi Vyas * fix(mxc): reject unrestricted egress fallback Signed-off-by: Prekshi Vyas * chore(mise): refresh lockfile Signed-off-by: Shailendra Singh * fix(mxc): unblock Windows validation Signed-off-by: Shailendra Singh * test(mxc): satisfy Windows clippy Signed-off-by: Shailendra Singh --------- Signed-off-by: Prekshi Vyas Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- crates/openshell-driver-mxc/README.md | 23 +- .../examples/e2e-policies/ws-agent.yaml | 17 +- .../examples/mxc-ws-agent.rs | 3 +- .../examples/mxc-ws-gateway.toml | 9 +- .../examples/ocsf-audit.yaml | 17 +- .../examples/run-ocsf-audit.ps1 | 3 + .../examples/run-ws-agent-test.ps1 | 15 +- crates/openshell-driver-mxc/src/driver.rs | 362 ++++++++++++------ crates/openshell-driver-mxc/src/mxc.rs | 4 - crates/openshell-driver-mxc/src/relay.rs | 21 +- .../tests/wxc_exec_real.rs | 5 +- mise.lock | 20 +- 12 files changed, 324 insertions(+), 175 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index b8c52f9898..7f595bf19c 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -23,7 +23,7 @@ it does not implement the Linux `ConnectSupervisor` protocol. |---|---| | Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | | UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | -| Network policy | With `egress_proxy = true` on `process_container`, split into MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | +| Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | | Process policy | Unsupported; MXC supplies OS isolation only. | | Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | @@ -60,6 +60,10 @@ pc_relay_target_port = 0 # (SYSTEMROOT/WINDIR/PATH/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an # EMPTY env for runtimes that need a fully curated per-sandbox environment. pc_minimal_env = false +# processContainer only: compatibility fallback for unrestricted outbound TCP. +# A sandbox with egress_proxy enabled but no explicit network rules rejects +# this fallback instead of silently changing governed egress to allow-all. +pc_network_allow = false # processContainer only: include "allowLocalNetwork": true in the MXC # network section. This compatibility setting broadens network access and is # not required by the BaseContainer qualification profile. @@ -72,8 +76,14 @@ etw_audit = false ``` When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback -`IP:PORT` seed. The driver preserves the configured IP and allocates a unique -ephemeral port for each sandbox's authenticated host CONNECT proxy. +`IP:PORT` seed. For policies with explicit network rules, the driver preserves +the configured IP and allocates a unique ephemeral port for that sandbox's +authenticated host CONNECT proxy. + +`pc_network_allow = true` is an explicit unrestricted-egress compatibility +fallback. If it is combined with `egress_proxy = true`, a sandbox policy +without explicit network rules is rejected synchronously rather than falling +through from governed egress to `defaultPolicy = "allow"`. Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: @@ -87,7 +97,7 @@ The `command` array is required and preserves Windows argument boundaries. `cwd` UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). -`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: the driver reserves a unique ephemeral loopback port for every sandbox. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. The current policy permits all loopback ports, so sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. +`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: for a sandbox policy with explicit network rules, the driver reserves a unique ephemeral loopback port. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. A policy without network rules keeps MXC's default network posture and receives neither a host listener nor proxy environment variables. The current governed-egress policy permits all loopback ports, so governed sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a @@ -142,8 +152,9 @@ or MXC-specific gateway composition variant. Provider resolver state uses a separate, create-scoped in-process handoff because it intentionally cannot be represented in the public compute-driver protobuf. -When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` -instead: MXC receives filesystem grants plus loopback-only egress, +When `egress_proxy` is enabled and the policy contains explicit network rules, +`EmbeddedPolicyMapper` uses `split_policy` instead: MXC receives filesystem +grants plus loopback-only egress, and the driver starts a host CONNECT proxy from the trimmed network-only `SandboxPolicy`. Policies containing `network_middlewares` are rejected synchronously until this host-proxy path can receive the gateway's diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml index 6160be6f5c..683a8c0c66 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -10,9 +10,8 @@ # system paths and share_dir). # - TCP socket binding on port 22000 (governed by pc_capabilities in the # gateway TOML, not by filesystem policy here). -# - Outbound TCP through the egress proxy, for openshell-supervisor-relay -# to dial the driver's on-demand relay — governed by egress_proxy in the -# TOML. +# - Private/loopback client access for openshell-supervisor-relay to dial +# the driver's on-demand relay — granted by pc_capabilities in the TOML. # - No writes to the host filesystem. # # workload directory (passed by run-ws-agent-test.ps1, default C:\work\openshell-mxc-ws) @@ -24,15 +23,9 @@ # patches this path and the per-sandbox driver config when -AgentDir overrides # the default below. # -# This example intentionally omits network_policies, not because the driver -# would reject it: with egress_proxy = true (set in mxc-ws-gateway.toml), the -# driver takes the lossless split path (policy_map::split_policy) and -# delegates network_policies verbatim to the OpenShell host CONNECT proxy for -# enforcement -- an "info" loss item, not an error, so it would be accepted. -# (Only the no-proxy coarse path, or an unsupported rule shape, can turn a -# network_policies entry into a rejected "error" loss item -- see -# policy_map/map.rs.) This scenario just doesn't need host-enforced network -# rules beyond the loopback/pc_capabilities grant above. +# This example intentionally omits network_policies because it does not need +# governed Internet egress. The driver therefore does not start a host CONNECT +# proxy or inject proxy environment variables for this sandbox. version: 1 filesystem_policy: diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs index dbd63f1426..1c09e7956a 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs +++ b/crates/openshell-driver-mxc/examples/mxc-ws-agent.rs @@ -282,7 +282,8 @@ async fn run_relay_proxy(relay_url: String, local_url: String, mut stop_rx: ones const LOCAL_CONNECT_ATTEMPTS: u32 = 15; const LOCAL_CONNECT_TIMEOUT: Duration = Duration::from_millis(500); const LOCAL_CONNECT_BACKOFF: Duration = Duration::from_millis(300); - // Connect to the gateway relay (outbound via egress_proxy). + // Connect directly to the gateway relay over the private network access + // granted by the qualification profile's privateNetworkClientServer capability. let relay_ws = match tokio_tungstenite::connect_async(&relay_url).await { Ok((ws, _)) => { eprintln!("[spawner] relay connected: {relay_url}"); diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml index afd04a44cd..8a2aa4e9b8 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -48,11 +48,10 @@ pc_capabilities = ["privateNetworkClientServer"] # process_container only: keep standard privilege level (not LPA). pc_least_privilege = false -# Egress proxy for outbound TCP connectivity -- required for -# openshell-supervisor-relay to dial out to the driver's on-demand relay -# (see mxc-openclaw-gateway.toml, which uses the same pattern). -egress_proxy = true -egress_proxy_addr = "127.0.0.1:18080" +# No governed Internet egress is needed. The relay reaches the driver's +# on-demand private-interface listener through privateNetworkClientServer above. +egress_proxy = false +egress_proxy_addr = "" # The workload command and cwd are supplied per sandbox by # run-ws-agent-test.ps1 through `sandbox create --driver-config-json`. diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml index 13e7cd571e..e036cc90ef 100644 --- a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -7,9 +7,10 @@ # else is default-deny. run-ocsf-audit.ps1 copies this policy into the result # bundle and replaces the default grant with -ShareDir for that run. # -# No network_policies block is needed here: the per-sandbox egress proxy is driven -# by `egress_proxy = true` in mxc-ocsf-audit.toml (that is what makes MXC emit the -# SandboxProxyConfigured event we map to OCSF), not by a policy rule. +# The explicit network rule activates the per-sandbox egress proxy configured +# in mxc-ocsf-audit.toml. That proxy configuration makes MXC emit the +# SandboxProxyConfigured event mapped to OCSF. run-ocsf-audit.ps1 removes this +# block for its explicit -NoProxy comparison. version: 1 filesystem_policy: @@ -17,3 +18,13 @@ filesystem_policy: read_only: [] read_write: - "C:/work/openshell-mxc-demo" + +network_policies: + audit_probe: + name: audit-probe + endpoints: + - host: example.com + port: 443 + protocol: tcp + binaries: + - path: "C:/Windows/System32/cmd.exe" diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 591c38bf24..00d32e10ef 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -144,6 +144,9 @@ try { $shareDirPolicy = $ShareDir.Replace('\', '/') $shareDirJson = ConvertTo-Json $shareDirPolicy -Compress $policyText = Get-Content $policySrc -Raw + if (-not $proxyOn) { + $policyText = [regex]::Replace($policyText, '(?ms)^network_policies:\s*.*\z', '') + } $defaultGrant = ' - "C:/work/openshell-mxc-demo"' if (-not $policyText.Contains($defaultGrant)) { throw "policy template does not contain the expected default ShareDir grant" diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index 0564d17fc5..0372864928 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -359,15 +359,12 @@ try { } Ok "wxc-exec: $WxcExecPath" - # A real run exercises process_container with egress_proxy = true - # (mxc-ws-gateway.toml). MXC schema 0.8.0-alpha's network_json() - # (mxc.rs) now emits a direct egress.allow rule for 127.0.0.0/8 - # instead of runtimeConfig.networkProxy when a proxy is configured, - # so the driver no longer calls the elevation-only - # NetworkIsolationSetAppContainerConfig -- process_container + - # egress_proxy selects the BaseContainer/PSEC tier and runs - # non-elevated. Elevation is therefore no longer required here; keep - # logging the elevation state for diagnostics only. + # A real run exercises process_container with egress_proxy disabled + # (mxc-ws-gateway.toml). The sandbox connects directly to the driver's + # route-selected private-interface relay listener through the + # privateNetworkClientServer capability; the governed host CONNECT + # proxy is not part of this qualification path. Elevation is not + # required here; keep logging the elevation state for diagnostics only. $wid = [Security.Principal.WindowsIdentity]::GetCurrent() $wp = New-Object Security.Principal.WindowsPrincipal($wid) $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 072b1debf1..513a92d858 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -82,7 +82,10 @@ pub struct MxcComputeConfig { /// `processContainer` only: inject a network section with /// `defaultPolicy: "allow"` so the `AppContainer` has unrestricted outbound /// TCP access. Required when `pc_capabilities` alone is insufficient to - /// enable network access in the target wxc-exec build. + /// enable network access in the target wxc-exec build. When `egress_proxy` + /// is also enabled, sandbox policies without explicit network rules are + /// rejected instead of falling back from governed egress to unrestricted + /// access. pub pc_network_allow: bool, /// `processContainer` only: include `"allowLocalNetwork": true` in the /// MXC network section. Required for node.js (and other runtimes that @@ -123,9 +126,11 @@ pub struct MxcComputeConfig { /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, - /// Enable Pattern-C governed egress. When true, MXC permits loopback-only - /// egress, the driver injects proxy environment variables, and the host - /// CONNECT proxy receives the full network policy. + /// Enable Pattern-C governed egress for sandbox policies that contain + /// explicit network rules. MXC permits loopback-only egress, the driver + /// injects proxy environment variables, and the host CONNECT proxy receives + /// the full network policy. Policies without network rules do not start a + /// listener or receive proxy environment variables. pub egress_proxy: bool, /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is /// enabled. The driver preserves the loopback IP and allocates a unique @@ -462,6 +467,26 @@ fn configured_egress_addr(config: &MxcComputeConfig) -> Result) -> bool { + policy.is_some_and(|policy| { + !policy.network_policies.is_empty() || !policy.network_middlewares.is_empty() + }) +} + +fn governed_egress_addr( + config: &MxcComputeConfig, + policy: Option<&SandboxPolicy>, +) -> Result, tonic::Status> { + let configured = configured_egress_addr(config)?; + let policy_activates_egress = policy_activates_governed_egress(policy); + if configured.is_some() && !policy_activates_egress && config.pc_network_allow { + return Err(tonic::Status::invalid_argument( + "mxc egress_proxy cannot be combined with pc_network_allow for a sandbox policy without explicit network rules; refusing unrestricted egress fallback", + )); + } + Ok(configured.filter(|_| policy_activates_egress)) +} + fn allocate_sandbox_proxy_addr( configured: SocketAddr, ) -> std::io::Result<(SocketAddr, std::net::TcpListener)> { @@ -816,7 +841,7 @@ impl MxcComputeBackend { } } - fn validate_sandbox_fields(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + fn validate_sandbox_fields(sandbox: &DriverSandbox) -> Result<(), tonic::Status> { if let Some(spec) = &sandbox.spec { if effective_driver_gpu_count(driver_gpu_requirements( spec.resource_requirements.as_ref(), @@ -859,9 +884,9 @@ impl MxcComputeBackend { } pub fn validate_sandbox_create(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { - self.validate_sandbox_fields(sandbox)?; + Self::validate_sandbox_fields(sandbox)?; let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); - let egress_addr = configured_egress_addr(&self.config)?; + let egress_addr = governed_egress_addr(&self.config, policy)?; self.map_sandbox_policy(&sandbox.id, policy, egress_addr)?; Ok(()) } @@ -890,9 +915,13 @@ impl MxcComputeBackend { .remove(&sandbox_id); validate_provider_child_env_keys(provider_credentials.as_ref())?; - self.validate_sandbox_fields(sandbox)?; + Self::validate_sandbox_fields(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; - let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { + let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); + let (egress_addr, reserved_proxy_listener) = match governed_egress_addr( + &self.config, + policy, + )? { Some(configured_addr) => { let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( |error| { @@ -909,7 +938,6 @@ impl MxcComputeBackend { // Policy translation is deterministic and side-effect free. Do it before // inserting the registry entry or launching MXC so invalid requests fail // synchronously at the CreateSandbox boundary. - let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()); let mapped = self.map_sandbox_policy(&sandbox_id, policy, egress_addr)?; if provider_credentials .as_ref() @@ -917,7 +945,7 @@ impl MxcComputeBackend { && egress_addr.is_none() { return Err(tonic::Status::failed_precondition( - "mxc provider credentials require governed egress; enable egress_proxy so placeholders can be resolved by the host proxy", + "mxc provider credentials require governed egress; enable egress_proxy and configure at least one network policy so placeholders can be resolved by the host proxy", )); } @@ -1425,9 +1453,8 @@ async fn run_lifecycle( let proxy_addr = mapped.proxy_addr; let proxy_auth = proxy_addr.map(|_| SandboxProxyAuth::generate()); let trimmed_policy = mapped.trimmed_policy.clone(); - let host_proxy = if !invoker.is_mock() - && let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = - (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) + let host_proxy = if let (Some(addr), Some(proxy_policy), Some(proxy_auth)) = + (proxy_addr, trimmed_policy.clone(), proxy_auth.as_ref()) { drop(reserved_proxy_listener.take()); match openshell_supervisor_network::host::start_host_proxy( @@ -1447,7 +1474,14 @@ async fn run_lifecycle( ) .await { - Ok(handle) => Some(handle), + Ok(handle) => { + info!( + sandbox = %sandbox_name, + address = %addr, + "MXC host egress proxy started" + ); + Some(handle) + } Err(error) => { set_failed( ®istry, @@ -2366,7 +2400,9 @@ mod lifecycle_tests { use std::time::Duration; fn driver_sandbox(id: &str) -> DriverSandbox { - driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + driver_sandbox_with_command(id, "", vec![shell, "/c".into(), "exit 0".into()]) } #[tokio::test] @@ -2432,6 +2468,19 @@ mod lifecycle_tests { } } + fn long_running_command(share: &str) -> (String, Vec) { + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + let command = vec![ + shell.clone(), + "/d".into(), + "/s".into(), + "/c".into(), + format!(r#"cd /d "{share}" && ping -n 61 127.0.0.1 >nul"#), + ]; + (shell, command) + } + fn github_provider_credentials() -> ProviderCredentialState { ProviderCredentialState::from_bound_environment( 42, @@ -2659,6 +2708,8 @@ mod lifecycle_tests { .expect("provider credential staging lock poisoned") .insert("sb-provider-env".to_string(), credentials); + let shell = + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); let mut policy = fs_policy(&[]); policy.network_policies.insert( "github".to_string(), @@ -2668,13 +2719,12 @@ mod lifecycle_tests { host: "api.github.com".to_string(), port: 443, protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "full".to_string(), provider_credentialed: true, ..Default::default() }], - binaries: vec![NetworkBinary { - path: "cmd".to_string(), - ..Default::default() - }], + binaries: vec![NetworkBinary { path: shell }], }, ); let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); @@ -2695,11 +2745,15 @@ mod lifecycle_tests { .create_sandbox(&sandbox) .await .expect("create accepted"); - wait_for(&backend, "sb-provider-env", |_| { + let recorded = wait_for(&backend, "sb-provider-env", |_| { crate::mxc::mock_recorded_config("sb-provider-env").is_some() }) - .await - .expect("MXC config should be recorded"); + .await; + assert!( + recorded.is_some(), + "MXC config should be recorded; sandbox: {:#?}", + backend.get_sandbox("sb-provider-env").await + ); let recorded = crate::mxc::mock_recorded_config("sb-provider-env").expect("mock recorded config"); @@ -3065,121 +3119,195 @@ mod lifecycle_tests { } #[tokio::test] - async fn split_path_provisions_with_proxy_redirect() { - use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + async fn explicit_network_policies_start_and_cleanup_host_proxy() { + for (sandbox_id, host, pc_network_allow) in [ + ("sb-egress-allow", "example.com", false), + ("sb-egress-no-match", "allowed.invalid", true), + ] { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let (shell, command) = long_running_command(&share); + let config = MxcComputeConfig { + backend: MxcBackend::ProcessContainer, + pc_network_allow, + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }; + let backend = MxcComputeBackend::new_mocked(config); + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "explicit".into(), + NetworkPolicyRule { + name: "explicit".into(), + endpoints: vec![NetworkEndpoint { + host: host.into(), + ports: vec![443], + protocol: "tcp".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { path: shell }], + }, + ); + let sandbox = with_policy( + driver_sandbox_with_command(sandbox_id, &share, command), + policy.clone(), + ); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + wait_for(&backend, sandbox_id, |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("explicit network policy sandbox should reach Ready=True"); + + let recorded = + crate::mxc::mock_recorded_config(sandbox_id).expect("mock recorded config"); + assert_eq!(recorded["network"]["egress"]["default"], "deny"); + assert_eq!( + recorded["network"]["egress"]["allow"], + serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) + ); + assert!(recorded["network"].get("proxy").is_none()); + + let proxy_addr = { + let registry = backend.registry.lock().await; + let entry = registry.get(sandbox_id).expect("registry entry"); + assert!( + entry.host_proxy.is_some(), + "{sandbox_id}: governed egress must hold a live host proxy" + ); + assert_eq!( + entry.trimmed_policy.as_ref().unwrap().network_policies, + policy.network_policies + ); + entry.proxy_addr.expect("proxy address") + }; + tokio::time::timeout( + Duration::from_secs(2), + tokio::net::TcpStream::connect(proxy_addr), + ) + .await + .expect("proxy listener connect timed out") + .expect("proxy listener must accept connections"); + + let child_env = recorded["process"]["env"].as_array().expect("child env"); + let proxy_env = child_env + .iter() + .filter_map(serde_json::Value::as_str) + .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) + .expect("HTTP_PROXY must direct clients to the authenticated proxy"); + let (credentials, address) = proxy_env + .strip_prefix("http://openshell:") + .and_then(|value| value.split_once('@')) + .expect("proxy URL must contain the per-sandbox credential"); + assert!(!credentials.is_empty()); + assert_eq!(address, proxy_addr.to_string()); + + backend + .stop_sandbox(sandbox_id) + .await + .expect("stop should clean up the governed egress proxy"); + assert!( + backend + .registry + .lock() + .await + .get(sandbox_id) + .is_some_and(|entry| entry.host_proxy.is_none()), + "{sandbox_id}: stop must drop the host proxy handle" + ); + assert!( + backend + .delete_sandbox(&sandbox.id, &sandbox.name) + .await + .expect("delete after stop"), + "sandbox should be removed" + ); + } + } + + #[tokio::test] + async fn empty_network_policy_does_not_activate_host_proxy() { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); - let hello = format!("{share}/hello.txt"); - let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {hello} -Value hi"), - ]; - let config = MxcComputeConfig { + let (_shell, command) = long_running_command(&share); + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { backend: MxcBackend::ProcessContainer, egress_proxy: true, egress_proxy_addr: "127.0.0.1:18080".into(), ..Default::default() - }; - let backend = MxcComputeBackend::new_mocked(config); - let mut stream = backend.watch_sandboxes().await; - - let mut policy = fs_policy(&[&share]); - policy.network_policies.insert( - "api".into(), - NetworkPolicyRule { - name: "api".into(), - endpoints: vec![NetworkEndpoint { - host: "example.com".into(), - ports: vec![443], - protocol: "rest".into(), - ..Default::default() - }], - binaries: vec![NetworkBinary { - path: "/usr/bin/curl".into(), - ..Default::default() - }], - }, - ); + }); let sandbox = with_policy( - driver_sandbox_with_command("sb-egress", &share, cmd), - policy.clone(), + driver_sandbox_with_command("sb-egress-default", &share, command), + fs_policy(&[&share]), ); backend .create_sandbox(&sandbox) .await .expect("create accepted"); - - let ready = wait_for(&backend, "sb-egress", |s| { - ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + wait_for(&backend, &sandbox.name, |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") }) - .await; + .await + .expect("default-policy sandbox should reach Ready=True"); + + let recorded = crate::mxc::mock_recorded_config(&sandbox.id).expect("mock recorded config"); + let registry = backend.registry.lock().await; + let entry = registry.get(&sandbox.id).expect("registry entry"); + assert!(entry.proxy_addr.is_none()); + assert!(entry.host_proxy.is_none()); + assert!(entry.trimmed_policy.is_none()); + drop(registry); + assert!(recorded.get("network").is_none()); assert!( - ready.is_some(), - "egress split sandbox should reach Ready=True" + recorded["process"]["env"] + .as_array() + .expect("child env") + .iter() + .filter_map(serde_json::Value::as_str) + .all(|entry| !PROXY_ENV_KEYS.iter().any(|key| { + entry + .split_once('=') + .is_some_and(|(entry_key, _)| entry_key.eq_ignore_ascii_case(key)) + })) ); - let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); - assert_eq!(recorded["version"], "0.8.0-alpha"); - assert_eq!(recorded["network"]["egress"]["default"], "deny"); - assert_eq!( - recorded["network"]["egress"]["allow"], - serde_json::json!([{"to": [{"cidr": "127.0.0.1/32"}]}]) - ); - assert!(recorded["network"].get("allowedHosts").is_none()); - assert!(recorded["network"].get("blockedHosts").is_none()); - assert!(recorded["network"].get("proxy").is_none()); - assert!(recorded.get("networkProxy").is_none()); - - let reg = backend.registry.lock().await; - let entry = reg.get("sb-egress").expect("registry entry"); - let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); - assert_eq!( - entry_proxy_addr.ip(), - std::net::IpAddr::from([127, 0, 0, 1]) + backend.stop_sandbox(&sandbox.name).await.expect("stop"); + assert!( + backend + .delete_sandbox(&sandbox.id, &sandbox.name) + .await + .expect("delete after stop") ); - assert_ne!(entry_proxy_addr.port(), 0); - let child_env = recorded["process"]["env"].as_array().expect("child env"); - let proxy_env = child_env - .iter() - .filter_map(serde_json::Value::as_str) - .find_map(|entry| entry.strip_prefix("HTTP_PROXY=")) - .expect("HTTP_PROXY must direct clients to the authenticated proxy"); - let (credentials, address) = proxy_env - .strip_prefix("http://openshell:") - .and_then(|value| value.split_once('@')) - .expect("proxy URL must contain the per-sandbox credential"); - assert!(!credentials.is_empty()); - assert_eq!(address, entry_proxy_addr.to_string()); - assert_eq!( - entry.trimmed_policy.as_ref().unwrap().network_policies, - policy.network_policies + } + + #[test] + fn empty_network_policy_rejects_unrestricted_fallback() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::ProcessContainer, + pc_network_allow: true, + egress_proxy: true, + egress_proxy_addr: "127.0.0.1:18080".into(), + ..Default::default() + }); + let sandbox = with_policy( + driver_sandbox("sb-egress-unrestricted-fallback"), + fs_policy(&[]), ); - drop(reg); - let mut saw_redirect = false; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { - Ok(Some(Ok(ev))) => { - if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload - && pe - .event - .as_ref() - .is_some_and(|e| e.reason == "EgressRedirect") - { - saw_redirect = true; - break; - } - } - Ok(_) => break, - Err(_) => {} - } - } - assert!(saw_redirect, "expected EgressRedirect platform event"); + let error = backend + .validate_sandbox_create(&sandbox) + .expect_err("mixed egress configuration must fail closed without network rules"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("egress_proxy")); + assert!(error.message().contains("pc_network_allow")); + assert!(error.message().contains("unrestricted egress fallback")); } #[tokio::test] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index eecc1f7155..dd65b06358 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -407,10 +407,6 @@ impl WxcExecInvoker { } } - pub(crate) const fn is_mock(&self) -> bool { - self.mock - } - /// Test-only constructor that forces mock mode without touching the /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across /// parallel tests under edition 2024's `unsafe` `set_var`). diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs index da0a000188..45b0776290 100644 --- a/crates/openshell-driver-mxc/src/relay.rs +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -3,16 +3,17 @@ //! WebSocket relay embedded in the gateway for MXC `ProcessContainer` sandboxes. //! -//! When `egress_proxy = true` the `AppContainer` has outbound TCP via the -//! `OpenShell` host CONNECT proxy. The driver binds a relay listener on demand -//! (`start_relay`, e.g. from `ForwardSink::open_dynamic_forward`) and tells -//! the in-sandbox spawner its address over the stdin/stdout control channel; -//! the spawner connects outward to it as a WebSocket CLIENT (Phase A). Host -//! clients connect as raw TCP (Phase B); the relay tunnels their bytes -//! through Phase A so the in-sandbox agent can pipe them directly to the -//! target service. Each relay is per-request and short-lived — bound fresh -//! for each `openshell forward service` call, torn down when that forward -//! ends. +//! The `AppContainer` needs private-network client access (for example, the +//! `privateNetworkClientServer` capability used by the qualification profile). +//! The driver binds a relay listener on demand (`start_relay`, e.g. from +//! `ForwardSink::open_dynamic_forward`) and tells the in-sandbox spawner its +//! address over the stdin/stdout control channel; the spawner connects directly +//! to it as a WebSocket CLIENT (Phase A). Governed egress and its host CONNECT +//! proxy are not part of this relay path. Host clients connect as raw TCP +//! (Phase B); the relay tunnels their bytes through Phase A so the in-sandbox +//! agent can pipe them directly to the target service. Each relay is +//! per-request and short-lived — bound fresh for each `openshell forward +//! service` call, torn down when that forward ends. //! //! ```text //! host TCP client -> relay (gateway, raw TCP accept) diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 36b6edb804..abd048c73c 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -885,10 +885,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { access: "read-only".to_string(), ..Default::default() }], - binaries: vec![NetworkBinary { - path: cmd_string, - ..Default::default() - }], + binaries: vec![NetworkBinary { path: cmd_string }], }, )]), ..Default::default() diff --git a/mise.lock b/mise.lock index a5e38be2c2..77d25ea64e 100644 --- a/mise.lock +++ b/mise.lock @@ -168,6 +168,12 @@ url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9 url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501885637" provenance = "github-attestations" +[tools."github:nextest-rs/nextest"."platforms.windows-arm64"] +checksum = "sha256:58c1637ba2396e6c556aa0092f9aa4388695594b8ddda5a4b8b39212574678ce" +url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-aarch64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/nextest-rs/nextest/releases/assets/501886128" +provenance = "github-attestations" + [tools."github:nextest-rs/nextest"."platforms.windows-x64"] checksum = "sha256:c670ba18e8731fd2eff33a47af33a0fa53d1afa6d0678344e82dc6f8fc7344ac" url = "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.143/cargo-nextest-0.9.143-x86_64-pc-windows-msvc.zip" @@ -439,15 +445,19 @@ backend = "aqua:GoogleContainerTools/skaffold" "vars.version_prefix" = "v" [tools.skaffold."platforms.linux-arm64"] +checksum = "sha256:0062e1ff61f35200168454fa5a836306dd30b51acf30999d5d8836770d70ad2f" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-linux-arm64" [tools.skaffold."platforms.linux-x64"] +checksum = "sha256:de75d5697d4a14d6a5e87105671ef9a30990270ab14ad301913f8f8d41a1ecca" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-linux-amd64" [tools.skaffold."platforms.macos-arm64"] +checksum = "sha256:73478a431c17674d2f75d2c6e2fcbf35aa019de4ad6027e5573cc0f581767fbc" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-darwin-arm64" [tools.skaffold."platforms.windows-x64"] +checksum = "sha256:a257da2988782b4c1971bb58b9c7bb52176f8a0b587b6520dec0064f271fa416" url = "https://storage.googleapis.com/skaffold/releases/v2.20.0/skaffold-windows-amd64.exe" [[tools.uv]] @@ -455,13 +465,15 @@ version = "0.10.12" backend = "aqua:astral-sh/uv" [tools.uv."platforms.linux-arm64"] -checksum = "sha256:55bd1c1c10ec8b95a8c184f5e18b566703c6ab105f0fc118aaa4d748aabf28e4" -url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-musl.tar.gz" +checksum = "sha256:0ed7d20f49f6b9b60d45fdfcac28f3ac01a671a6ef08672401ed2833423fea2a" +url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491938" provenance = "github-attestations" [tools.uv."platforms.linux-x64"] -checksum = "sha256:adccf40b5d1939a5e0093081ec2307ea24235adf7c2d96b122c561fa37711c46" -url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:ec72570c9d1f33021aa80b176d7baba390de2cfeb1abcbefca346d563bf17484" +url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491996" provenance = "github-attestations" [tools.uv."platforms.macos-arm64"] From d78430c66cd8ac85f463e8d6d266fdb3f42b0407 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:25:31 -0700 Subject: [PATCH 04/39] test(mxc): verify ProcessContainer token isolation (#3430) Adds real-MXC regression coverage for ProcessContainer token isolation, including an unsandboxed SCM positive control, and documents the AppContainer authorization model. --- .../tests/wxc_exec_real.rs | 197 ++++++++++++++++++ docs/reference/gateway-config.mdx | 11 + 2 files changed, 208 insertions(+) diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index abd048c73c..97359d4edc 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -40,6 +40,97 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +mod token_probe { + #![allow(unsafe_code)] + + use std::ffi::c_void; + use std::ptr; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> isize; + fn CloseHandle(handle: isize) -> i32; + fn GetLastError() -> u32; + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32; + fn GetTokenInformation( + token: isize, + class: i32, + info: *mut c_void, + len: u32, + return_len: *mut u32, + ) -> i32; + fn OpenSCManagerW(machine: *const u16, database: *const u16, access: u32) -> isize; + fn CloseServiceHandle(handle: isize) -> i32; + } + + fn token_u32(token: isize, class: i32) -> Option { + let mut value = 0u32; + let mut returned = 0u32; + let ok = unsafe { + GetTokenInformation( + token, + class, + (&raw mut value).cast(), + u32::try_from(size_of::()).expect("u32 size fits Win32 length"), + &raw mut returned, + ) + }; + (ok != 0).then_some(value) + } + + fn has_appcontainer_sid(token: isize) -> bool { + let mut buf = [0u8; 256]; + let mut returned = 0u32; + let ok = unsafe { + GetTokenInformation( + token, + 31, // TokenAppContainerSid + buf.as_mut_ptr().cast(), + u32::try_from(buf.len()).expect("token buffer length fits u32"), + &raw mut returned, + ) + }; + ok != 0 && !unsafe { ptr::read_unaligned(buf.as_ptr().cast::<*const c_void>()) }.is_null() + } + + pub fn service_manager_create_access() -> (bool, u32) { + let manager = unsafe { + OpenSCManagerW( + ptr::null(), + ptr::null(), + 0x0002, // SC_MANAGER_CREATE_SERVICE + ) + }; + if manager == 0 { + return (false, unsafe { GetLastError() }); + } + unsafe { CloseServiceHandle(manager) }; + (true, 0) + } + + pub fn snapshot() -> serde_json::Value { + let mut token = 0isize; + let opened = unsafe { OpenProcessToken(GetCurrentProcess(), 0x0008, &raw mut token) }; + assert_ne!(opened, 0, "OpenProcessToken failed"); + + let (can_create_service, create_service_error) = service_manager_create_access(); + let snapshot = serde_json::json!({ + "is_appcontainer": token_u32(token, 29), // TokenIsAppContainer + "has_appcontainer_sid": has_appcontainer_sid(token), + "can_create_service": can_create_service, + "create_service_error": create_service_error, + }); + unsafe { CloseHandle(token) }; + snapshot + } +} + +const TOKEN_PROBE_MARKER: &str = "OPENSHELL_MXC_TOKEN_PROBE="; + // ── Path resolution ────────────────────────────────────────────────────────── /// Resolve the path to `wxc-exec.exe`. @@ -477,6 +568,17 @@ fn dryrun_accepts_split_policy_output() { // These skip on this box (processcontainer velocity keys not enabled; // isolation_session backend absent). They PASS where backends are live. +/// Entrypoint used by `pc_oneshot_token_is_appcontainer_without_admin_access`. +/// The parent test relaunches this integration-test binary inside MXC so the +/// probe observes the workload token rather than the host test runner's token. +#[test] +fn child_token_probe_entry() { + if std::env::var("OPENSHELL_MXC_CHILD_TOKEN_PROBE").as_deref() != Ok("1") { + return; + } + println!("{TOKEN_PROBE_MARKER}{}", token_probe::snapshot()); +} + /// Probe the processcontainer backend. /// /// Runs a trivial one-shot (`cmd /c exit 0`, user-owned temp grant). Returns @@ -801,6 +903,101 @@ fn pc_oneshot_in_policy_write_succeeds() { ); } +/// Verify the default `ProcessContainer` token and an administrator-gated +/// access attempt. `whoami /all` is not sufficient for this assertion: the +/// package SID is exposed through `TokenAppContainerSid`, and `AppContainer` +/// access is the intersection of the user/group and package/capability grants. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_token_is_appcontainer_without_admin_access() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + let (host_can_create_service, host_error) = token_probe::service_manager_create_access(); + if !host_can_create_service { + eprintln!( + "SKIP: host test runner lacks SC_MANAGER_CREATE_SERVICE (Win32 error {host_error}); sandboxed denial would not prove isolation" + ); + return; + } + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let (tempdir, temp_path) = temp_fixture(); + let test_exe = std::env::current_exe().expect("resolve integration-test executable"); + let test_exe_parent = test_exe + .parent() + .expect("integration-test executable has a parent") + .to_string_lossy() + .into_owned(); + let command_line = format!( + "\"{}\" --exact child_token_probe_entry --nocapture", + test_exe.display() + ); + let mut child_env = vec!["OPENSHELL_MXC_CHILD_TOKEN_PROBE=1".to_string()]; + child_env.extend( + ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"] + .into_iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| format!("{key}={value}")) + }), + ); + let config = serde_json::json!({ + "version": "0.8.0-alpha", + "containerId": "pc-token-identity", + "containment": "processcontainer", + "process": { + "commandLine": command_line, + "cwd": temp_path, + "env": child_env, + "timeout": 30_000, + }, + "filesystem": { + "readwritePaths": [temp_path], + "readonlyPaths": [test_exe_parent], + }, + "processContainer": { + "leastPrivilege": false, + }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, + }); + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec token probe spawn"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + out.status.success(), + "token probe should exit successfully\nstdout={stdout}\nstderr={stderr}" + ); + let snapshot: serde_json::Value = stdout + .lines() + .find_map(|line| line.trim().strip_prefix(TOKEN_PROBE_MARKER)) + .map_or_else( + || panic!("token probe marker missing\nstdout={stdout}\nstderr={stderr}"), + |value| serde_json::from_str(value).expect("parse token probe JSON"), + ); + + assert_eq!(snapshot["is_appcontainer"], 1); + assert_eq!(snapshot["has_appcontainer_sid"], true); + assert_eq!(snapshot["can_create_service"], false); + assert_eq!(snapshot["create_service_error"], 5); // ERROR_ACCESS_DENIED + drop(tempdir); +} + /// 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`. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 44de908678..a292cb030c 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -949,6 +949,17 @@ debug = false etw_audit = false ``` +The default `pc_least_privilege = false` still runs the workload with an +AppContainer token. Set it to `true` to request the stricter Less Privileged +AppContainer (LPAC) variant. Windows AppContainer tokens retain the launching +account's user SID and group SIDs. Seeing the same username or +`BUILTIN\\Administrators` in `whoami /all` therefore does not mean the sandbox +can exercise administrator access: Windows +requires both the user/group and AppContainer package/capability sides of an +access check to grant the requested operation. The MXC real-binary integration +suite verifies `TokenIsAppContainer`, `TokenAppContainerSid`, and denial of a +non-mutating administrator-gated Service Control Manager open. + Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. From b2d628079495310e1593bcb156648c31910da364 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:17:48 -0700 Subject: [PATCH 05/39] fix(windows): repair OpenClaw ProcessContainer startup (NVBug 6782898) (#3475) --- Cargo.toml | 2 +- .../examples/mxc-openclaw-gateway.toml | 3 + .../examples/mxc-openclaw-isolation.toml | 3 + .../examples/mxc-openclaw-localnet.toml | 3 + .../examples/run-openclaw-forward-test.ps1 | 99 +++-- .../src/control_channel.rs | 84 ++-- crates/openshell-driver-mxc/src/driver.rs | 283 ++++++++++--- .../tests/openclaw_appcontainer_compat.rs | 52 +++ .../tests/wxc_exec_real.rs | 74 ++++ crates/openshell-supervisor-relay/src/imp.rs | 376 +++++++++++------- .../tests/control_channel_contract.rs | 112 ++++-- 11 files changed, 781 insertions(+), 310 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e916b4c52a..8f8a2efcc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,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_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml index b85617a216..b49116fca7 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml @@ -15,6 +15,9 @@ # (the AppContainer here can only read paths granted by policy -- see the # script's "Stage artifacts into share_dir" step for why). +[openshell] +version = 2 + [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml index b81f5b5565..2bab7c5655 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml @@ -26,6 +26,9 @@ # openshell-supervisor-relay.exe / the caller's OpenClaw install into # the sandbox's policy-authorized working directory before creating it. +[openshell] +version = 2 + [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml index f0e4c578d9..6fbaced702 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml @@ -6,6 +6,9 @@ # allowLocalNetwork=true lets the AppContainer reach the host's loopback (the gateway # relay) without routing through the egress proxy, which breaks node.js DLL init. +[openshell] +version = 2 + [openshell.drivers.mxc] wxc_exec_path = "C:\\FromSenthil\\mxc-fixes-env-vars\\wxc-exec.exe" diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index 6935b68754..7c3a7c6e80 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -56,15 +56,10 @@ param( [Parameter(Mandatory = $true)] [string] $OpenClawInstallDir, # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw, not - # C:\work\openshell-openclaw). Node's CommonJS module resolver calls - # fs.realpathSync while resolving the entry script, which lstat()s every - # parent directory up the chain -- including ones OUTSIDE share_dir. The - # AppContainer only grants share_dir itself, so an intermediate parent like - # C:\work fails with EPERM (confirmed empirically: this exact test failed - # with "EPERM: operation not permitted, lstat 'C:\work'" until the share - # dir was moved to the drive root). The drive root itself (C:\) apparently - # doesn't need an explicit grant to lstat successfully, so a one-level path - # sidesteps the problem entirely. + # C:\work\openshell-openclaw). The staged Node invocation below uses + # --preserve-symlinks-main so Node does not realpath the main module before + # our capture script starts; keeping a one-level path also avoids exposing + # or depending on unrelated intermediate directories. [string] $ShareDir = "C:\openshell-openclaw", [int] $TargetPort = 18889, [int] $ForwardLocalPort = 28889, @@ -217,6 +212,7 @@ $fwdProc = $null $fwdLog = Join-Path $resultDir "forward.log" $fwdErrLog = Join-Path $resultDir "forward.err.log" $passed = $false +$failureMessage = "" $healthJson = $null $selfProbeOutcome = "not-recorded" $selfProbeResponseBytes = 0 @@ -426,6 +422,7 @@ try { mxc = @{ command = @( "$shareDirToml/node.exe", + "--preserve-symlinks-main", "$shareDirToml/openclaw-capture.mjs", "gateway", "run", "--dev", "--allow-unconfigured", "--auth", "token", "--bind", "loopback", "--port", "$TargetPort" @@ -456,43 +453,23 @@ try { "--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999", "--no-tty", "--", "exit" ) + # Windows PowerShell 5.1 wraps native stderr as ErrorRecord objects. Keep + # warnings in the captured diagnostic without letting them terminate the + # command before its real exit code and output are collected. + $createPrevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE } catch { $createOut = $_.Exception.Message; $createCode = 1 } + finally { $ErrorActionPreference = $createPrevEAP } $createBenign = Show-SandboxCreate $createOut $SandboxName if ($createCode -ne 0 -and -not $createBenign) { throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)" } - # 9. Wait for OpenClaw's gateway to report ready, by tailing the gateway's - # own log for the line it prints on successful startup (forwarded from - # the sandbox's stdout via "wxc-exec stdout:"). Generous timeout: Node - # startup + AppContainer/UAC elevation + plugin warmup can take a while - # on a cold run. - Step "Wait for OpenClaw gateway readiness" - $readyDeadline = (Get-Date).AddSeconds(90) - $openclawReady = $false - while ((Get-Date) -lt $readyDeadline) { - if (Test-Path $gwLog) { - # `.*` (not `\s+`) between "[gateway]" and "ready": OpenClaw wraps its - # log lines in ANSI color codes whenever it inherits enough of the host - # env to detect a color-capable terminal -- which happens with - # mxc-openclaw-localnet.toml (-UseLocalNetwork), since that config - # doesn't set pc_minimal_env and so inherits the full host env, unlike - # mxc-openclaw-gateway.toml's curated minimal set. A strict \s+ match - # missed this entirely and timed out waiting for a line that had - # already printed. Those codes render in this log as LITERAL backslash- - # escaped text (e.g. "...\x1b[36mready..."), not real ESC bytes -- so - # "m" from "36m" directly abuts "ready" with no word boundary, which is - # why a \bready\b tightening (tried once) also failed to match; a bare - # substring check is what actually works here. The resulting collision - # risk with "already" is theoretical -- no such line has been observed - # on this "[gateway]"-tagged forwarded-stdout path in practice. - if (Select-String -Path $gwLog -Pattern '\[gateway\].*ready' -Quiet -ErrorAction SilentlyContinue) { $openclawReady = $true; break } - } - Start-Sleep -Seconds 2 - } - if (-not $openclawReady) { throw "OpenClaw did not report ready within 90s (see gateway.log in the results bundle)" } - Ok "OpenClaw gateway ready" + # `sandbox create` does not return success until the MXC driver receives the + # relay's target_ready event. Trust that lifecycle result directly instead + # of racing a second, text-based poll against gateway.log. + Ok "OpenClaw gateway ready (sandbox target_ready received)" # 10. openshell forward service: opens a fresh, on-demand relay for this # one call and bridges TargetPort (inside the sandbox) to @@ -610,26 +587,55 @@ try { } } catch { - Bad $_.Exception.Message + $failureMessage = $_.Exception.Message + Bad $failureMessage } finally { # Stop the forward before the sandbox so its relay tears down cleanly. if ($fwdProc -and -not $fwdProc.HasExited) { try { Stop-Process -Id $fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} } + if ($fwdProc) { + try { + if (-not $fwdProc.WaitForExit(5000)) { + throw "forward process did not exit within 5s" + } + } catch { + Info "forward teardown: $($_.Exception.Message)" + if ($passed) { $passed = $false; $failureMessage = $_.Exception.Message } + } + } # Tear down the sandbox while the gateway is still up (delete needs it). if ($cli -and $SandboxName) { - try { & $cli sandbox delete $SandboxName 2>&1 | Out-Null } - catch { Info "sandbox teardown '$SandboxName': $($_.Exception.Message) (continuing)" } + $deleteCode = 1 + $deleteOut = @() + $deletePrevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { $deleteOut = & $cli sandbox delete $SandboxName 2>&1; $deleteCode = $LASTEXITCODE } + catch { $deleteOut = $_.Exception.Message } + finally { $ErrorActionPreference = $deletePrevEAP } + if ($deleteCode -ne 0) { + $cleanupFailure = "sandbox teardown '$SandboxName' failed (exit $deleteCode): $($deleteOut | Out-String)" + Info $cleanupFailure + if ($passed) { $passed = $false; $failureMessage = $cleanupFailure } + } } if ($KeepRunning -and $gw -and -not $gw.HasExited) { Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop with: Stop-Process -Id $($gw.Id) -Force" } elseif ($gw -and -not $gw.HasExited) { Step "Cleanup"; Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue - try { $gw.WaitForExit(5000) | Out-Null } catch {} - Info "stopped gateway pid $($gw.Id)" + try { + if (-not $gw.WaitForExit(5000)) { + throw "gateway process did not exit within 5s" + } + Info "stopped gateway pid $($gw.Id)" + } catch { + $cleanupFailure = "gateway teardown failed: $($_.Exception.Message)" + Info $cleanupFailure + if ($passed) { $passed = $false; $failureMessage = $cleanupFailure } + } } Step "Gateway log (tail)" @@ -663,12 +669,17 @@ finally { Step "RESULT" $verdict = if ($passed) { "PASS" } else { "FAIL" } + if (-not $passed -and [string]::IsNullOrWhiteSpace($failureMessage)) { + $failureMessage = "one or more qualification assertions failed" + } + $failureSummary = ($failureMessage -replace '\s+', ' ').Trim() $summary = @" OpenShell MXC OpenClaw + dynamic forward test ===================================================================== timestamp : $stamp machine : $env:COMPUTERNAME verdict : $verdict +failure : $failureSummary sandbox : $SandboxName backend : $Backend config : $tomlName diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs index 673b82177e..76df43456a 100644 --- a/crates/openshell-driver-mxc/src/control_channel.rs +++ b/crates/openshell-driver-mxc/src/control_channel.rs @@ -30,13 +30,13 @@ pub enum ControlChannelError { type PendingMap = Mutex>>; /// Slot for one of the spawner's one-time, unsolicited events -- startup- -/// ready (see `try_route_ready`) and target-ready (see -/// `try_route_target_ready`) each get their own instance of this type. +/// ready (see `try_route_ready`) and target status (see +/// `try_route_target_status`) each get their own instance of this type. /// Not part of `PendingMap`: neither has a correlation id or is a reply to /// anything the driver sent. The payload is `Ok(())` for a normal fire, or /// `Err(reason)` when the event fired but something about it was rejected -/// (currently only the "ready" event's protocol version check uses this; -/// `"target_ready"` always sends `Ok(())`). +/// `"target_ready"` sends `Ok(())`; `"target_failed"` sends its diagnostic +/// as `Err(reason)`. pub type ReadySlot = Mutex>>>; /// Wire protocol version this driver requires from @@ -50,7 +50,7 @@ pub type ReadySlot = Mutex>>>; /// "forward", or the `"target_ready"` event itself) -- an independently /// staged, stale relay binary then fails fast with a clear error instead of /// hanging or misbehaving against fields/events it doesn't understand. -const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 2; +const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 4; /// One control channel per sandboxed process. `request()` is safe to call /// concurrently — each call gets its own correlation id and awaits only its @@ -129,20 +129,25 @@ impl ControlChannel { .await } - /// Try to recognize `line` as the spawner's unsolicited target-ready - /// event (`{"event":"target_ready"}`) -- sent once the spawner has - /// actually spawned the target and confirmed its configured port is - /// accepting connections (see `wait_for_port_ready` in - /// `openshell-supervisor-relay`). Distinct from the `"launch"` - /// control-channel *response*, which only confirms the command/env - /// arrived, not that the target is running: driver.rs awaits this event - /// too before publishing the sandbox `Ready=True`, so a caller acting on - /// `Ready` can't race a target that hasn't bound its port yet. Returns - /// `true` if `line` was consumed this way. No version gate here -- the - /// startup "ready" handshake above already rejected an incompatible - /// peer long before this could fire. - pub async fn try_route_target_ready(target_ready: &ReadySlot, line: &str) -> bool { - Self::try_route_named_event(target_ready, line, "target_ready", |_| Ok(())).await + /// Route the spawner's one-time target status event. `target_ready` + /// confirms the configured port is accepting connections; + /// `target_failed` carries the target's actual exit/error diagnostic. + /// Both are distinct from the `launch` response, which only confirms the + /// command and environment arrived. No version gate is needed here: the + /// startup handshake already rejected an incompatible peer. + pub async fn try_route_target_status(target_status: &ReadySlot, line: &str) -> bool { + if Self::try_route_named_event(target_status, line, "target_ready", |_| Ok(())).await { + return true; + } + Self::try_route_named_event(target_status, line, "target_failed", |value| { + let error = value + .get("error") + .and_then(Value::as_str) + .filter(|error| !error.trim().is_empty()) + .unwrap_or("target failed without a diagnostic"); + Err(error.to_string()) + }) + .await } async fn try_route_named_event( @@ -279,7 +284,7 @@ mod tests { let (slot, rx) = armed_ready_slot(); let consumed = - ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":2}"#) + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":4}"#) .await; assert!(consumed); @@ -298,13 +303,13 @@ mod tests { consumed, "a recognized ready event is consumed even when rejected" ); - let err = rx.await.unwrap().expect_err("version 2 must be rejected"); + let err = rx.await.unwrap().expect_err("version 1 must be rejected"); assert!( - err.contains('2'), + err.contains('1'), "error should name the offending version: {err}" ); assert!( - err.contains('1'), + err.contains('4'), "error should name the required version: {err}" ); } @@ -334,7 +339,7 @@ mod tests { assert!(!ControlChannel::try_route_ready(&slot, "garbage").await); } - // ── try_route_target_ready ─────────────────────────────────────────── + // ── try_route_target_status ────────────────────────────────────────── #[tokio::test] async fn try_route_target_ready_fires_ok_with_no_version_gate() { @@ -342,9 +347,9 @@ mod tests { // No protocol_version field at all -- unlike "ready", "target_ready" // must not be gated on one (see the doc comment on - // try_route_target_ready). + // try_route_target_status). let consumed = - ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await; assert!(consumed); assert_eq!(rx.await.unwrap(), Ok(())); @@ -356,9 +361,9 @@ mod tests { // "ready" and "target_ready" must not be cross-routed into each // other's slot. - let consumed = ControlChannel::try_route_target_ready( + let consumed = ControlChannel::try_route_target_status( &slot, - r#"{"event":"ready","protocol_version":2}"#, + r#"{"event":"ready","protocol_version":4}"#, ) .await; @@ -369,13 +374,15 @@ mod tests { async fn try_route_named_event_is_a_safe_no_op_once_the_slot_is_already_empty() { let (slot, rx) = armed_ready_slot(); - assert!(ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await); + assert!( + ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await + ); // The slot's sender was taken (and used) on the first fire. A // repeat of the same event on the wire is still recognized as a // "target_ready" line (so the caller doesn't mistake it for plain // log text) but must not panic just because the slot is now empty. let consumed_again = - ControlChannel::try_route_target_ready(&slot, r#"{"event":"target_ready"}"#).await; + ControlChannel::try_route_target_status(&slot, r#"{"event":"target_ready"}"#).await; assert!( consumed_again, @@ -388,6 +395,23 @@ mod tests { ); } + #[tokio::test] + async fn try_route_target_failed_preserves_the_diagnostic() { + let (slot, rx) = armed_ready_slot(); + + let consumed = ControlChannel::try_route_target_status( + &slot, + r#"{"event":"target_failed","error":"exit 23; stderr: early crash"}"#, + ) + .await; + + assert!(consumed); + assert_eq!( + rx.await.unwrap(), + Err("exit 23; stderr: early crash".to_string()) + ); + } + // ── fail_all_pending ────────────────────────────────────────────────── #[tokio::test] diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 513a92d858..01a679eb4b 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -21,6 +21,7 @@ use openshell_core::proto_struct::struct_to_json_value; use openshell_core::provider_credentials::ProviderCredentialState; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::mem::size_of; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -30,12 +31,119 @@ use tokio::process::Child; use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; use tracing::{info, warn}; +use windows::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; +use windows::Win32::NetworkManagement::IpHelper::{ + GetExtendedTcpTable, MIB_TCP_STATE_LISTEN, MIB_TCPROW_LH, MIB_TCPTABLE, + TCP_TABLE_BASIC_LISTENER, +}; +use windows::Win32::Networking::WinSock::AF_INET; 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 TARGET_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(5); +const TARGET_READY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300); + +#[allow(unsafe_code)] +fn tcp_listener_is_present(port: u16) -> std::io::Result { + let mut byte_count = 0_u32; + // SAFETY: The null-buffer call only asks Windows for the required size; + // `byte_count` points to initialized writable storage. + let status = unsafe { + GetExtendedTcpTable( + None, + &raw mut byte_count, + false, + u32::from(AF_INET.0), + TCP_TABLE_BASIC_LISTENER, + 0, + ) + }; + if status != ERROR_INSUFFICIENT_BUFFER.0 && status != 0 { + return Err(std::io::Error::from_raw_os_error(status.cast_signed())); + } + if (byte_count as usize) < size_of::() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Windows returned an invalid TCP listener table size", + )); + } + + let mut buffer; + loop { + let word_count = (byte_count as usize).div_ceil(size_of::()); + buffer = vec![0_u32; word_count]; + // SAFETY: `buffer` has the returned table's alignment and at least + // the requested byte count. Windows updates `byte_count` if the table + // grows concurrently. + let status = unsafe { + GetExtendedTcpTable( + Some(buffer.as_mut_ptr().cast()), + &raw mut byte_count, + false, + u32::from(AF_INET.0), + TCP_TABLE_BASIC_LISTENER, + 0, + ) + }; + if status == ERROR_INSUFFICIENT_BUFFER.0 { + continue; + } + if status != 0 { + return Err(std::io::Error::from_raw_os_error(status.cast_signed())); + } + break; + } + + let table = buffer.as_ptr().cast::(); + // SAFETY: A successful call writes a `MIB_TCPTABLE` header followed by + // `dwNumEntries` rows into the caller-provided buffer. + let entry_count = unsafe { (*table).dwNumEntries as usize }; + let row_offset = std::mem::offset_of!(MIB_TCPTABLE, table); + let available_rows = (byte_count as usize) + .saturating_sub(row_offset) + .checked_div(size_of::()) + .unwrap_or_default(); + if entry_count > available_rows { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Windows returned a truncated TCP listener table", + )); + } + // SAFETY: The bounds check proves every row lies within `buffer`. + let rows = unsafe { std::slice::from_raw_parts((*table).table.as_ptr(), entry_count) }; + Ok(rows.iter().any(|row| { + let port_bytes = row.dwLocalPort.to_ne_bytes(); + let listener_port = u16::from_be_bytes([port_bytes[0], port_bytes[1]]); + // SAFETY: `dwState` and `State` are views of the same SDK union field, + // and Windows initialized every returned row. + let state = unsafe { row.Anonymous.dwState }; + state == MIB_TCP_STATE_LISTEN.0.cast_unsigned() && listener_port == port + })) +} + +async fn wait_for_target_listener(port: u16) -> std::io::Result<()> { + let start = tokio::time::Instant::now(); + let deadline = start + TARGET_READY_TIMEOUT; + info!(port, timeout = ?TARGET_READY_TIMEOUT, "waiting for target listener in host TCP table"); + loop { + if tcp_listener_is_present(port)? { + info!(port, elapsed = ?start.elapsed(), "target listener observed in host TCP table"); + return Ok(()); + } + let now = tokio::time::Instant::now(); + if now >= deadline { + break; + } + tokio::time::sleep_until(std::cmp::min(now + TARGET_READY_POLL_INTERVAL, deadline)).await; + } + Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("timed out after {TARGET_READY_TIMEOUT:?} waiting for port {port}"), + )) +} // ── Config ──────────────────────────────────────────────────────────────────── @@ -1801,14 +1909,11 @@ async fn run_lifecycle( } else { (None, None) }; - // Target-ready signal from the spawner (see control_channel.rs's - // try_route_target_ready) -- fired once the target is actually running - // and its configured port is accepting connections, distinct from the - // "launch" response below (which only confirms the command/env - // arrived). Awaited after "launch" succeeds and before publishing - // Ready=True, so Ready can't be reported while the target is still - // unreachable. Always Ok(()) when it fires (no version gate on this - // event -- see try_route_target_ready). + // Target-status signal from the spawner (see control_channel.rs's + // try_route_target_status): Ok once the host observes the target listener + // and the relay confirms the target has not exited, or Err with the + // target's real exit/stderr diagnostic. Distinct from the "launch" + // response below, which only confirms the command/env arrived. let (target_ready_slot, target_ready_rx) = if spawner_wrapping_active { let (tx, rx) = oneshot::channel::>(); (Some(Arc::new(Mutex::new(Some(tx)))), Some(rx)) @@ -1830,7 +1935,9 @@ async fn run_lifecycle( None => false, }; let routed_target_ready = match &target_ready_slot { - Some(slot) => ControlChannel::try_route_target_ready(slot, &line).await, + Some(slot) => { + ControlChannel::try_route_target_status(slot, &line).await + } None => false, }; let routed = routed_ready @@ -1861,7 +1968,7 @@ async fn run_lifecycle( }); } // Drop this scope's Arc clones now that the stdout task holds its own: - // if the spawner exits before ever sending "ready"/"target_ready", the + // if the spawner exits before ever sending "ready"/target status, the // stdout task's clone is the only thing keeping the // Mutex> alive, so its loop ending (EOF) drops the last // reference -- which drops the still-`Some` Sender and makes @@ -1888,14 +1995,14 @@ async fn run_lifecycle( // Publish a cancellable handle (exec_child, and for ProcessContainer // shutdown_tx/terminated_rx too) and release the startup gate now, // rather than holding it until the target-readiness wait below (up to - // ~430s worst case: 120s ready + 310s target_ready) completes or times + // ~430s worst case: 120s relay-ready + 300s listener + handshakes) completes or times // out. stop_sandbox/delete_sandbox block on lifecycle_gate before doing // anything else, so holding it this long meant a stop/delete arriving // while a target is slow to (or never does) come up had no way to // interrupt that wait -- it just queued up behind it. See also imp.rs's - // matching fix: openshell-supervisor-relay now races its own - // port-readiness wait against a "shutdown" request instead of only - // observing shutdown once that wait finishes. + // matching fix: openshell-supervisor-relay now races the host-readiness + // confirmation against a "shutdown" request instead of only observing + // shutdown once startup finishes. let shutdown_rx = { let mut reg = registry.lock().await; let Some(entry) = reg.get_mut(&sandbox_id) else { @@ -2021,54 +2128,100 @@ async fn run_lifecycle( let launch_err = if let Some(e) = ready_err { Some(e) } else { - let launch_data = serde_json::json!({ - "command": sandbox_config.command, - "env": env, - }); - match channel - .request("launch", launch_data, std::time::Duration::from_mins(2)) - .await - { - Ok(resp) if resp.get("ok").and_then(serde_json::Value::as_bool) == Some(true) => { - info!(sandbox = %sandbox_name, "control-channel launch acknowledged"); - // The "launch" response above only confirms the - // command/env reached the spawner -- it still needs - // to spawn the target and confirm its configured - // port is accepting connections - // (openshell-supervisor-relay's own - // wait_for_port_ready, up to ~300s worst case across - // its own retries). Await that distinct - // "target_ready" event before treating launch as - // successful, so a caller acting on Ready=True below - // can never race a target that hasn't bound its port - // yet. - let target_ready_timeout = std::time::Duration::from_secs(310); - match tokio::time::timeout(target_ready_timeout, target_ready_rx).await { - Ok(Ok(Ok(()))) => { - info!(sandbox = %sandbox_name, "control-channel target ready"); - None - } - // No version gate on this event, so this arm - // never actually fires today -- see - // try_route_target_ready -- but match it - // explicitly rather than unreachable!(), in case - // that ever changes. - Ok(Ok(Err(target_err))) => Some(target_err), - Ok(Err(_)) => { - Some("spawner exited before its target became ready".to_string()) + let target_port = config.pc_relay_target_port; + match tcp_listener_is_present(target_port) { + Ok(true) => Some(format!( + "target port {target_port} is already listening before launch" + )), + Err(error) => Some(format!( + "failed to inspect target port {target_port} before launch: {error}" + )), + Ok(false) => { + let launch_data = serde_json::json!({ + "command": sandbox_config.command, + "env": env, + }); + match channel + .request("launch", launch_data, std::time::Duration::from_mins(2)) + .await + { + Ok(resp) + if resp.get("ok").and_then(serde_json::Value::as_bool) + == Some(true) => + { + info!(sandbox = %sandbox_name, "control-channel launch acknowledged"); + // The AppContainer cannot safely probe its own + // pre-listener loopback port or inspect the TCP + // table. Observe the listener from the host while + // racing the relay's early-exit diagnostic. + let mut target_ready_rx = target_ready_rx; + let listener_error = tokio::select! { + result = wait_for_target_listener(target_port) => { + result.err().map(|error| error.to_string()) + } + status = &mut target_ready_rx => { + Some(match status { + Ok(Err(target_err)) => target_err, + Ok(Ok(())) => "spawner reported target ready before host confirmation".to_string(), + Err(_) => "spawner exited before its target became ready".to_string(), + }) + } + }; + if let Some(error) = listener_error { + Some(error) + } else { + let confirm_timeout = std::time::Duration::from_secs(10); + match channel + .request( + "target_ready", + serde_json::Value::Null, + confirm_timeout, + ) + .await + { + Ok(resp) + if resp.get("ok").and_then(serde_json::Value::as_bool) + == Some(true) => + { + match tokio::time::timeout( + confirm_timeout, + &mut target_ready_rx, + ) + .await + { + Ok(Ok(Ok(()))) => { + info!(sandbox = %sandbox_name, "control-channel target ready"); + None + } + Ok(Ok(Err(target_err))) => Some(target_err), + Ok(Err(_)) => Some( + "spawner exited before confirming target readiness" + .to_string(), + ), + Err(_) => Some(format!( + "timed out after {confirm_timeout:?} waiting for target readiness confirmation" + )), + } + } + Ok(resp) => Some( + resp.get("error") + .and_then(|value| value.as_str()) + .unwrap_or("target readiness confirmation rejected") + .to_string(), + ), + Err(error) => Some(error.to_string()), + } + } } - Err(_) => Some(format!( - "timed out after {target_ready_timeout:?} waiting for target to become ready" - )), + Ok(resp) => Some( + resp.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("launch rejected") + .to_string(), + ), + Err(e) => Some(e.to_string()), } } - Ok(resp) => Some( - resp.get("error") - .and_then(|v| v.as_str()) - .unwrap_or("launch rejected") - .to_string(), - ), - Err(e) => Some(e.to_string()), } }; if let Some(err) = launch_err { @@ -2399,6 +2552,18 @@ mod lifecycle_tests { }; use std::time::Duration; + #[test] + fn target_ready_budget_remains_five_minutes() { + assert_eq!(TARGET_READY_TIMEOUT, Duration::from_mins(5)); + } + + #[tokio::test] + async fn host_tcp_table_observes_loopback_listener() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(tcp_listener_is_present(port).unwrap()); + } + fn driver_sandbox(id: &str) -> DriverSandbox { let shell = std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); diff --git a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs index 3a3a16af59..609de6ee05 100644 --- a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs +++ b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs @@ -3,6 +3,9 @@ const CAPTURE: &str = include_str!("../examples/openclaw-capture.mjs"); const RUNNER: &str = include_str!("../examples/run-openclaw-forward-test.ps1"); +const PROCESS_CONTAINER_CONFIG: &str = include_str!("../examples/mxc-openclaw-gateway.toml"); +const ISOLATION_CONFIG: &str = include_str!("../examples/mxc-openclaw-isolation.toml"); +const LOCAL_NETWORK_CONFIG: &str = include_str!("../examples/mxc-openclaw-localnet.toml"); #[cfg(windows)] #[test] @@ -44,6 +47,30 @@ fn capture_preloads_appcontainer_safe_realpath_before_openclaw() { assert!(CAPTURE.contains("syncBuiltinESMExports()")); } +#[test] +fn runner_preserves_the_node_main_symlink_inside_processcontainer() { + let node = RUNNER + .find("$shareDirToml/node.exe") + .expect("runner must launch the staged Node.js binary"); + let preserve_main = RUNNER[node..] + .find("--preserve-symlinks-main") + .map(|offset| node + offset) + .expect("runner must prevent Node's pre-entrypoint realpath of the drive root"); + let capture = RUNNER[node..] + .find("$shareDirToml/openclaw-capture.mjs") + .map(|offset| node + offset) + .expect("runner must launch the OpenClaw capture entry point"); + + assert!( + preserve_main < capture, + "--preserve-symlinks-main must be a Node option before the main module" + ); + assert!( + !RUNNER.contains("Grant-AppContainerWritableDirectory $shareDirNorm"), + "the runtime workaround must not widen the share grant to the drive root" + ); +} + #[test] fn runner_limits_package_group_dacl_grants_to_writable_data_directories() { assert!(RUNNER.contains("*S-1-15-2-1:(OI)(CI)(M)")); @@ -56,3 +83,28 @@ fn runner_limits_package_group_dacl_grants_to_writable_data_directories() { ); assert!(!RUNNER.contains("Grant-AppContainerWritableDirectory $shareDirNorm")); } + +#[test] +fn runner_uses_lifecycle_readiness_and_preserves_failure_diagnostics() { + assert!(RUNNER.contains("sandbox target_ready received")); + assert!( + !RUNNER.contains("Select-String -Path $gwLog -Pattern '\\[gateway\\].*ready'"), + "target readiness must come from the lifecycle event, not log polling" + ); + assert!(RUNNER.contains("$failureMessage = $_.Exception.Message")); + assert!(RUNNER.contains("failure : $failureSummary")); + assert!(RUNNER.contains("$fwdProc.WaitForExit(5000)")); + assert!(RUNNER.contains("$deleteCode = $LASTEXITCODE")); + assert!(RUNNER.contains("$gw.WaitForExit(5000)")); +} + +#[test] +fn openclaw_gateway_configs_declare_the_current_schema() { + for config in [ + PROCESS_CONTAINER_CONFIG, + ISOLATION_CONFIG, + LOCAL_NETWORK_CONFIG, + ] { + assert!(config.contains("[openshell]\nversion = 2")); + } +} diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 97359d4edc..1e6e041932 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -1227,6 +1227,80 @@ fn pc_oneshot_out_of_policy_write_denied() { ); } +/// Reading an unrelated root-level path remains denied when only the workload +/// fixture is granted. This guards the `OpenClaw` Node.js workaround against +/// accidentally granting broad access beneath `C:\`. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_unrelated_root_path_read_denied() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + let granted_dir = tempfile::tempdir().expect("granted tempdir"); + let denied_root_dir = tempfile::Builder::new() + .prefix("openshell-pc-denied-") + .tempdir_in(r"C:\") + .expect("root-level denied tempdir"); + let denied_file = denied_root_dir.path().join("sentinel.txt"); + std::fs::write(&denied_file, "root-level secret").expect("write denied sentinel"); + let denied_file_str = denied_file.to_string_lossy().into_owned(); + let granted_str = granted_dir.path().to_string_lossy().into_owned(); + let diagnostic = granted_dir.path().join("root-read.txt"); + let diagnostic_str = diagnostic.to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-root-read-denied", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /d /c type \"{denied_file_str}\" 1>\"{diagnostic_str}\" 2>&1"), + "cwd": granted_str, + "timeout": 30_000, + }, + "filesystem": { + "readwritePaths": [granted_str], + }, + "processContainer": { + "leastPrivilege": false, + }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, + }); + + let json = serde_json::to_string(&config).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let out = Command::new(&wxc) + .arg("--config-base64") + .arg(&b64) + .output() + .expect("wxc-exec spawn"); + let code = out.status.code().unwrap_or(-1); + let root_read = std::fs::read_to_string(&diagnostic) + .expect("sandboxed cmd must run and write its drive-root diagnostic"); + + assert_ne!( + code, 0, + "unrelated root-level read must remain denied; diagnostic={root_read}" + ); + assert!( + root_read.to_ascii_lowercase().contains("access is denied"), + "failure must specifically be the root-level access denial; diagnostic={root_read}" + ); + assert!( + !root_read.contains("root-level secret"), + "root-level file contents must not be readable; diagnostic={root_read}" + ); +} + // ── Isolation session enforcement tests ────────────────────────────────────── /// Full `isolation_session` round trip: provision → start → exec → stop → diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs index 8db89f176e..568696b96e 100644 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -36,10 +36,9 @@ //! ``` //! //! Usage: `openshell-supervisor-relay.exe ` -- `` -//! is the TCP port the launched command is expected to bind (an early -//! liveness check, `wait_for_port_ready`: if the target never binds it -//! within the bounded five-minute cold-start budget, this process exits with -//! an error instead of sitting around with a target that will never work). +//! is the TCP port the launched command is expected to bind. The host MXC +//! driver observes that listener and confirms it over the control channel; +//! the relay races that confirmation against target exit and shutdown. //! This binary uses no `share_dir` files //! at all -- command/env and shutdown both travel over the control channel. //! @@ -63,7 +62,9 @@ //! Request: `{"id": , "op": "", "data": }` //! Response: `{"id": , "ok": true, "data": }` //! or `{"id": , "ok": false, "error": ""}` -//! Event (unsolicited, no id): `{"event": ""}` +//! Event (unsolicited, no id): `{"event": ""}` (startup also emits +//! either `target_ready` or `target_failed`; the latter includes an +//! `error` string with the target's exit and bounded stderr detail) //! //! Startup handshake: before spawning anything, this process emits //! `{"event":"ready","protocol_version":N}` on stdout (`N` = `PROTOCOL_VERSION` @@ -82,7 +83,8 @@ //! //! Ops: `launch` (see above), `shutdown` (no data; acked, then wakes //! `run_lifecycle` to kill the target and exit -- see Shutdown above), -//! `ping`, `echo`, and `forward` -- `forward` opens a new, independent relay +//! `target_ready` (host listener confirmation), `ping`, `echo`, and `forward` +//! -- `forward` opens a new, independent relay //! bridge for the target port and relay address given in the request (see //! `handle_control_request`'s doc comment for the full shape). //! @@ -93,8 +95,8 @@ use base64::Engine; use futures::{SinkExt, StreamExt}; -use std::collections::HashMap; -use std::sync::Arc; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message; @@ -108,7 +110,22 @@ use tokio_tungstenite::tungstenite::Message; /// out-of-sync peer can't safely ignore, so an independently staged, stale /// binary on either side fails fast with a clear version-mismatch error /// instead of hanging or misbehaving against a field/event it predates. -const PROTOCOL_VERSION: u64 = 2; +const PROTOCOL_VERSION: u64 = 4; + +const TARGET_STDERR_TAIL_LINES: usize = 20; +const TARGET_STDERR_LINE_CHARS: usize = 1024; + +struct SpawnedTarget { + child: tokio::process::Child, + stderr_tail: Arc>>, + stderr_forwarder: Option>, +} + +enum StartupOutcome { + Ready, + Exited(std::io::Result), + Shutdown, +} struct ForwardSession { reader: tokio::sync::Mutex, @@ -131,19 +148,24 @@ pub async fn run() -> anyhow::Result<()> { // files are used by this process at all. let (launch_tx, launch_rx) = oneshot::channel::<(Vec, Vec)>(); let launch_slot = Arc::new(tokio::sync::Mutex::new(Some(launch_tx))); + let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); + let target_ready_slot = Arc::new(tokio::sync::Mutex::new(Some(target_ready_tx))); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let shutdown_slot = Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); let forward_sessions = Arc::new(ForwardSessions::new(HashMap::new())); - // Lets main() ask run_control_channel's task to announce "target_ready" - // on stdout once the target is actually up (see below) -- routed - // through that task rather than a second independent stdout handle - // here, since concurrent writers to tokio's stdout can interleave. - let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); + // Lets main() ask run_control_channel's task to announce either + // "target_ready" or "target_failed" on stdout. The acknowledgement makes + // failure delivery deterministic: do not let this process exit until the + // real target diagnostic has been flushed into the pipe to the driver. + let (target_status_tx, target_status_rx) = oneshot::channel::>(); + let (target_status_ack_tx, target_status_ack_rx) = oneshot::channel::<()>(); tokio::spawn(run_control_channel( launch_slot, + target_ready_slot, shutdown_slot, forward_sessions, - target_ready_rx, + target_status_rx, + target_status_ack_tx, )); eprintln!("[openshell-supervisor-relay] waiting for launch request from driver..."); @@ -151,29 +173,56 @@ pub async fn run() -> anyhow::Result<()> { .await .map_err(|_| anyhow::anyhow!("control channel closed before a launch request arrived"))?; - let mut child = spawn_target(command, env)?; + let mut target = match spawn_target(command, env) { + Ok(target) => target, + Err(error) => { + let message = format!("target process failed to start: {error:#}"); + announce_target_status(target_status_tx, target_status_ack_rx, Err(message.clone())) + .await; + eprintln!("[openshell-supervisor-relay] {message}"); + std::process::exit(1); + } + }; - eprintln!("[openshell-supervisor-relay] waiting for target on 127.0.0.1:{port} ..."); - // Race the (up to ~300s worst case) port-readiness wait against a - // "shutdown" control-channel request, rather than only observing - // shutdown once run_lifecycle's select starts below. Without this, a - // shutdown arriving while the target is still coming up (or never - // binds) gets acknowledged immediately by run_control_channel -- which - // just fires this oneshot, nothing more -- but nothing actually acts on - // it until wait_for_port_ready returns on its own, leaving this process - // (and the target it spawned) alive for up to the full port-readiness - // budget after a caller was told shutdown succeeded. + eprintln!( + "[openshell-supervisor-relay] waiting for host-confirmed target readiness on port {port} ..." + ); + // The AppContainer cannot reliably connect to a not-yet-listening + // loopback port or inspect the Windows TCP table. The host driver can + // observe that table, so it sends `target_ready` after the listener + // appears. Race that confirmation against target exit and shutdown so a + // failed target still reports its real status/stderr immediately. let mut shutdown_rx = shutdown_rx; - tokio::select! { - result = wait_for_port_ready(&mut child, port, PORT_READY_PER_TRY_TIMEOUT) => { - result?; + let startup = tokio::select! { + biased; + // If the target exits at the same instant the host confirms its + // listener, preserve the real exit/stderr diagnostic instead of + // briefly publishing a stale Ready state. + status = target.child.wait() => StartupOutcome::Exited(status), + _ = &mut shutdown_rx => StartupOutcome::Shutdown, + result = target_ready_rx => match result { + Ok(()) => StartupOutcome::Ready, + Err(_) => StartupOutcome::Exited(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "control channel closed before target readiness was confirmed", + ))), + }, + }; + match startup { + StartupOutcome::Ready => {} + StartupOutcome::Exited(status) => { + let message = target_failure_message(&mut target, port, status).await; + announce_target_status(target_status_tx, target_status_ack_rx, Err(message.clone())) + .await; + eprintln!("[openshell-supervisor-relay] {message}"); + std::process::exit(1); } - _ = &mut shutdown_rx => { + StartupOutcome::Shutdown => { eprintln!( "[openshell-supervisor-relay] shutdown request -- stopping before target became ready" ); - let _ = child.kill().await; - let _ = child.wait().await; + let _ = target.child.kill().await; + let _ = target.child.wait().await; eprintln!("[openshell-supervisor-relay] done"); // Not `return Ok(())`: run_control_channel loops on // stdin.next_line() for this process's entire lifetime and @@ -187,18 +236,18 @@ pub async fn run() -> anyhow::Result<()> { std::process::exit(0); } } - eprintln!("[openshell-supervisor-relay] target is up on port {port}"); + eprintln!("[openshell-supervisor-relay] host confirmed target listener on port {port}"); // Unsolicited event, distinct from the "launch" control-channel // response (which only confirmed the command/env arrived, not that the // target is actually reachable) -- driver.rs awaits this before // publishing the sandbox Ready=True. A send failure just means the // control-channel task already exited; nothing to do about that here. - let _ = target_ready_tx.send(()); + announce_target_status(target_status_tx, target_status_ack_rx, Ok(())).await; // No bridge at startup -- relay bridging is entirely on-demand via the // control channel's "forward" op (see module docs). Just run the target // process's lifecycle from here. - run_lifecycle(child, shutdown_rx).await; + run_lifecycle(target, shutdown_rx).await; Ok(()) } @@ -244,7 +293,7 @@ fn byte_preview(data: &[u8]) -> String { /// because our stdout is reserved exclusively for the control-channel /// protocol with the driver. The child's stdin is closed; it isn't part of /// this channel. -fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { +fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { if command.is_empty() { anyhow::bail!("launch command must not be empty"); } @@ -284,26 +333,93 @@ fn spawn_target(command: Vec, env: Vec) -> anyhow::Result>>>, +) { use tokio::io::{AsyncBufReadExt, BufReader}; let mut lines = BufReader::new(reader).lines(); while let Ok(Some(line)) = lines.next_line().await { + if let Some(tail) = &capture_tail { + let mut chars = line.chars(); + let mut captured: String = chars.by_ref().take(TARGET_STDERR_LINE_CHARS).collect(); + if chars.next().is_some() { + captured.push_str("..."); + } + if let Ok(mut tail) = tail.lock() { + if tail.len() == TARGET_STDERR_TAIL_LINES { + tail.pop_front(); + } + tail.push_back(captured); + } + } eprintln!("[{label}] {line}"); } } +async fn announce_target_status( + sender: oneshot::Sender>, + announced: oneshot::Receiver<()>, + status: Result<(), String>, +) { + if sender.send(status).is_err() { + return; + } + if tokio::time::timeout(Duration::from_secs(5), announced) + .await + .is_err() + { + eprintln!("[openshell-supervisor-relay] control channel did not flush target status"); + } +} + +async fn target_failure_message( + target: &mut SpawnedTarget, + port: u16, + status: std::io::Result, +) -> String { + if let Some(forwarder) = target.stderr_forwarder.take() { + let _ = tokio::time::timeout(Duration::from_secs(2), forwarder).await; + } + let status = status.map_or_else( + |error| format!("status unavailable: {error}"), + |status| status.to_string(), + ); + let stderr = target + .stderr_tail + .lock() + .map(|tail| tail.iter().cloned().collect::>().join(" | ")) + .unwrap_or_default(); + let stderr = if stderr.is_empty() { + String::new() + } else { + format!("; stderr: {stderr}") + }; + format!("target process exited before port {port} came up: {status}{stderr}") +} + // ── Control channel ─────────────────────────────────────────────────────────── // // See module docs for the protocol and why this is safe to run with no @@ -314,6 +430,9 @@ async fn forward_tagged_lines(reader: impl tokio::io::AsyncRead + Unpin, label: /// env)` to `main()`. `None` after the first successful launch (or if /// `main()` already gave up on it) -- a second `"launch"` is rejected. type LaunchSlot = tokio::sync::Mutex, Vec)>>>; +/// Holds the one-shot sender the driver's host-side listener observation +/// fires. Startup does not become ready until this confirmation arrives. +type TargetReadySlot = tokio::sync::Mutex>>; /// Holds the one-shot sender the `"shutdown"` op fires, waking /// `run_lifecycle`'s select so it can kill the target and exit. `None` /// after the first shutdown request -- a second one is a no-op ack. @@ -321,9 +440,11 @@ type ShutdownSlot = tokio::sync::Mutex>>; async fn run_control_channel( launch: Arc, + target_ready: Arc, shutdown: Arc, forward_sessions: Arc, - target_ready_rx: oneshot::Receiver<()>, + target_status_rx: oneshot::Receiver>, + target_status_ack: oneshot::Sender<()>, ) { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -344,11 +465,11 @@ async fn run_control_channel( } eprintln!("[openshell-supervisor-relay] control channel ready (stdin/stdout)"); - // `None` once fired (or once main()'s sender is dropped without firing, - // e.g. wait_for_port_ready failed) -- the `if` guard below then disables - // that select arm instead of it firing repeatedly on every subsequent - // poll of an already-resolved oneshot. - let mut target_ready_rx = Some(target_ready_rx); + // `None` once fired (or once main()'s sender is dropped without firing) -- + // the `if` guard below then disables that select arm instead of it firing + // repeatedly on every subsequent poll of an already-resolved oneshot. + let mut target_status_rx = Some(target_status_rx); + let mut target_status_ack = Some(target_status_ack); loop { tokio::select! { @@ -381,6 +502,7 @@ async fn run_control_channel( let response = handle_control_request( trimmed, &launch, + &target_ready, &shutdown, &forward_sessions, ) @@ -395,19 +517,25 @@ async fn run_control_channel( // See module docs' startup handshake -- distinct from the // "launch" response, which only confirms the command/env // arrived. Unsolicited, like "ready" above. - result = async { target_ready_rx.as_mut().unwrap().await }, if target_ready_rx.is_some() => { - target_ready_rx = None; - if result.is_ok() { - let event = serde_json::json!({"event": "target_ready"}).to_string() + "\n"; - if stdout.write_all(event.as_bytes()).await.is_err() || stdout.flush().await.is_err() { - eprintln!("[openshell-supervisor-relay] control channel: failed to announce target_ready"); + result = async { target_status_rx.as_mut().unwrap().await }, if target_status_rx.is_some() => { + target_status_rx = None; + let event = match result { + Ok(Ok(())) => Some(serde_json::json!({"event": "target_ready"})), + Ok(Err(error)) => Some(serde_json::json!({"event": "target_failed", "error": error})), + Err(_) => None, + }; + if let Some(event) = event { + let out = event.to_string() + "\n"; + if stdout.write_all(out.as_bytes()).await.is_err() || stdout.flush().await.is_err() { + eprintln!("[openshell-supervisor-relay] control channel: failed to announce target status"); + } + if let Some(ack) = target_status_ack.take() { + let _ = ack.send(()); } } - // A dropped sender (main() bailed before the target ever - // came up, e.g. wait_for_port_ready's own error) means - // there's nothing to announce -- driver.rs's timeout on the - // corresponding event will surface that as a launch failure - // on its own. + // A dropped sender means main() exited before announcing + // target status. There is nothing to write in that case; + // driver.rs will observe control-channel EOF. } } } @@ -446,6 +574,7 @@ fn describe_control_request(line: &str) -> String { async fn handle_control_request( line: &str, launch: &LaunchSlot, + target_ready: &TargetReadySlot, shutdown: &ShutdownSlot, forward_sessions: &ForwardSessions, ) -> serde_json::Value { @@ -461,6 +590,33 @@ async fn handle_control_request( let op = req.get("op").and_then(|v| v.as_str()).unwrap_or(""); match op { + // The host MXC driver can observe the AppContainer listener without + // the AppContainer's own network/table restrictions. It sends this + // only after the configured target port appears in the host TCP + // table; main races this signal against target exit and shutdown. + "target_ready" => { + let slot = target_ready.lock().await.take(); + slot.map_or_else( + || { + serde_json::json!({ + "id": id, + "ok": false, + "error": "target readiness already confirmed" + }) + }, + |tx| { + if tx.send(()).is_ok() { + serde_json::json!({"id": id, "ok": true}) + } else { + serde_json::json!({ + "id": id, + "ok": false, + "error": "target readiness receiver is unavailable" + }) + } + }, + ) + } // Driver sends this on sandbox delete instead of writing a // openshell-shutdown.signal file -- wakes run_lifecycle's select so // it can kill the target and exit. Acked even on a repeat (the @@ -714,74 +870,6 @@ where Err(last_error) } -/// Number of full-budget tries `wait_for_port_ready` makes -- each try gets -/// its own complete `per_try_timeout` window, not a slice of it. Worst case -/// total wait is `max_tries * per_try_timeout` (5 * 60s = 300s today). Keep -/// this aligned with the MXC driver's 310-second target-ready timeout so a -/// cold process remains actively probed for the full advertised budget. -const PORT_READY_MAX_TRIES: u32 = 5; -const PORT_READY_PER_TRY_TIMEOUT: Duration = Duration::from_mins(1); - -/// Poll for the target port accepting TCP connections, bailing out early -/// (rather than waiting out all tries) if the child process exits first — a -/// dead child will never open the port, so there's no reason to wait. Makes -/// up to `PORT_READY_MAX_TRIES` tries, each given the full `per_try_timeout` -/// budget, logging the start of every try so a long cold-start wait (Node.js -/// first-run JIT/module resolution, first-touch AV scan of freshly staged -/// binaries, etc.) is visible rather than silent until success or final -/// timeout. -async fn wait_for_port_ready( - child: &mut tokio::process::Child, - port: u16, - per_try_timeout: Duration, -) -> anyhow::Result<()> { - // Bound each individual connect attempt: on at least one observed - // wxc-exec build, a connect() against a not-yet-listening loopback port - // inside the AppContainer never resolved at all (no fast ECONNREFUSED, - // no error) instead of failing quickly like an ordinary closed-port - // connect. Without this, a single early attempt can hang forever and - // this function -- and the whole readiness wait -- never returns even - // after the target's port genuinely opens, since nothing ever retries. - const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); - - let overall_start = tokio::time::Instant::now(); - for try_num in 1..=PORT_READY_MAX_TRIES { - eprintln!( - "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} (up to {per_try_timeout:?}) for port {port}" - ); - let try_deadline = tokio::time::Instant::now() + per_try_timeout; - loop { - let attempt = tokio::time::timeout( - ATTEMPT_TIMEOUT, - tokio::net::TcpStream::connect(("127.0.0.1", port)), - ) - .await; - if let Ok(Ok(_)) = attempt { - eprintln!( - "[openshell-supervisor-relay] port {port} ready after {:?} (try {try_num}/{PORT_READY_MAX_TRIES})", - overall_start.elapsed() - ); - return Ok(()); - } - if let Ok(Some(status)) = child.try_wait() { - anyhow::bail!("target process exited before port {port} came up: {status}"); - } - if tokio::time::Instant::now() >= try_deadline { - eprintln!( - "[openshell-supervisor-relay] port readiness try {try_num}/{PORT_READY_MAX_TRIES} timed out after {per_try_timeout:?} (elapsed {:?} total)", - overall_start.elapsed() - ); - break; - } - tokio::time::sleep(Duration::from_millis(300)).await; - } - } - anyhow::bail!( - "timed out after {PORT_READY_MAX_TRIES} tries ({:?} total) waiting for port {port}", - overall_start.elapsed() - ); -} - // ── Relay bridge ────────────────────────────────────────────────────────────── // // Implements the sandbox side of relay.rs's Phase A protocol exactly: @@ -893,14 +981,14 @@ async fn run_relay_bridge( msg = relay_read.next() => match msg { Some(Ok(Message::Text(t))) => { if t == "SESSION_START" { - // Retry briefly: even though wait_for_port_ready() already - // confirmed a raw TCP accept succeeds once, that doesn't - // guarantee the target's listener stays continuously - // accept-ready under a freshly-started process (observed as - // a genuine, reproducible ~500ms startup race elsewhere in - // this codebase -- see mxc-ws-agent.rs's local-connect - // retry). A session-open failure here would otherwise - // silently drop the host's connection attempt. + // Retry briefly: the host driver's TCP-table observation + // confirms a listener exists, but does not guarantee it + // stays continuously accept-ready under a freshly-started + // process (observed as a genuine, reproducible ~500ms + // startup race elsewhere in this codebase -- see + // mxc-ws-agent.rs's local-connect retry). A session-open + // failure here would otherwise silently drop the host's + // connection attempt. match connect_forward_target(|| tokio::net::TcpStream::connect(("127.0.0.1", port))).await { Ok(s) => { // Latency-sensitive request/response tunnel -- @@ -998,17 +1086,17 @@ async fn run_relay_bridge( /// Any active dynamic relay bridges are tokio tasks in this same process, so /// `std::process::exit` below tears them down too -- no separate stop signal /// needed. -async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::Receiver<()>) { +async fn run_lifecycle(mut target: SpawnedTarget, shutdown_rx: oneshot::Receiver<()>) { tokio::select! { - status = child.wait() => { + status = target.child.wait() => { let code = status.map_or(1, |s| s.code().unwrap_or(1)); eprintln!("[openshell-supervisor-relay] target exited with code {code}"); std::process::exit(code); } _ = shutdown_rx => { eprintln!("[openshell-supervisor-relay] shutdown request -- stopping"); - let _ = child.kill().await; - let _ = child.wait().await; + let _ = target.child.kill().await; + let _ = target.child.wait().await; eprintln!("[openshell-supervisor-relay] done"); std::process::exit(0); } @@ -1017,20 +1105,12 @@ async fn run_lifecycle(mut child: tokio::process::Child, shutdown_rx: oneshot::R #[cfg(test)] mod forward_connect_tests { - use super::{PORT_READY_MAX_TRIES, PORT_READY_PER_TRY_TIMEOUT, connect_forward_target}; + use super::connect_forward_target; use std::io::{Error, ErrorKind}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; - #[test] - fn port_ready_budget_covers_five_minute_cold_start_contract() { - assert_eq!( - PORT_READY_PER_TRY_TIMEOUT * PORT_READY_MAX_TRIES, - Duration::from_mins(5), - ); - } - #[tokio::test(start_paused = true)] async fn forward_connect_returns_success_without_retry_delay() { let start = tokio::time::Instant::now(); diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs index 1f3fb51464..c74fbc63ca 100644 --- a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs +++ b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs @@ -187,7 +187,7 @@ impl RelayProcess { async fn expect_ready(&mut self) { let v = self.next_json().await; assert_eq!(v["event"], "ready"); - assert_eq!(v["protocol_version"], 2); + assert_eq!(v["protocol_version"], 4); } async fn launch(&mut self, id: u64, command: &[&str]) -> Value { @@ -199,6 +199,11 @@ impl RelayProcess { .await; self.next_json().await } + + async fn confirm_target_ready(&mut self, id: u64) -> Value { + self.send(json!({"id": id, "op": "target_ready"})).await; + self.next_json().await + } } /// Spawn an in-process TCP echo server (a stand-in for whatever real @@ -318,7 +323,8 @@ async fn launch_success_then_target_ready_ordering() { l.local_addr().unwrap().port() }; let script = format!( - "$l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ + "Start-Sleep -Milliseconds 800; \ + $l=[System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback,{port}); \ $l.Start(); Start-Sleep -Seconds 30" ); @@ -340,12 +346,24 @@ async fn launch_success_then_target_ready_ordering() { assert_eq!(ack["id"], 1); assert_eq!(ack["ok"], true, "launch ack: {ack}"); - // The "launch" response only confirms the command/env arrived -- the - // unsolicited "target_ready" event (no correlation id) is the actual - // liveness confirmation once the port readiness poll succeeds, and it - // must not have been sent already (it can't have been: nothing before - // this point in the protocol lets the spawner know the port bound). - // Reading it as the very next line asserts the ordering directly. + // Production performs this observation in the host driver, where the TCP + // table is accessible. Stand in for that driver by waiting until the + // delayed target listener is visible, then explicitly confirm it. + tokio::time::timeout(TIMEOUT, async { + loop { + if TcpStream::connect(("127.0.0.1", port)).await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("delayed target did not bind"); + let confirmation = relay.confirm_target_ready(2).await; + assert_eq!(confirmation, json!({"id": 2, "ok": true})); + + // The correlated confirmation response precedes the relay's unsolicited + // event, which remains the driver's final race-safe startup signal. let target_ready = relay.next_json().await; assert_eq!(target_ready["event"], "target_ready"); assert!( @@ -354,6 +372,45 @@ async fn launch_success_then_target_ready_ordering() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn early_child_crash_is_reported_promptly_with_its_stderr() { + let port = { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + listener.local_addr().unwrap().port() + }; + let mut relay = RelayProcess::spawn(port).await; + relay.expect_ready().await; + + let ack = relay + .launch( + 1, + &[ + "cmd", + "/d", + "/c", + "echo early-child-crash-sentinel 1>&2 & exit /b 23", + ], + ) + .await; + assert_eq!(ack["ok"], true, "launch ack: {ack}"); + + let failed = relay.next_json().await; + assert_eq!(failed["event"], "target_failed", "failure event: {failed}"); + let error = failed["error"] + .as_str() + .expect("target_failed error string"); + assert!(error.contains("23"), "exit status missing from: {error}"); + assert!( + error.contains("early-child-crash-sentinel"), + "target stderr missing from: {error}" + ); + + tokio::time::timeout(TIMEOUT, relay.child.wait()) + .await + .expect("relay must exit promptly after reporting an early target crash") + .expect("wait() failed"); +} + #[tokio::test(flavor = "multi_thread")] async fn shutdown_is_acked_and_the_process_exits() { let port = { @@ -380,12 +437,14 @@ async fn shutdown_is_acked_and_the_process_exits() { ) .await; assert_eq!(ack["ok"], true); + let confirmation = relay.confirm_target_ready(2).await; + assert_eq!(confirmation, json!({"id": 2, "ok": true})); let target_ready = relay.next_json().await; assert_eq!(target_ready["event"], "target_ready"); - relay.send(json!({"id": 2, "op": "shutdown"})).await; + relay.send(json!({"id": 3, "op": "shutdown"})).await; let ack = relay.next_json().await; - assert_eq!(ack, json!({"id": 2, "ok": true})); + assert_eq!(ack, json!({"id": 3, "ok": true})); let status = tokio::time::timeout(TIMEOUT, relay.child.wait()) .await @@ -395,11 +454,10 @@ async fn shutdown_is_acked_and_the_process_exits() { } /// Reproduces the reported race: a "shutdown" request arriving while the -/// target is still coming up (here, one that never binds the port at all) -/// must stop this process promptly, not leave it waiting out the full -/// port-readiness budget (~300s worst case, see `wait_for_port_ready`) -/// before ever observing the shutdown that `run_control_channel` already -/// acknowledged. Without racing that wait against shutdown, the final +/// target is still coming up (here, one the host never confirms as ready) +/// must stop this process promptly instead of leaving it waiting for host +/// confirmation after `run_control_channel` already acknowledged shutdown. +/// Without racing that wait against shutdown, the final /// `child.wait()` below would time out instead of completing within /// `SHUTDOWN_TIMEOUT`. /// @@ -428,12 +486,11 @@ async fn shutdown_is_acked_and_the_process_exits() { /// process, observed externally, never exited. This test would have /// caught that. #[tokio::test(flavor = "multi_thread")] -async fn shutdown_during_port_wait_stops_promptly() { +async fn shutdown_while_waiting_for_host_readiness_stops_promptly() { // Generous: covers this host's observed CreateProcess stalls (up to // ~60s) plus real margin, not just the fast path. const SPAWN_TIMEOUT: Duration = Duration::from_mins(2); - // Well under the ~300s port-readiness budget this fix exists to avoid - // waiting out -- generous only relative to `TIMEOUT`, since a bare + // Generous only relative to `TIMEOUT`, since a bare // `std::process::exit` completes in well under a second. const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(20); @@ -444,20 +501,19 @@ async fn shutdown_during_port_wait_stops_promptly() { let mut relay = RelayProcess::spawn_capturing_stderr(port).await; relay.expect_ready().await; - // A real target that never binds `port` -- port-readiness polling never - // succeeds on its own. cmd.exe rather than powershell.exe: smaller, - // simpler, and this test's target just needs to run for a while and - // never bind `port`, not do anything powershell-specific. + // A real target the host never confirms as ready. Do not use `timeout.exe` + // here: it exits immediately + // when its stdin is not attached to a console, which is precisely how the + // relay launches targets. let ack = relay - .launch(1, &["cmd", "/c", "timeout /t 300 /nobreak >nul"]) + .launch(1, &["cmd", "/d", "/c", "ping -n 301 127.0.0.1 >nul"]) .await; assert_eq!(ack["ok"], true, "launch ack: {ack}"); - // Confirms spawn_target() has returned and wait_for_port_ready has - // started -- only from this point on is the fix under test actually - // in play. + // Confirms spawn_target() has returned and the host-confirmation wait has + // started -- only from this point on is the fix under test in play. relay - .wait_for_stderr_line("waiting for target on", SPAWN_TIMEOUT) + .wait_for_stderr_line("waiting for host-confirmed target readiness", SPAWN_TIMEOUT) .await; relay.send(json!({"id": 2, "op": "shutdown"})).await; @@ -468,7 +524,7 @@ async fn shutdown_during_port_wait_stops_promptly() { .await .expect( "relay did not exit promptly after shutdown during port-wait \ - (see imp.rs's port-wait/shutdown race)", + (see imp.rs's host-readiness/shutdown race)", ) .expect("wait() failed"); assert!(status.success(), "expected a clean exit, got {status:?}"); From 80b35dbfe0dd6ee63813ac8d209fff22b3ac7db5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:28:52 -0700 Subject: [PATCH 06/39] fix(mxc): repair Windows inference demos (NVBug 6782874) (#3473) * fix(mxc): repair Windows inference demos Signed-off-by: Prekshi Vyas * fix(mxc): address inference demo review feedback Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- Cargo.lock | 1 + crates/openshell-driver-mxc/Cargo.toml | 2 + .../openshell-driver-mxc/examples/README.txt | 48 +++ .../examples/inference.yaml | 24 ++ .../examples/mxc-inference.toml | 19 ++ .../examples/mxc-ollama.toml | 21 ++ .../openshell-driver-mxc/examples/ollama.yaml | 24 ++ .../examples/run-inference-test.ps1 | 294 ++++++++++++++++++ .../examples/run-ollama-test.ps1 | 282 +++++++++++++++++ crates/openshell-driver-mxc/src/driver.rs | 76 +++++ .../tests/demo_examples.rs | 185 +++++++++++ .../tests/mxc_demo_configs.rs | 25 ++ 12 files changed, 1001 insertions(+) create mode 100644 crates/openshell-driver-mxc/examples/README.txt create mode 100644 crates/openshell-driver-mxc/examples/inference.yaml create mode 100644 crates/openshell-driver-mxc/examples/mxc-inference.toml create mode 100644 crates/openshell-driver-mxc/examples/mxc-ollama.toml create mode 100644 crates/openshell-driver-mxc/examples/ollama.yaml create mode 100644 crates/openshell-driver-mxc/examples/run-inference-test.ps1 create mode 100644 crates/openshell-driver-mxc/examples/run-ollama-test.ps1 create mode 100644 crates/openshell-driver-mxc/tests/demo_examples.rs create mode 100644 crates/openshell-gateway/tests/mxc_demo_configs.rs diff --git a/Cargo.lock b/Cargo.lock index 431b79335a..572d0742a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4187,6 +4187,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-tungstenite 0.26.2", + "toml", "tonic", "tracing", "uuid", diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index a85e709d4e..bfead4f202 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -58,6 +58,8 @@ openshell-policy = { path = "../openshell-policy" } base64 = { workspace = true } rustls = { workspace = true } serde_json = { workspace = true } +# Parse the shipped gateway templates in cross-platform drift guards. +toml = { workspace = true } # Used by the drift guard test (handled_fields_inventory) to parse YAML into a # generic serde_json::Value for key enumeration. serde_yml = { workspace = true } diff --git a/crates/openshell-driver-mxc/examples/README.txt b/crates/openshell-driver-mxc/examples/README.txt new file mode 100644 index 0000000000..659124f9e5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README.txt @@ -0,0 +1,48 @@ +OpenShell MXC Windows inference demos +==================================== + +These two demos exercise the full gateway -> MXC processContainer path: + + Local inference (Hello World) + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ollama-test.ps1 + + Cloud inference (T1) + $env:NV_API_KEY = "nvapi-..." + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-inference-test.ps1 + +Prerequisites +------------- + + - Windows 11 build 26300.8553 or newer with MXC processContainer support. + - openshell-gateway.exe and openshell.exe beside these files, or explicit + -GatewayPath and -CliPath arguments. + - wxc-exec.exe beside these files, on PATH, named by + OPENSHELL_WXC_EXEC_PATH, or passed with -WxcExecPath. + - Local demo: an Ollama-compatible service on 127.0.0.1:11434 by default. + Override -OllamaHost, -OllamaPort, and -Model when needed. + - Cloud demo: NV_API_KEY and outbound HTTPS to integrate.api.nvidia.com. + +The runners use a unique temporary share directory and an available loopback +gateway port for each run. They never require C:\mxc-kit, C:\work, a fixed +gateway port, or edits to the checked-in templates. Missing prerequisites fail +before sandbox creation with a diagnostic naming the parameter or environment +variable that can supply them. + +Security model +-------------- + +Both demos use the fail-closed process_container backend. Their rendered policy +grants only the per-run share and the one requested endpoint, and no broad +AppContainer network capability is enabled. Cloud traffic traverses OpenShell's +enforcing CONNECT proxy. For local Ollama only, curl bypasses proxy variables +for the requested loopback hostname because the proxy's SSRF defense rejects +all loopback destinations; MXC still limits direct traffic to host loopback. This +inherits the driver's documented limitation that governed MXC sandboxes can +reach other host-loopback ports and must not be treated as loopback-service +isolation. The cloud key is passed with `--env-from NV_API_KEY`, is not placed in +argv or written to the results directory, and is never printed. + +Each run leaves a results-* directory containing the rendered TOML and policy, +gateway logs, response artifacts, and summary.txt. The script exits 0 only when +the sandbox creates successfully and the expected inference response is +observed. Use -KeepArtifacts to retain the temporary share for debugging. diff --git a/crates/openshell-driver-mxc/examples/inference.yaml b/crates/openshell-driver-mxc/examples/inference.yaml new file mode 100644 index 0000000000..e20f4020b8 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/inference.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Template rendered by run-inference-test.ps1 into its per-run results directory. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "__OPENSHELL_DEMO_SHARE__" + +network_policies: + nvidia_inference: + name: nvidia-inference + endpoints: + - host: integrate.api.nvidia.com + port: 443 + protocol: rest + # Chat completions use POST. + access: read-write + enforcement: enforce + binaries: + - path: '__CMD_EXE__' diff --git a/crates/openshell-driver-mxc/examples/mxc-inference.toml b/crates/openshell-driver-mxc/examples/mxc-inference.toml new file mode 100644 index 0000000000..1c0d0274fd --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-inference.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway template for the cloud-inference (T1) demo. The runner resolves +# wxc-exec and writes a disposable copy. NV_API_KEY is supplied at sandbox +# creation with --env-from and is never stored in this file. + +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +egress_proxy = true +egress_proxy_addr = "127.0.0.1:0" +debug = false diff --git a/crates/openshell-driver-mxc/examples/mxc-ollama.toml b/crates/openshell-driver-mxc/examples/mxc-ollama.toml new file mode 100644 index 0000000000..8b7a693dd5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ollama.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Gateway template for the local-inference demo. run-ollama-test.ps1 resolves +# wxc-exec and writes a disposable copy; workload command, cwd, and environment +# are sandbox-scoped and passed to `openshell sandbox create`. + +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "wxc-exec.exe" +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +egress_proxy = true +# The address is a seed. The driver reserves a distinct ephemeral port for each +# sandbox, so this does not claim a machine-specific port. +egress_proxy_addr = "127.0.0.1:0" +debug = false diff --git a/crates/openshell-driver-mxc/examples/ollama.yaml b/crates/openshell-driver-mxc/examples/ollama.yaml new file mode 100644 index 0000000000..d1668955df --- /dev/null +++ b/crates/openshell-driver-mxc/examples/ollama.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Template rendered by run-ollama-test.ps1 into its per-run results directory. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "__OPENSHELL_DEMO_SHARE__" + +network_policies: + local_ollama: + name: local-ollama + endpoints: + - host: "__OLLAMA_HOST__" + port: __OLLAMA_PORT__ + protocol: rest + # Both /api/tags (GET) and /api/generate (POST) are required. + access: read-write + enforcement: enforce + binaries: + - path: '__CMD_EXE__' diff --git a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 new file mode 100644 index 0000000000..1dc08f5a5f --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 @@ -0,0 +1,294 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Cloud inference (T1) demo for OpenShell on MXC. PowerShell 5.1 compatible. + +[CmdletBinding()] +param( + [string] $WxcExecPath, + [string] $GatewayPath, + [string] $CliPath, + [string] $ShareDir, + [string] $Model = "nvidia/nemotron-3.5-lightning-30b-a3b", + [string] $Prompt = "Say hello in exactly five words.", + [ValidateRange(0, 65535)] [int] $Port = 0, + [string] $SandboxName, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-inference-$stamp-$PID" +New-Item -ItemType Directory -Path $resultDir | Out-Null + +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Executable([string]$explicit, [string]$leaf, [string]$environmentName) { + $candidates = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($explicit)) { [void]$candidates.Add($explicit) } + if (-not [string]::IsNullOrWhiteSpace($environmentName)) { + $fromEnvironment = [Environment]::GetEnvironmentVariable($environmentName) + if (-not [string]::IsNullOrWhiteSpace($fromEnvironment)) { [void]$candidates.Add($fromEnvironment) } + } + [void]$candidates.Add((Join-Path $here $leaf)) + [void]$candidates.Add((Join-Path (Join-Path $here "bin") $leaf)) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + $command = Get-Command $leaf -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + if ($environmentName) { + throw "$leaf was not found. Pass -WxcExecPath, set $environmentName, place $leaf beside this script, or add it to PATH." + } + throw "$leaf was not found. Pass its path explicitly or place it beside this script." +} + +function Get-AvailablePort { + $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Port([int]$candidate) { + $client = New-Object System.Net.Sockets.TcpClient + try { + $pending = $client.BeginConnect("127.0.0.1", $candidate, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +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-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $allArgs = @("--gateway-endpoint", $endpoint) + $CommandArgs + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($allArgs | 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 + if (-not $process.Start()) { throw "failed to start OpenShell CLI '$cli'" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $text = (@($stdout.Result, $stderr.Result) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" + } + return @{ ExitCode = $process.ExitCode; Text = $text } +} + +function Write-Utf8([string]$path, [string]$contents) { + [System.IO.File]::WriteAllText($path, $contents, $utf8NoBom) +} + +$gateway = $null +$cli = $null +$wxc = $null +$gatewayProcess = $null +$createdShare = $false +$success = $false +$failure = $null +$oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG +$oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER +$oldApiKey = $env:NV_API_KEY +$apiKey = $env:NV_API_KEY + +try { + if ([string]::IsNullOrWhiteSpace($apiKey)) { + throw "NV_API_KEY is missing. Set `$env:NV_API_KEY; the key is forwarded with --env-from and is never written to disk or placed in argv." + } + $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" + $cli = Resolve-Executable $CliPath "openshell.exe" "" + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + foreach ($fixture in @("mxc-inference.toml", "inference.yaml")) { + if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { + throw "required demo fixture '$fixture' is missing beside the runner" + } + } + if ($Port -eq 0) { $Port = Get-AvailablePort } + $endpoint = "http://127.0.0.1:$Port" + if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "inference-$PID" } + if ([string]::IsNullOrWhiteSpace($ShareDir)) { + $ShareDir = Join-Path ([System.IO.Path]::GetTempPath()) "openshell-mxc-inference-$PID-$([Guid]::NewGuid().ToString('N'))" + $createdShare = $true + } + $ShareDir = [System.IO.Path]::GetFullPath($ShareDir).TrimEnd('\', '/') + if ($ShareDir.Contains('"') -or $ShareDir.Contains("`n") -or $ShareDir.Contains("`r")) { + throw "ShareDir contains a quote or newline and cannot be rendered safely" + } + New-Item -ItemType Directory -Path $ShareDir -Force | Out-Null + + Info "gateway: $gateway" + Info "CLI: $cli" + Info "wxc-exec: $wxc" + Info "share: $ShareDir" + Info "NV_API_KEY: present (value redacted)" + + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $curlExe = Join-Path $env:SystemRoot "System32\curl.exe" + $findStrExe = Join-Path $env:SystemRoot "System32\findstr.exe" + foreach ($systemTool in @($cmdExe, $curlExe, $findStrExe)) { + if (-not (Test-Path -LiteralPath $systemTool -PathType Leaf)) { + throw "required Windows tool was not found at '$systemTool'" + } + } + + $tomlUsed = Join-Path $resultDir "mxc-inference.used.toml" + $tomlText = [System.IO.File]::ReadAllText((Join-Path $here "mxc-inference.toml")) + $escapedWxc = $wxc.Replace('\', '\\').Replace('"', '\"') + $tomlText = $tomlText.Replace('wxc_exec_path = "wxc-exec.exe"', "wxc_exec_path = `"$escapedWxc`"") + Write-Utf8 $tomlUsed $tomlText + + $policyUsed = Join-Path $resultDir "inference.used.yaml" + $sharePolicy = $ShareDir.Replace('\', '/') + $policyText = [System.IO.File]::ReadAllText((Join-Path $here "inference.yaml")) + $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) + $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + Write-Utf8 $policyUsed $policyText + + $requestPath = Join-Path $ShareDir "inference-request.json" + $responsePath = Join-Path $ShareDir "inference-response.json" + $headersPath = Join-Path $ShareDir "inference-headers.txt" + $donePath = Join-Path $ShareDir "inference-pass.txt" + $errorPath = Join-Path $ShareDir "inference-error.txt" + $requestJson = @{ model = $Model; messages = @(@{ role = "user"; content = $Prompt }); max_tokens = 128; reasoning_budget = 64; stream = $false } | ConvertTo-Json -Compress -Depth 8 + Write-Utf8 $requestPath $requestJson + + $probePath = Join-Path $ShareDir "inference-probe.cmd" + $probeLines = @( + "@echo off", + # Inbox curl uses Schannel and does not honor CURL_CA_BUNDLE by itself. + # Point --cacert at the public bundle staged by the governed proxy. + "`"$curlExe`" --silent --show-error --fail-with-body --ssl-no-revoke --cacert `"%CURL_CA_BUNDLE%`" --max-time 120 -D `"$headersPath`" -H `"Authorization: Bearer %NV_API_KEY%`" -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"https://integrate.api.nvidia.com/v1/chat/completions`" 2> `"$errorPath`" || exit /b 31", + "`"$findStrExe`" /C:`"choices`" `"$responsePath`" >nul || exit /b 32", + "echo PASS> `"$donePath`"" + ) + Write-Utf8 $probePath ($probeLines -join "`r`n") + + $driverConfig = @{ mxc = @{ command = @($cmdExe, "/d", "/s", "/c", $probePath); cwd = $ShareDir } } | ConvertTo-Json -Compress -Depth 8 + + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + # The credential belongs to sandbox creation, not gateway configuration. + Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue + try { + $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info") -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + } finally { + $env:NV_API_KEY = $apiKey + } + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { + if ($gatewayProcess.HasExited) { + $details = (Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue) -join "`n" + throw "gateway exited before listening: $details" + } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Port $Port)) { throw "gateway did not listen on $endpoint within 30 seconds" } + Ok "gateway is listening at $endpoint" + + $create = Invoke-Cli @("sandbox", "create", "--name", $SandboxName, "--policy", $policyUsed, "--driver-config-json", $driverConfig, "--env-from", "NV_API_KEY", "--no-tty", "--output", "json") + Ok "sandbox '$SandboxName' created" + $deadline = (Get-Date).AddSeconds(150) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $donePath)) { + $status = Invoke-Cli @("sandbox", "get", $SandboxName, "--output", "json") -AllowFailure + if ($status.ExitCode -eq 0 -and $status.Text -match '"phase"\s*:\s*"Error"') { + $httpStatus = "unknown" + if (Test-Path -LiteralPath $headersPath) { + $statusLines = @([System.IO.File]::ReadAllLines($headersPath) | Where-Object { $_ -match '^HTTP/\S+\s+\d{3}' }) + if ($statusLines.Count -gt 0 -and $statusLines[-1] -match '^HTTP/\S+\s+(\d{3})') { $httpStatus = $Matches[1] } + } + $apiDetail = "" + if (Test-Path -LiteralPath $responsePath) { + try { + $errorResponse = [System.IO.File]::ReadAllText($responsePath) | ConvertFrom-Json + if ($errorResponse.detail) { $apiDetail = ([string]$errorResponse.detail).Trim() } + } catch {} + } + $curlDetail = if (Test-Path -LiteralPath $errorPath) { ([System.IO.File]::ReadAllText($errorPath)).Trim() } else { "no curl diagnostic was produced" } + $diagnostic = "sandbox was created, but cloud inference failed (HTTP $httpStatus): $apiDetail $curlDetail".Trim() + throw $diagnostic.Replace($apiKey, "***REDACTED***") + } + Start-Sleep -Milliseconds 500 + } + if (-not (Test-Path -LiteralPath $donePath)) { throw "sandbox did not finish cloud inference within 150 seconds" } + $response = [System.IO.File]::ReadAllText($responsePath) + $parsed = $response | ConvertFrom-Json + if (-not $parsed.choices -or $parsed.choices.Count -eq 0) { throw "cloud response did not contain a completion in 'choices'" } + Copy-Item -LiteralPath $requestPath, $responsePath, $headersPath, $probePath -Destination $resultDir -Force + $success = $true + Ok "cloud inference returned a completion from inside the sandbox" +} catch { + $failure = $_.Exception.Message + Bad $failure + foreach ($artifact in @("inference-response.json", "inference-headers.txt", "inference-error.txt")) { + $source = if ($ShareDir) { Join-Path $ShareDir $artifact } else { $null } + if ($source -and (Test-Path -LiteralPath $source)) { Copy-Item -LiteralPath $source -Destination $resultDir -Force -ErrorAction SilentlyContinue } + } +} finally { + if ($cli -and $endpoint -and $SandboxName) { + try { [void](Invoke-Cli @("sandbox", "delete", $SandboxName) -AllowFailure) } catch {} + } + if ($gatewayProcess -and -not $gatewayProcess.HasExited) { + Stop-Process -Id $gatewayProcess.Id -Force -ErrorAction SilentlyContinue + try { [void]$gatewayProcess.WaitForExit(5000) } catch {} + } + $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig + $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if ([string]::IsNullOrWhiteSpace($oldApiKey)) { Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue } else { $env:NV_API_KEY = $oldApiKey } + if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { + Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$verdict = if ($success) { "PASS" } else { "FAIL" } +$summary = "verdict=$verdict`r`nbase=cloud-inference`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +Write-Utf8 (Join-Path $resultDir "summary.txt") $summary +Write-Host "`n$summary" +Write-Host "Results: $resultDir" +if ($success) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 new file mode 100644 index 0000000000..59c5bbcbb6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 @@ -0,0 +1,282 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Hello World local-inference demo for OpenShell on MXC. PowerShell 5.1 compatible. + +[CmdletBinding()] +param( + [string] $WxcExecPath, + [string] $GatewayPath, + [string] $CliPath, + [string] $ShareDir, + [string] $OllamaHost = "127.0.0.1", + [ValidateRange(1, 65535)] [int] $OllamaPort = 11434, + [string] $Model = "qwen3.5:0.8b", + [string] $Prompt = "Say hello in exactly five words.", + [ValidateRange(0, 65535)] [int] $Port = 0, + [string] $SandboxName, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-ollama-$stamp-$PID" +New-Item -ItemType Directory -Path $resultDir | Out-Null + +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Executable([string]$explicit, [string]$leaf, [string]$environmentName) { + $candidates = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($explicit)) { [void]$candidates.Add($explicit) } + if (-not [string]::IsNullOrWhiteSpace($environmentName)) { + $fromEnvironment = [Environment]::GetEnvironmentVariable($environmentName) + if (-not [string]::IsNullOrWhiteSpace($fromEnvironment)) { [void]$candidates.Add($fromEnvironment) } + } + [void]$candidates.Add((Join-Path $here $leaf)) + [void]$candidates.Add((Join-Path (Join-Path $here "bin") $leaf)) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + $command = Get-Command $leaf -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { return $command.Source } + if ($environmentName) { + throw "$leaf was not found. Pass -WxcExecPath, set $environmentName, place $leaf beside this script, or add it to PATH." + } + throw "$leaf was not found. Pass its path explicitly or place it beside this script." +} + +function Get-AvailablePort { + $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Port([int]$candidate) { + $client = New-Object System.Net.Sockets.TcpClient + try { + $pending = $client.BeginConnect("127.0.0.1", $candidate, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +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-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $allArgs = @("--gateway-endpoint", $endpoint) + $CommandArgs + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($allArgs | 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 + if (-not $process.Start()) { throw "failed to start OpenShell CLI '$cli'" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $text = (@($stdout.Result, $stderr.Result) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" + } + return @{ ExitCode = $process.ExitCode; Text = $text } +} + +function Write-Utf8([string]$path, [string]$contents) { + [System.IO.File]::WriteAllText($path, $contents, $utf8NoBom) +} + +$gateway = $null +$cli = $null +$wxc = $null +$gatewayProcess = $null +$createdShare = $false +$success = $false +$failure = $null +$oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG +$oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER + +try { + $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" + $cli = Resolve-Executable $CliPath "openshell.exe" "" + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + foreach ($fixture in @("mxc-ollama.toml", "ollama.yaml")) { + if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { + throw "required demo fixture '$fixture' is missing beside the runner" + } + } + if ($OllamaHost -notmatch '^[A-Za-z0-9.-]+$') { + throw "OllamaHost '$OllamaHost' is invalid; use an IPv4 address or DNS name" + } + if ($Port -eq 0) { $Port = Get-AvailablePort } + $endpoint = "http://127.0.0.1:$Port" + if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "ollama-$PID" } + if ([string]::IsNullOrWhiteSpace($ShareDir)) { + $ShareDir = Join-Path ([System.IO.Path]::GetTempPath()) "openshell-mxc-ollama-$PID-$([Guid]::NewGuid().ToString('N'))" + $createdShare = $true + } + $ShareDir = [System.IO.Path]::GetFullPath($ShareDir).TrimEnd('\', '/') + if ($ShareDir.Contains('"') -or $ShareDir.Contains("`n") -or $ShareDir.Contains("`r")) { + throw "ShareDir contains a quote or newline and cannot be rendered safely" + } + New-Item -ItemType Directory -Path $ShareDir -Force | Out-Null + + Info "gateway: $gateway" + Info "CLI: $cli" + Info "wxc-exec: $wxc" + Info "share: $ShareDir" + Info "Ollama: http://${OllamaHost}:$OllamaPort" + + try { + $probe = Invoke-WebRequest -UseBasicParsing -Uri "http://${OllamaHost}:$OllamaPort/api/tags" -TimeoutSec 8 + if ([int]$probe.StatusCode -ne 200) { throw "HTTP $($probe.StatusCode)" } + } catch { + throw "Ollama prerequisite is unavailable at http://${OllamaHost}:$OllamaPort/api/tags. Start Ollama or pass -OllamaHost/-OllamaPort. $($_.Exception.Message)" + } + Ok "host-side Ollama prerequisite returned HTTP 200" + + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $curlExe = Join-Path $env:SystemRoot "System32\curl.exe" + $findStrExe = Join-Path $env:SystemRoot "System32\findstr.exe" + foreach ($systemTool in @($cmdExe, $curlExe, $findStrExe)) { + if (-not (Test-Path -LiteralPath $systemTool -PathType Leaf)) { + throw "required Windows tool was not found at '$systemTool'" + } + } + + $tomlUsed = Join-Path $resultDir "mxc-ollama.used.toml" + $tomlText = [System.IO.File]::ReadAllText((Join-Path $here "mxc-ollama.toml")) + $escapedWxc = $wxc.Replace('\', '\\').Replace('"', '\"') + $tomlText = $tomlText.Replace('wxc_exec_path = "wxc-exec.exe"', "wxc_exec_path = `"$escapedWxc`"") + Write-Utf8 $tomlUsed $tomlText + + $policyUsed = Join-Path $resultDir "ollama.used.yaml" + $sharePolicy = $ShareDir.Replace('\', '/') + $policyText = [System.IO.File]::ReadAllText((Join-Path $here "ollama.yaml")) + $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) + $policyText = $policyText.Replace("__OLLAMA_HOST__", $OllamaHost) + $policyText = $policyText.Replace("__OLLAMA_PORT__", [string]$OllamaPort) + $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + Write-Utf8 $policyUsed $policyText + + $requestPath = Join-Path $ShareDir "ollama-request.json" + $responsePath = Join-Path $ShareDir "ollama-response.json" + $tagsPath = Join-Path $ShareDir "ollama-tags.json" + $donePath = Join-Path $ShareDir "ollama-pass.txt" + $errorPath = Join-Path $ShareDir "ollama-error.txt" + $requestJson = @{ model = $Model; prompt = $Prompt; stream = $false } | ConvertTo-Json -Compress + Write-Utf8 $requestPath $requestJson + + $probePath = Join-Path $ShareDir "ollama-probe.cmd" + $probeLines = @( + "@echo off", + # MXC's governed path permits only host loopback at the OS boundary. The + # CONNECT proxy deliberately blocks loopback as SSRF, so this local-only + # demo bypasses proxy variables for precisely the requested Ollama host. + "`"$curlExe`" --noproxy `"$OllamaHost`" --silent --show-error --fail --max-time 15 -o `"$tagsPath`" `"http://${OllamaHost}:$OllamaPort/api/tags`" 2> `"$errorPath`" || exit /b 21", + "`"$curlExe`" --noproxy `"$OllamaHost`" --silent --show-error --fail --max-time 120 -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"http://${OllamaHost}:$OllamaPort/api/generate`" 2>> `"$errorPath`" || exit /b 22", + "`"$findStrExe`" /C:`"response`" `"$responsePath`" >nul || exit /b 23", + "echo PASS> `"$donePath`"" + ) + Write-Utf8 $probePath ($probeLines -join "`r`n") + + $driverConfig = @{ mxc = @{ command = @($cmdExe, "/d", "/s", "/c", $probePath); cwd = $ShareDir } } | ConvertTo-Json -Compress -Depth 8 + + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info") -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { + if ($gatewayProcess.HasExited) { + $details = (Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue) -join "`n" + throw "gateway exited before listening: $details" + } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Port $Port)) { throw "gateway did not listen on $endpoint within 30 seconds" } + Ok "gateway is listening at $endpoint" + + $create = Invoke-Cli @("sandbox", "create", "--name", $SandboxName, "--policy", $policyUsed, "--driver-config-json", $driverConfig, "--no-tty", "--output", "json") + Ok "sandbox '$SandboxName' created" + $deadline = (Get-Date).AddSeconds(150) + while ((Get-Date) -lt $deadline -and -not (Test-Path -LiteralPath $donePath)) { + $status = Invoke-Cli @("sandbox", "get", $SandboxName, "--output", "json") -AllowFailure + if ($status.ExitCode -eq 0 -and $status.Text -match '"phase"\s*:\s*"Error"') { + $probeError = if (Test-Path -LiteralPath $errorPath) { ([System.IO.File]::ReadAllText($errorPath)).Trim() } else { "no curl diagnostic was produced" } + throw "sandbox was created, but local inference failed: $probeError" + } + Start-Sleep -Milliseconds 500 + } + if (-not (Test-Path -LiteralPath $donePath)) { throw "sandbox did not finish local inference within 150 seconds" } + $response = [System.IO.File]::ReadAllText($responsePath) + $parsed = $response | ConvertFrom-Json + if ([string]::IsNullOrWhiteSpace([string]$parsed.response)) { throw "Ollama response did not contain a nonempty 'response' field" } + Copy-Item -LiteralPath $requestPath, $responsePath, $tagsPath, $probePath -Destination $resultDir -Force + $success = $true + Ok "local inference returned a completion from inside the sandbox" +} catch { + $failure = $_.Exception.Message + Bad $failure +} finally { + if ($cli -and $endpoint -and $SandboxName) { + try { [void](Invoke-Cli @("sandbox", "delete", $SandboxName) -AllowFailure) } catch {} + } + if ($gatewayProcess -and -not $gatewayProcess.HasExited) { + Stop-Process -Id $gatewayProcess.Id -Force -ErrorAction SilentlyContinue + try { [void]$gatewayProcess.WaitForExit(5000) } catch {} + } + $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig + $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { + Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$verdict = if ($success) { "PASS" } else { "FAIL" } +$summary = "verdict=$verdict`r`nbase=local-ollama`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +Write-Utf8 (Join-Path $resultDir "summary.txt") $summary +Write-Host "`n$summary" +Write-Host "Results: $resultDir" +if ($success) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 01a679eb4b..a2192d36ff 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -2550,6 +2550,8 @@ mod lifecycle_tests { NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, StaticCredentialEndpointBinding, UiClipboardAccess, UiPolicy, }; + use openshell_policy::parse_sandbox_policy; + use std::path::Path; use std::time::Duration; #[test] @@ -2706,6 +2708,80 @@ mod lifecycle_tests { None } + fn shipped_demo_config(name: &str) -> MxcComputeConfig { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join(name); + let source = std::fs::read_to_string(&path).expect("read shipped demo config"); + let document: toml::Value = toml::from_str(&source).expect("parse shipped demo config"); + document["openshell"]["drivers"]["mxc"] + .clone() + .try_into() + .expect("deserialize shipped MXC driver config") + } + + fn shipped_demo_policy(name: &str, share: &str) -> SandboxPolicy { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join(name); + let rendered = std::fs::read_to_string(&path) + .expect("read shipped demo policy") + .replace("__OPENSHELL_DEMO_SHARE__", share) + .replace("__OLLAMA_HOST__", "127.0.0.1") + .replace("__OLLAMA_PORT__", "11434") + .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); + parse_sandbox_policy(&rendered).expect("parse rendered shipped demo policy") + } + + #[tokio::test] + async fn shipped_inference_examples_create_process_container_sandboxes() { + for (index, (config_name, policy_name)) in [ + ("mxc-ollama.toml", "ollama.yaml"), + ("mxc-inference.toml", "inference.yaml"), + ] + .into_iter() + .enumerate() + { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let proof = format!("{share}/demo-created.txt"); + let sandbox_name = format!("shipped-demo-{index}"); + let cmd = vec![ + r"C:\Windows\System32\cmd.exe".into(), + "/d".into(), + "/c".into(), + format!(r#"echo PASS>"{proof}""#), + ]; + let config = shipped_demo_config(config_name); + assert_eq!(config.backend, MxcBackend::ProcessContainer); + assert!(config.egress_proxy); + let backend = MxcComputeBackend::new_mocked(config); + let policy = shipped_demo_policy(policy_name, &share); + let sandbox = with_policy( + driver_sandbox_with_command(&sandbox_name, &share, cmd), + policy, + ); + + backend + .create_sandbox(&sandbox) + .await + .unwrap_or_else(|error| panic!("{config_name} create failed: {error}")); + let completed = wait_for(&backend, &sandbox_name, |sandbox| { + ready_condition(sandbox) + .is_some_and(|condition| condition.reason == "AgentCompleted") + }) + .await; + assert!( + completed.is_some(), + "{config_name} did not reach Ready/AgentCompleted" + ); + assert!( + tmp.path().join("demo-created.txt").is_file(), + "{config_name} did not run its in-policy workload" + ); + } + } + #[test] fn mxc_config_defaults_to_default_deny_process_container() { let config = MxcComputeConfig::default(); diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs new file mode 100644 index 0000000000..11e72e688d --- /dev/null +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Drift guards for the two shipped Windows inference demos. + +use std::path::{Path, PathBuf}; + +use openshell_policy::{parse_sandbox_policy, validate_sandbox_policy}; +use toml::Value; + +fn examples_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("examples") +} + +#[test] +fn shipped_inference_demo_assets_are_present() { + let root = examples_root(); + for name in [ + "mxc-ollama.toml", + "ollama.yaml", + "run-ollama-test.ps1", + "mxc-inference.toml", + "inference.yaml", + "run-inference-test.ps1", + ] { + let path = root.join(name); + assert!( + path.is_file(), + "shipped demo asset is missing: {}", + path.display() + ); + } +} + +fn read_example(name: &str) -> String { + let path = examples_root().join(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) +} + +#[test] +fn shipped_inference_configs_declare_required_mxc_settings() { + for name in ["mxc-ollama.toml", "mxc-inference.toml"] { + let source = read_example(name); + let parsed: Value = toml::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {name}: {error}")); + let openshell = parsed + .get("openshell") + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell]")); + assert_eq!( + openshell.get("version").and_then(Value::as_integer), + Some(2), + "{name} must use schema version 2" + ); + let mxc = openshell + .get("drivers") + .and_then(Value::as_table) + .and_then(|drivers| drivers.get("mxc")) + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell.drivers.mxc]")); + assert_eq!( + mxc.get("backend").and_then(Value::as_str), + Some("process_container"), + "{name} must use the filesystem-policy-capable backend" + ); + assert_eq!(mxc.get("egress_proxy").and_then(Value::as_bool), Some(true)); + assert!( + matches!( + mxc.get("egress_proxy_addr").and_then(Value::as_str), + Some(address) if address.starts_with("127.0.0.1:") + ), + "{name} must route network policy through governed loopback egress" + ); + for removed in ["share_dir", "agent_cwd", "agent_command", "agent_env"] { + assert!( + !mxc.contains_key(removed), + "{name} contains removed gateway field {removed}" + ); + } + } +} + +#[test] +fn shipped_inference_policies_are_narrow_and_valid_after_rendering() { + let share = if cfg!(windows) { + "C:/portable/demo" + } else { + "/portable/demo" + }; + let fixtures = [ + ("ollama.yaml", "local_ollama", "127.0.0.1", share), + ( + "inference.yaml", + "nvidia_inference", + "integrate.api.nvidia.com", + share, + ), + ]; + for (name, rule_name, endpoint, share) in fixtures { + let rendered = read_example(name) + .replace("__OPENSHELL_DEMO_SHARE__", share) + .replace("__OLLAMA_HOST__", "127.0.0.1") + .replace("__OLLAMA_PORT__", "11434") + .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); + let policy = parse_sandbox_policy(&rendered) + .unwrap_or_else(|error| panic!("failed to parse rendered {name}: {error}")); + validate_sandbox_policy(&policy) + .unwrap_or_else(|error| panic!("rendered {name} is invalid: {error:?}")); + let filesystem = policy + .filesystem + .as_ref() + .unwrap_or_else(|| panic!("{name} must have a filesystem policy")); + assert_eq!(filesystem.read_write, vec![share]); + assert_eq!(policy.network_policies.len(), 1); + let rule = policy + .network_policies + .get(rule_name) + .unwrap_or_else(|| panic!("{name} is missing rule {rule_name}")); + assert_eq!(rule.endpoints.len(), 1); + assert_eq!(rule.endpoints[0].host, endpoint); + assert_eq!(rule.binaries.len(), 1); + assert_eq!( + rule.binaries[0].path.to_ascii_lowercase(), + r"c:\windows\system32\cmd.exe" + ); + } +} + +#[test] +fn shipped_runners_supply_sandbox_scoped_workload_configuration() { + for name in ["run-ollama-test.ps1", "run-inference-test.ps1"] { + let source = read_example(name); + assert!( + source.contains("--driver-config-json"), + "{name} must pass command/cwd at sandbox creation" + ); + assert!( + !source.contains("C:\\mxc-kit"), + "{name} must not pin wxc-exec to a machine path" + ); + assert!( + !source.contains("C:\\work"), + "{name} must not pin its share to a machine path" + ); + assert!( + !source.contains("17670"), + "{name} must not pin the gateway to the historical fixed port" + ); + assert!(!source.contains("isolation_session")); + } + let cloud = read_example("run-inference-test.ps1"); + assert!(cloud.contains("--env-from")); + assert!(cloud.contains("NV_API_KEY")); + assert!(!cloud.contains("[string] $ApiKey")); + assert!(!cloud.contains("pass -ApiKey")); + assert!(cloud.contains("nvidia/nemotron-3.5-lightning-30b-a3b")); + assert!(!cloud.contains("nvidia/nvidia-nemotron-nano-9b-v2")); +} + +#[cfg(target_os = "windows")] +#[test] +fn shipped_runners_parse_in_windows_powershell() { + for name in ["run-ollama-test.ps1", "run-inference-test.ps1"] { + let path = examples_root().join(name); + let script = r" +$errors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile($env:OPENSHELL_DEMO_SCRIPT_TO_PARSE, [ref]$null, [ref]$errors) +if ($errors.Count -gt 0) { + $errors | ForEach-Object { [Console]::Error.WriteLine($_.Message) } + exit 1 +} +"; + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_DEMO_SCRIPT_TO_PARSE", &path) + .output() + .unwrap_or_else(|error| panic!("failed to launch PowerShell for {name}: {error}")); + assert!( + output.status.success(), + "{name} has PowerShell syntax errors:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/crates/openshell-gateway/tests/mxc_demo_configs.rs b/crates/openshell-gateway/tests/mxc_demo_configs.rs new file mode 100644 index 0000000000..a3124ddbaa --- /dev/null +++ b/crates/openshell-gateway/tests/mxc_demo_configs.rs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(target_os = "windows")] +#[test] +fn shipped_mxc_inference_configs_pass_gateway_preflight() { + let examples = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../openshell-driver-mxc/examples"); + + for name in ["mxc-ollama.toml", "mxc-inference.toml"] { + let path = examples.join(name); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_openshell-gateway")) + .args(["config", "preflight", "--path"]) + .arg(&path) + .output() + .unwrap_or_else(|error| panic!("failed to run gateway preflight for {name}: {error}")); + + assert!( + output.status.success(), + "gateway preflight rejected {name}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} From 3a4d4f98f7930bf99aa452138f2b33af227f1820 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:35:15 -0700 Subject: [PATCH 07/39] test(mxc): parse OpenClaw config schema portably (#3536) Signed-off-by: Prekshi Vyas (cherry picked from commit 3a0efc2d630bc98da297f0fef54ef99f8125662a) # Conflicts: # crates/openshell-driver-mxc/Cargo.toml --- .../tests/openclaw_appcontainer_compat.rs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs index 609de6ee05..c7152b7e4f 100644 --- a/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs +++ b/crates/openshell-driver-mxc/tests/openclaw_appcontainer_compat.rs @@ -100,11 +100,21 @@ fn runner_uses_lifecycle_readiness_and_preserves_failure_diagnostics() { #[test] fn openclaw_gateway_configs_declare_the_current_schema() { - for config in [ - PROCESS_CONTAINER_CONFIG, - ISOLATION_CONFIG, - LOCAL_NETWORK_CONFIG, + for (name, config) in [ + ("process-container", PROCESS_CONTAINER_CONFIG), + ("isolation", ISOLATION_CONFIG), + ("local-network", LOCAL_NETWORK_CONFIG), ] { - assert!(config.contains("[openshell]\nversion = 2")); + let config = config + .parse::() + .unwrap_or_else(|error| panic!("{name} config must be valid TOML: {error}")); + assert_eq!( + config + .get("openshell") + .and_then(|openshell| openshell.get("version")) + .and_then(toml::Value::as_integer), + Some(2), + "{name} config must declare the current OpenShell schema", + ); } } From 207eb562422a3436d4e5dff9ecbdaf7a35a763b3 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:07:11 +0530 Subject: [PATCH 08/39] fix(mxc): stage egress-proxy CA files regardless of env tier (#3496) * fix(mxc): stage egress-proxy CA files regardless of env tier stage_tls_ca_files was only invoked when pc_minimal_env was set, but a curated ProcessContainer can't read the host proxy's private temp folder under any env tier. With the default pc_minimal_env=false, the CA env vars pointed at a path the sandboxed process couldn't read at all, breaking TLS validation for allow-listed HTTPS requests through the egress proxy. Extract the staging decision into resolve_agent_proxy_ca_paths, which takes no env-tier argument, so the gap can't silently regress; existing tests never exercised this path since mocked invokers skip host-proxy startup entirely. Signed-off-by: Prashant Khodade (cherry picked from commit d0346af96aef9a93aca26a7e1825d8678a25567d) * fix(mxc): complete proxy CA staging isolation (#3535) * test(mxc): provide workload dir for CA staging Signed-off-by: Prekshi Vyas * fix(mxc): isolate staged proxy CA files Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prashant Khodade Signed-off-by: Prekshi Vyas Signed-off-by: Prekshi Vyas Co-authored-by: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> --- crates/openshell-driver-mxc/README.md | 10 +- crates/openshell-driver-mxc/src/driver.rs | 214 +++++++++++++++------- 2 files changed, 149 insertions(+), 75 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 7f595bf19c..81511deabd 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -163,11 +163,11 @@ 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. In curated-environment mode, the driver stages the public CA files -under the authorized `/.openshell-proxy/` directory. Other -environment modes grant the sandbox's unique public-CA directory as an internal -read-write share. The directory contains only public CA certificates; -the ephemeral CA private key remains in the host proxy's memory. The driver +process env. In every environment mode, the driver stages the public CA files +under the authorized `/.openshell-proxy/` directory. The host +proxy's private temporary directory is never added to the sandbox's read-write +shares. The staged 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 diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index a2192d36ff..8cf5e17a47 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -693,6 +693,25 @@ fn stage_tls_ca_files( Ok(Some((staged_ca, staged_bundle))) } +/// Resolve the CA paths to hand the sandboxed agent for TLS trust. +/// +/// A curated `ProcessContainer` cannot read the host proxy's private temp +/// folder, regardless of env tier -- stage only the public CA material +/// beneath `share_dir`, whose `AppContainer` DACL is already granted by the +/// policy, so HTTPS clients can authenticate the `OpenShell` inspection proxy +/// without broadening filesystem access. Staging must happen whenever a +/// host proxy CA exists at all, independent of `pc_minimal_env`. +fn resolve_agent_proxy_ca_paths( + host_proxy_ca_paths: Option<&(PathBuf, PathBuf)>, + share_dir: &str, + sandbox_id: &str, +) -> std::io::Result> { + if host_proxy_ca_paths.is_none() { + return Ok(None); + } + stage_tls_ca_files(host_proxy_ca_paths, share_dir, sandbox_id) +} + /// PROTOTYPE (2026-09-10): env-var-based governed egress, as an alternative /// to MXC's own `network.proxy`/`runtimeConfig.networkProxy` transparent /// redirect (both confirmed broken for this driver's use case -- see @@ -850,25 +869,6 @@ fn quote_windows_argument(arg: &str) -> String { quoted.push('"'); quoted } -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(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); @@ -1608,31 +1608,23 @@ async fn run_lifecycle( let host_proxy_ca_paths = host_proxy .as_ref() .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); - // A curated ProcessContainer cannot read the host's private temp folder. - // Stage only the public CA material beneath the per-sandbox working directory, - // DACL is already granted by the policy, so HTTPS clients can authenticate - // the OpenShell inspection proxy without broadening filesystem access. - let agent_proxy_ca_paths = if config.pc_minimal_env && host_proxy_ca_paths.is_some() { - match stage_tls_ca_files( - host_proxy_ca_paths.as_ref(), - &sandbox_config.cwd, - &sandbox_id, - ) { - Ok(paths) => paths, - Err(error) => { - set_failed( - ®istry, - &watch_tx, - &sandbox, - &sandbox_id, - &format!("failed to stage MXC egress proxy CA files: {error}"), - ) - .await; - return; - } + let agent_proxy_ca_paths = match resolve_agent_proxy_ca_paths( + host_proxy_ca_paths.as_ref(), + &sandbox_config.cwd, + &sandbox_id, + ) { + Ok(paths) => paths, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to stage MXC egress proxy CA files: {error}"), + ) + .await; + return; } - } else { - host_proxy_ca_paths.clone() }; if let Some(addr) = proxy_addr { { @@ -1650,10 +1642,7 @@ async fn run_lifecycle( )); } - let mut readwrite_paths = mapped.readwrite_paths; - if !config.pc_minimal_env { - append_tls_readwrite_grant(&mut readwrite_paths, host_proxy_ca_paths.as_ref()); - } + let readwrite_paths = mapped.readwrite_paths; let readonly_paths = mapped.readonly_paths; let ui = mapped.ui; let filesystem = MxcFilesystem { @@ -1689,8 +1678,8 @@ async fn run_lifecycle( .map(|(k, v)| format!("{k}={v}")) .collect(); append_provider_child_env(&mut env, provider_credentials.as_ref()); - // Layer proxy configuration for every env tier. Curated ProcessContainers - // use the staged CA copies above; other tiers use the original paths. + // Layer proxy configuration for every env tier using the staged CA copies + // above. The host proxy's private temporary directory is never shared. append_tls_env_vars(&mut env, agent_proxy_ca_paths.as_ref()); append_proxy_env_vars(&mut env, proxy_addr, proxy_auth.as_ref()); env.sort(); // deterministic order for logging / debugging @@ -2951,6 +2940,8 @@ mod lifecycle_tests { let shell = std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); + let workload = tempfile::tempdir().expect("temporary workload directory"); + let workload_dir = workload.path().to_string_lossy().into_owned(); let mut policy = fs_policy(&[]); policy.network_policies.insert( "github".to_string(), @@ -2965,10 +2956,19 @@ mod lifecycle_tests { provider_credentialed: true, ..Default::default() }], - binaries: vec![NetworkBinary { path: shell }], + binaries: vec![NetworkBinary { + path: shell.clone(), + }], }, ); - let mut sandbox = with_policy(driver_sandbox("sb-provider-env"), policy); + let mut sandbox = with_policy( + driver_sandbox_with_command( + "sb-provider-env", + &workload_dir, + vec![shell, "/c".into(), "exit 0".into()], + ), + policy, + ); sandbox .spec .as_mut() @@ -3117,6 +3117,49 @@ mod lifecycle_tests { assert_eq!(stage_tls_ca_files(None, "", "").unwrap(), None); } + #[test] + fn resolve_agent_proxy_ca_paths_stages_regardless_of_env_tier() { + // Regression test for the bug where CA staging was gated behind + // `config.pc_minimal_env`, so the default env tier (`pc_minimal_env + // == false`) left the agent pointed at the host proxy's private, + // AppContainer-unreadable temp directory instead of a staged copy. + // `resolve_agent_proxy_ca_paths` takes no env-tier argument at all, + // so this can't regress silently. + let source = tempfile::tempdir().unwrap(); + let share = tempfile::tempdir().unwrap(); + let ca = source.path().join("source-ca.pem"); + let bundle = source.path().join("source-bundle.pem"); + std::fs::write(&ca, b"ca").unwrap(); + std::fs::write(&bundle, b"bundle").unwrap(); + let host_proxy_ca_paths = (ca, bundle); + + let resolved = resolve_agent_proxy_ca_paths( + Some(&host_proxy_ca_paths), + share.path().to_str().expect("UTF-8 test path"), + "sandbox-default-env-tier", + ) + .unwrap() + .expect("resolved paths"); + + assert_eq!( + resolved.0.parent().unwrap(), + share + .path() + .join(".openshell-proxy") + .join("sandbox-default-env-tier") + ); + assert_ne!(resolved.0, host_proxy_ca_paths.0); + assert_ne!(resolved.1, host_proxy_ca_paths.1); + } + + #[test] + fn resolve_agent_proxy_ca_paths_is_none_without_a_host_proxy() { + assert_eq!( + resolve_agent_proxy_ca_paths(None, "unused-share", "sandbox-a").unwrap(), + None + ); + } + #[test] fn proxy_env_replaces_inherited_values_and_clears_bypass_rules() { let mut env = vec![ @@ -3208,19 +3251,6 @@ mod lifecycle_tests { 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]); - } - #[test] fn windows_command_line_preserves_argument_boundaries() { assert_eq!( @@ -3415,18 +3445,22 @@ mod lifecycle_tests { ); assert!(recorded["network"].get("proxy").is_none()); - let proxy_addr = { + let (proxy_addr, host_proxy_ca_paths) = { let registry = backend.registry.lock().await; let entry = registry.get(sandbox_id).expect("registry entry"); - assert!( - entry.host_proxy.is_some(), - "{sandbox_id}: governed egress must hold a live host proxy" - ); + let host_proxy = entry.host_proxy.as_ref().unwrap_or_else(|| { + panic!("{sandbox_id}: governed egress must hold a live host proxy") + }); assert_eq!( entry.trimmed_policy.as_ref().unwrap().network_policies, policy.network_policies ); - entry.proxy_addr.expect("proxy address") + ( + entry.proxy_addr.expect("proxy address"), + host_proxy + .ca_file_paths() + .expect("governed egress proxy must expose public CA paths"), + ) }; tokio::time::timeout( Duration::from_secs(2), @@ -3437,6 +3471,46 @@ mod lifecycle_tests { .expect("proxy listener must accept connections"); let child_env = recorded["process"]["env"].as_array().expect("child env"); + let tls_env = child_env + .iter() + .filter_map(serde_json::Value::as_str) + .filter_map(|entry| entry.split_once('=')) + .filter(|(key, _)| TLS_ENV_KEYS.contains(key)) + .collect::>(); + assert_eq!( + tls_env.len(), + TLS_ENV_KEYS.len(), + "{sandbox_id}: every TLS trust variable must be replaced" + ); + let staged_ca_dir = PathBuf::from(&share) + .join(".openshell-proxy") + .join(sandbox_id); + for key in TLS_ENV_KEYS { + let path = PathBuf::from( + tls_env + .get(key) + .unwrap_or_else(|| panic!("{sandbox_id}: missing {key}")), + ); + assert_eq!( + path.parent(), + Some(staged_ca_dir.as_path()), + "{sandbox_id}: {key} must use the staged CA directory" + ); + assert!(path.is_file(), "{sandbox_id}: staged {key} path must exist"); + } + let host_ca_dir = host_proxy_ca_paths + .0 + .parent() + .expect("host CA path must have a parent"); + assert!( + recorded["filesystem"]["readwritePaths"] + .as_array() + .expect("read-write paths") + .iter() + .filter_map(serde_json::Value::as_str) + .all(|path| Path::new(path) != host_ca_dir), + "{sandbox_id}: the host proxy CA directory must not be writable" + ); let proxy_env = child_env .iter() .filter_map(serde_json::Value::as_str) From a69d0319f22442866fb8a344bfaaa1e3085801e6 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:52:21 -0700 Subject: [PATCH 09/39] test(windows): define GB300 MXC qualification contract (NVBug 6643699) (#3471) * test(windows): define GB300 MXC qualification contract Signed-off-by: Prekshi Vyas * test(mxc): harden GB300 qualification provenance Signed-off-by: Prekshi Vyas * test(windows): package portable GB300 qualification Signed-off-by: Prekshi Vyas * test(windows): honor external qualification checkout Signed-off-by: Prekshi Vyas * revert: remove portable GB300 packaging Signed-off-by: Prekshi Vyas * test(mxc): bind GB300 evidence to exact inputs Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- .../build-openshell-mxc-windows/SKILL.md | 12 + architecture/windows-msvc-build.md | 11 + crates/openshell-driver-mxc/README.md | 14 +- .../qualification/README.md | 86 +++ .../qualification/gb300-woa.json | 597 ++++++++++++++++++ .../qualification/run-gb300-woa.ps1 | 507 +++++++++++++++ .../qualification/validate.py | 524 +++++++++++++++ python/gb300_mxc_qualification_test.py | 307 +++++++++ tasks/scripts/windows-msvc.ps1 | 54 +- tasks/windows.toml | 14 + 10 files changed, 2120 insertions(+), 6 deletions(-) create mode 100644 crates/openshell-driver-mxc/qualification/README.md create mode 100644 crates/openshell-driver-mxc/qualification/gb300-woa.json create mode 100644 crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 create mode 100644 crates/openshell-driver-mxc/qualification/validate.py create mode 100644 python/gb300_mxc_qualification_test.py diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 1120e6cfcc..0ee92ed6a7 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -168,6 +168,15 @@ neither. Both are skip-safe (they print a SKIP reason and exit 0 when 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 GB300 Windows ARM64 qualification, do not use the skip-safe developer task +as release evidence. `windows:test:mxc-gb300:arm64` runs the required +ProcessContainer subset and fails on any required `SKIP`. +`windows:qualify:mxc:gb300:contract` validates the static coverage matrix, and +`windows:qualify:mxc:gb300` runs the complete source, host, build/test, policy +E2E, and OpenClaw gate. Its inputs and evidence contract are documented in +`crates/openshell-driver-mxc/qualification/README.md`. Native-x64 NemoClaw and +x64 Windows results never satisfy this ARM64 contract. + For full validation, detect the Windows host architecture first and choose the native lane dynamically: @@ -257,6 +266,9 @@ crypto dependency builds. | `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:test:mxc-gb300:arm64` | Runs the required native ARM64 ProcessContainer subset and fails when a test skips. | +| `windows:qualify:mxc:gb300:contract` | Validates the required/optional/unsupported/architecture-constrained GB300 matrix. | +| `windows:qualify:mxc:gb300` | Runs the fail-closed GB300 ARM64 gate and validates hash-bound evidence. | | `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. | diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index 8795e78776..cd5fbd0615 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -90,6 +90,10 @@ Windows validation is exposed through `tasks/windows.toml`: | `windows:test:arm64` | Run the same suite natively on ARM64. | | `windows:test:unsupported:x64` | Run focused gateway-composition tests for unsupported driver contracts. | | `windows:test:unsupported:arm64` | Run the same focused contracts natively on ARM64. | +| `windows:test:mxc-real:arm64` | Run the native real-MXC developer suite; missing hardware remains skip-safe. | +| `windows:test:mxc-gb300:arm64` | Run the required real-MXC subset on native ARM64 and fail on any required skip. | +| `windows:qualify:mxc:gb300:contract` | Validate the static NVBug 6643699 scope matrix on any development host. | +| `windows:qualify:mxc:gb300` | Execute the complete native GB300 ARM64 gate and produce hash-bound evidence. | | `windows:ci` | Run check, build, test, unsupported-contract tests, and artifact reporting. | The Windows tasks call `tasks/scripts/windows-msvc.ps1`. The wrapper discovers @@ -185,6 +189,13 @@ native rather than emulated coverage. ## Validation Contract +The generic real-MXC lanes are diagnostics and deliberately remain skip-safe. +They cannot establish hardware qualification when a prerequisite or backend is +absent. GB300 release evidence uses the separate fail-closed contract in +[`crates/openshell-driver-mxc/qualification/`](../crates/openshell-driver-mxc/qualification/README.md). +That matrix separates native Windows ARM64 evidence from x64-only NemoClaw and +Windows lanes, and records all hardware-dependent exclusions explicitly. + A successful Windows build report should include: - x64 and ARM64 `cargo check` status. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 81511deabd..469c7a4820 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -194,16 +194,24 @@ This example uses `process_container`. The `IsoSessionApp.dll` and ## 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. +The generic real-`wxc-exec.exe` tasks are **skip-safe**: a test or scenario that +requires an absent binary or backend prints a SKIP reason and exits 0. They are +useful developer diagnostics, but a skipped run is not qualification evidence. +The GB300 task is deliberately strict and fails on every required skip. | 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:test:mxc-real:arm64` | Native ARM64 `tests/wxc_exec_real.rs` with the same contract | Pre-merge on an ARM64 Windows host with `wxc-exec` | +| `windows:test:mxc-gb300:arm64` | Required ARM64 ProcessContainer cases from `tests/wxc_exec_real.rs`; rejects x64 and every required `SKIP` | GB300 qualification only; requires a live backend and all prerequisites | | `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 | +| `windows:qualify:mxc:gb300` | Complete source, host, ARM64 build/test, strict MXC, policy E2E, OpenClaw, and hash-bound evidence contract | Review/release evidence on a native GB300 Windows ARM64 host | + +The exact required, optional, unsupported, and architecture-constrained GB300 +matrix is documented and machine-validated in +[`qualification/`](qualification/README.md). Native-x64 NemoClaw and Windows x64 +lanes are explicitly separate and cannot receive GB300 ARM64 credit. **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, diff --git a/crates/openshell-driver-mxc/qualification/README.md b/crates/openshell-driver-mxc/qualification/README.md new file mode 100644 index 0000000000..7397fc2dc3 --- /dev/null +++ b/crates/openshell-driver-mxc/qualification/README.md @@ -0,0 +1,86 @@ +# GB300 Windows ARM64 MXC qualification + +This directory is the repository-owned qualification contract for NVBug 6643699. +It closes the test-plan ambiguity without treating it as a product defect. + +[`gb300-woa.json`](gb300-woa.json) is the authoritative coverage matrix. Every +row declares its disposition, owner, architecture, hardware dependency, +prerequisites, command, pass criteria, skip policy, and required artifact roles. +[`validate.py`](validate.py) rejects a contract that weakens the required rows +and validates retained evidence by SHA256. [`run-gb300-woa.ps1`](run-gb300-woa.ps1) +executes the required native lane and produces that evidence. + +## Scope decisions + +- The release target is a **native GB300 Windows ARM64 host** using the MXC + `process_container` backend. WSL and emulated/cross-built test execution do + not receive credit. +- OpenClaw is the one required agent application because this repository owns + its MXC launch and dynamic-forwarding harness. The caller must supply a + versioned ARM64 Node.js binary and OpenClaw package. +- OpenShell does not choose or ship inference models. Model qualification and + additional agent applications are optional, application-owned evidence and + cannot replace an OpenShell/MXC gate. +- NemoClaw's native-x64 qualification guard is a separate application lane. It + cannot satisfy any GB300 ARM64 row. The bundled Node/OpenClaw install helper + is also x64-only; GB300 runs must not use its Node.js output. +- `isolation_session` is optional because it depends on the OS build, + `IsoSessionApp.dll`, and the `wxc-exec` build. Its absence does not weaken the + required ProcessContainer gate. +- MXC process-policy mapping, GPU passthrough, and interactive exec/connect are + unsupported. Windows Docker, Kubernetes, Podman, and VM drivers remain + unsupported. Host GPU presence is not evidence of MXC GPU passthrough. + +## Coverage summary + +The JSON manifest contains the full prerequisites, pass/fail criteria, and +artifact contract. This compact view makes the ownership boundary reviewable. + +| Disposition | Coverage IDs | Owner boundary | +|---|---|---| +| Required | `contract-integrity`, `source-provenance`, `gb300-arm64-host`, `arm64-msvc-check`, `arm64-release-build`, `arm64-native-tests`, `arm64-unsupported-driver-contracts`, `processcontainer-real-mxc`, `processcontainer-policy-e2e`, `openclaw-arm64-forward` | OpenShell development owns repository tests; GB300 QA owns host execution and evidence; the agent-app owner supplies versioned ARM64 OpenClaw inputs | +| Optional | `isolation-session`, `provider-credential-injection`, `etw-ocsf-audit`, `local-inference-models`, `additional-agent-apps` | The named component or product owner decides whether to attach extra evidence | +| Unsupported | `wsl`, `windows-non-mxc-drivers`, `mxc-process-policy`, `mxc-gpu-passthrough`, `mxc-interactive-exec` | No qualification claim is permitted | +| Architecture-constrained | `nemoclaw-native-x64`, `node-openclaw-x64-installer`, `windows-x64-lanes` | Native x64 lanes stay separate and receive no GB300 ARM64 credit | + +Required coverage is fail-closed: a missing prerequisite, `SKIP`, non-zero exit, +non-ARM64 result, missing artifact role, empty artifact, path outside the evidence +root, or SHA256 mismatch fails validation. Generic real-MXC developer tasks remain +skip-safe and are not qualification evidence. + +## Validate the contract + +Static validation and regression tests do not require Windows or MXC hardware: + +```text +uv run python crates/openshell-driver-mxc/qualification/validate.py contract +uv run pytest python/gb300_mxc_qualification_test.py +``` + +## Execute on GB300 + +Start from a clean review commit based directly on the freshly fetched +`origin/windows` tip. Run from elevated native ARM64 PowerShell: + +```powershell +$env:OPENSHELL_GB300_BASE_SHA = '' +$env:OPENSHELL_GB300_HARDWARE_ATTESTATION = 'GB300' +$env:OPENSHELL_WXC_EXEC_PATH = 'C:\mxc-kit\bin\wxc-exec.exe' +$env:OPENSHELL_GB300_NODE_PATH = 'C:\path\to\arm64\node.exe' +$env:OPENSHELL_GB300_OPENCLAW_DIR = 'C:\path\to\node_modules\openclaw' +$env:OPENSHELL_GB300_EVIDENCE_DIR = 'D:\evidence\openshell-gb300-' + +mise run --skip-tools windows:qualify:mxc:gb300 +``` + +The runner fetches `origin/windows`, rejects a moved or unrelated base, requires +a clean worktree, verifies the PE machine for `wxc-exec.exe` and `node.exe`, and +stages only tracked examples plus freshly built ARM64 binaries. It never treats +an existing source tree or an earlier result bundle as current evidence. + +The evidence directory contains environment/source provenance, the MXC host +probe, command logs with timings, ARM64 binary hashes, the complete MXC policy +and OpenClaw bundles, `evidence.json`, and the final validation log. Do not put +provider credentials in command lines or retained files. Optional credential +coverage must use a scoped non-production credential and prove that retained +artifacts contain no secret value. diff --git a/crates/openshell-driver-mxc/qualification/gb300-woa.json b/crates/openshell-driver-mxc/qualification/gb300-woa.json new file mode 100644 index 0000000000..f6d4e6c72e --- /dev/null +++ b/crates/openshell-driver-mxc/qualification/gb300-woa.json @@ -0,0 +1,597 @@ +{ + "schema_version": 1, + "contract_id": "nvbug-6643699-gb300-woa-mxc", + "nvbug": 6643699, + "title": "GB300 native Windows ARM64 OpenShell MXC qualification", + "target": { + "hardware": "GB300", + "operating_system": "windows", + "architecture": "arm64", + "rust_target": "aarch64-pc-windows-msvc", + "execution": "native", + "wsl": false, + "base_ref": "origin/windows" + }, + "decisions": { + "required_agent_apps": [ + "OpenClaw" + ], + "target_models": [], + "model_scope": "agent_application_owned", + "model_release_gate": false, + "model_rationale": "OpenShell provides the MXC sandbox and governed transport; it does not select or ship local-inference models. Product Management and the agent-app owner may attach optional model evidence without replacing an OpenShell/MXC gate.", + "required_skip_policy": "fail", + "nemoclaw_credit": "Native-x64 NemoClaw qualification is a separate gate and cannot satisfy native Windows ARM64 OpenShell/MXC coverage." + }, + "owners": { + "openshell_dev": { + "name": "Shailendra Singh", + "responsibility": "OpenShell MXC implementation, build, and repository-owned tests" + }, + "gb300_qa": { + "name": "Rohit Varma Kalidindi", + "responsibility": "GB300 host execution, evidence custody, and release verdict" + }, + "sw_program": { + "name": "Eric Tam", + "responsibility": "Cross-team schedule and unresolved dependency coordination" + }, + "product": { + "name": "Abhishek Gore", + "responsibility": "Agent-app and model product-scope changes" + }, + "agent_app": { + "name": "Agent application owner", + "responsibility": "Versioned ARM64 application/runtime inputs and application-level acceptance" + }, + "mxc_platform": { + "name": "MXC platform owner", + "responsibility": "GB300 wxc-exec build, enabled backend, and platform prerequisites" + } + }, + "matrix": [ + { + "id": "contract-integrity", + "title": "Qualification contract integrity", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "any", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Python 3.11 or newer", + "Repository checkout containing this manifest and validator" + ], + "commands": [ + "uv run python crates/openshell-driver-mxc/qualification/validate.py contract" + ], + "pass_criteria": [ + "Validator exits 0", + "Every matrix row has a valid disposition, owner, prerequisites, pass criteria, and artifact contract", + "Required ARM64 rows are non-skippable and x64-only rows cannot receive ARM64 credit" + ], + "artifact_roles": [ + "contract_validation_log" + ] + }, + { + "id": "source-provenance", + "title": "Exact windows-tip source provenance", + "disposition": "required", + "owner": "gb300_qa", + "architecture": "any", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Fresh fetch of the NVIDIA/OpenShell origin remote", + "Expected base SHA recorded before qualification", + "Clean worktree based directly on that origin/windows SHA" + ], + "commands": [ + "git fetch --prune origin windows", + "git rev-parse refs/remotes/origin/windows", + "git merge-base HEAD refs/remotes/origin/windows", + "git status --porcelain=v1" + ], + "pass_criteria": [ + "Fetched origin/windows equals the expected 40-character base SHA", + "HEAD descends directly from that base SHA", + "The worktree is clean before evidence collection" + ], + "artifact_roles": [ + "environment_and_source_provenance" + ] + }, + { + "id": "gb300-arm64-host", + "title": "Native GB300 Windows ARM64 host and MXC preflight", + "disposition": "required", + "owner": "gb300_qa", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "GB300 Windows host", + "Native ARM64 process architecture", + "wxc-exec.exe built for ARM64", + "ProcessContainer backend enabled", + "Elevated PowerShell for MXC loopback and AppContainer setup" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 -WxcExecPath -OutFile /mxc-host-capabilities.json -Full" + ], + "pass_criteria": [ + "Host OS is Windows and RuntimeInformation.OSArchitecture is Arm64", + "Execution is not WSL or emulation", + "Operator hardware attestation identifies GB300", + "wxc-exec PE machine is ARM64", + "Probe verdicts processcontainer=live and dryRun=ok" + ], + "artifact_roles": [ + "environment_and_source_provenance", + "mxc_host_capabilities", + "mxc_host_probe_log" + ] + }, + { + "id": "arm64-msvc-check", + "title": "ARM64 MSVC workspace check", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Visual Studio ARM64 compiler, Spectre libraries, Clang, CMake, and Windows SDK", + "Rust target aarch64-pc-windows-msvc" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 check aarch64-pc-windows-msvc -LogDir " + ], + "pass_criteria": [ + "Command exits 0", + "Windows-supported workspace and gateway dependency-boundary check both pass" + ], + "artifact_roles": [ + "arm64_check_log" + ] + }, + { + "id": "arm64-release-build", + "title": "ARM64 release artifacts", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "ARM64 MSVC check prerequisites", + "Pinned prebuilt Z3 runtime or a complete approved override" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 build aarch64-pc-windows-msvc -LogDir ", + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 artifacts aarch64-pc-windows-msvc" + ], + "pass_criteria": [ + "Release build exits 0", + "openshell-gateway.exe, openshell.exe, openshell-supervisor-relay.exe, and libz3.dll exist for aarch64-pc-windows-msvc", + "Size and SHA256 are recorded for every artifact" + ], + "artifact_roles": [ + "arm64_build_log", + "arm64_binary_manifest" + ] + }, + { + "id": "arm64-native-tests", + "title": "Native ARM64 Windows workspace tests", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "ARM64 release-build prerequisites", + "Native ARM64 test runner" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit aarch64-pc-windows-msvc -LogDir " + ], + "pass_criteria": [ + "Command exits 0", + "The CI nextest profile and server test-support suite pass natively" + ], + "artifact_roles": [ + "arm64_test_log" + ] + }, + { + "id": "arm64-unsupported-driver-contracts", + "title": "Unsupported Windows compute-driver boundaries", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": false, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Native ARM64 test runner" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-unsupported aarch64-pc-windows-msvc -LogDir " + ], + "pass_criteria": [ + "Focused unsupported-driver tests exit 0", + "Protocol-only, MXC-only, Docker-stub-only, and MXC-plus-Docker-stub compositions pass" + ], + "artifact_roles": [ + "unsupported_driver_contract_log" + ] + }, + { + "id": "processcontainer-real-mxc", + "title": "Strict live ProcessContainer integration", + "disposition": "required", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Live ARM64 wxc-exec ProcessContainer backend", + "Windows curl.exe", + "Network path to the allowlisted HTTPS fixture" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-mxc-gb300 aarch64-pc-windows-msvc -LogDir " + ], + "pass_criteria": [ + "Every required dry-run and ProcessContainer test passes", + "No required test emits SKIP", + "In-policy write succeeds, out-of-policy write is denied, and governed HTTPS reads the injected CA bundle" + ], + "artifact_roles": [ + "strict_real_mxc_log" + ] + }, + { + "id": "processcontainer-policy-e2e", + "title": "Tier-3 ProcessContainer policy scenarios", + "disposition": "required", + "owner": "gb300_qa", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Live ProcessContainer backend", + "Fresh ARM64 gateway, CLI, relay, and Z3 artifacts staged with tracked fixtures" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File run-mxc-e2e.ps1 -WxcExecPath -Backend process_container" + ], + "pass_criteria": [ + "fs-rw, fs-readonly, fs-default-deny, and network-reject all PASS", + "Summary is PASS=4 FAIL=0 SKIP=0", + "The exact rendered TOML, policies, gateway logs, transcript, and summary are retained" + ], + "artifact_roles": [ + "mxc_policy_e2e_log", + "mxc_policy_e2e_bundle" + ] + }, + { + "id": "openclaw-arm64-forward", + "title": "OpenClaw ARM64 MXC launch and dynamic forwarding", + "disposition": "required", + "owner": "agent_app", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": true, + "allow_skip": false, + "prerequisites": [ + "Caller-supplied ARM64 node.exe", + "Versioned OpenClaw package containing openclaw.mjs", + "Live ProcessContainer backend", + "Elevated PowerShell", + "Allowed HTTPS endpoint available to the governed-egress proof" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File run-openclaw-forward-test.ps1 -Backend process_container -WxcExecPath -NodeExePath -OpenClawInstallDir " + ], + "pass_criteria": [ + "node.exe PE machine is ARM64", + "OpenClaw package version is captured", + "Sandbox reaches ready and the authenticated host-side OpenClaw client returns ok=true through openshell forward service", + "Allowed HTTPS succeeds while denied host and direct Internet bypass remain blocked" + ], + "artifact_roles": [ + "openclaw_forward_log", + "openclaw_forward_bundle" + ] + }, + { + "id": "isolation-session", + "title": "MXC isolation_session backend", + "disposition": "optional", + "owner": "mxc_platform", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Windows build 26300.8553 or newer", + "IsoSessionApp.dll and isolation_session support in wxc-exec" + ], + "commands": [ + "mise run --skip-tools windows:test:mxc-real:arm64", + "powershell -NoProfile -ExecutionPolicy Bypass -File run-openclaw-forward-test.ps1 -Backend isolation_session " + ], + "pass_criteria": [ + "If executed, provision-start-exec-stop-deprovision completes and cleanup leaves no orphan", + "Absence is recorded with the probe reason and does not fail the GB300 ProcessContainer gate" + ], + "artifact_roles": [ + "optional_isolation_session_log" + ] + }, + { + "id": "provider-credential-injection", + "title": "MXC provider credential substitution", + "disposition": "optional", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Non-production scoped provider credential", + "Live ProcessContainer governed-egress backend" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File run-provider-credential-test.ps1" + ], + "pass_criteria": [ + "If executed, placeholder substitution succeeds only at the bound endpoint", + "No credential or bearer value appears in retained artifacts" + ], + "artifact_roles": [ + "optional_provider_credential_bundle" + ] + }, + { + "id": "etw-ocsf-audit", + "title": "MXC ETW to OCSF audit", + "disposition": "optional", + "owner": "openshell_dev", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Elevated ETW session access", + "Windows Sandboxing event providers enabled" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File run-ocsf-audit.ps1" + ], + "pass_criteria": [ + "If executed, events are attributed to the owned sandbox and secret-free OCSF JSONL is retained" + ], + "artifact_roles": [ + "optional_ocsf_bundle" + ] + }, + { + "id": "local-inference-models", + "title": "Agent-app local inference models", + "disposition": "optional", + "owner": "product", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Product-selected model and runtime build", + "Agent-app-owned inference acceptance criteria" + ], + "commands": [], + "pass_criteria": [ + "No model is implicitly claimed by the OpenShell contract", + "If supplied, optional evidence records exact model ID, model hash or version, inference runtime, prompt outcome, and latency" + ], + "artifact_roles": [ + "optional_model_evidence" + ] + }, + { + "id": "additional-agent-apps", + "title": "Agent applications other than OpenClaw", + "disposition": "optional", + "owner": "sw_program", + "architecture": "native_arm64", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Named application owner", + "Versioned native ARM64 build", + "Application-specific pass/fail oracle" + ], + "commands": [], + "pass_criteria": [ + "Applications are added explicitly rather than inferred from generic OpenShell coverage", + "Optional app evidence cannot replace the required OpenClaw or MXC rows" + ], + "artifact_roles": [ + "optional_agent_app_evidence" + ] + }, + { + "id": "wsl", + "title": "WSL execution", + "disposition": "unsupported", + "owner": "product", + "architecture": "none", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "No WSL result is accepted as native WoA evidence" + ], + "artifact_roles": [], + "reason": "NVBug 6643699 explicitly scopes GB300 coverage to native Windows-on-Arm and states WSL: No." + }, + { + "id": "windows-non-mxc-drivers", + "title": "Docker, Kubernetes, Podman, and VM compute drivers on Windows", + "disposition": "unsupported", + "owner": "openshell_dev", + "architecture": "none", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "Focused rejection contracts pass; no unsupported driver is presented as GB300 runtime coverage" + ], + "artifact_roles": [], + "reason": "The Windows gateway retains explicit unsupported stubs; only the in-process MXC compute driver is a native Windows runtime." + }, + { + "id": "mxc-process-policy", + "title": "Portable process policy enforcement in MXC", + "disposition": "unsupported", + "owner": "openshell_dev", + "architecture": "none", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "Explicit process policy is rejected rather than silently weakened" + ], + "artifact_roles": [], + "reason": "MXC supplies OS isolation but has no supported mapping for OpenShell portable process policy." + }, + { + "id": "mxc-gpu-passthrough", + "title": "GPU passthrough through the MXC driver", + "disposition": "unsupported", + "owner": "product", + "architecture": "none", + "hardware_dependent": true, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "No GPU passthrough claim is made from host GPU presence" + ], + "artifact_roles": [], + "reason": "GPU passthrough is not pursued by the host-side-governance MXC design." + }, + { + "id": "mxc-interactive-exec", + "title": "Interactive exec and connect", + "disposition": "unsupported", + "owner": "openshell_dev", + "architecture": "none", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "None" + ], + "commands": [], + "pass_criteria": [ + "Dynamic service forwarding is tested without claiming interactive exec/connect support" + ], + "artifact_roles": [], + "reason": "The supervisor relay supports launch, shutdown, and dynamic forwarding but not the Linux ConnectSupervisor protocol." + }, + { + "id": "nemoclaw-native-x64", + "title": "NemoClaw native-x64 qualification", + "disposition": "architecture_constrained", + "owner": "agent_app", + "architecture": "native_x64", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Native x64 host required by the NemoClaw qualification guard" + ], + "commands": [], + "pass_criteria": [ + "Run only in the separately owned native-x64 lane", + "Never use its result to satisfy a GB300 Windows ARM64 row" + ], + "artifact_roles": [], + "reason": "The current NemoClaw qualification guard requires native x64; this contract qualifies OpenShell/MXC on native ARM64 independently." + }, + { + "id": "node-openclaw-x64-installer", + "title": "Bundled win-x64 Node.js installer helper", + "disposition": "architecture_constrained", + "owner": "agent_app", + "architecture": "native_x64", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "x64-compatible Windows execution" + ], + "commands": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File install-nodejs-openclaw.ps1" + ], + "pass_criteria": [ + "The helper remains usable for x64 development", + "GB300 qualification instead supplies an independently versioned ARM64 node.exe and verifies its PE machine" + ], + "artifact_roles": [], + "reason": "The helper downloads node-v-win-x64.zip and therefore cannot establish native ARM64 application coverage." + }, + { + "id": "windows-x64-lanes", + "title": "Windows x64 build and test lanes", + "disposition": "architecture_constrained", + "owner": "openshell_dev", + "architecture": "native_x64", + "hardware_dependent": false, + "credits_gb300_arm64": false, + "allow_skip": true, + "prerequisites": [ + "Native x64 Windows host" + ], + "commands": [ + "mise run --skip-tools windows:ci", + "mise run --skip-tools windows:test:mxc-real:x64" + ], + "pass_criteria": [ + "Results remain x64 regression evidence only", + "Cross-builds and emulated execution cannot satisfy native ARM64 rows" + ], + "artifact_roles": [], + "reason": "GB300 WoA qualification requires native ARM64 execution." + } + ] +} diff --git a/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 b/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 new file mode 100644 index 0000000000..8823490f1c --- /dev/null +++ b/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 @@ -0,0 +1,507 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Execute the NVBug 6643699 GB300 native Windows ARM64 qualification contract. +# Evidence is written outside the checkout and hash-bound by validate.py. + +[CmdletBinding()] +param( + [string] $ExpectedBaseSha = $env:OPENSHELL_GB300_BASE_SHA, + [string] $WxcExecPath = $env:OPENSHELL_WXC_EXEC_PATH, + [string] $NodeExePath = $env:OPENSHELL_GB300_NODE_PATH, + [string] $OpenClawInstallDir = $env:OPENSHELL_GB300_OPENCLAW_DIR, + [string] $EvidenceDir = $env:OPENSHELL_GB300_EVIDENCE_DIR, + [ValidateSet("GB300")] + [string] $Hardware = $env:OPENSHELL_GB300_HARDWARE_ATTESTATION +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$ManifestPath = Join-Path $PSScriptRoot "gb300-woa.json" +$ValidatorPath = Join-Path $PSScriptRoot "validate.py" +$WindowsWrapper = Join-Path $RepoRoot "tasks\scripts\windows-msvc.ps1" +$ExampleSource = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$RustTarget = "aarch64-pc-windows-msvc" +$PowerShellExe = (Get-Process -Id $PID).Path +$Durations = @{} + +if ([string]::IsNullOrWhiteSpace($EvidenceDir)) { + $stamp = Get-Date -Format "yyyyMMdd-HHmmss" + $EvidenceDir = Join-Path $env:TEMP "openshell-gb300-qualification-$stamp" +} +$EvidenceDir = [System.IO.Path]::GetFullPath($EvidenceDir) +$repoPrefix = $RepoRoot.TrimEnd('\') + '\' +if ($EvidenceDir.StartsWith($repoPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "EvidenceDir must be outside the source checkout: $RepoRoot" +} +if (Test-Path $EvidenceDir) { + if (@(Get-ChildItem -LiteralPath $EvidenceDir -Force).Count -gt 0) { + throw "EvidenceDir must be new or empty: $EvidenceDir" + } +} else { + New-Item -ItemType Directory -Path $EvidenceDir | Out-Null +} +$EvidenceDir = (Resolve-Path $EvidenceDir).Path + +function Invoke-LoggedProcess( + [string] $CoverageId, + [string] $LogName, + [string] $FilePath, + [string[]] $Arguments +) { + $logPath = Join-Path $EvidenceDir $LogName + $timer = [System.Diagnostics.Stopwatch]::StartNew() + Write-Host "==> $CoverageId" + Write-Host " log: $logPath" + $savedErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & $FilePath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $savedErrorActionPreference + } + $timer.Stop() + $Durations[$CoverageId] = [Math]::Round($timer.Elapsed.TotalSeconds, 3) + @($output | ForEach-Object { $_.ToString() }) | Out-File -FilePath $logPath -Encoding utf8 + @($output) | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "$CoverageId failed with exit code $exitCode. See $logPath" + } +} + +function Invoke-Git([string[]] $Arguments) { + $savedErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & git -C $RepoRoot @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $savedErrorActionPreference + } + if ($exitCode -ne 0) { + throw "git $($Arguments -join ' ') failed: $($output -join [Environment]::NewLine)" + } + return @($output | ForEach-Object { $_.ToString() }) +} + +function Assert-Arm64Pe([string] $Path, [string] $Label) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "$Label was not found: $Path" + } + $stream = [System.IO.File]::OpenRead((Resolve-Path -LiteralPath $Path).Path) + try { + $reader = New-Object System.IO.BinaryReader($stream) + try { + $stream.Position = 0x3c + $peOffset = $reader.ReadInt32() + $stream.Position = $peOffset + 4 + $machine = $reader.ReadUInt16() + } finally { + $reader.Dispose() + } + } finally { + $stream.Dispose() + } + if ($machine -ne 0xaa64) { + throw "$Label must be an ARM64 PE (machine 0xAA64); found 0x$($machine.ToString('X4')) at $Path" + } +} + +function Write-Json([object] $Value, [string] $Path) { + $Value | ConvertTo-Json -Depth 20 | Out-File -FilePath $Path -Encoding utf8 +} + +function Get-Sha256([string] $Path) { + $stream = [System.IO.File]::OpenRead($Path) + try { + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace("-", "").ToLowerInvariant() + } finally { + $sha256.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Get-DirectorySha256([string] $Path) { + $root = (Resolve-Path -LiteralPath $Path).Path.TrimEnd('\') + '\' + $entries = @( + Get-ChildItem -LiteralPath $Path -File -Recurse -Force | ForEach-Object { + [pscustomobject]@{ + RelativePath = $_.FullName.Substring($root.Length).Replace('\', '/') + Sha256 = Get-Sha256 $_.FullName + } + } | Sort-Object RelativePath + ) + if ($entries.Count -eq 0) { + throw "Package directory contains no files: $Path" + } + $manifest = (($entries | ForEach-Object { "$($_.RelativePath)`0$($_.Sha256)" }) -join "`n") + "`n" + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($manifest) + return [BitConverter]::ToString($sha256.ComputeHash($bytes)).Replace("-", "").ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function New-EvidenceArtifact([string] $Role, [string] $Path) { + $resolved = (Resolve-Path -LiteralPath $Path).Path + $evidencePrefix = $EvidenceDir.TrimEnd('\') + '\' + if (-not $resolved.StartsWith($evidencePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Artifact is outside EvidenceDir: $resolved" + } + $relative = $resolved.Substring($evidencePrefix.Length).Replace('\', '/') + return [ordered]@{ + role = $Role + path = $relative + sha256 = Get-Sha256 $resolved + } +} + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows +)) { + throw "GB300 qualification requires Windows." +} +$osArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$processArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() +if ($osArchitecture -ne "Arm64" -or $processArchitecture -ne "Arm64") { + throw "GB300 qualification requires native ARM64; OS=$osArchitecture process=$processArchitecture" +} +if ($env:WSL_DISTRO_NAME -or $env:WSL_INTEROP) { + throw "WSL cannot satisfy the native GB300 Windows ARM64 contract." +} +if ($Hardware -ne "GB300") { + throw "Set OPENSHELL_GB300_HARDWARE_ATTESTATION=GB300 or pass -Hardware GB300 after verifying the host." +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "Run the qualification from an elevated PowerShell session." +} +if ([string]::IsNullOrWhiteSpace($WxcExecPath)) { + throw "Set OPENSHELL_WXC_EXEC_PATH or pass -WxcExecPath." +} +if ([string]::IsNullOrWhiteSpace($NodeExePath)) { + throw "Set OPENSHELL_GB300_NODE_PATH to a native ARM64 node.exe or pass -NodeExePath." +} +if ([string]::IsNullOrWhiteSpace($OpenClawInstallDir)) { + throw "Set OPENSHELL_GB300_OPENCLAW_DIR or pass -OpenClawInstallDir." +} +$WxcExecPath = (Resolve-Path -LiteralPath $WxcExecPath).Path +$NodeExePath = (Resolve-Path -LiteralPath $NodeExePath).Path +$OpenClawInstallDir = (Resolve-Path -LiteralPath $OpenClawInstallDir).Path +if (-not (Test-Path -LiteralPath (Join-Path $OpenClawInstallDir "openclaw.mjs") -PathType Leaf)) { + throw "OpenClawInstallDir must directly contain openclaw.mjs: $OpenClawInstallDir" +} +Assert-Arm64Pe $WxcExecPath "wxc-exec" +Assert-Arm64Pe $NodeExePath "Node.js" + +$sourceTimer = [System.Diagnostics.Stopwatch]::StartNew() +if ([string]::IsNullOrWhiteSpace($ExpectedBaseSha)) { + throw "ExpectedBaseSha is required. Set OPENSHELL_GB300_BASE_SHA to the reviewed origin/windows commit." +} +if ($ExpectedBaseSha -notmatch '^[0-9a-f]{40}$') { + throw "ExpectedBaseSha must be a full lowercase Git SHA." +} +$originUrl = (@(Invoke-Git @("remote", "get-url", "origin")))[0].Trim() +$normalizedOriginUrl = $originUrl.TrimEnd('/').ToLowerInvariant() +$canonicalOriginUrls = @( + "https://github.com/nvidia/openshell.git", + "https://github.com/nvidia/openshell", + "git@github.com:nvidia/openshell.git", + "ssh://git@github.com/nvidia/openshell.git" +) +if ($canonicalOriginUrls -notcontains $normalizedOriginUrl) { + throw "origin must be the canonical NVIDIA/OpenShell GitHub repository; found: $originUrl" +} +Invoke-Git @("fetch", "--prune", "origin", "windows") | ForEach-Object { Write-Host $_ } +$fetchedBaseSha = (@(Invoke-Git @("rev-parse", "refs/remotes/origin/windows")))[0].Trim() +if ($fetchedBaseSha -ne $ExpectedBaseSha) { + throw "origin/windows moved: expected $ExpectedBaseSha, fetched $fetchedBaseSha" +} +$headSha = (@(Invoke-Git @("rev-parse", "HEAD")))[0].Trim() +$mergeBase = (@(Invoke-Git @("merge-base", "HEAD", "refs/remotes/origin/windows")))[0].Trim() +if ($mergeBase -ne $ExpectedBaseSha) { + throw "HEAD is not based directly on the expected origin/windows tip: merge-base=$mergeBase expected=$ExpectedBaseSha" +} +$worktreeStatus = @(Invoke-Git @("status", "--porcelain=v1")) +if ($worktreeStatus.Count -gt 0 -and ($worktreeStatus -join '').Trim()) { + throw "Qualification requires a clean worktree. Commit or preserve changes first." +} +$sourceTimer.Stop() +$Durations["source-provenance"] = [Math]::Round($sourceTimer.Elapsed.TotalSeconds, 3) + +$uv = Get-Command uv -ErrorAction Stop +Invoke-LoggedProcess "contract-integrity" "contract-validation.log" $uv.Source @( + "run", "python", $ValidatorPath, "contract" +) + +$probePath = Join-Path $ExampleSource "probe-mxc-host.ps1" +$probeReport = Join-Path $EvidenceDir "mxc-host-capabilities.json" +Invoke-LoggedProcess "gb300-arm64-host" "mxc-host-probe.log" $PowerShellExe @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $probePath, + "-WxcExecPath", $WxcExecPath, "-OutFile", $probeReport, "-Full" +) +$probe = Get-Content -LiteralPath $probeReport -Raw | ConvertFrom-Json +if ($probe.verdicts.processcontainer -ne "live" -or $probe.verdicts.dryRun -ne "ok") { + throw "MXC preflight must report processcontainer=live and dryRun=ok. See $probeReport" +} + +$openClawSnapshotDir = Join-Path $EvidenceDir "openclaw-package" +New-Item -ItemType Directory -Path $openClawSnapshotDir | Out-Null +Get-ChildItem -LiteralPath $OpenClawInstallDir -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $openClawSnapshotDir -Recurse -Force +} +$OpenClawInstallDir = (Resolve-Path -LiteralPath $openClawSnapshotDir).Path +if (-not (Test-Path -LiteralPath (Join-Path $OpenClawInstallDir "openclaw.mjs") -PathType Leaf)) { + throw "The staged OpenClaw package must directly contain openclaw.mjs: $OpenClawInstallDir" +} + +$packageJsonPath = Join-Path $OpenClawInstallDir "package.json" +if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) { + throw "OpenClawInstallDir must contain a versioned package.json: $OpenClawInstallDir" +} +try { + $openClawPackage = Get-Content -LiteralPath $packageJsonPath -Raw | ConvertFrom-Json + $openClawVersion = [string]$openClawPackage.version +} catch { + throw "OpenClaw package.json is invalid or has no version: $packageJsonPath" +} +if ([string]::IsNullOrWhiteSpace($openClawVersion)) { + throw "OpenClaw package.json must declare a non-empty version: $packageJsonPath" +} +$openClawPackageSha256 = Get-DirectorySha256 $OpenClawInstallDir +$environmentPath = Join-Path $EvidenceDir "environment.json" +$environment = [ordered]@{ + contract_id = "nvbug-6643699-gb300-woa-mxc" + captured_at = (Get-Date).ToUniversalTime().ToString("o") + hardware = $Hardware + operating_system = "windows" + os_version = [System.Environment]::OSVersion.Version.ToString() + os_architecture = $osArchitecture + process_architecture = $processArchitecture + native = $true + wsl = $false + elevated = $true + origin_url = $originUrl + base_ref = "refs/remotes/origin/windows" + base_sha = $ExpectedBaseSha + head_sha = $headSha + merge_base = $mergeBase + worktree_clean = $true + rust_version = (& rustc --version).ToString() + node_version = (& $NodeExePath --version).ToString() + openclaw_version = $openClawVersion + openclaw_package_sha256 = $openClawPackageSha256 + openclaw_package_json_sha256 = Get-Sha256 $packageJsonPath + wxc_exec_path = $WxcExecPath + wxc_exec_sha256 = Get-Sha256 $WxcExecPath + node_path = $NodeExePath + node_sha256 = Get-Sha256 $NodeExePath +} +Write-Json $environment $environmentPath + +$env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath +$commonWrapperArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $WindowsWrapper) +Invoke-LoggedProcess "arm64-msvc-check" "arm64-check-command.log" $PowerShellExe @( + $commonWrapperArgs + @("check", $RustTarget, "-LogDir", $EvidenceDir) +) +Invoke-LoggedProcess "arm64-release-build" "arm64-build-command.log" $PowerShellExe @( + $commonWrapperArgs + @("build", $RustTarget, "-LogDir", $EvidenceDir) +) +Invoke-LoggedProcess "arm64-native-tests" "arm64-tests-command.log" $PowerShellExe @( + $commonWrapperArgs + @("test-precommit", $RustTarget, "-LogDir", $EvidenceDir) +) +Invoke-LoggedProcess "arm64-unsupported-driver-contracts" "arm64-unsupported.log" $PowerShellExe @( + $commonWrapperArgs + @("test-unsupported", $RustTarget, "-LogDir", $EvidenceDir) +) +Invoke-LoggedProcess "processcontainer-real-mxc" "strict-real-mxc.log" $PowerShellExe @( + $commonWrapperArgs + @("test-mxc-gb300", $RustTarget, "-LogDir", $EvidenceDir) +) + +$targetRoot = $env:CARGO_TARGET_DIR +if ([string]::IsNullOrWhiteSpace($targetRoot)) { + $targetRoot = Join-Path $RepoRoot "target" +} elseif (-not [System.IO.Path]::IsPathRooted($targetRoot)) { + $targetRoot = Join-Path $RepoRoot $targetRoot +} +$releaseDir = Join-Path $targetRoot "$RustTarget\release" +$binaryRows = @() +foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe", "libz3.dll")) { + $path = Join-Path $releaseDir $binary + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required ARM64 release artifact is missing: $path" + } + $item = Get-Item -LiteralPath $path + $binaryRows += [ordered]@{ + name = $binary + path = $item.FullName + size = $item.Length + sha256 = Get-Sha256 $path + } +} +$binaryManifestPath = Join-Path $EvidenceDir "arm64-binary-manifest.json" +Write-Json $binaryRows $binaryManifestPath + +$packageDir = Join-Path $EvidenceDir "package" +New-Item -ItemType Directory -Path $packageDir | Out-Null +$examplePrefix = "crates/openshell-driver-mxc/examples/" +$trackedExamples = @(Invoke-Git @("ls-files", "--", "$examplePrefix*")) +foreach ($relative in $trackedExamples) { + $packageRelative = $relative.Substring($examplePrefix.Length) + $destination = Join-Path $packageDir $packageRelative + $destinationParent = Split-Path -Parent $destination + if (-not (Test-Path $destinationParent)) { + New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null + } + Copy-Item -LiteralPath (Join-Path $RepoRoot $relative) -Destination $destination +} +foreach ($binary in $binaryRows) { + Copy-Item -LiteralPath $binary.path -Destination (Join-Path $packageDir $binary.name) +} + +$runStamp = Get-Date -Format "yyyyMMddHHmmss" +$mxcWorkDir = "C:\osq-mxc-$runStamp-$PID" +$openClawShareDir = "C:\osq-claw-$runStamp-$PID" +$mxcRunner = Join-Path $packageDir "run-mxc-e2e.ps1" +Invoke-LoggedProcess "processcontainer-policy-e2e" "mxc-policy-e2e.log" $PowerShellExe @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $mxcRunner, + "-WxcExecPath", $WxcExecPath, "-Backend", "process_container", "-DemoDir", $mxcWorkDir +) +$mxcLog = Get-Content -LiteralPath (Join-Path $EvidenceDir "mxc-policy-e2e.log") -Raw +if ($mxcLog -notmatch 'PASS=4\s+FAIL=0\s+SKIP=0') { + throw "The required MXC E2E scenarios did not report PASS=4 FAIL=0 SKIP=0." +} +$mxcBundle = Get-ChildItem -LiteralPath $packageDir -Filter "results-e2e-*.zip" -File | + Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 +if (-not $mxcBundle) { + throw "The MXC E2E runner did not produce a results bundle." +} +$mxcBundlePath = Join-Path $EvidenceDir "mxc-policy-e2e.zip" +Copy-Item -LiteralPath $mxcBundle.FullName -Destination $mxcBundlePath + +$openClawRunner = Join-Path $packageDir "run-openclaw-forward-test.ps1" +$openClawPreRunSha256 = Get-DirectorySha256 $OpenClawInstallDir +if ($openClawPreRunSha256 -ne $openClawPackageSha256) { + throw "The staged OpenClaw package changed before execution: expected $openClawPackageSha256, found $openClawPreRunSha256" +} +Invoke-LoggedProcess "openclaw-arm64-forward" "openclaw-forward.log" $PowerShellExe @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $openClawRunner, + "-Backend", "process_container", "-WxcExecPath", $WxcExecPath, + "-NodeExePath", $NodeExePath, "-OpenClawInstallDir", $OpenClawInstallDir, + "-ShareDir", $openClawShareDir +) +$openClawPostRunSha256 = Get-DirectorySha256 $OpenClawInstallDir +if ($openClawPostRunSha256 -ne $openClawPackageSha256) { + throw "The staged OpenClaw package changed during execution: expected $openClawPackageSha256, found $openClawPostRunSha256" +} +$openClawBundle = Get-ChildItem -LiteralPath $packageDir -Filter "results-openclaw-forward-*.zip" -File | + Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 +if (-not $openClawBundle) { + throw "The OpenClaw runner did not produce a results bundle." +} +$openClawBundlePath = Join-Path $EvidenceDir "openclaw-forward.zip" +Copy-Item -LiteralPath $openClawBundle.FullName -Destination $openClawBundlePath + +$requiredArtifacts = @{ + "contract-integrity" = @( + New-EvidenceArtifact "contract_validation_log" (Join-Path $EvidenceDir "contract-validation.log") + ) + "source-provenance" = @( + New-EvidenceArtifact "environment_and_source_provenance" $environmentPath + ) + "gb300-arm64-host" = @( + New-EvidenceArtifact "environment_and_source_provenance" $environmentPath + New-EvidenceArtifact "mxc_host_capabilities" $probeReport + New-EvidenceArtifact "mxc_host_probe_log" (Join-Path $EvidenceDir "mxc-host-probe.log") + ) + "arm64-msvc-check" = @( + New-EvidenceArtifact "arm64_check_log" (Join-Path $EvidenceDir "build-$RustTarget-check.log") + ) + "arm64-release-build" = @( + New-EvidenceArtifact "arm64_build_log" (Join-Path $EvidenceDir "build-$RustTarget-release.log") + New-EvidenceArtifact "arm64_binary_manifest" $binaryManifestPath + ) + "arm64-native-tests" = @( + New-EvidenceArtifact "arm64_test_log" (Join-Path $EvidenceDir "test-$RustTarget-precommit.log") + ) + "arm64-unsupported-driver-contracts" = @( + New-EvidenceArtifact "unsupported_driver_contract_log" (Join-Path $EvidenceDir "arm64-unsupported.log") + ) + "processcontainer-real-mxc" = @( + New-EvidenceArtifact "strict_real_mxc_log" (Join-Path $EvidenceDir "strict-real-mxc.log") + ) + "processcontainer-policy-e2e" = @( + New-EvidenceArtifact "mxc_policy_e2e_log" (Join-Path $EvidenceDir "mxc-policy-e2e.log") + New-EvidenceArtifact "mxc_policy_e2e_bundle" $mxcBundlePath + ) + "openclaw-arm64-forward" = @( + New-EvidenceArtifact "openclaw_forward_log" (Join-Path $EvidenceDir "openclaw-forward.log") + New-EvidenceArtifact "openclaw_forward_bundle" $openClawBundlePath + ) +} +$contract = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json +$results = @() +foreach ($row in $contract.matrix) { + if ($row.disposition -eq "required") { + $duration = $Durations[$row.id] + if ($null -eq $duration) { $duration = 0 } + $results += [ordered]@{ + coverage_id = $row.id + status = "pass" + architecture = "arm64" + command = ($row.commands -join " ; ") + exit_code = 0 + skip_count = 0 + duration_seconds = $duration + artifacts = @($requiredArtifacts[$row.id]) + } + } elseif ($row.disposition -eq "optional") { + $results += [ordered]@{ + coverage_id = $row.id + status = "not_run" + reason = "Optional coverage was not selected for this required GB300 ProcessContainer gate." + artifacts = @() + } + } else { + $results += [ordered]@{ + coverage_id = $row.id + status = "not_applicable" + reason = $row.reason + artifacts = @() + } + } +} +$recordPath = Join-Path $EvidenceDir "evidence.json" +$record = [ordered]@{ + schema_version = 1 + contract_id = "nvbug-6643699-gb300-woa-mxc" + base_sha = $ExpectedBaseSha + head_sha = $headSha + generated_at = (Get-Date).ToUniversalTime().ToString("o") + environment = [ordered]@{ + operating_system = "windows" + architecture = "arm64" + native = $true + wsl = $false + hardware = $Hardware + } + results = $results +} +Write-Json $record $recordPath +Invoke-LoggedProcess "evidence-validation" "evidence-validation.log" $uv.Source @( + "run", "python", $ValidatorPath, "evidence", "--record", $recordPath, + "--artifact-root", $EvidenceDir, "--expected-base-sha", $ExpectedBaseSha +) + +Write-Host "PASS: complete GB300 Windows ARM64 MXC evidence is valid." +Write-Host "Evidence: $EvidenceDir" diff --git a/crates/openshell-driver-mxc/qualification/validate.py b/crates/openshell-driver-mxc/qualification/validate.py new file mode 100644 index 0000000000..203dc555aa --- /dev/null +++ b/crates/openshell-driver-mxc/qualification/validate.py @@ -0,0 +1,524 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Validate the GB300 Windows ARM64 MXC contract and retained evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + +CONTRACT_ID = "nvbug-6643699-gb300-woa-mxc" +DISPOSITIONS = { + "required", + "optional", + "unsupported", + "architecture_constrained", +} +ARCHITECTURES = {"any", "native_arm64", "native_x64", "none"} +REQUIRED_IDS = { + "contract-integrity", + "source-provenance", + "gb300-arm64-host", + "arm64-msvc-check", + "arm64-release-build", + "arm64-native-tests", + "arm64-unsupported-driver-contracts", + "processcontainer-real-mxc", + "processcontainer-policy-e2e", + "openclaw-arm64-forward", +} +REQUIRED_REPOSITORY_PATHS = { + "crates/openshell-driver-mxc/examples/probe-mxc-host.ps1", + "crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1", + "crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1", + "crates/openshell-driver-mxc/qualification/gb300-woa.json", + "tasks/scripts/windows-msvc.ps1", + "tasks/windows.toml", +} +SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") + + +def load_json(path: Path) -> dict[str, Any]: + """Load JSON written by either PowerShell 5.1 or a UTF-8-native tool.""" + with path.open(encoding="utf-8-sig") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise ValueError(f"{path} must contain a JSON object") + return value + + +def _nonempty_strings(value: object) -> bool: + return ( + isinstance(value, list) + and bool(value) + and all(isinstance(item, str) and item.strip() for item in value) + ) + + +def validate_contract( + contract: dict[str, Any], repo_root: Path | None = None +) -> list[str]: + """Return every static contract violation without stopping at the first.""" + errors: list[str] = [] + if contract.get("schema_version") != 1: + errors.append("schema_version must be 1") + if contract.get("contract_id") != CONTRACT_ID: + errors.append(f"contract_id must be {CONTRACT_ID}") + if contract.get("nvbug") != 6643699: + errors.append("nvbug must be 6643699") + + target = contract.get("target") + if not isinstance(target, dict): + errors.append("target must be an object") + target = {} + expected_target = { + "hardware": "GB300", + "operating_system": "windows", + "architecture": "arm64", + "rust_target": "aarch64-pc-windows-msvc", + "execution": "native", + "wsl": False, + "base_ref": "origin/windows", + } + for key, expected in expected_target.items(): + if target.get(key) != expected: + errors.append(f"target.{key} must be {expected!r}") + + decisions = contract.get("decisions") + if not isinstance(decisions, dict): + errors.append("decisions must be an object") + decisions = {} + if decisions.get("required_agent_apps") != ["OpenClaw"]: + errors.append("OpenClaw must be the sole repository-owned required agent app") + if decisions.get("target_models") != []: + errors.append("target_models must be empty; model selection is app-owned") + if decisions.get("model_scope") != "agent_application_owned": + errors.append("model_scope must remain agent_application_owned") + if decisions.get("model_release_gate") is not False: + errors.append("model_release_gate must remain false") + if decisions.get("required_skip_policy") != "fail": + errors.append("required_skip_policy must be fail") + + owners = contract.get("owners") + if not isinstance(owners, dict) or not owners: + errors.append("owners must be a non-empty object") + owners = {} + for owner_id, owner in owners.items(): + if not isinstance(owner, dict): + errors.append(f"owner {owner_id} must be an object") + continue + if not isinstance(owner.get("name"), str) or not owner["name"].strip(): + errors.append(f"owner {owner_id} needs a name") + if ( + not isinstance(owner.get("responsibility"), str) + or not owner["responsibility"].strip() + ): + errors.append(f"owner {owner_id} needs a responsibility") + + matrix = contract.get("matrix") + if not isinstance(matrix, list) or not matrix: + errors.append("matrix must be a non-empty array") + return errors + + rows: dict[str, dict[str, Any]] = {} + disposition_counts = dict.fromkeys(DISPOSITIONS, 0) + for index, row in enumerate(matrix): + label = f"matrix[{index}]" + if not isinstance(row, dict): + errors.append(f"{label} must be an object") + continue + row_id = row.get("id") + if not isinstance(row_id, str) or not row_id.strip(): + errors.append(f"{label}.id must be a non-empty string") + continue + label = row_id + if row_id in rows: + errors.append(f"duplicate matrix id: {row_id}") + continue + rows[row_id] = row + + disposition = row.get("disposition") + if disposition not in DISPOSITIONS: + errors.append(f"{label}.disposition is invalid") + else: + disposition_counts[disposition] += 1 + if row.get("owner") not in owners: + errors.append(f"{label}.owner must reference owners") + if not _nonempty_strings(row.get("prerequisites")): + errors.append(f"{label}.prerequisites must be non-empty strings") + if not _nonempty_strings(row.get("pass_criteria")): + errors.append(f"{label}.pass_criteria must be non-empty strings") + if not isinstance(row.get("artifact_roles"), list) or not all( + isinstance(role, str) and role.strip() + for role in row.get("artifact_roles", []) + ): + errors.append(f"{label}.artifact_roles must be a string array") + if not isinstance(row.get("commands"), list) or not all( + isinstance(command, str) and command.strip() + for command in row.get("commands", []) + ): + errors.append(f"{label}.commands must be a string array") + if not isinstance(row.get("hardware_dependent"), bool): + errors.append(f"{label}.hardware_dependent must be boolean") + if not isinstance(row.get("credits_gb300_arm64"), bool): + errors.append(f"{label}.credits_gb300_arm64 must be boolean") + if not isinstance(row.get("allow_skip"), bool): + errors.append(f"{label}.allow_skip must be boolean") + if row.get("architecture") not in ARCHITECTURES: + errors.append(f"{label}.architecture is invalid") + + if disposition == "required": + if row.get("allow_skip") is not False: + errors.append(f"{label} is required and cannot allow skip") + if row.get("credits_gb300_arm64") is not True: + errors.append(f"{label} is required and must credit GB300 ARM64") + if row.get("architecture") not in {"any", "native_arm64"}: + errors.append(f"{label} has an invalid required architecture") + if not row.get("commands"): + errors.append(f"{label} is required and needs a command") + if not row.get("artifact_roles"): + errors.append(f"{label} is required and needs artifact roles") + command_text = " ".join(row.get("commands", [])).lower() + if "x86_64" in command_text or "nemoclaw" in command_text: + errors.append(f"{label} cannot use an x64/NemoClaw command") + elif disposition in {"unsupported", "architecture_constrained"}: + if row.get("credits_gb300_arm64") is not False: + errors.append(f"{label} cannot credit GB300 ARM64") + if not isinstance(row.get("reason"), str) or not row["reason"].strip(): + errors.append(f"{label} needs an explicit reason") + elif disposition == "optional" and row.get("credits_gb300_arm64") is not False: + errors.append(f"{label} is optional and cannot credit GB300 ARM64") + + for disposition, count in disposition_counts.items(): + if count == 0: + errors.append(f"matrix needs at least one {disposition} row") + actual_required = { + row_id for row_id, row in rows.items() if row.get("disposition") == "required" + } + if actual_required != REQUIRED_IDS: + missing = sorted(REQUIRED_IDS - actual_required) + extra = sorted(actual_required - REQUIRED_IDS) + errors.append(f"required row set mismatch: missing={missing}, extra={extra}") + + _require_row(errors, rows, "wsl", "unsupported", "none", False) + _require_row( + errors, + rows, + "nemoclaw-native-x64", + "architecture_constrained", + "native_x64", + False, + ) + _require_row( + errors, + rows, + "node-openclaw-x64-installer", + "architecture_constrained", + "native_x64", + False, + ) + _require_row( + errors, + rows, + "local-inference-models", + "optional", + "native_arm64", + False, + ) + openclaw = rows.get("openclaw-arm64-forward", {}) + if not any( + "run-openclaw-forward-test.ps1" in command + for command in openclaw.get("commands", []) + ): + errors.append("openclaw-arm64-forward must invoke the repository harness") + + if repo_root is not None: + for relative in sorted(REQUIRED_REPOSITORY_PATHS): + if not (repo_root / relative).is_file(): + errors.append(f"referenced repository path is missing: {relative}") + + return errors + + +def _require_row( + errors: list[str], + rows: dict[str, dict[str, Any]], + row_id: str, + disposition: str, + architecture: str, + credit: bool, +) -> None: + row = rows.get(row_id) + if row is None: + errors.append(f"matrix row is missing: {row_id}") + return + if row.get("disposition") != disposition: + errors.append(f"{row_id}.disposition must be {disposition}") + if row.get("architecture") != architecture: + errors.append(f"{row_id}.architecture must be {architecture}") + if row.get("credits_gb300_arm64") is not credit: + errors.append(f"{row_id}.credits_gb300_arm64 must be {credit}") + + +def validate_evidence( + contract: dict[str, Any], + record: dict[str, Any], + artifact_root: Path, + expected_base_sha: str, +) -> list[str]: + """Validate one retained evidence record against the static matrix.""" + errors = validate_contract(contract, repo_root=None) + if not SHA_PATTERN.fullmatch(expected_base_sha): + errors.append("expected base SHA must be 40 lowercase hexadecimal characters") + if record.get("schema_version") != 1: + errors.append("evidence schema_version must be 1") + if record.get("contract_id") != CONTRACT_ID: + errors.append(f"evidence contract_id must be {CONTRACT_ID}") + if record.get("base_sha") != expected_base_sha: + errors.append( + "evidence base_sha does not match the expected origin/windows SHA" + ) + if not isinstance(record.get("head_sha"), str) or not SHA_PATTERN.fullmatch( + record["head_sha"] + ): + errors.append("evidence head_sha must be a full lowercase Git SHA") + if ( + not isinstance(record.get("generated_at"), str) + or not record["generated_at"].strip() + ): + errors.append("evidence generated_at is required") + + environment = record.get("environment") + if not isinstance(environment, dict): + errors.append("evidence environment must be an object") + environment = {} + expected_environment = { + "operating_system": "windows", + "architecture": "arm64", + "native": True, + "wsl": False, + "hardware": "GB300", + } + for key, expected in expected_environment.items(): + if environment.get(key) != expected: + errors.append(f"evidence environment.{key} must be {expected!r}") + + results = record.get("results") + if not isinstance(results, list): + errors.append("evidence results must be an array") + return errors + by_id: dict[str, dict[str, Any]] = {} + for index, result in enumerate(results): + if not isinstance(result, dict): + errors.append(f"evidence results[{index}] must be an object") + continue + coverage_id = result.get("coverage_id") + if not isinstance(coverage_id, str) or not coverage_id: + errors.append(f"evidence results[{index}] needs coverage_id") + continue + if coverage_id in by_id: + errors.append(f"duplicate evidence result: {coverage_id}") + continue + by_id[coverage_id] = result + + rows = {row["id"]: row for row in contract["matrix"]} + if set(by_id) != set(rows): + errors.append( + "evidence result IDs must exactly match the contract matrix: " + f"missing={sorted(set(rows) - set(by_id))}, " + f"extra={sorted(set(by_id) - set(rows))}" + ) + + artifact_root = artifact_root.resolve() + for row_id, row in rows.items(): + result = by_id.get(row_id) + if result is None: + continue + disposition = row["disposition"] + status = result.get("status") + artifacts = result.get("artifacts") + if not isinstance(artifacts, list): + errors.append(f"{row_id}: artifacts must be an array") + artifacts = [] + + if disposition == "required": + if status != "pass": + errors.append(f"{row_id}: required result must be pass, got {status}") + if result.get("exit_code") != 0: + errors.append(f"{row_id}: required exit_code must be 0") + if result.get("skip_count") != 0: + errors.append(f"{row_id}: required skip_count must be 0") + if result.get("architecture") != "arm64": + errors.append(f"{row_id}: required evidence must be ARM64") + duration = result.get("duration_seconds") + if not isinstance(duration, (int, float)) or duration < 0: + errors.append(f"{row_id}: duration_seconds must be non-negative") + if ( + not isinstance(result.get("command"), str) + or not result["command"].strip() + ): + errors.append(f"{row_id}: required evidence needs the command") + actual_roles = { + item.get("role") for item in artifacts if isinstance(item, dict) + } + expected_roles = set(row["artifact_roles"]) + if actual_roles != expected_roles: + errors.append( + f"{row_id}: artifact roles mismatch; " + f"expected={sorted(expected_roles)}, actual={sorted(actual_roles)}" + ) + elif disposition == "optional": + if status not in {"pass", "not_run"}: + errors.append(f"{row_id}: optional status must be pass or not_run") + if status == "not_run" and not _has_reason(result): + errors.append(f"{row_id}: not_run requires a reason") + if status == "pass": + if result.get("exit_code") != 0: + errors.append(f"{row_id}: optional pass exit_code must be 0") + if result.get("skip_count") != 0: + errors.append(f"{row_id}: optional pass skip_count must be 0") + if result.get("architecture") != "arm64": + errors.append(f"{row_id}: optional pass evidence must be ARM64") + duration = result.get("duration_seconds") + if not isinstance(duration, (int, float)) or duration < 0: + errors.append( + f"{row_id}: optional pass duration_seconds must be non-negative" + ) + if ( + not isinstance(result.get("command"), str) + or not result["command"].strip() + ): + errors.append(f"{row_id}: optional pass needs the command") + actual_roles = { + item.get("role") for item in artifacts if isinstance(item, dict) + } + expected_roles = set(row["artifact_roles"]) + if actual_roles != expected_roles: + errors.append( + f"{row_id}: optional artifact roles mismatch; " + f"expected={sorted(expected_roles)}, " + f"actual={sorted(actual_roles)}" + ) + else: + if status != "not_applicable": + errors.append(f"{row_id}: excluded status must be not_applicable") + if not _has_reason(result): + errors.append(f"{row_id}: not_applicable requires a reason") + if artifacts: + errors.append(f"{row_id}: excluded rows must not claim artifacts") + + if status == "pass": + errors.extend(_validate_artifacts(row_id, artifacts, artifact_root)) + + return errors + + +def _has_reason(result: dict[str, Any]) -> bool: + return isinstance(result.get("reason"), str) and bool(result["reason"].strip()) + + +def _validate_artifacts( + row_id: str, artifacts: list[object], artifact_root: Path +) -> list[str]: + errors: list[str] = [] + seen_roles: set[str] = set() + for index, artifact in enumerate(artifacts): + label = f"{row_id}.artifacts[{index}]" + if not isinstance(artifact, dict): + errors.append(f"{label} must be an object") + continue + role = artifact.get("role") + relative = artifact.get("path") + expected_hash = artifact.get("sha256") + if not isinstance(role, str) or not role: + errors.append(f"{label}.role is required") + continue + if role in seen_roles: + errors.append(f"{row_id}: duplicate artifact role {role}") + seen_roles.add(role) + if not isinstance(relative, str) or not relative: + errors.append(f"{label}.path is required") + continue + relative_path = Path(relative) + if relative_path.is_absolute() or ".." in relative_path.parts: + errors.append(f"{label}.path must stay under the artifact root") + continue + path = (artifact_root / relative_path).resolve() + if not path.is_relative_to(artifact_root): + errors.append(f"{label}.path resolves outside the artifact root") + continue + if not path.is_file() or path.stat().st_size == 0: + errors.append(f"{label}.path must be a non-empty file: {relative}") + continue + if not isinstance(expected_hash, str) or not re.fullmatch( + r"[0-9a-f]{64}", expected_hash + ): + errors.append(f"{label}.sha256 must be lowercase hexadecimal") + continue + actual_hash = hashlib.sha256(path.read_bytes()).hexdigest() + if actual_hash != expected_hash: + errors.append(f"{label}.sha256 does not match {relative}") + return errors + + +def _print_errors(errors: list[str]) -> int: + if not errors: + return 0 + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + +def main(argv: list[str] | None = None) -> int: + script_dir = Path(__file__).resolve().parent + repo_root = script_dir.parents[2] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", + type=Path, + default=script_dir / "gb300-woa.json", + help="Path to the qualification contract", + ) + subparsers = parser.add_subparsers(dest="mode", required=True) + subparsers.add_parser("contract", help="Validate the static scope contract") + evidence = subparsers.add_parser( + "evidence", help="Validate retained qualification evidence" + ) + evidence.add_argument("--record", type=Path, required=True) + evidence.add_argument("--artifact-root", type=Path, required=True) + evidence.add_argument("--expected-base-sha", required=True) + args = parser.parse_args(argv) + + try: + contract = load_json(args.manifest) + if args.mode == "contract": + errors = validate_contract(contract, repo_root=repo_root) + else: + record = load_json(args.record) + errors = validate_evidence( + contract, + record, + args.artifact_root, + args.expected_base_sha, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + if errors: + return _print_errors(errors) + if args.mode == "contract": + print(f"PASS: {CONTRACT_ID} contract is valid") + else: + print(f"PASS: {CONTRACT_ID} evidence is complete and hash-bound") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/gb300_mxc_qualification_test.py b/python/gb300_mxc_qualification_test.py new file mode 100644 index 0000000000..5a1aeeb117 --- /dev/null +++ b/python/gb300_mxc_qualification_test.py @@ -0,0 +1,307 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for the GB300 Windows ARM64 MXC qualification contract.""" + +from __future__ import annotations + +import copy +import hashlib +import importlib.util +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict, cast + +if TYPE_CHECKING: + from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[1] +QUALIFICATION_DIR = REPO_ROOT / "crates" / "openshell-driver-mxc" / "qualification" + + +class ContractRow(TypedDict, total=False): + id: str + disposition: str + allow_skip: bool + architecture: str + commands: list[str] + artifact_roles: list[str] + reason: str + + +class Contract(TypedDict): + matrix: list[ContractRow] + + +class EvidenceArtifact(TypedDict): + role: str + path: str + sha256: str + + +class EvidenceResult(TypedDict, total=False): + coverage_id: str + status: str + architecture: str + command: str + exit_code: int + skip_count: int + duration_seconds: float + artifacts: list[EvidenceArtifact] + reason: str + + +class EvidenceRecord(TypedDict): + schema_version: int + contract_id: str + base_sha: str + head_sha: str + generated_at: str + environment: dict[str, object] + results: list[EvidenceResult] + + +def _load_validator() -> ModuleType: + path = QUALIFICATION_DIR / "validate.py" + spec = importlib.util.spec_from_file_location("gb300_mxc_validator", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +VALIDATOR = _load_validator() + + +def _load_contract() -> Contract: + return cast("Contract", VALIDATOR.load_json(QUALIFICATION_DIR / "gb300-woa.json")) + + +def _valid_evidence(contract: Contract, artifact_root: Path) -> EvidenceRecord: + results: list[EvidenceResult] = [] + for row in contract["matrix"]: + row_id = row["id"] + disposition = row["disposition"] + if disposition == "required": + artifacts: list[EvidenceArtifact] = [] + for role in row["artifact_roles"]: + artifact_path = artifact_root / f"{row_id}-{role}.txt" + artifact_path.write_text(f"evidence for {row_id}\n", encoding="utf-8") + artifacts.append( + { + "role": role, + "path": artifact_path.name, + "sha256": hashlib.sha256( + artifact_path.read_bytes() + ).hexdigest(), + } + ) + results.append( + { + "coverage_id": row_id, + "status": "pass", + "architecture": "arm64", + "command": row["commands"][0], + "exit_code": 0, + "skip_count": 0, + "duration_seconds": 0.1, + "artifacts": artifacts, + } + ) + elif disposition == "optional": + results.append( + { + "coverage_id": row_id, + "status": "not_run", + "reason": "Not selected for the required qualification gate.", + "artifacts": [], + } + ) + else: + results.append( + { + "coverage_id": row_id, + "status": "not_applicable", + "reason": row["reason"], + "artifacts": [], + } + ) + + return { + "schema_version": 1, + "contract_id": "nvbug-6643699-gb300-woa-mxc", + "base_sha": "a" * 40, + "head_sha": "b" * 40, + "generated_at": "2026-09-18T12:00:00Z", + "environment": { + "operating_system": "windows", + "architecture": "arm64", + "native": True, + "wsl": False, + "hardware": "GB300", + }, + "results": results, + } + + +def test_runner_requires_explicit_canonical_source_provenance() -> None: + source = (QUALIFICATION_DIR / "run-gb300-woa.ps1").read_text(encoding="utf-8") + + assert "ExpectedBaseSha is required" in source + assert "$ExpectedBaseSha = $fetchedBaseSha" not in source + assert "https://github.com/nvidia/openshell.git" in source.lower() + assert "origin must be the canonical NVIDIA/OpenShell" in source + + +def test_runner_hash_binds_a_versioned_openclaw_package() -> None: + source = (QUALIFICATION_DIR / "run-gb300-woa.ps1").read_text(encoding="utf-8") + + assert "package.json must declare a non-empty version" in source + assert "openclaw_package_sha256" in source + assert "openclaw_package_json_sha256" in source + assert "Get-ChildItem -LiteralPath $OpenClawInstallDir -Force" in source + assert "Copy-Item -LiteralPath $_.FullName" in source + assert "$openClawPreRunSha256 = Get-DirectorySha256 $OpenClawInstallDir" in source + assert "$openClawPostRunSha256 = Get-DirectorySha256 $OpenClawInstallDir" in source + + +def test_gb300_gate_invokes_only_required_real_mxc_tests() -> None: + source = (REPO_ROOT / "tasks" / "scripts" / "windows-msvc.ps1").read_text( + encoding="utf-8" + ) + + gb300_function = source.split("function Invoke-MxcGb300Tests", 1)[1].split( + "function Get-Sha256", 1 + )[0] + + assert "$test -- --ignored --exact --test-threads=1 --nocapture" in gb300_function + assert ( + "--target $RustTarget -- --ignored --test-threads=1 --nocapture" + not in gb300_function + ) + + +def test_repository_contract_is_valid() -> None: + errors = VALIDATOR.validate_contract(_load_contract(), repo_root=REPO_ROOT) + assert errors == [] + + +def test_required_coverage_cannot_be_skip_safe() -> None: + contract = copy.deepcopy(_load_contract()) + required = next( + row for row in contract["matrix"] if row["id"] == "processcontainer-real-mxc" + ) + required["allow_skip"] = True + + errors = VALIDATOR.validate_contract(contract) + + assert "processcontainer-real-mxc is required and cannot allow skip" in errors + + +def test_required_coverage_cannot_be_x64_or_nemoclaw() -> None: + contract = copy.deepcopy(_load_contract()) + required = next( + row for row in contract["matrix"] if row["id"] == "openclaw-arm64-forward" + ) + required["architecture"] = "native_x64" + required["commands"] = ["run-nemoclaw-qualification.ps1"] + + errors = VALIDATOR.validate_contract(contract) + + assert "openclaw-arm64-forward has an invalid required architecture" in errors + assert "openclaw-arm64-forward cannot use an x64/NemoClaw command" in errors + + +def test_nemoclaw_exclusion_is_mandatory() -> None: + contract = copy.deepcopy(_load_contract()) + contract["matrix"] = [ + row for row in contract["matrix"] if row["id"] != "nemoclaw-native-x64" + ] + + errors = VALIDATOR.validate_contract(contract) + + assert "matrix row is missing: nemoclaw-native-x64" in errors + + +def test_complete_hash_bound_evidence_passes(tmp_path: Path) -> None: + contract = _load_contract() + record = _valid_evidence(contract, tmp_path) + + errors = VALIDATOR.validate_evidence( + contract, + record, + artifact_root=tmp_path, + expected_base_sha="a" * 40, + ) + + assert errors == [] + + +def test_required_skip_fails_evidence_validation(tmp_path: Path) -> None: + contract = _load_contract() + record = _valid_evidence(contract, tmp_path) + result = next( + item + for item in record["results"] + if item["coverage_id"] == "processcontainer-real-mxc" + ) + result["status"] = "skip" + result["skip_count"] = 1 + + errors = VALIDATOR.validate_evidence( + contract, + record, + artifact_root=tmp_path, + expected_base_sha="a" * 40, + ) + + assert "processcontainer-real-mxc: required result must be pass, got skip" in errors + assert "processcontainer-real-mxc: required skip_count must be 0" in errors + + +def test_optional_pass_must_carry_declared_arm64_artifacts(tmp_path: Path) -> None: + contract = _load_contract() + record = _valid_evidence(contract, tmp_path) + result = next( + item + for item in record["results"] + if item["coverage_id"] == "local-inference-models" + ) + result.update( + { + "status": "pass", + "architecture": "native_x64", + "command": "run-model-evaluation.ps1", + "exit_code": 0, + "skip_count": 0, + "duration_seconds": 1.0, + "artifacts": [], + } + ) + result.pop("reason") + + errors = VALIDATOR.validate_evidence( + contract, + record, + artifact_root=tmp_path, + expected_base_sha="a" * 40, + ) + + assert "local-inference-models: optional pass evidence must be ARM64" in errors + assert any("optional artifact roles mismatch" in error for error in errors) + + +def test_artifact_hash_mismatch_fails_evidence_validation(tmp_path: Path) -> None: + contract = _load_contract() + record = _valid_evidence(contract, tmp_path) + artifact = record["results"][0]["artifacts"][0] + (tmp_path / artifact["path"]).write_text("tampered\n", encoding="utf-8") + + errors = VALIDATOR.validate_evidence( + contract, + record, + artifact_root=tmp_path, + expected_base_sha="a" * 40, + ) + + assert any("sha256 does not match" in error for error in errors) diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 92569c57fd..54bb721eac 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", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300", "artifacts", "ci")] [string] $Action, [Parameter(Position = 1)] @@ -591,6 +591,49 @@ function Invoke-MxcRealTests([string] $RustTarget) { -LogName "test-$RustTarget-mxc-real.log" } +function Invoke-MxcGb300Tests([string] $RustTarget) { + Assert-NativeTestTarget $RustTarget + if ($RustTarget -ne "aarch64-pc-windows-msvc") { + throw "The GB300 qualification contract requires native ARM64; target $RustTarget cannot receive credit." + } + + $tests = @( + "dryrun_accepts_minimal_processcontainer_config", + "dryrun_accepts_processcontainer_ui_policy_matrix", + "dryrun_accepts_network_block_without_proxy", + "dryrun_accepts_localhost_proxy_shape", + "dryrun_rejects_host_port_proxy_shape", + "dryrun_rejects_unknown_containment", + "dryrun_accepts_split_policy_output", + "pc_oneshot_in_policy_write_succeeds", + "pc_https_egress_reads_injected_ca_bundle", + "pc_oneshot_out_of_policy_write_denied" + ) + $logName = "test-$RustTarget-mxc-gb300.log" + $logPath = Join-Path $LogDir $logName + $combinedLog = @() + foreach ($test in $tests) { + $testLogName = "test-$RustTarget-mxc-gb300-$test.log" + Invoke-VsCargo ` + -RustTarget $RustTarget ` + -CargoArgs "cargo test -p openshell-driver-mxc --test wxc_exec_real --target $RustTarget $test -- --ignored --exact --test-threads=1 --nocapture" ` + -LogName $testLogName + + $testLogPath = Join-Path $LogDir $testLogName + $testLog = @(Get-Content -LiteralPath $testLogPath) + $combinedLog += "===== $test =====" + $combinedLog += $testLog + if ($testLog -match 'SKIP:') { + throw "Required GB300 MXC test $test skipped. See $testLogPath" + } + $escapedTest = [Regex]::Escape($test) + if (-not ($testLog -match "^test $escapedTest \.\.\. ok$")) { + throw "Required GB300 MXC test $test did not report a passing assertion. See $testLogPath" + } + } + $combinedLog | Out-File -LiteralPath $logPath -Encoding utf8 +} + function Get-Sha256([string] $Path) { $stream = [System.IO.File]::OpenRead($Path) try { @@ -635,13 +678,13 @@ if ($Action -eq "ci" -and (Get-HostArch) -ne "amd64") { } $targets = Get-SelectedTargets $Target -if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real")) { +if ($Action -in @("test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300")) { foreach ($rustTarget in $targets) { Assert-NativeTestTarget $rustTarget } } -if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "ci")) { +if ($Action -in @("check", "lint", "build", "test", "test-precommit", "test-unsupported", "test-mxc-real", "test-mxc-gb300", "ci")) { $z3Features = Configure-Z3 $Z3WorkspaceFeatures = $z3Features.WorkspaceFeatures $Z3ServerFeatures = $z3Features.ServerFeatures @@ -689,6 +732,11 @@ switch ($Action) { Invoke-MxcRealTests $rustTarget } } + "test-mxc-gb300" { + foreach ($rustTarget in $targets) { + Invoke-MxcGb300Tests $rustTarget + } + } "artifacts" { Show-Artifacts $targets } diff --git a/tasks/windows.toml b/tasks/windows.toml index 7509a934c6..fb8e64d52f 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -85,6 +85,20 @@ description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip-saf 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-real aarch64-pc-windows-msvc" +["windows:test:mxc-gb300:arm64"] +description = "Run native ARM64 GB300 real-MXC tests (strict: any required missing prerequisite or SKIP fails)" +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-gb300 aarch64-pc-windows-msvc" + +["windows:qualify:mxc:gb300:contract"] +description = "Validate the NVBug 6643699 GB300 Windows ARM64 MXC scope contract" +run = "uv run python crates/openshell-driver-mxc/qualification/validate.py contract" + +["windows:qualify:mxc:gb300"] +description = "Execute and validate the complete GB300 Windows ARM64 MXC qualification contract" +run = "echo 'GB300 qualification requires a native Windows ARM64 host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1" + ["windows:e2e:mxc"] description = "Run MXC Tier-3 e2e scenario runner against real wxc-exec (probe-gated; skip-safe on hosts without the binary)" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" From 69d73a7eaf4bb8326da7964651fca3294ba6a2ec Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:55:34 -0700 Subject: [PATCH 10/39] fix(ocsf): attribute MXC proxy events to sandboxes (#3434) * fix(ocsf): attribute MXC proxy events to sandboxes NVBug 6783086 Signed-off-by: Prekshi Vyas * fix(ocsf): preserve sandbox context across proxy denials Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- architecture/security-policy.md | 7 + .../openshell-supervisor-network/src/host.rs | 76 ++- .../src/l7/mod.rs | 29 +- .../src/l7/relay.rs | 1 + .../src/l7/websocket.rs | 8 +- .../openshell-supervisor-network/src/proxy.rs | 444 +++++++++++++++--- .../src/proxy/relay.rs | 86 +++- .../src/proxy/tests/compatibility.rs | 6 + 8 files changed, 555 insertions(+), 102 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 655886d87c..7503532ed8 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -418,6 +418,13 @@ middleware, token grants, credential rewriting, policy-generation checks, and the HTTP relay have succeeded so a later denial cannot coexist with an allowed record for the same request. +Each Windows MXC sandbox owns a separate host proxy. That proxy carries an +immutable per-sandbox OCSF context so proxy lifecycle events and top-level +CONNECT/forward decisions use the correct `container.uid` and `container.name` +even when one gateway serves multiple sandboxes concurrently. The process-wide +sandbox context is only suitable for the one-supervisor-per-sandbox runtime +model. + Never log secrets, credentials, bearer tokens, or query parameters in OCSF messages. OCSF JSONL output may be shipped to external systems. The gateway-local OCSF JSONL file sink is restricted to the Windows/MXC path diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index eb09634772..ea4082b9fa 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -22,7 +22,7 @@ use openshell_core::proposals::AgentProposals; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_ocsf::{ - ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, + ConfigStateChangeBuilder, EventContext, SeverityId, StateId, StatusId, ocsf_emit, }; use tokio::sync::mpsc::UnboundedSender; @@ -68,7 +68,11 @@ pub struct HostProxyConfig { /// Per-sandbox client authentication. Host-side MXC proxies must set this /// so another sandbox cannot borrow this proxy's identity and policy. pub client_auth: HostProxyClientAuth, + /// Stable sandbox identifier used to attribute host-proxy OCSF events. + /// Required and non-empty for every host-side proxy. pub sandbox_id: Option, + /// Sandbox display name used to attribute host-proxy OCSF events. + /// Required and non-empty for every host-side proxy. pub sandbox_name: Option, pub openshell_endpoint: Option, pub provider_credentials: Option, @@ -99,6 +103,36 @@ impl HostProxyHandle { } } +fn host_proxy_event_context(config: &HostProxyConfig) -> Result { + let sandbox_id = config + .sandbox_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| miette::miette!("host proxy requires a non-empty sandbox_id"))?; + let sandbox_name = config + .sandbox_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| miette::miette!("host proxy requires a non-empty sandbox_name"))?; + + Ok(EventContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: String::new(), + hostname: std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .ok() + .map(|hostname| hostname.trim().to_string()) + .filter(|hostname| !hostname.is_empty()) + .unwrap_or_else(|| "openshell-gateway".to_string()), + product_version: env!("CARGO_PKG_VERSION").to_string(), + proxy_ip: config.bind_addr.ip(), + proxy_port: config.bind_addr.port(), + }) +} + /// Start a host-side proxy for one sandbox. /// /// Linux supervisor mode should continue to use `run::run_networking`; this API @@ -117,6 +151,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result Result Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -174,7 +209,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -189,7 +224,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -203,7 +238,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result { ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) + ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::High) .status(StatusId::Failure) .state(StateId::Disabled, "disabled") @@ -218,7 +253,8 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result String { let mut request = String::from( "GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n", diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 2085b46681..d0ecb1e18a 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -35,6 +35,7 @@ use std::sync::{Arc, Mutex}; use crate::opa::PolicyGenerationGuard; pub(crate) fn build_credential_endpoint_mismatch_finding( + event_context: &openshell_ocsf::EventContext, policy_name: &str, host: &str, protocol: Option<&str>, @@ -50,7 +51,7 @@ pub(crate) fn build_credential_endpoint_mismatch_finding( } evidence.push(("disposition", "denied")); - DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + DetectionFindingBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -413,8 +414,13 @@ pub fn parse_l7_config(val: ®orus::Value) -> Option { }) } -pub(crate) fn emit_uninspected_credential_finding(host: &str, policy_name: &str, surface: &str) { - let event = openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) +pub(crate) fn build_uninspected_credential_finding( + event_context: &openshell_ocsf::EventContext, + host: &str, + policy_name: &str, + surface: &str, +) -> openshell_ocsf::OcsfEvent { + openshell_ocsf::DetectionFindingBuilder::new(event_context) .severity(openshell_ocsf::SeverityId::High) .finding_info(openshell_ocsf::FindingInfo::new( "openshell.credentials.traffic_uninspectable", @@ -427,8 +433,21 @@ pub(crate) fn emit_uninspected_credential_finding(host: &str, policy_name: &str, ("disposition", "denied"), ]) .message("Uninspected credential-bearing traffic denied") - .build(); - openshell_ocsf::ocsf_emit!(event); + .build() +} + +pub(crate) fn emit_uninspected_credential_finding( + event_context: &openshell_ocsf::EventContext, + host: &str, + policy_name: &str, + surface: &str, +) { + openshell_ocsf::ocsf_emit!(build_uninspected_credential_finding( + event_context, + host, + policy_name, + surface, + )); } impl L7EndpointConfig { diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 31acd3a7fd..b319c54112 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -371,6 +371,7 @@ fn build_credential_resolution_event( fn build_credential_endpoint_mismatch_finding(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { crate::l7::build_credential_endpoint_mismatch_finding( + openshell_ocsf::ctx::ctx(), &ctx.policy_name, &ctx.host, None, diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index dab11a1112..60f2b1c535 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -636,6 +636,7 @@ fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { .build() ); ocsf_emit!(crate::l7::build_credential_endpoint_mismatch_finding( + openshell_ocsf::ctx::ctx(), policy_name, host, Some("websocket"), @@ -1387,7 +1388,12 @@ fn emit_uninspected_credential_denial(host: &str, port: u16, policy_name: &str, )) .build(); ocsf_emit!(event); - crate::l7::emit_uninspected_credential_finding(host, policy_name, surface); + crate::l7::emit_uninspected_credential_finding( + openshell_ocsf::ctx::ctx(), + host, + policy_name, + surface, + ); } fn inspect_websocket_text_message( diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index c6dc767d95..0bfea5731d 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -31,8 +31,8 @@ use openshell_core::policy::ProxyPolicy; use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderCredentialState}; use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse, - NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, DispositionId, Endpoint, EventContext, HttpActivityBuilder, HttpRequest, + HttpResponse, NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; #[cfg(target_os = "linux")] use std::mem::size_of; @@ -74,12 +74,13 @@ const FORWARD_ENCODED_SLASH_REJECTION_DETAIL: &str = const SIDECAR_SUPERVISOR_TOPOLOGY: &str = "sidecar"; fn build_credential_endpoint_mismatch_event( + event_context: &EventContext, method: &str, host: &str, port: u16, policy_name: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .http_request(HttpRequest { http_method: method.parse().expect("HTTP method parsing is infallible"), @@ -99,10 +100,18 @@ fn build_credential_endpoint_mismatch_event( .build() } -fn emit_credential_endpoint_mismatch(method: &str, host: &str, port: u16, policy_name: &str) { - let event = build_credential_endpoint_mismatch_event(method, host, port, policy_name); +fn emit_credential_endpoint_mismatch( + event_context: &EventContext, + method: &str, + host: &str, + port: u16, + policy_name: &str, +) { + let event = + build_credential_endpoint_mismatch_event(event_context, method, host, port, policy_name); ocsf_emit!(event); let finding = crate::l7::build_credential_endpoint_mismatch_finding( + event_context, policy_name, host, None, @@ -175,6 +184,9 @@ pub(crate) enum ProxyIdentityMode { binary_path: PathBuf, binary_sha256: String, required_proxy_authorization: Option>, + /// Per-sandbox context for host-side proxies. The process-wide OCSF + /// context cannot identify one sandbox when a gateway hosts many. + event_context: Option>, }, } @@ -206,9 +218,33 @@ impl ProxyIdentityMode { binary_path, binary_sha256, required_proxy_authorization, + event_context: None, }) } + #[cfg(target_os = "windows")] + pub(super) fn with_event_context(mut self, context: EventContext) -> Self { + match &mut self { + #[cfg(target_os = "linux")] + Self::Procfs { .. } => {} + Self::Static { event_context, .. } => { + *event_context = Some(Arc::new(context)); + } + } + self + } + + fn event_context(&self) -> &EventContext { + match self { + #[cfg(any(not(target_os = "linux"), test))] + Self::Static { + event_context: Some(context), + .. + } => context, + _ => openshell_ocsf::ctx::ctx(), + } + } + fn required_proxy_authorization(&self) -> Option<&str> { match self { #[cfg(target_os = "linux")] @@ -269,8 +305,9 @@ impl ProxyHandle { let listener = TcpListener::bind(http_addr).await.into_diagnostic()?; let local_addr = listener.local_addr().into_diagnostic()?; + let event_context = Arc::new(identity_mode.event_context().clone()); { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Listen) .severity(SeverityId::Informational) .status(StatusId::Success) @@ -307,22 +344,21 @@ impl ProxyHandle { // access. let upstream_proxy: Arc> = Arc::new( UpstreamProxyConfig::from_args(upstream_proxy_args).map_err(|err| { - let event = - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(SeverityId::High) - .status(StatusId::Failure) - .state(openshell_ocsf::StateId::Disabled, "invalid") - .message(format!( - "Upstream corporate proxy configuration invalid; \ + let event = openshell_ocsf::ConfigStateChangeBuilder::new(&event_context) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(openshell_ocsf::StateId::Disabled, "invalid") + .message(format!( + "Upstream corporate proxy configuration invalid; \ refusing to start: {err}" - )) - .build(); + )) + .build(); ocsf_emit!(event); miette::miette!("invalid upstream corporate proxy configuration: {err}") })?, ); if let Some(cfg) = upstream_proxy.as_ref() { - let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + let event = openshell_ocsf::ConfigStateChangeBuilder::new(&event_context) .severity(SeverityId::Informational) .status(StatusId::Success) .state(openshell_ocsf::StateId::Enabled, "enabled") @@ -392,6 +428,7 @@ impl ProxyHandle { let dtx = denial_tx.clone(); let atx = activity_tx.clone(); let endpoint_observations = endpoint_observation_tx.clone(); + let event_context = event_context.clone(); tokio::spawn(async move { #[allow(clippy::large_futures)] if let Err(err) = handle_tcp_connection( @@ -412,7 +449,7 @@ impl ProxyHandle { ) .await { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -429,7 +466,7 @@ impl ProxyHandle { &mut consecutive_unknown_errors, ) { AcceptAction::Terminal => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::High) .status(StatusId::Failure) @@ -441,7 +478,7 @@ impl ProxyHandle { break; } AcceptAction::Retry { backoff, severity } => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(severity) .status(StatusId::Failure) @@ -790,7 +827,14 @@ async fn handle_transparent_tcp_connection( } )); emit_activity(&activity_tx, false, "transparent_tcp"); - relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await + relay::relay_tcp( + &mut client, + &mut upstream, + &generation_guard, + &ctx, + openshell_ocsf::ctx::ctx(), + ) + .await } #[cfg(any(target_os = "linux", test))] @@ -1366,6 +1410,7 @@ fn emit_denial_simple( #[allow(clippy::too_many_arguments)] fn build_connect_allow_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, host: &str, port: u16, @@ -1381,7 +1426,7 @@ fn build_connect_allow_ocsf_event( } else { "CONNECT" }; - NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) @@ -1397,6 +1442,7 @@ fn build_connect_allow_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_allow_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1408,7 +1454,7 @@ fn build_forward_allow_ocsf_event( cmdline: &str, policy: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) @@ -1426,8 +1472,11 @@ fn build_forward_allow_ocsf_event( .build() } -fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) +fn build_forward_parse_error_ocsf_event( + event_context: &EventContext, + path: &str, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(event_context) .activity(ActivityId::Other) .http_response(HttpResponse { code: StatusCode::BAD_REQUEST.as_u16(), @@ -1443,12 +1492,13 @@ fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent /// contain credentials; the method and generated response provide the HTTP /// context required by OCSF 1.8. fn build_forward_unsupported_scheme_ocsf_event( + event_context: &EventContext, method: &str, scheme: &str, host: &str, port: u16, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .http_request(HttpRequest { http_method: method.parse().expect("HTTP method parsing is infallible"), @@ -1470,6 +1520,7 @@ fn build_forward_unsupported_scheme_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_l7_parse_rejection_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1482,7 +1533,7 @@ fn build_forward_l7_parse_rejection_ocsf_event( policy: &str, detail: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1505,6 +1556,7 @@ fn build_forward_l7_parse_rejection_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_policy_deny_ocsf_event( + event_context: &EventContext, peer_addr: SocketAddr, method: &str, host: &str, @@ -1516,7 +1568,7 @@ fn build_forward_policy_deny_ocsf_event( cmdline: &str, reason: &str, ) -> openshell_ocsf::OcsfEvent { - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::Other) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1556,6 +1608,7 @@ fn endpoint_result_for_destination_failure(kind: DestinationDenialKind) -> Endpo #[allow(clippy::too_many_arguments)] fn build_connect_destination_deny_ocsf_event( + event_context: &EventContext, denial: &DestinationDenial, peer_addr: SocketAddr, host: &str, @@ -1572,7 +1625,7 @@ fn build_connect_destination_deny_ocsf_event( format!("CONNECT blocked: {detail} for {host}:{port}") }; - NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1589,6 +1642,7 @@ fn build_connect_destination_deny_ocsf_event( #[allow(clippy::too_many_arguments)] fn build_forward_destination_deny_ocsf_event( + event_context: &EventContext, denial: &DestinationDenial, peer_addr: SocketAddr, method: &str, @@ -1608,7 +1662,7 @@ fn build_forward_destination_deny_ocsf_event( detail }; - HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + HttpActivityBuilder::new(event_context) .activity(ActivityId::for_http_method(method)) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1629,6 +1683,7 @@ fn build_forward_destination_deny_ocsf_event( #[allow(clippy::too_many_arguments)] async fn deny_connect_destination( + event_context: &EventContext, client: &mut TcpStream, denial: &DestinationDenial, peer_addr: SocketAddr, @@ -1644,7 +1699,15 @@ async fn deny_connect_destination( ) -> Result<()> { let detail = destination_denial_detail(denial.kind); ocsf_emit!(build_connect_destination_deny_ocsf_event( - denial, peer_addr, host, port, binary, pid, ancestors, cmdline, + event_context, + denial, + peer_addr, + host, + port, + binary, + pid, + ancestors, + cmdline, )); emit_denial( @@ -1675,6 +1738,7 @@ async fn deny_connect_destination( #[allow(clippy::too_many_arguments)] async fn deny_forward_destination( + event_context: &EventContext, client: &mut TcpStream, denial: &DestinationDenial, peer_addr: SocketAddr, @@ -1693,7 +1757,18 @@ async fn deny_forward_destination( ) -> Result<()> { let detail = destination_denial_detail(denial.kind); ocsf_emit!(build_forward_destination_deny_ocsf_event( - denial, peer_addr, method, host, port, path, binary, pid, ancestors, cmdline, policy, + event_context, + denial, + peer_addr, + method, + host, + port, + path, + binary, + pid, + ancestors, + cmdline, + policy, )); emit_denial_simple( @@ -1782,6 +1857,7 @@ async fn handle_tcp_connection( activity_tx: Option, endpoint_observation_tx: Option, ) -> Result<()> { + let event_context = identity_mode.event_context().clone(); // Capture authority before request parsing or policy selection can yield. // A later inventory installation cannot acquire this connection's result. let endpoint_observation_context = endpoint_observation_tx @@ -1935,7 +2011,7 @@ async fn handle_tcp_connection( // Allowed connections are logged after the L7 config check (below) // so we can distinguish CONNECT (L4-only) from CONNECT_L7 (L7 follows). if matches!(decision.action, NetworkAction::Deny { .. }) { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -1981,6 +2057,7 @@ async fn handle_tcp_connection( Ok(guard) => guard, Err(error) => { reject_stale_connect_policy( + &event_context, &mut client, &host_lc, port, @@ -2021,6 +2098,7 @@ async fn handle_tcp_connection( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_connect_destination( + &event_context, &mut client, &denial, workload_addr, @@ -2062,6 +2140,7 @@ async fn handle_tcp_connection( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_connect_destination( + &event_context, &mut client, &denial, workload_addr, @@ -2093,7 +2172,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TlsFailed); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2131,7 +2210,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2151,6 +2230,7 @@ async fn handle_tcp_connection( .build(); ocsf_emit!(event); crate::l7::emit_uninspected_credential_finding( + &event_context, &host_lc, policy_str, if effective_tls_skip { "tls-skip" } else { "l4" }, @@ -2179,8 +2259,15 @@ async fn handle_tcp_connection( if let Err(error) = relay::validate_route_generation(l7_route, connect_generation_guard.captured_generation()) { - reject_stale_connect_policy(&mut client, &host_lc, port, activity_tx.as_ref(), error) - .await?; + reject_stale_connect_policy( + &event_context, + &mut client, + &host_lc, + port, + activity_tx.as_ref(), + error, + ) + .await?; return Ok(()); } @@ -2190,6 +2277,7 @@ async fn handle_tcp_connection( }; let Some(upstream_result) = upstream_result else { reject_stale_connect_policy( + &event_context, &mut client, &host_lc, port, @@ -2214,8 +2302,15 @@ async fn handle_tcp_connection( } }; if let Err(error) = connect_generation_guard.ensure_current() { - reject_stale_connect_policy(&mut client, &host_lc, port, activity_tx.as_ref(), error) - .await?; + reject_stale_connect_policy( + &event_context, + &mut client, + &host_lc, + port, + activity_tx.as_ref(), + error, + ) + .await?; return Ok(()); } @@ -2231,6 +2326,7 @@ async fn handle_tcp_connection( // Log the allowed CONNECT — use CONNECT_L7 when L7 inspection follows, // so log consumers can distinguish L4-only decisions from tunnel lifecycle events. ocsf_emit!(build_connect_allow_ocsf_event( + &event_context, workload_addr, &host_lc, port, @@ -2297,11 +2393,19 @@ async fn handle_tcp_connection( port = port, "tls: skip — bypassing TLS auto-detection, raw tunnel" ); - let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &opa_engine, &decision) + let Some(generation_guard) = + relay::prepare_raw_relay(l7_route, &opa_engine, &decision, &event_context) else { return Ok(()); }; - relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await?; + relay::relay_tcp( + &mut client, + &mut upstream, + &generation_guard, + &ctx, + &event_context, + ) + .await?; return Ok(()); } @@ -2339,7 +2443,7 @@ async fn handle_tcp_connection( if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TlsFailed); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2351,9 +2455,13 @@ async fn handle_tcp_connection( } }; let tls_result = async { - let Some(relay_context) = - relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) - else { + let Some(relay_context) = relay::prepare_http_relay( + l7_route, + &opa_engine, + &decision, + &ctx, + &event_context, + ) else { return Ok(()); }; @@ -2368,7 +2476,7 @@ async fn handle_tcp_connection( "TLS connection closed" ); } else { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2391,7 +2499,7 @@ async fn handle_tcp_connection( // placeholder verbatim). const DETAIL: &str = "TLS termination unavailable after tunnel establishment; \ closing connection - credential rewrite would be bypassed"; - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2426,7 +2534,8 @@ async fn handle_tcp_connection( // Plaintext HTTP detected. ctx.request_default_port = Some(80); let is_l7_relay = l7_route.is_some_and(|route| !route.configs.is_empty()); - let Some(relay_context) = relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) + let Some(relay_context) = + relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx, &event_context) else { return Ok(()); }; @@ -2443,7 +2552,7 @@ async fn handle_tcp_connection( } else { format!("HTTP relay error: {e}") }; - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -2462,7 +2571,7 @@ async fn handle_tcp_connection( if requirement == InspectionRequirement::RequiredMiddleware { crate::l7::middleware::emit_middleware_uninspectable(&ctx, protocol_detail, true); } - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2513,11 +2622,19 @@ async fn handle_tcp_connection( port = port, "Non-TLS non-HTTP traffic detected, raw tunnel" ); - let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &opa_engine, &decision) + let Some(generation_guard) = + relay::prepare_raw_relay(l7_route, &opa_engine, &decision, &event_context) else { return Ok(()); }; - relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await?; + relay::relay_tcp( + &mut client, + &mut upstream, + &generation_guard, + &ctx, + &event_context, + ) + .await?; } Ok(()) @@ -2981,8 +3098,13 @@ fn authorize_egress_intent( } } -fn emit_l7_tunnel_close_after_policy_change(host: &str, port: u16, error: miette::Report) { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) +fn build_l7_tunnel_close_after_policy_change_event( + event_context: &EventContext, + host: &str, + port: u16, + error: &miette::Report, +) -> openshell_ocsf::OcsfEvent { + NetworkActivityBuilder::new(event_context) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -2992,11 +3114,21 @@ fn emit_l7_tunnel_close_after_policy_change(host: &str, port: u16, error: miette .message(format!( "L7 tunnel closed before inspection because policy changed: {error}" )) - .build(); + .build() +} + +fn emit_l7_tunnel_close_after_policy_change( + event_context: &EventContext, + host: &str, + port: u16, + error: miette::Report, +) { + let event = build_l7_tunnel_close_after_policy_change_event(event_context, host, port, &error); ocsf_emit!(event); } async fn reject_stale_connect_policy( + event_context: &EventContext, client: &mut TcpStream, host: &str, port: u16, @@ -3009,7 +3141,7 @@ async fn reject_stale_connect_policy( error = %error, "CONNECT rejected because policy changed after L4 authorization" ); - emit_l7_tunnel_close_after_policy_change(host, port, error); + emit_l7_tunnel_close_after_policy_change(event_context, host, port, error); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, @@ -4491,6 +4623,7 @@ async fn handle_forward_proxy( activity_tx: Option<&ActivitySender>, endpoint_observation_tx: Option, ) -> Result<()> { + let event_context = identity_mode.event_context().clone(); // Capture authority before asynchronous authorization or credential selection. // Policy and provider snapshots must belong to this same installation. let endpoint_observation_context = endpoint_observation_tx @@ -4501,7 +4634,10 @@ async fn handle_forward_proxy( // canonicalized below before credential binding, policy-path evaluation, // upstream bytes, or telemetry consume it. let Ok((scheme, host, port, mut path)) = parse_proxy_uri(target_uri) else { - ocsf_emit!(build_forward_parse_error_ocsf_event(&telemetry_path)); + ocsf_emit!(build_forward_parse_error_ocsf_event( + &event_context, + &telemetry_path + )); respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; @@ -4543,7 +4679,13 @@ async fn handle_forward_proxy( } if scheme != "http" { - let event = build_forward_unsupported_scheme_ocsf_event(method, &scheme, &host_lc, port); + let event = build_forward_unsupported_scheme_ocsf_event( + &event_context, + method, + &scheme, + &host_lc, + port, + ); ocsf_emit!(event); if scheme == "https" { respond( @@ -4624,6 +4766,7 @@ async fn handle_forward_proxy( NetworkAction::Allow { matched_policy } => matched_policy.clone(), NetworkAction::Deny { reason } => { ocsf_emit!(build_forward_policy_deny_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -4685,7 +4828,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because policy generation changed after L4 decision" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, @@ -4735,7 +4878,7 @@ async fn handle_forward_proxy( let prepared_target = match prepare_forward_target(&path, canonicalize_options) { Ok(prepared) => prepared, Err(error) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -4806,6 +4949,7 @@ async fn handle_forward_proxy( "Forward proxy rejected request because L7 route lookup used a different policy generation" ); emit_l7_tunnel_close_after_policy_change( + &event_context, &host_lc, port, miette::miette!( @@ -4838,7 +4982,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because L7 tunnel engine could not be cloned" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, @@ -4906,6 +5050,7 @@ async fn handle_forward_proxy( observer.observe(EndpointResult::PolicyDenied); } ocsf_emit!(build_forward_l7_parse_rejection_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -4935,7 +5080,7 @@ async fn handle_forward_proxy( if let Some(observer) = endpoint_observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); } - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Other) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -5015,7 +5160,7 @@ async fn handle_forward_proxy( { Ok(info) => info, Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -5069,7 +5214,7 @@ async fn handle_forward_proxy( { Ok(body) => body, Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) @@ -5132,7 +5277,7 @@ async fn handle_forward_proxy( || { crate::l7::relay::evaluate_l7_request(&tunnel_engine, &l7_ctx, &request_info) .unwrap_or_else(|e| { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = NetworkActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -5197,7 +5342,7 @@ async fn handle_forward_proxy( ) }, ); - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Other) .action(action_id) .disposition(disposition_id) @@ -5271,6 +5416,7 @@ async fn handle_forward_proxy( observer.observe(EndpointResult::PolicyDenied); } deny_forward_destination( + &event_context, client, &denial, workload_addr, @@ -5311,6 +5457,7 @@ async fn handle_forward_proxy( observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_forward_destination( + &event_context, client, &denial, workload_addr, @@ -5344,7 +5491,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because policy changed before upstream connect" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, @@ -5375,6 +5522,7 @@ async fn handle_forward_proxy( observer.observe(EndpointResult::PolicyDenied); } emit_l7_tunnel_close_after_policy_change( + &event_context, &host_lc, port, miette::miette!( @@ -5597,7 +5745,13 @@ async fn handle_forward_proxy( if let Some(observer) = endpoint_observer.as_ref() { observer.observe_credential_failure(true); } - emit_credential_endpoint_mismatch(method, &host_lc, port, policy_str); + emit_credential_endpoint_mismatch( + &event_context, + method, + &host_lc, + port, + policy_str, + ); respond( client, &build_json_error_response( @@ -5672,7 +5826,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because policy changed before relay" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) @@ -5702,7 +5856,7 @@ async fn handle_forward_proxy( if let Some(observer) = endpoint_observer.as_ref() { observer.observe(EndpointResult::TransportFailed); } - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + let event = HttpActivityBuilder::new(&event_context) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) @@ -5752,7 +5906,7 @@ async fn handle_forward_proxy( error = %e, "Forward proxy rejected request because policy changed during upstream connect" ); - emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); + emit_l7_tunnel_close_after_policy_change(&event_context, &host_lc, port, e); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) @@ -5860,6 +6014,7 @@ async fn handle_forward_proxy( // rewriting, generation checks, and the HTTP relay. Only now record the // final allowed outcome. ocsf_emit!(build_forward_allow_ocsf_event( + &event_context, workload_addr, method, &host_lc, @@ -6154,6 +6309,18 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + fn proxy_event_context(sandbox_id: &str, sandbox_name: &str) -> EventContext { + EventContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: String::new(), + hostname: "windows-gateway".to_string(), + product_version: "test".to_string(), + proxy_ip: Ipv4Addr::LOCALHOST.into(), + proxy_port: 3128, + } + } + #[test] fn endpoint_result_distinguishes_resolution_from_policy() { assert_eq!( @@ -7172,6 +7339,7 @@ network_policies: fn forward_policy_denial_ocsf_includes_validation_rationale() { let reason = "policy validation failed; fail-closed quarantine is active; candidate version 7 rejected: conflicting tls metadata"; let event = build_forward_policy_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), "127.0.0.1:45123".parse().unwrap(), "GET", "api.example.com", @@ -7190,9 +7358,45 @@ network_policies: assert_eq!(json["disposition"], "Blocked"); } + #[test] + fn forward_policy_denial_ocsf_keeps_per_proxy_sandbox_attribution() { + use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; + + let peer = "127.0.0.1:45123".parse().unwrap(); + let build_event = |context: &EventContext| { + build_forward_policy_deny_ocsf_event( + context, + peer, + "GET", + "api.example.com", + 80, + "/v1/models", + r"C:\agent.exe", + "-", + "-", + r"C:\agent.exe", + "endpoint is not allowed by any policy", + ) + .to_json() + .unwrap() + }; + + let sandbox_a = build_event(&proxy_event_context("sandbox-a-id", "sandbox-a")); + let sandbox_b = build_event(&proxy_event_context("sandbox-b-id", "sandbox-b")); + + assert_eq!(sandbox_a["container"]["uid"], "sandbox-a-id"); + assert_eq!(sandbox_a["container"]["name"], "sandbox-a"); + assert_eq!(sandbox_b["container"]["uid"], "sandbox-b-id"); + assert_eq!(sandbox_b["container"]["name"], "sandbox-b"); + let schema = load_class_schema("http_activity"); + validate_required_fields(&sandbox_a, &schema); + validate_required_fields(&sandbox_b, &schema); + } + #[test] fn forward_l7_parse_rejection_ocsf_includes_denial_context() { let event = build_forward_l7_parse_rejection_ocsf_event( + openshell_ocsf::ctx::ctx(), "127.0.0.1:45123".parse().unwrap(), "GET", "api.example.com", @@ -7367,6 +7571,7 @@ network_policies: assert_eq!(path, "/v1/[CREDENTIAL]"); let allowed = build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "api.example.com", @@ -7381,6 +7586,7 @@ network_policies: .to_json() .unwrap(); let denied = build_forward_policy_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "api.example.com", @@ -7407,6 +7613,7 @@ network_policies: assert_eq!(host, "api.example.com"); assert_eq!(path, "/?token=real-secret"); let no_path_query = build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", &host, @@ -7426,9 +7633,12 @@ network_policies: assert!(!serialized.contains("real-secret"), "{serialized}"); assert!(!serialized.contains("?token="), "{serialized}"); - let malformed = build_forward_parse_error_ocsf_event(&forward_telemetry_path( - "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", - )) + let malformed = build_forward_parse_error_ocsf_event( + openshell_ocsf::ctx::ctx(), + &forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + ), + ) .to_json() .unwrap(); assert_eq!( @@ -10390,8 +10600,13 @@ network_policies: fn unsupported_forward_scheme_event_omits_request_url() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; - let event = - build_forward_unsupported_scheme_ocsf_event("GET", "https", "api.example.com", 443); + let event = build_forward_unsupported_scheme_ocsf_event( + openshell_ocsf::ctx::ctx(), + "GET", + "https", + "api.example.com", + 443, + ); let json = event.to_json().unwrap(); assert_eq!(json["http_request"]["http_method"], "GET"); @@ -10404,18 +10619,93 @@ network_policies: fn credential_endpoint_mismatch_event_includes_method_and_response() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; - let event = - build_credential_endpoint_mismatch_event("POST", "api.example.com", 443, "bound"); + let event_context = proxy_event_context("sandbox-incident-id", "sandbox-incident"); + let event = build_credential_endpoint_mismatch_event( + &event_context, + "POST", + "api.example.com", + 443, + "bound", + ); let json = event.to_json().unwrap(); + let finding = crate::l7::build_credential_endpoint_mismatch_finding( + &event_context, + "bound", + "api.example.com", + None, + "Provider credential endpoint binding mismatch; request denied", + ) + .to_json() + .unwrap(); assert_eq!(json["class_uid"], 4002); assert_eq!(json["activity_name"], "Post"); assert_eq!(json["http_request"]["http_method"], "POST"); assert!(json["http_request"].get("url").is_none()); assert_eq!(json["http_response"]["code"], 403); + for incident_event in [&json, &finding] { + assert_eq!(incident_event["container"]["uid"], "sandbox-incident-id"); + assert_eq!(incident_event["container"]["name"], "sandbox-incident"); + } validate_required_fields(&json, &load_class_schema("http_activity")); } + #[test] + fn uninspected_credential_finding_keeps_per_proxy_sandbox_attribution() { + let event_context = proxy_event_context("sandbox-uninspected-id", "sandbox-uninspected"); + let finding = crate::l7::build_uninspected_credential_finding( + &event_context, + "api.example.com", + "bound", + "tls-skip", + ) + .to_json() + .unwrap(); + + assert_eq!(finding["container"]["uid"], "sandbox-uninspected-id"); + assert_eq!(finding["container"]["name"], "sandbox-uninspected"); + } + + #[tokio::test] + async fn stale_connect_rejection_keeps_context_and_returns_policy_denial() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let client = TcpStream::connect(address); + let accepted = listener.accept(); + let (client, accepted) = tokio::join!(client, accepted); + let mut client = client.unwrap(); + let (mut proxy, _) = accepted.unwrap(); + let event_context = proxy_event_context("sandbox-stale-id", "sandbox-stale"); + + reject_stale_connect_policy( + &event_context, + &mut proxy, + "api.example.com", + 443, + None, + miette::miette!("policy generation is stale"), + ) + .await + .unwrap(); + + let mut response = vec![0; 512]; + let bytes_read = client.read(&mut response).await.unwrap(); + let response = String::from_utf8_lossy(&response[..bytes_read]); + assert!(response.starts_with("HTTP/1.1 403 Forbidden"), "{response}"); + assert!(response.contains("policy_denied"), "{response}"); + + let event = build_l7_tunnel_close_after_policy_change_event( + &event_context, + "api.example.com", + 443, + &miette::miette!("policy generation is stale"), + ) + .to_json() + .unwrap(); + assert_eq!(event["container"]["uid"], "sandbox-stale-id"); + assert_eq!(event["container"]["name"], "sandbox-stale"); + } + #[test] fn forward_credentials_capture_endpoint_resolver_and_revision_together() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 4c89e5e382..485b5eb04d 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -36,6 +36,7 @@ pub(super) struct RelayContext<'a> { request: &'a L7EvalContext, policy: PreparedHttpPolicy, middleware_engine: &'a OpaEngine, + event_context: &'a openshell_ocsf::EventContext, } /// Non-blocking observation channels attached to an authorized HTTP relay. @@ -146,9 +147,11 @@ pub(super) fn prepare_http_relay<'a>( opa_engine: &'a OpaEngine, decision: &EgressDecision, request: &'a L7EvalContext, + event_context: &'a openshell_ocsf::EventContext, ) -> Option> { if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -161,6 +164,7 @@ pub(super) fn prepare_http_relay<'a>( Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -182,6 +186,7 @@ pub(super) fn prepare_http_relay<'a>( Ok(guard) => guard, Err(error) => { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -196,6 +201,7 @@ pub(super) fn prepare_http_relay<'a>( request, policy, middleware_engine: opa_engine, + event_context, }) } @@ -206,9 +212,11 @@ pub(super) fn prepare_raw_relay( route: Option<&L7RouteSnapshot>, opa_engine: &OpaEngine, decision: &EgressDecision, + event_context: &openshell_ocsf::EventContext, ) -> Option { if let Err(error) = validate_route_generation(route, decision.policy_generation) { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -220,6 +228,7 @@ pub(super) fn prepare_raw_relay( Ok(guard) => Some(guard), Err(error) => { emit_l7_tunnel_close_after_policy_change( + event_context, &decision.intent.destination.host, decision.intent.destination.port, error, @@ -255,7 +264,11 @@ where context.request, ) => result, () = generation_guard.wait_until_stale() => { - emit_stale_relay_close(context.request, &generation_guard); + emit_stale_relay_close( + context.request, + &generation_guard, + context.event_context, + ); Ok(()) } } @@ -271,7 +284,11 @@ where context.request, ) => result, () = generation_guard.wait_until_stale() => { - emit_stale_relay_close(context.request, &generation_guard); + emit_stale_relay_close( + context.request, + &generation_guard, + context.event_context, + ); Ok(()) } } @@ -286,7 +303,11 @@ where Some(context.middleware_engine), ) => result, () = generation_guard.wait_until_stale() => { - emit_stale_relay_close(context.request, &generation_guard); + emit_stale_relay_close( + context.request, + &generation_guard, + context.event_context, + ); Ok(()) } } @@ -300,6 +321,7 @@ pub(super) async fn relay_tcp( upstream: &mut U, generation_guard: &PolicyGenerationGuard, request: &L7EvalContext, + event_context: &openshell_ocsf::EventContext, ) -> Result<()> where C: AsyncRead + AsyncWrite + Unpin, @@ -310,14 +332,19 @@ where result.into_diagnostic()?; } () = generation_guard.wait_until_stale() => { - emit_stale_relay_close(request, generation_guard); + emit_stale_relay_close(request, generation_guard, event_context); } } Ok(()) } -fn emit_stale_relay_close(request: &L7EvalContext, guard: &PolicyGenerationGuard) { +fn emit_stale_relay_close( + request: &L7EvalContext, + guard: &PolicyGenerationGuard, + event_context: &openshell_ocsf::EventContext, +) { emit_l7_tunnel_close_after_policy_change( + event_context, &request.host, request.port, miette::miette!( @@ -380,8 +407,14 @@ mod tests { let decision = decision(engine.current_generation()); let request = request_context(); - let context = prepare_http_relay(None, &engine, &decision, &request) - .expect("current L4 generation should prepare a relay"); + let context = prepare_http_relay( + None, + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .expect("current L4 generation should prepare a relay"); let PreparedHttpPolicy::Passthrough { generation_guard } = context.policy else { panic!("route-less relay should use a generation guard"); }; @@ -403,7 +436,14 @@ mod tests { let request = request_context(); assert!( - prepare_http_relay(Some(&route), &engine, &decision, &request).is_none(), + prepare_http_relay( + Some(&route), + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .is_none(), "a current L7 lookup must not freshen a stale L4 allow" ); } @@ -441,7 +481,14 @@ mod tests { let request = request_context(); assert!( - prepare_http_relay(Some(&route), &engine, &decision, &request).is_none(), + prepare_http_relay( + Some(&route), + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .is_none(), "an inspected route must use the generation that authorized CONNECT" ); } @@ -456,7 +503,8 @@ mod tests { }; assert!( - prepare_raw_relay(Some(&route), &engine, &decision).is_none(), + prepare_raw_relay(Some(&route), &engine, &decision, openshell_ocsf::ctx::ctx(),) + .is_none(), "a raw relay must not freshen a stale L4 allow" ); } @@ -469,7 +517,14 @@ mod tests { engine.reload(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); assert!( - prepare_http_relay(None, &engine, &decision, &request).is_none(), + prepare_http_relay( + None, + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .is_none(), "policy reload must prevent a stale relay from starting" ); } @@ -485,7 +540,14 @@ mod tests { let (_upstream_peer, mut proxy_upstream) = tokio::io::duplex(64); let relay = tokio::spawn(async move { - relay_tcp(&mut proxy_client, &mut proxy_upstream, &guard, &request).await + relay_tcp( + &mut proxy_client, + &mut proxy_upstream, + &guard, + &request, + openshell_ocsf::ctx::ctx(), + ) + .await }); tokio::task::yield_now().await; diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 5948cd5224..759f888b20 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -92,6 +92,7 @@ async fn destination_denials_preserve_adapter_specific_wire_contracts() { let (mut app, mut proxy) = tcp_pair().await; deny_connect_destination( + openshell_ocsf::ctx::ctx(), &mut proxy, &denial, peer, @@ -119,6 +120,7 @@ async fn destination_denials_preserve_adapter_specific_wire_contracts() { let (mut app, mut proxy) = tcp_pair().await; deny_forward_destination( + openshell_ocsf::ctx::ctx(), &mut proxy, &denial, peer, @@ -165,6 +167,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { // global tracing pipeline. Its callsite-interest cache is process-global, // so parallel tests can otherwise make captured-event assertions flaky. let connect = serde_json::to_value(build_connect_destination_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), &denial, peer, "target.example", @@ -194,6 +197,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { assert_eq!(connect["status_detail"], denial_reason); let forward = serde_json::to_value(build_forward_destination_deny_ocsf_event( + openshell_ocsf::ctx::ctx(), &denial, peer, "POST", @@ -229,6 +233,7 @@ fn representative_adapter_denials_preserve_ocsf_fields() { fn representative_adapter_allows_preserve_ocsf_fields() { let peer: SocketAddr = "127.0.0.1:41000".parse().unwrap(); let connect = serde_json::to_value(build_connect_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "target.example", 8443, @@ -254,6 +259,7 @@ fn representative_adapter_allows_preserve_ocsf_fields() { assert_eq!(connect["message"], "CONNECT_L7 allowed target.example:8443"); let forward = serde_json::to_value(build_forward_allow_ocsf_event( + openshell_ocsf::ctx::ctx(), peer, "GET", "target.example", From 651ed7e03d3f30b930acaf6693147595499542b4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:38:07 -0700 Subject: [PATCH 11/39] NVBug 6783374: make MXC HTTPS L7 qualification authoritative (#3479) * test(mxc): qualify HTTPS L7 enforcement (NVBug 6783374) Signed-off-by: Prekshi Vyas * docs(mxc): clarify real qualification failures Signed-off-by: Prekshi Vyas * docs(mxc): align real test skip semantics Signed-off-by: Shailendra Singh --------- Signed-off-by: Prekshi Vyas Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- .../build-openshell-mxc-windows/SKILL.md | 9 +- crates/openshell-driver-mxc/README.md | 27 ++++-- .../tests/wxc_exec_real.rs | 83 ++++++------------- docs/reference/gateway-config.mdx | 2 + docs/security/best-practices.mdx | 2 + tasks/windows.toml | 4 +- 6 files changed, 55 insertions(+), 72 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 0ee92ed6a7..13ae8f19f8 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -163,9 +163,12 @@ 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 +neither. Both print a SKIP reason and exit 0 when `wxc-exec` or the matching +backend is unavailable. Once the host proves that ProcessContainer is live, +however, required capabilities are authoritative: rejection of +`network.proxy` or another enforcement failure fails the task. Running the +architecture-appropriate task is therefore safe without real MXC hardware but +must not be described as wholly skip-safe on supported hardware. Neither task is part of `windows:ci`'s ordered contract, so invoke it explicitly. For GB300 Windows ARM64 qualification, do not use the skip-safe developer task diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 469c7a4820..b4ff31b5df 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -167,11 +167,18 @@ process env. In every environment mode, the driver stages the public CA files under the authorized `/.openshell-proxy/` directory. The host proxy's private temporary directory is never added to the sandbox's read-write shares. The staged 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 unless the gateway explicitly opts into another environment mode. +CA private key remains in the host proxy's memory. + +Windows inbox `curl.exe` uses Schannel and ignores `CURL_CA_BUNDLE` as an +environment variable, so workloads using it must pass +`--cacert %CURL_CA_BUNDLE%` explicitly. Clients that honor the injected trust +variables consume the same per-sandbox bundle directly. + +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 unless the gateway explicitly opts into another +environment mode. When governed egress is disabled, any network rule fails closed during sandbox creation. @@ -194,10 +201,12 @@ This example uses `process_container`. The `IsoSessionApp.dll` and ## Real-MXC test lane -The generic real-`wxc-exec.exe` tasks are **skip-safe**: a test or scenario that -requires an absent binary or backend prints a SKIP reason and exits 0. They are -useful developer diagnostics, but a skipped run is not qualification evidence. -The GB300 task is deliberately strict and fails on every required skip. +The generic real-`wxc-exec.exe` tasks print a SKIP reason and exit 0 when the +binary or requested backend is unavailable. Once ProcessContainer is live, +required capabilities are authoritative: rejection of `network.proxy` or +another enforcement failure fails the task. These tasks are useful developer +diagnostics, but a skipped run is not qualification evidence. The GB300 task +is deliberately strict and fails on every required skip. | Task | What it runs | When to use | |---|---|---| diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 1e6e041932..e5e7dbd399 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -659,58 +659,6 @@ 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 }, - }, - }); - - 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) - .output() - .map_err(|error| format!("wxc-exec proxy probe failed to spawn: {error}"))?; - - if output.status.success() { - return Ok(()); - } - - 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), - )) -} - /// Probe the `isolation_session` backend. /// /// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or @@ -1013,11 +961,6 @@ 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}"); - return; - } - 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"); @@ -1029,10 +972,14 @@ 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 post_response_path = output_dir.path().join("post-response.json"); + let post_status_path = output_dir.path().join("post-status.txt"); let diagnostic_path = output_dir.path().join("https-diagnostic.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 post_response_path_string = post_response_path.to_string_lossy().into_owned(); + let post_status_path_string = post_status_path.to_string_lossy().into_owned(); let diagnostic_path_string = diagnostic_path.to_string_lossy().into_owned(); let cmd_string = cmd.to_string_lossy().into_owned(); // Schannel's revocation lookup targets are intentionally outside this @@ -1046,8 +993,15 @@ async fn pc_https_egress_reads_injected_ca_bundle() { --cacert \"%CURL_CA_BUNDLE%\" \ https://example.com/ --output \"{output_path_string}\" \ --write-out \"%{{certs}}\" 1>\"{certificate_path_string}\" \ + 2>>\"{diagnostic_path_string}\" && \ + \"{}\" --silent --show-error --ssl-no-revoke \ + --cacert \"%CURL_CA_BUNDLE%\" --request POST \ + --header \"Content-Type: application/json\" --data \"{{}}\" \ + https://example.com/ --output \"{post_response_path_string}\" \ + --write-out \"%{{http_code}}\" 1>\"{post_status_path_string}\" \ 2>>\"{diagnostic_path_string}\"", - curl.display() + curl.display(), + curl.display(), ); let command = vec![ cmd_string.clone(), @@ -1156,6 +1110,19 @@ async fn pc_https_egress_reads_injected_ca_bundle() { peer_certificate.contains("OpenShell Sandbox CA"), "HTTPS response must use a certificate issued by the host proxy CA" ); + let post_status = std::fs::read_to_string(post_status_path).expect("POST status output"); + assert_eq!( + post_status.trim(), + "403", + "read-only policy must deny HTTPS POST" + ); + let post_response = + std::fs::read_to_string(post_response_path).expect("POST denial response body"); + assert!( + post_response.contains("policy_denied") + || post_response.contains("no matching L7 allow rule"), + "POST denial must come from the OpenShell L7 policy: {post_response}" + ); } /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index a292cb030c..ce1d9735e0 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -962,6 +962,8 @@ non-mutating administrator-gated Service Control Manager open. Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. +The driver points common TLS trust variables, including `CURL_CA_BUNDLE`, at the staged public CA bundle. Windows inbox `curl.exe` uses Schannel and does not honor `CURL_CA_BUNDLE` automatically. Invoke it with `--cacert %CURL_CA_BUNDLE%` so HTTPS requests through the governed-egress proxy trust the per-sandbox CA. + MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. Supply the workload command and optional working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index bd7cd5ff61..4fff61f276 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -125,6 +125,8 @@ This enables credential injection and L7 inspection without explicit configurati | Risk if relaxed | `tls: skip` disables placeholder credential rewriting, dynamic token grant injection, and L7 inspection for that endpoint. The proxy relays encrypted traffic without seeing the contents. | | Recommendation | Use auto-detect (the default) for most endpoints. Use `tls: skip` only when the upstream requires the client's own TLS certificate (mTLS) or uses a non-HTTP protocol. A provider-credentialed endpoint also requires the explicit `allow_uninspected_credentials: true` exception. | +On Windows, inbox `curl.exe` uses Schannel and does not automatically honor the injected `CURL_CA_BUNDLE` value. Pass `--cacert %CURL_CA_BUNDLE%` explicitly when that client sends HTTPS traffic through an MXC governed-egress proxy. Other clients continue to use the trust variable they support. + ### SSRF Protection After OPA policy allows a connection, the proxy resolves DNS and rejects undeclared internal destinations. diff --git a/tasks/windows.toml b/tasks/windows.toml index fb8e64d52f..5b8bab4008 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -76,12 +76,12 @@ 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 native x64 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 only when binary/backend is unavailable; supported ProcessContainer failures are fatal)" 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-real x86_64-pc-windows-msvc" ["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)" +description = "Run native ARM64 real-wxc-exec Tier-2 integration tests (skip only when binary/backend is unavailable; supported ProcessContainer failures are fatal)" 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-real aarch64-pc-windows-msvc" From 27a36b400453d32fc84f96c20ce45fb239bbf8d2 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:09:43 +0530 Subject: [PATCH 12/39] fix(mxc): reject a non-absolute wxc_exec_path at gateway startup (#3497) * fix(mxc): reject a non-absolute wxc_exec_path at gateway startup wxc_exec_path is the binary that builds every sandbox, but nothing validated it before spawning: a relative value (including the shipped default, a bare "wxc-exec.exe") let PATH-lookup or working-directory- relative resolution execute a decoy binary with the gateway's identity instead of the approved wxc-exec, turning the containment mechanism itself into an arbitrary-code-execution primitive. Add MxcComputeConfig::validate_configuration, wired into the existing (previously no-op) compute-driver config preflight, rejecting an empty or non-absolute wxc_exec_path with a clear diagnostic. Change the default from the relative "wxc-exec.exe" to an empty string so the field must be explicitly configured -- no usable-but-insecure fallback survives. Update the architecture doc's stale "else PATH" discovery claim to match. Signed-off-by: Prashant Khodade (cherry picked from commit d4192a0072f8f0ca0a4035f10cae07889c2b578f) * test(mxc): cover wxc_exec_path enforcement at the gateway boundary MxcComputeConfig::validate_configuration already had unit coverage, but that only proves the validation function itself is correct -- it says nothing about whether MxcFactory::validate_config (src/lib.rs) still calls it. Before this fix, that factory method discarded the parsed config entirely, so a regression back to that no-op shape would leave every unit test passing while a relative wxc_exec_path again reached gateway startup. Add an integration test that spawns the actual compiled openshell-gateway binary through its config preflight subcommand, exercising the real chain: CLI parsing, TOML loading, driver selection, MxcFactory::validate_config, and MxcComputeConfig::validate_configuration. Assertions check only pass/fail, not message content: run_effective_config_preflight replaces any validation failure with a generic message whenever a config file is used, to keep file-sourced values out of preflight diagnostics -- pre-existing, deliberate, and covered by its own tests. Also document the new required-and-absolute wxc_exec_path constraint in the gateway config reference and the driver README, which previously only showed example values without stating the requirement. Signed-off-by: Prashant Khodade --------- Signed-off-by: Prashant Khodade --- crates/openshell-driver-mxc/README.md | 8 ++ crates/openshell-driver-mxc/src/driver.rs | 84 ++++++++++++++- crates/openshell-gateway/src/lib.rs | 4 +- .../tests/mxc_config_preflight.rs | 100 ++++++++++++++++++ docs/reference/gateway-config.mdx | 15 +++ 5 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 crates/openshell-gateway/tests/mxc_config_preflight.rs diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index b4ff31b5df..89269bb3a1 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -75,6 +75,14 @@ debug = false etw_audit = false ``` +`wxc_exec_path` is required and must be an absolute path to `wxc-exec.exe`. +The gateway rejects an omitted or relative value (including the bare filename +`wxc-exec.exe`) at startup, before any sandbox is created: `wxc-exec.exe` is +the binary that builds every sandbox, so a relative value would let +PATH-lookup or working-directory-relative resolution execute an unapproved +binary with the gateway's identity instead of the approved `wxc-exec`. There +is no usable default. + When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback `IP:PORT` seed. For policies with explicit network rules, the driver preserves the configured IP and allocates a unique ephemeral port for that sandbox's diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 8cf5e17a47..412b352b34 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -179,7 +179,12 @@ impl MxcBackend { #[serde(default, deny_unknown_fields)] #[allow(clippy::struct_excessive_bools)] // Independent, existing gateway TOML options. pub struct MxcComputeConfig { - /// Path to `wxc-exec.exe`. Required for live runs. + /// Path to `wxc-exec.exe`. Required for live runs, and must be an + /// absolute path: `wxc-exec` is the binary that builds every sandbox, so + /// a relative path (including the unset default) would let PATH-lookup + /// or working-directory-relative resolution execute a decoy binary with + /// the gateway's identity instead of the approved `wxc-exec`. Enforced + /// at gateway startup by the compute-driver config preflight. pub wxc_exec_path: String, /// Backend to target. Default: `process_container`. pub backend: MxcBackend, @@ -256,7 +261,12 @@ pub struct MxcComputeConfig { impl Default for MxcComputeConfig { fn default() -> Self { Self { - wxc_exec_path: "wxc-exec.exe".into(), + // No usable default: `wxc_exec_path` must be explicitly set to an + // absolute path (see `validate_configuration` and the field doc + // comment above). Shipping a bare relative filename here would + // silently reintroduce the exact PATH/CWD-hijack risk the + // validation exists to reject. + wxc_exec_path: String::new(), backend: MxcBackend::default(), pc_least_privilege: false, pc_capabilities: Vec::new(), @@ -275,6 +285,32 @@ impl Default for MxcComputeConfig { } } +impl MxcComputeConfig { + /// Validate startup configuration without touching `wxc-exec` or the + /// filesystem beyond `Path::is_absolute`. + /// + /// `wxc_exec_path` must be set to an absolute path: it is the binary + /// that builds every sandbox, so a relative path (including an unset, + /// empty value) would let PATH-lookup or working-directory-relative + /// resolution execute a decoy binary with the gateway's identity instead + /// of the approved `wxc-exec`, turning the containment mechanism itself + /// into an arbitrary-code-execution primitive. + pub fn validate_configuration(&self) -> openshell_core::Result<()> { + if self.wxc_exec_path.trim().is_empty() { + return Err(openshell_core::Error::config( + "[openshell.drivers.mxc] wxc_exec_path must be set to an absolute path to wxc-exec.exe", + )); + } + if !Path::new(&self.wxc_exec_path).is_absolute() { + return Err(openshell_core::Error::config(format!( + "[openshell.drivers.mxc] wxc_exec_path must be an absolute path, got '{}'", + self.wxc_exec_path + ))); + } + Ok(()) + } +} + /// Per-sandbox MXC workload settings supplied through /// `template.driver_config.mxc` / `--driver-config-json`. #[derive(Debug, Clone, Deserialize)] @@ -2790,6 +2826,50 @@ mod lifecycle_tests { } } + #[test] + fn validate_configuration_rejects_unset_wxc_exec_path() { + // Regression test: the shipped default used to be the bare relative + // filename "wxc-exec.exe", which is exactly the PATH/CWD-hijack + // primitive this validation exists to reject. The default must stay + // rejected, not silently become a usable-but-insecure fallback. + let config = MxcComputeConfig::default(); + assert!(config.wxc_exec_path.is_empty()); + let error = config.validate_configuration().unwrap_err(); + assert!(error.to_string().contains("wxc_exec_path")); + } + + #[test] + fn validate_configuration_rejects_relative_wxc_exec_path() { + let config = MxcComputeConfig { + wxc_exec_path: "wxc-exec.exe".into(), + ..Default::default() + }; + let error = config.validate_configuration().unwrap_err(); + assert!(error.to_string().contains("wxc_exec_path")); + + let config = MxcComputeConfig { + wxc_exec_path: r"..\wxc-exec.exe".into(), + ..Default::default() + }; + assert!(config.validate_configuration().is_err()); + } + + #[test] + fn validate_configuration_accepts_absolute_wxc_exec_path() { + let config = MxcComputeConfig { + wxc_exec_path: r"C:\mxc-kit\bin\wxc-exec.exe".into(), + ..Default::default() + }; + config.validate_configuration().unwrap(); + } + + #[test] + fn governed_egress_defaults_off_and_allocates_unique_loopback_ports() { + let config = MxcComputeConfig::default(); + assert!(!config.egress_proxy); + assert!(config.egress_proxy_addr.is_empty()); + } + #[test] fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { let configured = "127.0.0.1:18080".parse().unwrap(); diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index 30e1c83727..f5f2074da9 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -149,8 +149,8 @@ impl openshell_server::ComputeDriverFactory for MxcFactory { &self, context: openshell_server::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { - let _: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; - Ok(()) + let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?; + config.validate_configuration() } async fn build( 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/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ce1d9735e0..a4ca56a250 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -687,6 +687,14 @@ debug = false etw_audit = true ``` +`wxc_exec_path` is required and must be an absolute path to `wxc-exec.exe`. +The gateway rejects an omitted or relative value at startup: `wxc-exec.exe` +is the binary that builds every sandbox, so a relative value would let +PATH-lookup or working-directory-relative resolution execute an unapproved +binary with the gateway's identity instead of the intended `wxc-exec`. There +is no usable default; older configurations that relied on the bare relative +filename `wxc-exec.exe` must set an absolute path before upgrading. + `etw_audit` defaults to `false`. When enabled, the gateway account must be an administrator or belong to the Windows Performance Log Users group. Workload commands and working directories remain sandbox-scoped and must be supplied in @@ -949,6 +957,13 @@ debug = false etw_audit = false ``` +`wxc_exec_path` is required and must be an absolute path; the gateway rejects +an omitted or relative value at startup. `wxc-exec.exe` builds every sandbox, +so a relative value would let PATH-lookup or working-directory-relative +resolution execute an unapproved binary with the gateway's identity. There is +no usable default -- older configurations that relied on the bare relative +filename `wxc-exec.exe` must set an absolute path before upgrading. + The default `pc_least_privilege = false` still runs the workload with an AppContainer token. Set it to `true` to request the stricter Less Privileged AppContainer (LPAC) variant. Windows AppContainer tokens retain the launching From 37c2bae2e6e79ae3ee7c48d4fe022656f0fea226 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:11:12 +0530 Subject: [PATCH 13/39] feat(mxc): warn when the ETW consumer receives zero provider events (#3499) * feat(mxc): warn when the ETW consumer receives zero provider events EnableTraceEx2 succeeding only proves the request to enable the Sandboxing provider succeeded, not that the provider exists on this host/build or will ever fire. A provider-identity mismatch or a non-firing provider left the ETW->OCSF audit trail silently empty across real, successful sandbox lifecycles, with no error, warning, or diagnostic anywhere. Track raw provider-matched events received per session and add a zero-events watchdog on the consumer thread: once real sandbox activity has happened (register_launch called at least once) and a grace period elapses with zero events matched, log a warning and emit a Detection Finding [2004] naming the gap. Gated on actual activity (not just session uptime) so an idle gateway with etw_audit=true and no sandboxes created never warns. Also exposes EtwSession::events_received() alongside the existing is_capture_alive(), so a status/diagnostics surface can query capture health directly, not just infer it from tracing output. The watchdog's decision logic is extracted into a pure function (should_warn_zero_events) so it's unit-testable without a real ETW session or elevation. Signed-off-by: Prashant Khodade (cherry picked from commit 4fcfa716a808eaa91c6476e9c69366f91702b1fa) * fix(mxc): repair build/lint breaks left by the zero-events watchdog commit warn_zero_events_received() referenced a nonexistent SESSION_NAME constant (only SESSION_NAME_PREFIX exists), so the crate failed to compile. Separately, the consumer thread's decode-or-log match on Option tripped clippy::single_match_else under -D warnings. Neither issue is specific to a platform or toolchain version -- both reproduce on a clean checkout of this branch's tip. Signed-off-by: Prashant Khodade * fix(mxc): attribute ETW Sandboxing-provider events via identity/CV, not PID The Sandboxing provider's events are logged under two PIDs that are never the driver's own wxc-exec PID: a short-lived launcher PID (wxc-exec.exe exits within seconds even while the sandboxed workload keeps running) and a shared, constant PID hosted by a system-wide OS broker service across unrelated sandboxes. Anchoring attribution on `register_launch`'s wxc_pid therefore left every event unattributable, so the OCSF audit trail stayed empty despite the provider firing correctly (confirmed against a raw logman/tracerpt capture running alongside this consumer). wxc-exec never reports its OS-generated `identity`/`__TlgCV__` back to the driver, so there is nothing to pre-seed `by_identity` with at registration time the way `by_pid` is pre-seeded. Track launches still awaiting their first identity/CV in a `pending_launches` queue instead, and bind opportunistically in `resolve()`: when an event carries a never-seen identity/CV and has no `by_pid` registration at all (the real-world shape of these events) and exactly one launch is pending, it can only be that launch's burst. Zero or multiple pending launches stay ambiguous and fall through to the existing unresolved-event buffer/TTL path rather than guess -- misattributing an audit event to the wrong sandbox_id is worse than dropping it. A PID registration that does exist (even generation-mismatched) is treated as positive evidence of an existing PID-reuse race and takes precedence over the opportunistic path, preserving the existing generation-key guarantees. Signed-off-by: Prashant Khodade * fix(mxc): surface dropped-unattributed ETW events above debug level An event that ages out of the unresolved-event buffer unattributed is a permanent audit-trail gap: the OS action it represents will never appear in the OCSF log, and nothing retries it afterward. That was only visible at --log-level debug, so an operator running with the default level would never see it. Promote it to warn, matching the severity already used for the zero-events watchdog's own gap warning. Signed-off-by: Prashant Khodade * fix(mxc): keep ETW attribution fail-closed, rate-limit drop warnings The prior opportunistic "single pending launch" fallback attributed any fresh identity/CV from an unregistered or system-broker PID to the sole pending OpenShell launch. Queue cardinality is not attribution evidence: unrelated, non-OpenShell AppContainer or UAC activity shares this same OS Sandboxing provider, so an event arriving in the five-second window could cross-link an unrelated identity and emit subsequent events under the wrong sandbox_id, corrupting the audit trail. Revert it -- attribution requires the driver-owned wxc-exec PID and its kernel process start key to both match, exactly as before. Records without that generation-backed evidence remain unattributed rather than guessed. The dropped-unattributed warning also moved from one line per record to a rate-limited, aggregated warning: unattributed drops are expected, ordinary system-wide activity, not a rare condition, so warning per record could flood operator logs during a burst of unrelated AppContainer/UAC activity. UnattributedDropReporter mirrors the existing OverloadReporter pattern -- warn immediately on the first drop, then coalesce to one warning with a running count every 30 seconds while drops continue. Updates the MXC observability documentation to match: the unattributed-record sentence now explains why fail-closed is deliberate, documents the coalesced warning cadence, and documents the mxc-etw-zero-events OCSF Detection Finding [2004], which was not mentioned anywhere before. Signed-off-by: Prashant Khodade --------- Signed-off-by: Prashant Khodade --- crates/openshell-driver-mxc/README.md | 21 +- .../openshell-driver-mxc/src/etw_consumer.rs | 301 +++++++++++++++++- 2 files changed, 317 insertions(+), 5 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 89269bb3a1..553bea1566 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -132,7 +132,26 @@ 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. +them. Records without matching generation evidence remain unattributed -- +deliberately: misattributing an ETW record to the wrong `sandbox_id` would +corrupt the audit trail, which is worse than a coverage gap. Unrelated, +non-OpenShell AppContainer or UAC activity shares this same OS Sandboxing +provider and cannot be told apart from OpenShell's own records without this +generation evidence, so guessing (for example, by assuming a lone pending +launch owns an unmatched record) is not a safe substitute for it. + +An unattributed record is dropped after five seconds, and the driver warns +once immediately, then coalesces further drops to at most one aggregated +warning every 30 seconds while they continue -- unattributed drops are +expected, ordinary activity, not a rare condition, so warning once per record +would let a burst of that activity flood operator logs. + +If the real-time ETW session itself never matches a single record from the +Sandboxing provider despite observed sandbox activity -- for example a +provider-identity mismatch, or the provider not firing at all on a given +host/build -- the driver warns once per session and emits a `mxc-etw-zero-events` +OCSF Detection Finding [2004] naming the gap, distinct from the per-record +unattributed-drop warning above. Each sandbox receives a distinct proxy listener and a random per-sandbox credential through its proxy environment. Missing, incorrect, duplicate, or another sandbox's proxy credentials receive HTTP 407 before policy evaluation or forwarding. This authenticates requests to the OpenShell proxy; it does not restrict access to unrelated host-loopback services or authenticate individual processes inside a sandbox. Proxy credentials and command/environment payloads must not be logged. diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 09b8079868..612412bf5f 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -111,6 +111,20 @@ const EVENT_QUEUE_BYTE_CAPACITY: usize = 16 * 1024 * 1024; /// while the consumer remains behind. const OVERLOAD_WARNING_INTERVAL: Duration = Duration::from_secs(30); +/// Emit the first dropped-unattributed warning immediately, then coalesce +/// additional drops the same way [`OVERLOAD_WARNING_INTERVAL`] does for queue +/// overload. Unattributed drops are expected, ordinary system-wide activity +/// (unrelated AppContainer/UAC events sharing this same OS Sandboxing +/// provider) rather than a rare condition, so warning once per record would +/// let a burst of that unrelated activity flood operator logs. +const UNATTRIBUTED_DROP_WARNING_INTERVAL: Duration = Duration::from_secs(30); + +/// Grace period, after the first sandbox activity is observed, before the +/// zero-events watchdog warns that the session has matched no provider +/// events at all. Generous on purpose: the goal is to catch a genuinely +/// non-firing provider, not to flag normal per-sandbox event latency. +const ZERO_EVENTS_GRACE: Duration = Duration::from_secs(30); + /// `EVENT_CONTROL_CODE_ENABLE_PROVIDER`. const EVENT_CONTROL_CODE_ENABLE_PROVIDER: u32 = 1; @@ -291,6 +305,14 @@ struct CaptureHealth { queued_bytes: AtomicUsize, /// Largest observed value of `queued_bytes`, retained for diagnostics. queue_high_water_bytes: AtomicUsize, + /// Count of raw events the callback matched to [`SANDBOXING_PROVIDER_GUID`] + /// and forwarded to the consumer thread, regardless of whether TDH decode + /// later succeeded. Zero here after real sandbox activity means the OS + /// session is not delivering *any* events under this GUID at all -- a + /// provider-identity mismatch or a provider that isn't firing on this + /// host/build, not a decode/attribution bug. See the zero-events watchdog + /// in `start_session`'s consumer loop. + events_matched: AtomicU64, } struct CallbackContext { @@ -298,6 +320,50 @@ struct CallbackContext { health: Arc, } +/// Rate-limits the dropped-unattributed warning the same way +/// [`OverloadReporter`] rate-limits the queue-overload warning: report the +/// first drop immediately, then at most once every +/// [`UNATTRIBUTED_DROP_WARNING_INTERVAL`] while drops continue, aggregating +/// the count instead of warning per record. Unrelated, non-OpenShell +/// AppContainer/UAC activity shares this OS Sandboxing provider and cannot be +/// attributed without authoritative per-sandbox evidence, so a burst of that +/// ordinary activity must not flood operator logs one line per event. +#[derive(Default)] +struct UnattributedDropReporter { + total_dropped: u64, + last_reported_dropped: u64, + last_warning: Option, +} + +impl UnattributedDropReporter { + /// Record one more dropped-unattributed event and warn if due. Returns + /// `true` when a warning was actually emitted (useful for tests). + fn record_drop(&mut self, reason: &str, summary: &str) -> bool { + self.total_dropped += 1; + + let now = Instant::now(); + if self + .last_warning + .is_some_and(|last| now.duration_since(last) < UNATTRIBUTED_DROP_WARNING_INTERVAL) + { + return false; + } + + let dropped_since_last_warning = self.total_dropped - self.last_reported_dropped; + self.last_reported_dropped = self.total_dropped; + self.last_warning = Some(now); + tracing::warn!( + target: "mxc_etw", + total_dropped = self.total_dropped, + dropped_since_last_warning, + reason, + last_summary = summary, + "MXC ETW events dropped unattributed; audit coverage has a gap" + ); + true + } +} + #[derive(Default)] struct OverloadReporter { last_reported_drops: u64, @@ -380,6 +446,13 @@ impl EtwSession { pub fn dropped_event_count(&self) -> u64 { self.health.dropped_events.load(Ordering::Relaxed) } + + /// Count of raw provider-matched events received since the session + /// started. Exposed so the backend can surface "capture is running but + /// producing nothing" in status/diagnostics, alongside `is_capture_alive`. + pub fn events_received(&self) -> u64 { + self.health.events_matched.load(Ordering::Relaxed) + } } impl Drop for EtwSession { @@ -420,10 +493,12 @@ pub(crate) fn start_session(index: Arc>) -> Result(EVENT_QUEUE_CAPACITY); - let consumer_thread = std::thread::Builder::new() .name("etw-ocsf-consumer".into()) .spawn(move || { @@ -435,10 +510,23 @@ pub(crate) fn start_session(index: Arc>) -> Result = None; + let mut warned_no_events = false; loop { match rx.recv_timeout(Duration::from_millis(200)) { Ok(mut raw) => { release_queue_bytes(&consumer_health, raw.queued_bytes); + consumer_health + .events_matched + .fetch_add(1, Ordering::Relaxed); if let Some(ev) = decode_raw(&mut raw) { process_event(&index, ev); } else { @@ -462,6 +550,18 @@ pub(crate) fn start_session(index: Arc>) -> Result, + /// Total sandboxes ever registered, never decremented by [`Self::forget`]. + /// Used to gate the zero-events watchdog: a session that hasn't seen any + /// sandbox activity yet is expected to be quiet, so only warn once real + /// activity has happened and still produced nothing. + total_launches: u64, + /// Rate-limits the dropped-unattributed warning; see + /// [`UnattributedDropReporter`]. + unattributed_drops: UnattributedDropReporter, } impl AttributionIndex { @@ -1300,6 +1408,7 @@ impl AttributionIndex { wxc_pid: u32, process_start_key: u64, ) { + self.total_launches += 1; let now = Instant::now(); self.purge_expired_retirements(now); let previous = self.by_pid.remove(&wxc_pid); @@ -1371,6 +1480,12 @@ impl AttributionIndex { .any(|registration| registration.sid == sandbox_id) } + /// Total sandboxes ever registered via [`Self::register_launch`], including + /// ones since [`Self::forget`]-ten. Used to gate the zero-events watchdog. + pub fn total_launches(&self) -> u64 { + self.total_launches + } + /// Drop all keys for a finished sandbox to bound memory. pub fn forget(&mut self, sandbox_id: &str) { self.by_pid @@ -1480,7 +1595,15 @@ impl AttributionIndex { if now.duration_since(front.at) > PENDING_TTL { let stale = self.pending.pop_front(); if let Some(p) = stale { - tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); + // A permanently dropped event is an audit-trail gap (the + // OS action it represents will never appear in the OCSF + // log), so it's worth surfacing above debug level by + // default rather than only under `--log-level debug`. + // Rate-limited and aggregated: unattributed drops are + // expected, ordinary activity from unrelated AppContainer/ + // UAC events sharing this provider, not a rare condition. + self.unattributed_drops + .record_drop("aged_out", &p.ev.summary()); } } else { break; @@ -1489,7 +1612,8 @@ impl AttributionIndex { if self.pending.len() >= PENDING_MAX && let Some(p) = self.pending.pop_front() { - tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (buffer full) {}", p.ev.summary()); + self.unattributed_drops + .record_drop("buffer_full", &p.ev.summary()); } self.pending.push_back(PendingEvent { at: now, ev }); } @@ -1509,7 +1633,8 @@ impl AttributionIndex { let mut keep = VecDeque::with_capacity(drained.len()); for p in drained { if now.duration_since(p.at) > PENDING_TTL { - tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); + self.unattributed_drops + .record_drop("aged_out", &p.ev.summary()); continue; } match self.resolve(&p.ev) { @@ -1828,6 +1953,74 @@ fn map_finding(ctx: &EventContext, ev: &DecodedEtwEvent) -> OcsfEvent { .build() } +/// Decide whether the zero-events watchdog should fire, and track when +/// sandbox activity was first observed. Pure aside from `*activity_since`, +/// which the caller retains across calls (and across a loop tick that +/// doesn't warn) so the grace period is measured from first activity, not +/// re-armed on every tick. +/// +/// Gates on `total_launches` (not the index's current size) specifically so +/// a quiet, idle gateway with `etw_audit=true` but zero sandboxes created +/// never warns -- only genuine "activity happened, nothing arrived" does. +fn should_warn_zero_events( + events_matched: u64, + total_launches: u64, + activity_since: &mut Option, + grace: Duration, +) -> bool { + if events_matched != 0 { + return false; + } + if activity_since.is_none() && total_launches > 0 { + *activity_since = Some(Instant::now()); + } + activity_since.is_some_and(|since| since.elapsed() >= grace) +} + +/// Fired once per session by the zero-events watchdog when real sandbox +/// activity has happened but the session has never matched a single event to +/// [`SANDBOXING_PROVIDER_GUID`]. `EnableTraceEx2` success only proves the +/// *request* to enable the provider succeeded, not that the provider exists +/// on this host/build or will ever actually fire -- this is the detection gap +/// that made a real-world provider-identity mismatch silently produce an +/// empty OCSF audit trail with no diagnostic at all. +fn warn_zero_events_received() { + tracing::warn!( + target: "mxc_etw", + provider = ?SANDBOXING_PROVIDER_GUID, + session = SESSION_NAME_PREFIX, + "MXC ETW->OCSF consumer has received zero events from the Sandboxing \ + provider despite sandbox activity; the OS-sourced audit trail is \ + empty for this session. EnableTraceEx2 succeeding does not prove the \ + provider exists on this host/build or will ever fire -- verify with \ + `logman query providers` and confirm wxc-exec targets this GUID." + ); + emit_ocsf( + "", + DetectionFindingBuilder::new(&etw_ctx("", "mxc-etw-consumer")) + .activity(ActivityId::Open) // finding label = "Create" + .severity(SeverityId::High) + .is_alert(true) + .finding_info( + FindingInfo::new( + "mxc-etw-zero-events", + "MXC ETW audit consumer received zero provider events", + ) + .with_desc( + "The Sandboxing ETW provider produced zero events despite \ + observed sandbox activity; the OS-sourced portion of the \ + audit trail is empty for this session.", + ), + ) + .message( + "MXC ETW->OCSF consumer active but received zero events from \ + the Sandboxing provider after sandbox activity" + .to_string(), + ) + .build(), + ); +} + /// Best-effort executable name from a command line: first whitespace-delimited /// token, stripped of any directory prefix and surrounding quotes. fn exe_name(cmd_line: &str) -> String { @@ -2009,6 +2202,38 @@ mod tests { assert_eq!(reporter.last_reported_drops, 3); } + #[test] + fn unattributed_drop_reporter_warns_immediately_then_coalesces() { + let mut reporter = UnattributedDropReporter::default(); + + // The very first drop, ever, warns immediately (no prior warning to + // rate-limit against). + assert!(reporter.record_drop("aged_out", "SandboxConfig (id=0)")); + assert_eq!(reporter.total_dropped, 1); + assert_eq!(reporter.last_reported_dropped, 1); + + // Further drops within the interval are aggregated, not re-warned, + // but still counted so the next warning reports the true total. + assert!(!reporter.record_drop("aged_out", "EnforceOsPolicy (id=0)")); + assert!(!reporter.record_drop("buffer_full", "ProcessLaunched (id=0)")); + assert_eq!( + reporter.total_dropped, 3, + "every drop is counted even when coalesced" + ); + assert_eq!( + reporter.last_reported_dropped, 1, + "the reported total only advances when a warning actually fires" + ); + + // Force the rate limit open by backdating the last warning, then + // confirm the next drop reports the two that were coalesced since. + reporter.last_warning = Instant::now() + .checked_sub(UNATTRIBUTED_DROP_WARNING_INTERVAL + Duration::from_millis(1)); + assert!(reporter.record_drop("aged_out", "SetUILimitsOnJob (id=0)")); + assert_eq!(reporter.total_dropped, 4); + assert_eq!(reporter.last_reported_dropped, 4); + } + #[test] fn gateway_processes_and_restarts_use_distinct_session_names() { let first_gateway = format_session_name(1001, 0x1111, 0); @@ -2198,6 +2423,74 @@ mod tests { assert_eq!(idx.resolve(&ev_b).as_deref(), Some("sbx-B")); } + // Regression test for the zero-events watchdog: it must gate on whether + // sandbox activity has *ever* happened, not on the index's current size, + // since `forget` empties the index for every sandbox that completes + // normally -- `total_launches` must keep counting past that. + #[test] + fn total_launches_survives_forget() { + let mut idx = AttributionIndex::new(); + assert_eq!(idx.total_launches(), 0); + + idx.register_launch("sbx-1", "s1", 100, 1); + idx.forget("sbx-1"); + assert_eq!(idx.total_launches(), 1); + + idx.register_launch("sbx-2", "s2", 200, 2); + idx.register_launch("sbx-3", "s3", 300, 3); + assert_eq!(idx.total_launches(), 3); + } + + #[test] + fn zero_events_watchdog_stays_quiet_without_sandbox_activity() { + // An idle gateway with etw_audit=true but no sandboxes created yet + // must never warn, however long it's been running. + let mut activity_since = None; + assert!(!should_warn_zero_events( + 0, + 0, + &mut activity_since, + Duration::ZERO + )); + assert!(activity_since.is_none()); + } + + #[test] + fn zero_events_watchdog_stays_quiet_once_any_event_matched() { + let mut activity_since = None; + assert!(!should_warn_zero_events( + 1, + 5, + &mut activity_since, + Duration::ZERO + )); + } + + #[test] + fn zero_events_watchdog_waits_out_the_grace_period() { + let mut activity_since = None; + let grace = Duration::from_hours(1); + // First tick after activity starts: grace hasn't elapsed yet. + assert!(!should_warn_zero_events(0, 1, &mut activity_since, grace)); + assert!(activity_since.is_some()); + // A later tick still within the (long) grace window: still quiet. + assert!(!should_warn_zero_events(0, 1, &mut activity_since, grace)); + } + + #[test] + fn zero_events_watchdog_fires_once_grace_elapses() { + let mut activity_since = Some(Instant::now().checked_sub(Duration::from_mins(1)).unwrap()); + assert!(should_warn_zero_events( + 0, + 1, + &mut activity_since, + Duration::from_secs(30) + )); + } + + // Shailendra #1 (cmd ambiguity): two sandboxes running the identical command + // line must not let that command line resolve anything (it's ambiguous); a + // unique command line still works as a fallback. #[test] fn displaced_live_pid_does_not_claim_new_pre_registration_event() { let mut idx = AttributionIndex::new(); From e084e1b09e646e25aca0af11f1855da88acb495a Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:13:49 +0530 Subject: [PATCH 14/39] fix(server): keep Error-phase sandbox records through the prune sweep (#3498) * fix(server): keep Error-phase sandbox records through the prune sweep The periodic store-vs-backend reconciliation sweep deleted any persisted sandbox not present in the driver's live backend snapshot, except for Completed and failed-main-process phases. A driver whose registry is in-process-only and never rehydrates after a restart (no persistence of its own) reports every previously-known sandbox as missing on the very first sweep after startup -- including ones already correctly, terminally marked Error by earlier crash detection -- so the sweep silently deleted them shortly after gateway restart, racing any client (GetSandbox/ListSandboxes/DeleteSandbox) working with the same sandbox in that window. Treat Error the same as the existing Completed exemption: it is already a settled, informational terminal state with no live compute resource to reclaim, so keep the durable record instead of deleting it. Signed-off-by: Prashant Khodade (cherry picked from commit 721a1659a822a72e76e3c0dffc6847f17129a3fc) * fix(server): narrow the prune sweep's Error-phase exemption The blanket phase == SandboxPhase::Error exemption changed established missing-backend cleanup for every compute driver, not only the MXC restart race the PR intended to fix. It also matched BackendResourceMissing (set by gateway-startup recovery when a previously-known sandbox's backend resource is already gone), StartFailed (startup recovery's driver-error case), and ComputeResourceMissing (this same sweep's own first-pass Error transition for a Stopping/Stopped/Starting sandbox). All three mark exactly the orphaned resources this sweep exists to reclaim across Docker, Podman, VM, Kubernetes, and extension drivers -- exempting them left orphaned names and gateway-owned records in place indefinitely and skipped the idempotent driver cleanup for volumes/secrets until a user explicitly deleted the sandbox. Add is_missing_compute_resource_reason to inspect the sandbox's Ready condition and narrow the exemption to a settled Error record only: one whose reason isn't one of those three. A crashed main process or any other non-resource failure keeps the exemption (no live resource ever expected again); a resource-missing reason keeps flowing through the normal delete-and-cleanup path exactly as before this PR. Adds regression coverage for BackendResourceMissing and ComputeResourceMissing confirming they are still pruned with driver cleanup invoked, and documents the settled-vs-missing-resource retention distinction in architecture/compute-runtimes.md. Signed-off-by: Prashant Khodade --------- Signed-off-by: Prashant Khodade --- architecture/compute-runtimes.md | 13 ++ crates/openshell-server/src/compute/mod.rs | 182 ++++++++++++++++++++- 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 8f7c5e5080..1205b67594 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -282,6 +282,19 @@ owned task prevents cancellation from stranding a mutation. A gateway restart does not start a persisted `Deleting` operation. If the backend completed the delete, reconciliation removes the row; otherwise it can remain `Deleting`. +The prune sweep retains a `Completed` sandbox and a settled `Error` sandbox +(a crashed main process, or any other terminal failure not specifically about +the compute resource itself) even when the driver reports it missing: both +are audit records with no live resource to reclaim, and deleting one out from +under a concurrent `GetSandbox`/`ListSandboxes`/`DeleteSandbox` caller would +be a silent race, not a cleanup. An `Error` sandbox whose condition reports +the compute resource itself as missing -- set either by startup recovery +finding a previously-known sandbox already gone, or by this same sweep +transitioning a `Stopping`/`Stopped`/`Starting` sandbox to `Error` on an +earlier pass -- is not retained this way: it is exactly the orphaned resource +this sweep exists to reclaim, so it keeps flowing through the normal +delete-and-cleanup path above. + ## Runtime Summary | Runtime | Best fit | Sandbox boundary | Notes | diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index be57ca32dd..9b5bab1259 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -3875,9 +3875,33 @@ impl ComputeRuntime { let sandbox = decode_sandbox_record(¤t_record)?; let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); - if phase == SandboxPhase::Completed || is_failed_main_process_result(&sandbox) { + if phase == SandboxPhase::Completed + || (phase == SandboxPhase::Error && !is_missing_compute_resource_reason(&sandbox)) + || is_failed_main_process_result(&sandbox) + { // A terminal canonical process may legitimately have removed its // transient compute object. Keep the durable command result. + // + // A settled Error record (crash detection, an explicit + // non-resource failure, etc.) is the same kind of terminal state + // with no live compute resource to reclaim -- deleting it outright + // instead of leaving it in place silently races a concurrent + // GetSandbox/ListSandboxes/DeleteSandbox caller. A driver whose + // registry never rehydrates after a restart (in-process-only + // state, e.g. MXC) reports every previously-known sandbox as + // "missing" on the very first sweep after startup, even ones + // already correctly marked Error by earlier crash detection, so + // this is reached far more than the "orphaned compute resource" + // case this pruning otherwise exists for. + // + // This must NOT exempt an Error record whose reason specifically + // says the compute resource itself is missing + // (`is_missing_compute_resource_reason`) -- those are exactly + // this sweep's intended target (see that function's doc comment), + // set either by startup recovery or by this same sweep transitioning + // Stopping/Stopped/Starting below. Exempting them too would leave + // orphaned names and gateway-owned records in place indefinitely + // and skip the idempotent driver cleanup for volumes/secrets. return Ok(()); } if matches!( @@ -4040,6 +4064,38 @@ fn is_failed_main_process_result(sandbox: &Sandbox) -> bool { }) } +/// Ready-condition reasons the gateway uses to mark an Error-phase sandbox +/// specifically because its compute resource was found missing, rather than +/// because a running process crashed or exited: `BackendResourceMissing` and +/// `StartFailed` come from gateway-startup recovery +/// (`resume_sandboxes_at_startup`) finding a `NotFound`/other backend error +/// for a previously-known sandbox, and `ComputeResourceMissing` comes from +/// `prune_missing_sandbox` itself transitioning a Stopping/Stopped/Starting +/// sandbox to Error the first time its resource is found missing. +/// +/// These reasons mark exactly the sandboxes `prune_missing_sandbox`'s orphan +/// cleanup exists to reclaim -- an Error phase alone must not exempt them +/// from that sweep's delete-and-cleanup path the way a settled, no-resource- +/// expected Error record (a crashed main process, an explicit non-resource +/// failure) is exempted, or orphaned names and gateway-owned records would +/// never get pruned and idempotent driver cleanup for volumes/secrets would +/// never run. +const MISSING_COMPUTE_RESOURCE_REASONS: [&str; 3] = [ + "BackendResourceMissing", + "StartFailed", + "ComputeResourceMissing", +]; + +fn is_missing_compute_resource_reason(sandbox: &Sandbox) -> bool { + sandbox + .status + .as_ref() + .and_then(|status| status.conditions.iter().find(|c| c.r#type == "Ready")) + .is_some_and(|condition| { + MISSING_COMPUTE_RESOURCE_REASONS.contains(&condition.reason.as_str()) + }) +} + /// Connect to an unmanaged remote compute driver that is already listening on /// `socket_path` and return the acquired endpoint. /// @@ -9372,6 +9428,130 @@ mod tests { ); } + #[tokio::test] + async fn prune_missing_sandbox_keeps_error_phase_records() { + // Regression test: a driver whose registry never rehydrates after a + // restart (in-process-only state, e.g. MXC) reports every + // previously-known sandbox as missing on the first sweep after + // startup -- including ones already correctly, terminally marked + // Error by earlier crash detection. The sweep must not delete those; + // it must treat Error the same as the existing Completed exemption + // and leave the durable record in place. + let driver = ControlledDriver::new(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Error); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .reconcile_store_with_backend(Duration::ZERO) + .await + .unwrap(); + + let retained = runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .expect("Error-phase sandbox record must survive the prune sweep"); + assert_eq!(retained.phase(), SandboxPhase::Error as i32); + assert_eq!(driver.delete_calls(), 0); + } + + #[tokio::test] + async fn prune_missing_sandbox_still_reclaims_backend_resource_missing_records() { + // Regression test for a review finding on the Error-phase exemption + // above: BackendResourceMissing is the reason gateway-startup + // recovery uses (see `resume_sandboxes_at_startup`) when a + // previously-known sandbox's backend resource is already gone. The + // Error-phase exemption must not swallow this case -- it is exactly + // the "orphaned compute resource" this sweep exists to reclaim, so + // the record and its driver-owned resources must still be pruned. + let driver = ControlledDriver::new(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Error); + let name = sandbox.object_name().to_string(); + upsert_ready_condition( + &mut sandbox.status, + &name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "BackendResourceMissing".to_string(), + message: "Sandbox compute resource disappeared while the gateway was offline" + .to_string(), + last_transition_time: String::new(), + }, + ); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .reconcile_store_with_backend(Duration::ZERO) + .await + .unwrap(); + + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none(), + "BackendResourceMissing must not be exempted from pruning" + ); + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!(driver.delete_calls(), 1); + } + + #[tokio::test] + async fn prune_missing_sandbox_still_reclaims_compute_resource_missing_records() { + // Regression test for the same review finding: ComputeResourceMissing + // is the reason this same sweep sets when it first finds a + // Stopping/Stopped/Starting sandbox's resource missing (see below). + // A later sweep must still reclaim it once the retention window + // passes, not treat the Error phase it left behind as settled. + let driver = ControlledDriver::new(); + driver.set_get_outcome(ControlledGetOutcome::Missing); + let runtime = test_runtime(driver.clone()).await; + let mut sandbox = sandbox_record("sb-1", "sandbox-a", SandboxPhase::Error); + let name = sandbox.object_name().to_string(); + upsert_ready_condition( + &mut sandbox.status, + &name, + SandboxCondition { + r#type: "Ready".to_string(), + status: "False".to_string(), + reason: "ComputeResourceMissing".to_string(), + message: "The compute driver could not find the retained sandbox resource; delete the sandbox to clean up its remaining state" + .to_string(), + last_transition_time: String::new(), + }, + ); + runtime.store.put_message(&sandbox).await.unwrap(); + + runtime + .reconcile_store_with_backend(Duration::ZERO) + .await + .unwrap(); + + assert!( + runtime + .store + .get_message::("sb-1") + .await + .unwrap() + .is_none(), + "ComputeResourceMissing must not be exempted from pruning" + ); + tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()) + .await + .expect("background driver cleanup did not run"); + assert_eq!(driver.delete_calls(), 1); + } + #[tokio::test] async fn prune_sweep_does_not_block_on_a_stuck_driver_delete_call() { // Regression test: the prune sweep's driver cleanup must not be From 0b8f3821e2a9ac79c4ea074c2ab33c7383fd92d0 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:47:25 -0700 Subject: [PATCH 15/39] fix(network): normalize Windows binary paths (NVBug 6782969) (#3482) * fix(network): normalize Windows policy binary paths Match Windows executable identities using a stable case-insensitive, separator-normalized representation across policy data, L4 input, and L7 relay evaluation. Preserve exact matching on other platforms and keep the original path for hashing and filesystem access. NVBug 6782969 Signed-off-by: Prekshi Vyas * fix(network): harden Windows binary matching Signed-off-by: Prekshi Vyas * fix(ci): scope Windows relay test imports * fix(network): harden Windows binary path matching --------- Signed-off-by: Prekshi Vyas --- Cargo.lock | 1 + .../openshell-supervisor-network/Cargo.toml | 3 + .../data/sandbox-policy.rego | 50 ++- .../src/l7/relay.rs | 1 + .../openshell-supervisor-network/src/opa.rs | 408 +++++++++++++++++- .../openshell-supervisor-network/src/proxy.rs | 10 +- .../src/proxy/egress.rs | 2 + .../src/proxy/relay.rs | 125 +++++- .../src/proxy/tests/compatibility.rs | 1 + docs/reference/policy-schema.mdx | 7 + docs/security/best-practices.mdx | 8 + 11 files changed, 598 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 572d0742a6..182b278170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4685,6 +4685,7 @@ dependencies = [ "tracing-subscriber", "uuid", "webpki-roots", + "windows", ] [[package]] diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index e4a2f12917..23db6efcfe 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -69,6 +69,9 @@ tokio-stream = { workspace = true, features = ["net"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } + [target.'cfg(unix)'.dev-dependencies] [lints] diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 278291a288..1c10eb0565 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -141,25 +141,54 @@ binary_allowed(_, _) if { not binary_identity_required } +# Binary path matching uses exact spelling by default. On Windows, Rust adds an +# ASCII-folded candidate only after querying every parent directory of the +# runtime-observed executable and confirming case-insensitive lookup throughout. +binary_path_matches(binary, candidate) if { + binary.path == candidate.path +} + +binary_path_matches(binary, candidate) if { + candidate.ascii_case_folded != "" + binary.ascii_case_folded_path == candidate.ascii_case_folded +} + +binary_glob_matches(binary, candidate) if { + glob.match(binary.path, ["/"], candidate.path) +} + +binary_glob_matches(binary, candidate) if { + candidate.ascii_case_folded != "" + glob.match(binary.ascii_case_folded_path, ["/"], candidate.ascii_case_folded) +} + # Binary matching: exact path. # SHA256 integrity is enforced in Rust via trust-on-first-use (TOFU) cache, # not in Rego. The proxy computes and caches binary hashes at runtime. binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] + some b in policy.binaries not contains(b.path, "*") b.path == exec.path } # Binary matching: ancestor exact path (e.g., claude spawns node). binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] + some b in policy.binaries not contains(b.path, "*") - ancestor := exec.ancestors[_] + some ancestor in exec.ancestors b.path == ancestor } +# Trusted runtime evidence may additionally authorize an ASCII case-folded +# Windows path. Legacy and synthetic L7 inputs omit match_paths and continue to +# use only the exact rules above. +binary_allowed(policy, exec) if { + some b in policy.binaries + not contains(b.path, "*") + some candidate in exec.match_paths + binary_path_matches(b, candidate) +} + # Binary matching: glob pattern against exe path or any ancestor. # NOTE: cmdline_paths are intentionally excluded — argv[0] is trivially # spoofable via execve and must not be used as a grant-access signal. @@ -167,8 +196,15 @@ binary_allowed(policy, exec) if { some b in policy.binaries contains(b.path, "*") all_paths := array.concat([exec.path], exec.ancestors) - some p in all_paths - glob.match(b.path, ["/"], p) + some path in all_paths + glob.match(b.path, ["/"], path) +} + +binary_allowed(policy, exec) if { + some b in policy.binaries + contains(b.path, "*") + some candidate in exec.match_paths + binary_glob_matches(b, candidate) } # --- Network action (allow / deny) --- diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index b319c54112..ca7ca2f57a 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -2936,6 +2936,7 @@ fn evaluate_l7_request_once( "path": ctx.binary_path, "ancestors": ctx.ancestors, "cmdline_paths": ctx.cmdline_paths, + "match_paths": engine.binary_match_paths(), }, "request": { "method": request.action, diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 31fc7f99ee..a4bf2c319b 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -78,6 +78,7 @@ pub struct EgressAuthorization { pub matched_endpoints: Vec, pub exact_declared_endpoint_host: bool, pub generation: u64, + pub(crate) binary_match_paths: Vec, } /// Input for a network access policy evaluation. @@ -230,6 +231,7 @@ pub struct TunnelPolicyEngine { generation_guard: PolicyGenerationGuard, middleware_runner: ChainRunner, websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget, + binary_match_paths: Vec, } impl TunnelPolicyEngine { @@ -257,6 +259,10 @@ impl TunnelPolicyEngine { &self.middleware_runner } + pub(crate) fn binary_match_paths(&self) -> &[NetworkBinaryPathEvidence] { + &self.binary_match_paths + } + pub(crate) fn websocket_assembly_budget( &self, ) -> crate::l7::websocket::WebSocketAssemblyBudget { @@ -448,6 +454,7 @@ impl OpaEngine { let mut data: serde_json::Value = serde_json::from_str(&data_json_str) .map_err(|e| miette::miette!("internal: failed to parse proto JSON: {e}"))?; inject_runtime_policy_data(&mut data, require_binary_identity); + normalize_network_binary_paths(&mut data); normalize_endpoint_protocols(&mut data); // Validate BEFORE expanding presets @@ -554,7 +561,8 @@ impl OpaEngine { #[cfg(test)] record_test_opa_query(); - let input_json = network_input_json(input); + let binary_match_paths = network_binary_path_evidence_for_input(input); + let input_json = network_input_json_with_match_paths(input, &binary_match_paths); let mut engine = self .engine @@ -574,6 +582,7 @@ impl OpaEngine { matched_endpoints: Vec::new(), exact_declared_endpoint_host: false, generation, + binary_match_paths, }); } @@ -613,6 +622,7 @@ impl OpaEngine { matched_endpoints, exact_declared_endpoint_host, generation, + binary_match_paths, }) } @@ -1060,6 +1070,14 @@ impl OpaEngine { /// and only duplicates interpreter state (~microseconds). The cloned /// engine can be used without Mutex contention. pub fn clone_engine_for_tunnel(&self, expected_generation: u64) -> Result { + self.clone_engine_for_tunnel_with_match_paths(expected_generation, Vec::new()) + } + + pub(crate) fn clone_engine_for_tunnel_with_match_paths( + &self, + expected_generation: u64, + binary_match_paths: Vec, + ) -> Result { let engine = self .engine .lock() @@ -1079,6 +1097,7 @@ impl OpaEngine { }, middleware_runner: self.middleware_runner()?, websocket_assembly_budget: self.websocket_assembly_budget(), + binary_match_paths, }) } } @@ -1138,10 +1157,31 @@ fn get_str_array(val: ®orus::Value, key: &str) -> Vec { } fn network_input_json(input: &NetworkInput) -> serde_json::Value { - let ancestor_strs: Vec = input - .ancestors + let match_paths = network_binary_path_evidence_for_input(input); + network_input_json_with_match_paths(input, &match_paths) +} + +fn network_binary_path_evidence_for_input(input: &NetworkInput) -> Vec { + let mut match_paths = Vec::with_capacity(input.ancestors.len() + 1); + match_paths.push(network_binary_path_evidence(&input.binary_path)); + match_paths.extend( + input + .ancestors + .iter() + .map(|path| network_binary_path_evidence(path)), + ); + match_paths +} + +fn network_input_json_with_match_paths( + input: &NetworkInput, + match_paths: &[NetworkBinaryPathEvidence], +) -> serde_json::Value { + let binary = &match_paths[0]; + let ancestor_strs: Vec<&str> = match_paths .iter() - .map(|p| p.to_string_lossy().into_owned()) + .skip(1) + .map(|path| path.path.as_str()) .collect(); let cmdline_strs: Vec = input .cmdline_paths @@ -1150,9 +1190,10 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { .collect(); serde_json::json!({ "exec": { - "path": input.binary_path.to_string_lossy(), + "path": &binary.path, "ancestors": ancestor_strs, "cmdline_paths": cmdline_strs, + "match_paths": match_paths, }, "network": { "host": input.host, @@ -1161,6 +1202,126 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { }) } +/// Return the stable representation used only for network-policy path matching. +/// +/// Windows paths accept either path separator. Namespace paths retain their exact +/// spelling, and other platforms retain exact matching. This lexical operation +/// never probes the filesystem, so it is also safe for policy-authored paths. +pub(crate) fn network_binary_match_path(path: &Path) -> String { + let path = path.to_string_lossy(); + #[cfg(target_os = "windows")] + { + windows_network_binary_match_path(&path) + } + #[cfg(not(target_os = "windows"))] + { + path.into_owned() + } +} + +#[cfg(any(target_os = "windows", test))] +fn windows_network_binary_match_path(path: &str) -> String { + if is_windows_namespace_path(path) { + return path.to_owned(); + } + path.replace('\\', "/") +} + +#[cfg(any(target_os = "windows", test))] +fn is_windows_namespace_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 2 && matches!(bytes[0], b'/' | b'\\') && matches!(bytes[1], b'/' | b'\\') +} + +#[derive(Clone, Debug, serde::Serialize)] +pub(crate) struct NetworkBinaryPathEvidence { + path: String, + /// Present only when trusted runtime evidence confirms that every parent + /// directory uses case-insensitive lookup. + ascii_case_folded: String, +} + +fn network_binary_path_evidence(path: &Path) -> NetworkBinaryPathEvidence { + let normalized = network_binary_match_path(path); + #[cfg(target_os = "windows")] + let ascii_case_folded = windows_path_components_case_insensitive(path) + .filter(|case_insensitive| *case_insensitive) + .map_or_else(String::new, |_| normalized.to_ascii_lowercase()); + #[cfg(not(target_os = "windows"))] + let ascii_case_folded = String::new(); + + NetworkBinaryPathEvidence { + path: normalized, + ascii_case_folded, + } +} + +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +fn windows_directory_case_sensitive(path: &Path) -> Option { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + FILE_CASE_SENSITIVE_INFO, FileCaseSensitiveInfo, GetFileInformationByHandleEx, + }; + + let handle = open_windows_directory_for_attributes(path)?; + let mut info = FILE_CASE_SENSITIVE_INFO::default(); + let info_size = u32::try_from(size_of::()).ok()?; + // SAFETY: `handle` stays alive for the call and `info` is the exact buffer + // type and size required by `FileCaseSensitiveInfo`. + unsafe { + GetFileInformationByHandleEx( + HANDLE(handle.as_raw_handle()), + FileCaseSensitiveInfo, + (&raw mut info).cast(), + info_size, + ) + } + .ok()?; + Some(info.Flags & 1 != 0) +} + +#[cfg(target_os = "windows")] +fn windows_path_components_case_insensitive(path: &Path) -> Option { + let text = path.to_string_lossy(); + let bytes = text.as_bytes(); + if text.contains(['*', '?']) + || is_windows_namespace_path(&text) + || bytes.len() < 3 + || !bytes[0].is_ascii_alphabetic() + || bytes[1] != b':' + || !matches!(bytes[2], b'/' | b'\\') + { + return None; + } + + let parent = path.parent()?; + for directory in parent.ancestors().filter(|p| !p.as_os_str().is_empty()) { + if windows_directory_case_sensitive(directory)? { + return Some(false); + } + } + Some(true) +} + +#[cfg(target_os = "windows")] +fn open_windows_directory_for_attributes(path: &Path) -> Option { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + + std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path) + .ok() +} + /// Sets an already-built JSON value as Regorus input without encoding and reparsing JSON text. /// /// The explicit fallible conversion preserves evaluator errors because Regorus's infallible @@ -1463,6 +1624,7 @@ fn preprocess_yaml_data( .map_err(|e| miette::miette!("failed to parse YAML data: {e}"))?; validate_opa_data_structure(&data)?; inject_runtime_policy_data(&mut data, require_binary_identity); + normalize_network_binary_paths(&mut data); normalize_endpoint_protocols(&mut data); // Normalize port → ports for all endpoints so Rego always sees "ports" array. @@ -1564,6 +1726,43 @@ fn normalize_endpoint_protocols(data: &mut serde_json::Value) { } } +/// Normalize configured binary paths without accessing the filesystem. +/// +/// Policy paths are untrusted input and may name remote shares or device +/// namespaces. Case-insensitive matching is therefore decided later using only +/// trusted runtime path evidence. +fn normalize_network_binary_paths(data: &mut serde_json::Value) { + let Some(policies) = data + .get_mut("network_policies") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + + for policy in policies.values_mut() { + let Some(binaries) = policy + .get_mut("binaries") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + for binary in binaries { + let Some(binary) = binary.as_object_mut() else { + continue; + }; + let Some(value) = binary.get("path").and_then(serde_json::Value::as_str) else { + continue; + }; + let normalized = network_binary_match_path(Path::new(value)); + binary.insert( + "ascii_case_folded_path".to_string(), + normalized.to_ascii_lowercase().into(), + ); + binary.insert("path".to_string(), normalized.into()); + } + } +} + /// Normalize endpoint port/ports in JSON data. /// /// YAML policies may use `port: N` (single) or `ports: [N, M]` (multi). @@ -2397,6 +2596,127 @@ mod tests { OpaEngine::from_strings(TEST_POLICY, TEST_DATA_YAML).expect("Failed to load test policy") } + #[test] + fn windows_binary_match_path_normalizes_case_and_separators() { + let normalized = windows_network_binary_match_path(r"C:\WINDOWS\SYSTEM32\CURL.EXE"); + assert_eq!(normalized, "C:/WINDOWS/SYSTEM32/CURL.EXE"); + assert_ne!( + normalized, + windows_network_binary_match_path(r"C:\Windows\System32\powershell.exe") + ); + } + + #[test] + fn windows_binary_match_path_preserves_namespace_paths_without_probing() { + for namespace_path in [ + r"\\?\C:\Windows\System32\CURL.EXE", + r"\\.\PhysicalDrive0", + r"\\server\share\tool.exe", + ] { + assert_eq!( + windows_network_binary_match_path(namespace_path), + namespace_path + ); + } + } + + #[cfg(target_os = "windows")] + #[allow(unsafe_code)] + fn set_windows_directory_case_sensitive(path: &Path, enabled: bool) { + use std::mem::size_of; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + FILE_CASE_SENSITIVE_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, + FileCaseSensitiveInfo, SetFileInformationByHandle, + }; + + let handle = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0 | FILE_WRITE_ATTRIBUTES.0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path) + .expect("open temporary directory for case-sensitivity update"); + let info = FILE_CASE_SENSITIVE_INFO { + Flags: u32::from(enabled), + }; + let info_size = u32::try_from(size_of::()) + .expect("case-sensitivity info size fits in u32"); + // SAFETY: `handle` and `info` remain alive for the call, and the buffer + // has the exact type and size required by `FileCaseSensitiveInfo`. + unsafe { + SetFileInformationByHandle( + HANDLE(handle.as_raw_handle()), + FileCaseSensitiveInfo, + (&raw const info).cast(), + info_size, + ) + } + .expect("set temporary directory case sensitivity"); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_binary_matching_denies_case_mismatch_under_sensitive_ancestor() { + let root = tempfile::tempdir().expect("create temporary directory"); + set_windows_directory_case_sensitive(root.path(), true); + let containing_directory = root.path().join("TrustedTools"); + std::fs::create_dir(&containing_directory).expect("create binary directory"); + set_windows_directory_case_sensitive(&containing_directory, false); + let runtime_binary = containing_directory.join("curl.exe"); + std::fs::write(&runtime_binary, b"test executable").expect("create test executable"); + + assert_eq!(windows_directory_case_sensitive(root.path()), Some(true)); + assert_eq!( + windows_directory_case_sensitive(&containing_directory), + Some(false), + "the immediate parent must be insensitive to reproduce the old bug" + ); + assert_eq!( + windows_path_components_case_insensitive(&runtime_binary), + Some(false), + "a sensitive ancestor must keep the whole match case-exact" + ); + + let policy_binary = runtime_binary + .to_string_lossy() + .replace("TrustedTools", "trustedtools"); + assert_ne!(policy_binary, runtime_binary.to_string_lossy()); + let mut proto = openshell_policy::restrictive_default_policy(); + proto.network_policies.insert( + "windows_sensitive_ancestor".to_string(), + NetworkPolicyRule { + name: "windows_sensitive_ancestor".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: policy_binary, + }], + }, + ); + let engine = OpaEngine::from_proto(&proto).expect("load Windows policy"); + let decision = engine + .evaluate_network(&NetworkInput { + host: "example.com".to_string(), + port: 443, + binary_path: runtime_binary, + binary_sha256: "unused".to_string(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }) + .expect("evaluate Windows policy"); + + assert!( + !decision.allowed, + "case-distinct paths under a sensitive ancestor must not share grants" + ); + } + fn opa_container_policy() -> serde_json::Value { serde_json::json!({ "filesystem_policy": {}, @@ -3420,6 +3740,84 @@ network_policies: assert_eq!(decision.matched_policy.as_deref(), Some("claude_code")); } + #[cfg(target_os = "windows")] + #[test] + fn from_proto_matches_windows_equivalent_binary_path() { + let mut proto = openshell_policy::restrictive_default_policy(); + proto.network_policies.insert( + "windows_binary".to_string(), + NetworkPolicyRule { + name: "windows_binary".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: r"C:\WINDOWS\SYSTEM32\CURL.EXE".to_string(), + }], + }, + ); + let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); + + let equivalent = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&equivalent).unwrap(); + assert!( + decision.allowed, + "Windows-equivalent binary path should be allowed: {}", + decision.reason + ); + + let different_binary = NetworkInput { + binary_path: PathBuf::from("c:/windows/system32/powershell.exe"), + ..equivalent + }; + let decision = engine.evaluate_network(&different_binary).unwrap(); + assert!( + !decision.allowed, + "normalization must not allow a different binary" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn from_strings_matches_windows_equivalent_binary_path() { + let engine = OpaEngine::from_strings( + TEST_POLICY, + r#" +network_policies: + windows_binary: + endpoints: + - { host: example.com, port: 443 } + binaries: + - { path: 'C:\WINDOWS\SYSTEM32\CURL.EXE' } +"#, + ) + .expect("Failed to create engine from YAML"); + let input = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Windows-equivalent YAML binary path should be allowed: {}", + decision.reason + ); + } + #[test] fn from_proto_denies_unmatched_request() { let proto = test_proto(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 0bfea5731d..fb722ad4da 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2883,6 +2883,7 @@ fn authorize_egress_intent_procfs( binary_pid, ancestors, cmdline_paths, + binary_match_paths: Vec::new(), } }; @@ -2953,6 +2954,7 @@ fn authorize_egress_intent_procfs( binary_pid: Some(binary_pid), ancestors, cmdline_paths, + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(e) => deny( format!("policy evaluation error: {e}"), @@ -3010,6 +3012,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(e) => EgressDecision { intent, @@ -3025,6 +3028,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), }, } } @@ -3079,6 +3083,7 @@ fn authorize_egress_intent( binary_pid: None, ancestors: Vec::new(), cmdline_paths: Vec::new(), + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(error) => EgressDecision { intent, @@ -3092,6 +3097,7 @@ fn authorize_egress_intent( binary_pid: None, ancestors: Vec::new(), cmdline_paths: Vec::new(), + binary_match_paths: Vec::new(), }, } } @@ -4971,7 +4977,7 @@ async fn handle_forward_proxy( .await?; return Ok(()); } - let tunnel_engine = match relay::pin_l7_evaluator(&opa_engine, route.l7_policy_generation) { + let tunnel_engine = match relay::pin_l7_evaluator(&opa_engine, &decision) { Ok(engine) => engine, Err(e) => { warn!( @@ -8614,6 +8620,7 @@ network_policies: binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }; let route = query_l7_route_snapshot(&decision, host, port).expect("L7 route should match"); let config = select_l7_config_for_path(&route.configs, path) @@ -12673,6 +12680,7 @@ network_policies: binary_pid: Some(1), ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }; query_tls_mode(&decision, "203.0.113.10", 443) }; diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index 314596b048..345360e5e2 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -156,6 +156,8 @@ pub(super) struct EgressDecision { pub(super) ancestors: Vec, /// Cmdline-derived absolute paths (for script detection). pub(super) cmdline_paths: Vec, + /// Runtime-derived path representations carried into pinned L7 evaluation. + pub(super) binary_match_paths: Vec, } #[cfg(test)] diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 485b5eb04d..9c5cfb1af8 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -77,12 +77,12 @@ pub(super) fn http_context( binary_path: decision .binary .as_ref() - .map(|path| path.to_string_lossy().into_owned()) + .map(|path| crate::opa::network_binary_match_path(path)) .unwrap_or_default(), ancestors: decision .ancestors .iter() - .map(|path| path.to_string_lossy().into_owned()) + .map(|path| crate::opa::network_binary_match_path(path)) .collect(), cmdline_paths: decision .cmdline_paths @@ -115,9 +115,12 @@ pub(super) fn pin_policy_generation( /// Clone an L7 evaluator for a relay or the forward HTTP single-request path. pub(super) fn pin_l7_evaluator( opa_engine: &OpaEngine, - expected_generation: u64, + decision: &EgressDecision, ) -> Result { - opa_engine.clone_engine_for_tunnel(expected_generation) + opa_engine.clone_engine_for_tunnel_with_match_paths( + decision.policy_generation, + decision.binary_match_paths.clone(), + ) } pub(super) fn validate_route_generation( @@ -160,7 +163,7 @@ pub(super) fn prepare_http_relay<'a>( } let policy = if let Some(route) = route.filter(|route| !route.configs.is_empty()) { - let evaluator = match pin_l7_evaluator(opa_engine, decision.policy_generation) { + let evaluator = match pin_l7_evaluator(opa_engine, decision) { Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -357,8 +360,16 @@ fn emit_stale_relay_close( #[cfg(test)] mod tests { + #[cfg(target_os = "windows")] + use super::super::query_l7_route_snapshot; use super::super::{EgressIntent, EndpointDecision, ProcessIdentityEvidence}; use super::*; + #[cfg(target_os = "windows")] + use crate::opa::NetworkInput; + #[cfg(target_os = "windows")] + use std::path::PathBuf; + #[cfg(target_os = "windows")] + use tokio::io::{AsyncReadExt, AsyncWriteExt}; const POLICY_REGO: &str = include_str!("../../data/sandbox-policy.rego"); const EMPTY_POLICY_DATA: &str = "network_policies: {}\n"; @@ -376,6 +387,7 @@ mod tests { binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } @@ -401,6 +413,109 @@ mod tests { } } + #[cfg(target_os = "windows")] + #[tokio::test] + async fn inspected_http_relay_matches_mixed_case_windows_binary_path() { + let (mut caller, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut server) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + let engine = OpaEngine::from_strings( + POLICY_REGO, + r" +network_policies: + windows_binary: + endpoints: + - host: example.com + port: 80 + protocol: rest + access: full + binaries: + - path: 'C:\WINDOWS\SYSTEM32\CURL.EXE' +", + ) + .expect("load Windows L7 policy"); + let input = NetworkInput { + host: "example.com".to_string(), + port: 80, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".to_string(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + let authorization = engine.authorize_egress(&input).expect("authorize egress"); + assert!(matches!(authorization.action, NetworkAction::Allow { .. })); + let decision = EgressDecision { + intent: EgressIntent::connect("example.com".to_string(), 80), + action: authorization.action.clone(), + policy_generation: authorization.generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::from_authorization(&authorization), + binary: Some(input.binary_path), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + binary_match_paths: authorization.binary_match_paths.clone(), + }; + let route = query_l7_route_snapshot(&decision, "example.com", 80) + .expect("REST endpoint should produce an inspected route"); + let mut request = http_context( + &decision, + None, + None, + None, + openshell_core::proposals::AgentProposals::default(), + String::new(), + RelaySignals { + activity: None, + endpoint_observation: None, + }, + ); + request.request_default_port = Some(80); + let context = prepare_http_relay(Some(&route), &engine, &decision, &request) + .expect("current policy generation should prepare the relay"); + relay_http_stream(&mut relay_client, &mut relay_upstream, context).await + }); + + caller + .write_all(b"GET /v1 HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n") + .await + .expect("write client request"); + let mut forwarded = [0_u8; 512]; + let forwarded_len = tokio::time::timeout( + std::time::Duration::from_secs(2), + server.read(&mut forwarded), + ) + .await + .expect("request should reach upstream") + .expect("read relayed request"); + assert!( + forwarded[..forwarded_len].starts_with(b"GET /v1 HTTP/1.1\r\n"), + "unexpected upstream request: {:?}", + String::from_utf8_lossy(&forwarded[..forwarded_len]) + ); + server + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .expect("write upstream response"); + + let mut response = [0_u8; 512]; + let response_len = tokio::time::timeout( + std::time::Duration::from_secs(2), + caller.read(&mut response), + ) + .await + .expect("response should reach client") + .expect("read relay response"); + assert!(response[..response_len].starts_with(b"HTTP/1.1 204 No Content")); + drop(caller); + drop(server); + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("HTTP relay should complete") + .expect("relay task should not panic") + .expect("HTTP relay should allow the normalized binary path"); + } + #[test] fn relay_without_route_pins_l4_decision_generation() { let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 759f888b20..767f4ef2db 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -20,6 +20,7 @@ fn allowed_decision(intent: EgressIntent) -> EgressDecision { binary_pid: Some(42), ancestors: vec![PathBuf::from("/usr/bin/sh")], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 36bfa0778a..fbe403a190 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -584,6 +584,13 @@ Identifies an executable that is permitted to use the associated endpoints. |---|---|---|---| | `path` | string | Yes | Filesystem path to the executable. Supports glob patterns with `*` and `**`. For example, `/sandbox/.vscode-server/**` matches any executable under that directory tree. | +On Windows, ordinary binary paths treat `/` and `\` as equivalent. ASCII case is +folded only when Windows confirms that every parent directory of the +runtime-observed executable uses case-insensitive lookup. If any component is +case-sensitive or cannot be checked, matching remains case-exact and fails +closed. Loading policy never opens a policy-authored path. UNC, device, and +verbatim namespace paths retain their exact spelling and separators. + ## Network Middleware **Category:** Dynamic diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 4fff61f276..0f108ffcac 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -90,6 +90,14 @@ The proxy SHA256-hashes each binary on first use (trust-on-first-use). If someon | Risk if relaxed | Broad glob patterns (like `/**`) allow any binary to reach the endpoint, defeating the purpose of binary-scoped enforcement. | | Recommendation | Scope binaries to the specific executables that need each endpoint. Use narrow globs when the exact path varies (for example, across Python virtual environments). | +On Windows, OpenShell normalizes `/` and `\` for ordinary binary paths and folds +ASCII case only when trusted runtime evidence confirms that every parent +directory uses case-insensitive lookup. A sensitive or indeterminate component +keeps the whole match case-exact, so a case-distinct executable cannot inherit +another executable's grant. Policy loading never opens policy-authored paths. +UNC, device, and verbatim namespace paths are matched exactly without separator +or case rewriting; prefer ordinary absolute paths. + ### L4-Only vs L7 Inspection The `protocol` field on an endpoint controls whether the proxy inspects individual HTTP requests inside the tunnel. From 7305209ac7cb339c6a1b4ab8b303f25f9dee14d9 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:19:45 +0530 Subject: [PATCH 16/39] fix(mxc): honor the generic sandbox create -- syntax (#3553) * fix(mxc): honor the generic sandbox create -- syntax sandbox_config only ever read the command from the MXC-specific driver_config (--driver-config-json), never DriverSandboxSpec.command -- the portable field the CLI's documented, driver-agnostic `sandbox create -- ` syntax actually populates, and the same field every other compute driver honors. A caller following that syntax got "driver_config.command must contain a non-empty executable", a message that reads as if no command was supplied at all. Add spec.command as a fallback source when no driver_config is present, and reword the empty-command error to name both ways to supply one. NVBug 6782884 Signed-off-by: Prashant Khodade * docs(mxc): document generic sandbox commands Signed-off-by: Shailendra Singh --------- Signed-off-by: Prashant Khodade Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- crates/openshell-driver-mxc/README.md | 2 +- crates/openshell-driver-mxc/src/driver.rs | 101 +++++++++++++++++++--- docs/reference/gateway-config.mdx | 7 +- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 553bea1566..678632210f 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -101,7 +101,7 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` --driver-config-json $config --env MODE=demo --no-tty ``` -The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. Supply per-sandbox environment variables with `--env` or `--env-from`; gateway configuration does not carry workload commands or environment. Provider-owned keys override matching entries case-insensitively, but raw static values remain in the host proxy; MXC receives their revision-scoped placeholders. When governed egress is enabled, the driver replaces common TLS trust environment variables with paths to public proxy CA files staged under `/.openshell-proxy//`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. +The `command` array is required and preserves Windows argument boundaries. `cwd` is optional. The generic, driver-agnostic `sandbox create -- ` CLI syntax also works and is honored when no `--driver-config-json` is supplied; `--driver-config-json`'s `command` wins if both are somehow present. Supply per-sandbox environment variables with `--env` or `--env-from`; gateway configuration does not carry workload commands or environment. Provider-owned keys override matching entries case-insensitively, but raw static values remain in the host proxy; MXC receives their revision-scoped placeholders. When governed egress is enabled, the driver replaces common TLS trust environment variables with paths to public proxy CA files staged under `/.openshell-proxy//`, and injects `HTTP_PROXY`/`HTTPS_PROXY` while clearing `NO_PROXY` so inherited bypass rules cannot skip policy enforcement. UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 412b352b34..7f28408535 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -469,23 +469,39 @@ impl std::fmt::Debug for MxcComputeBackend { } fn sandbox_config(sandbox: &DriverSandbox) -> Result { - let config = sandbox + let driver_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", - ) - })?; - 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}")) - })?; + .and_then(|template| template.driver_config.as_ref()); + let config = match driver_config { + // Explicit, MXC-specific override (`--driver-config-json`). Takes + // priority over the generic CLI command below since it's the most + // deliberately-targeted input a caller can supply for this driver. + Some(driver_config) => serde_json::from_value(struct_to_json_value(driver_config)) + .map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?, + // Generic, driver-agnostic `sandbox create -- ` syntax + // (`DriverSandboxSpec.command`, the same field every other compute + // driver honors). Previously silently ignored here: the caller's + // typed command was accepted by the CLI and discarded before ever + // reaching this function, surfacing only as a "must contain a + // non-empty executable" error that gave no hint a command had been + // supplied at all. + None => MxcSandboxConfig { + command: sandbox + .spec + .as_ref() + .map(|spec| spec.command.clone()) + .unwrap_or_default(), + cwd: String::new(), + }, + }; 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", + "mxc sandbox command must contain a non-empty executable: set it via \ + `sandbox create -- ` or `--driver-config-json`", )); } Ok(config) @@ -2649,6 +2665,67 @@ mod lifecycle_tests { status: None, } } + + /// A `DriverSandbox` carrying only the generic `sandbox create -- ` + /// field (`DriverSandboxSpec.command`), with no MXC-specific + /// `driver_config` at all -- the shape the CLI's documented, + /// driver-agnostic syntax actually produces. + fn driver_sandbox_with_cli_command(id: &str, command: Vec) -> DriverSandbox { + DriverSandbox { + id: id.to_string(), + name: id.to_string(), + namespace: String::new(), + workspace: String::new(), + spec: Some(DriverSandboxSpec { + sandbox_token: "test-token".into(), + command, + ..Default::default() + }), + status: None, + } + } + + #[test] + fn sandbox_config_honors_generic_cli_command() { + // Regression test: `sandbox create -- ` (the CLI's own + // documented, driver-agnostic syntax) must actually reach the MXC + // driver instead of being silently discarded. + let sandbox = driver_sandbox_with_cli_command( + "sb-cli-cmd", + vec!["cmd.exe".into(), "/c".into(), "exit".into()], + ); + let config = sandbox_config(&sandbox).unwrap(); + assert_eq!( + config.command, + vec!["cmd.exe".to_string(), "/c".to_string(), "exit".to_string()] + ); + } + + #[test] + fn sandbox_config_driver_config_takes_priority_over_cli_command() { + // `--driver-config-json` is the more deliberately-targeted input for + // this driver; if both are somehow supplied, it must win. + let mut sandbox = + driver_sandbox_with_command("sb-both", "", vec!["driver-config-cmd.exe".into()]); + sandbox.spec.as_mut().unwrap().command = vec!["cli-cmd.exe".into()]; + let config = sandbox_config(&sandbox).unwrap(); + assert_eq!(config.command, vec!["driver-config-cmd.exe".to_string()]); + } + + #[test] + fn sandbox_config_rejects_empty_command_from_every_source() { + let sandbox = driver_sandbox_with_cli_command("sb-empty", Vec::new()); + let error = sandbox_config(&sandbox).unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + let message = error.message(); + // Actionable: names both ways to supply a command, unlike the old + // "driver_config.command must contain a non-empty executable" + // message, which read as if the CLI's own command syntax weren't + // one of them. + assert!(message.contains("sandbox create -- ")); + assert!(message.contains("--driver-config-json")); + } + fn fs_policy(read_write: &[&str]) -> SandboxPolicy { SandboxPolicy { filesystem: Some(FilesystemPolicy { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index a4ca56a250..dce1b313a5 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -697,8 +697,9 @@ filename `wxc-exec.exe` must set an absolute path before upgrading. `etw_audit` defaults to `false`. When enabled, the gateway account must be an administrator or belong to the Windows Performance Log Users group. Workload -commands and working directories remain sandbox-scoped and must be supplied in -the `mxc` driver configuration when creating a sandbox. +commands and working directories remain sandbox-scoped. Supply the command with +the generic trailing-command syntax or through the `mxc` driver configuration +described below. Set `egress_proxy = true` with a loopback seed such as `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern-C split. @@ -981,7 +982,7 @@ The driver points common TLS trust variables, including `CURL_CA_BUNDLE`, at the MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry. -Supply the workload command and optional working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. +Supply the workload command with the generic `sandbox create -- ` syntax. Use `sandbox create --driver-config-json` for an optional working directory or the legacy MXC-specific command form, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. If both forms supply a command, `mxc.command` wins. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration. Attached provider credentials require this governed-egress path. The gateway gives MXC only revision-scoped environment placeholders and keeps real values in the host proxy's endpoint-bound resolver. Provider keys override matching sandbox environment entries case-insensitively. Sandbox creation fails when attached provider material exists but `egress_proxy` is disabled. MXC takes its static provider snapshot at creation and rejects credentials with an expiration timestamp because it has no live refresh channel. Dynamic token grants continue to mint credentials per request in the host proxy. Recreate the sandbox after attaching, detaching, rotating, or revoking a non-expiring static provider credential. From b031dc037a099e9ff1f1cdf5f15c7c60a867537d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:44:44 -0700 Subject: [PATCH 17/39] fix(mxc): reject unsupported live policy updates (#3480) * fix(mxc): reject unsupported live policy updates (NVBug 6782891) Signed-off-by: Prekshi Vyas * fix(mxc): gate all live policy mutations Signed-off-by: Prekshi Vyas * fix(mxc): gate composed policy mutations Signed-off-by: Prekshi Vyas * fix(ci): satisfy provider update lint * fix(ci): order provider validation branches * test(mxc): make policy synchronization deterministic * fix(server): scope MXC policy synchronization * fix(server): serialize provider-backed sandbox creation * test(server): use valid provider create fixture name --------- Signed-off-by: Prekshi Vyas --- architecture/compute-runtimes.md | 10 + crates/openshell-cli/src/run.rs | 41 +- .../sandbox_name_fallback_integration.rs | 88 +++- crates/openshell-driver-docker/src/lib.rs | 1 + .../openshell-driver-kubernetes/src/driver.rs | 1 + crates/openshell-driver-mxc/src/driver.rs | 13 + crates/openshell-driver-podman/src/driver.rs | 1 + crates/openshell-driver-vm/src/driver.rs | 1 + crates/openshell-server/src/compute/mod.rs | 25 +- crates/openshell-server/src/grpc/policy.rs | 491 +++++++++++++++++- crates/openshell-server/src/grpc/provider.rs | 141 ++++- crates/openshell-server/src/grpc/sandbox.rs | 178 ++++++- crates/openshell-server/src/test_support.rs | 1 + docs/sandboxes/policies.mdx | 2 +- proto/compute_driver.proto | 5 + skills/openshell-cli/SKILL.md | 4 +- 16 files changed, 944 insertions(+), 59 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 1205b67594..e9320323be 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -118,6 +118,16 @@ be supplied by a gateway-global policy because it is applied at startup. When a global dynamic policy is active, effective-policy reads retain the UI block from the sandbox's creation policy. +Live policy updates are also capability-negotiated. A driver that reports +`supports_live_policy_updates = true` must apply every gateway mutation that can +change an existing sandbox's effective dynamic policy. A driver that reports +`false` causes the gateway to reject those mutations before persistence and to +serialize sandbox creation with global policy and provider changes. Rejected +mutations include direct replacement or merge, global policy changes while a +sandbox exists, policy-advisor approval or undo, provider attach or detach, and +updates to a provider profile used by a running sandbox. An omitted capability +retains the legacy `true` behavior for compatibility with existing drivers. + The gateway records driver identity and version from the startup capability response. Elevated gateway info reports that initialized driver snapshot instead of re-querying drivers on each request. diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 673d4a9da3..c13eecbeeb 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5171,6 +5171,34 @@ where } else { config.version }; + let (status, active_version) = if policy_source == PolicySource::Global { + // Drivers without live policy updates reject global policy mutations + // while any sandbox exists, so this sandbox necessarily received the + // reported global version at startup. Other drivers retain the + // synchronous global-policy contract. Do not query global history here: + // sandbox policy reads are workspace-readable, while that endpoint is + // platform-admin scoped. + (PolicyStatus::Loaded, version) + } else { + let status_response = client + .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { + name: name.to_string(), + version, + global: false, + workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + }) + .await + .into_diagnostic()? + .into_inner(); + let revision = status_response + .revision + .as_ref() + .ok_or_else(|| miette!("policy version {version} is missing from policy history"))?; + ( + PolicyStatus::try_from(revision.status).unwrap_or(PolicyStatus::Unspecified), + status_response.active_version, + ) + }; match output { "json" => { @@ -5178,9 +5206,15 @@ where obj.insert("scope".to_string(), serde_json::json!("sandbox")); obj.insert("sandbox".to_string(), serde_json::json!(name)); obj.insert("version".to_string(), serde_json::json!(version)); - obj.insert("active_version".to_string(), serde_json::json!(version)); + obj.insert( + "active_version".to_string(), + serde_json::json!(active_version), + ); obj.insert("hash".to_string(), serde_json::json!(config.policy_hash)); - obj.insert("status".to_string(), serde_json::json!("effective")); + obj.insert( + "status".to_string(), + serde_json::json!(policy_status_json_name(status)), + ); obj.insert( "config_revision".to_string(), serde_json::json!(config.config_revision), @@ -5211,8 +5245,9 @@ where } "table" => { writeln!(stdout, "Version: {version}").into_diagnostic()?; + writeln!(stdout, "Active: {active_version}").into_diagnostic()?; writeln!(stdout, "Hash: {}", config.policy_hash).into_diagnostic()?; - writeln!(stdout, "Status: Effective").into_diagnostic()?; + writeln!(stdout, "Status: {status:?}").into_diagnostic()?; writeln!(stdout, "Source: {policy_source_label}").into_diagnostic()?; writeln!(stdout, "Config rev: {}", config.config_revision).into_diagnostic()?; if config.global_policy_version > 0 { diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index d040501590..6e18dec517 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -25,6 +25,7 @@ use openshell_core::proto::{ ServiceStatus, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, }; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::{Mutex, mpsc}; @@ -38,6 +39,7 @@ use tonic::{Response, Status}; #[derive(Clone, Default)] struct SandboxState { last_get_name: Arc>>, + global_policy: Arc, } #[derive(Clone, Default)] @@ -191,6 +193,7 @@ impl OpenShell for TestOpenShell { req.sandbox_id, "test-id", "GetSandboxConfig should pass the id from GetSandbox" ); + let global_policy = self.state.global_policy.load(Ordering::Relaxed); Ok(Response::new(GetSandboxConfigResponse { policy: Some(SandboxPolicy { version: 9, @@ -233,7 +236,12 @@ impl OpenShell for TestOpenShell { version: 9, policy_hash: "sha256:effective-policy".to_string(), config_revision: 42, - policy_source: openshell_core::proto::PolicySource::Sandbox.into(), + policy_source: if global_policy { + openshell_core::proto::PolicySource::Global.into() + } else { + openshell_core::proto::PolicySource::Sandbox.into() + }, + global_policy_version: if global_policy { 4 } else { 0 }, ..Default::default() })) } @@ -460,9 +468,22 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { let req = request.into_inner(); assert_eq!(req.name, "my-sandbox"); - assert_eq!(req.version, 3); assert!(!req.global); + if req.version == 9 { + return Ok(Response::new(GetSandboxPolicyStatusResponse { + revision: Some(SandboxPolicyRevision { + version: 9, + policy_hash: "sha256:effective-policy".to_string(), + status: PolicyStatus::Pending.into(), + ..Default::default() + }), + active_version: 7, + })); + } + + assert_eq!(req.version, 3); + let policy = SandboxPolicy { version: 7, network_policies: std::iter::once(( @@ -843,9 +864,9 @@ async fn policy_get_full_json_cli_prints_policy_payload() { assert_eq!(json["scope"], "sandbox"); assert_eq!(json["sandbox"], "my-sandbox"); assert_eq!(json["version"], 9); - assert_eq!(json["active_version"], 9); + assert_eq!(json["active_version"], 7); assert_eq!(json["hash"], "sha256:effective-policy"); - assert_eq!(json["status"], "effective"); + assert_eq!(json["status"], "pending"); assert_eq!(json["config_revision"], 42); assert_eq!(json["policy_source"], "sandbox"); assert_eq!( @@ -891,7 +912,7 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() { serde_json::from_slice(&stdout).expect("stdout should be valid JSON"); assert_eq!(json["scope"], "sandbox"); assert_eq!(json["sandbox"], "my-sandbox"); - assert_eq!(json["status"], "effective"); + assert_eq!(json["status"], "pending"); assert!( json["policy"]["network_policies"] .get("_provider_api") @@ -904,6 +925,63 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() { ); } +#[tokio::test] +async fn policy_get_latest_table_reports_persisted_status_and_active_version() { + let ts = run_server().await; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + run::sandbox_policy_get_to_writer( + &ts.endpoint, + "my-sandbox", + 0, + run::PolicyGetView::Metadata, + "table", + "default", + &ts.tls, + (&mut stdout, &mut stderr), + ) + .await + .expect("policy get should succeed"); + + assert!(stderr.is_empty()); + let output = String::from_utf8(stdout).expect("table output should be UTF-8"); + assert!(output.contains("Version: 9"), "{output}"); + assert!(output.contains("Active: 7"), "{output}"); + assert!(output.contains("Status: Pending"), "{output}"); +} + +#[tokio::test] +async fn policy_get_global_effective_policy_remains_workspace_readable() { + let ts = run_server().await; + ts.openshell + .state + .global_policy + .store(true, Ordering::Relaxed); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + run::sandbox_policy_get_to_writer( + &ts.endpoint, + "my-sandbox", + 0, + run::PolicyGetView::Metadata, + "json", + "default", + &ts.tls, + (&mut stdout, &mut stderr), + ) + .await + .expect("workspace-readable global effective policy should not query admin-only history"); + + assert!(stderr.is_empty()); + let json: serde_json::Value = serde_json::from_slice(&stdout).expect("valid JSON"); + assert_eq!(json["version"], 4); + assert_eq!(json["active_version"], 4); + assert_eq!(json["status"], "loaded"); + assert_eq!(json["policy_source"], "global"); +} + #[tokio::test] async fn policy_get_explicit_revision_uses_stored_policy_status() { let ts = run_server().await; diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 62ba8c9f69..376617e306 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -668,6 +668,7 @@ impl DockerComputeDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, } } diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 7b33d4b963..c6b8f3d218 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -580,6 +580,7 @@ impl KubernetesComputeDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }) } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 7f28408535..10411258cb 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -998,6 +998,7 @@ impl MxcComputeBackend { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: self.config.backend == MxcBackend::ProcessContainer, + supports_live_policy_updates: Some(false), } } @@ -2632,12 +2633,24 @@ mod lifecycle_tests { fn ui_policy_capability_tracks_configured_backend() { let process_container = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); assert!(process_container.capabilities().supports_ui_policy); + assert_eq!( + process_container + .capabilities() + .supports_live_policy_updates, + Some(false) + ); let isolation_session = MxcComputeBackend::new_mocked(MxcComputeConfig { backend: MxcBackend::IsolationSession, ..Default::default() }); assert!(!isolation_session.capabilities().supports_ui_policy); + assert_eq!( + isolation_session + .capabilities() + .supports_live_policy_updates, + Some(false) + ); } fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index ae564cd4ab..c92f4e8ced 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -529,6 +529,7 @@ impl PodmanComputeDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }) } diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index b1b3073958..ecd3fa68fa 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -801,6 +801,7 @@ impl VmDriver { .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), supports_ui_policy: false, + supports_live_policy_updates: None, } } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 9b5bab1259..5cc050f705 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -382,6 +382,9 @@ pub struct ComputeDriverInfoSnapshot { /// Whether this configured driver instance completely enforces the portable /// UI policy contract. pub supports_ui_policy: bool, + /// Whether this driver can apply policy changes after sandbox creation. + /// `None` preserves the behavior of older external drivers. + pub supports_live_policy_updates: Option, } /// Interval between store-vs-backend reconciliation sweeps. @@ -743,6 +746,7 @@ impl ComputeRuntime { 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, + supports_live_policy_updates: capabilities.supports_live_policy_updates, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -924,6 +928,15 @@ impl ComputeRuntime { self.driver_info.supports_sandbox_authentication } + /// Whether operator-authored policy updates can reach an already-created + /// sandbox. Unspecified preserves compatibility with older drivers. + #[must_use] + pub(crate) fn supports_live_policy_updates(&self) -> bool { + self.driver_info + .supports_live_policy_updates + .unwrap_or(true) + } + pub(crate) async fn authenticate_sandbox(&self, credential: &str) -> Result { if !self.supports_sandbox_authentication() { return Err(Status::unimplemented( @@ -5057,6 +5070,7 @@ impl ComputeDriver for NoopTestDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }, )) } @@ -5182,7 +5196,12 @@ pub async fn new_test_runtime(store: Arc) -> ComputeRuntime { #[cfg(test)] pub async fn new_test_runtime_for_driver(store: Arc, driver_name: &str) -> ComputeRuntime { - new_test_runtime_with_driver(store, driver_name, Arc::new(NoopTestDriver::default())).await + let mut runtime = + new_test_runtime_with_driver(store, driver_name, Arc::new(NoopTestDriver::default())).await; + if driver_name == "mxc" { + runtime.driver_info.supports_live_policy_updates = Some(false); + } + runtime } #[cfg(test)] @@ -5205,6 +5224,7 @@ pub async fn new_test_runtime_with_driver( rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, @@ -5529,6 +5549,7 @@ mod tests { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, })) } @@ -5895,6 +5916,7 @@ mod tests { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, })) } @@ -6109,6 +6131,7 @@ mod tests { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }, telemetry_compute_driver: TelemetryComputeDriver::custom(), driver_process: None, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cd71e8064f..3164378c34 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1646,7 +1646,7 @@ async fn auto_approve_chunk( .await?; let credential_binding_context = merge_validation.credential_binding_context(); let merge_result = merge_chunk_into_policy_with_validation( - state.store.as_ref(), + state, sandbox_id, context.workspace, &chunk, @@ -2323,6 +2323,34 @@ async fn validate_provider_composition_for_existing_sandboxes( } } +pub(super) fn require_live_policy_update_support(state: &ServerState) -> Result<(), Status> { + if state.compute.supports_live_policy_updates() { + return Ok(()); + } + + Err(Status::failed_precondition(format!( + "compute driver '{}' cannot apply effective policy changes to an existing sandbox; delete and recreate the sandbox with the requested policy", + state.compute.configured_driver_name() + ))) +} + +async fn require_global_policy_update_support(state: &ServerState) -> Result<(), Status> { + if state.compute.supports_live_policy_updates() { + return Ok(()); + } + + let page = state + .store + .list_message_page::(ObjectListQuery::AllWorkspaces, None, 1) + .await + .map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?; + if page.messages.is_empty() { + return Ok(()); + } + + require_live_policy_update_support(state) +} + pub async fn validate_provider_composition_startup_preflight( state: &ServerState, ) -> Result<(), Status> { @@ -3677,6 +3705,7 @@ async fn handle_update_config_inner( // Serialize its writes after validation so a report cannot commit // evidence derived from the policy this update has replaced. let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + require_global_policy_update_support(state).await?; let latest = state .store .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) @@ -3782,6 +3811,12 @@ async fn handle_update_config_inner( None }; let mut global_settings = load_global_settings(state.store.as_ref()).await?; + if key == POLICY_SETTING_KEY + && req.delete_setting + && global_settings.settings.contains_key(POLICY_SETTING_KEY) + { + require_global_policy_update_support(state).await?; + } let provider_composition_was_enabled = provider_policy_composition_enabled_in(&global_settings)?; let changed = if req.delete_setting { @@ -3844,6 +3879,10 @@ async fn handle_update_config_inner( let sandbox_id = sandbox.object_id().to_string(); let mut response_annotations = sandbox_metadata_annotations(&sandbox); + if !sandbox_caller && (has_policy || has_merge_ops) { + require_live_policy_update_support(state)?; + } + if has_setting { let _settings_guard = state.settings_mutex.lock().await; @@ -3965,7 +4004,7 @@ async fn handle_update_config_inner( }; let baseline_policy = spec.policy.clone(); let (version, hash, updated_sandbox) = apply_merge_operations_with_retry( - state.store.as_ref(), + state, &sandbox_id, &workspace, baseline_policy.as_ref(), @@ -5247,7 +5286,7 @@ async fn handle_approve_draft_chunk_inner( sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names).await?; let credential_binding_context = merge_validation.credential_binding_context(); let merge_result = merge_chunk_into_policy_with_validation( - state.store.as_ref(), + state, &sandbox_id, &workspace, &chunk, @@ -5658,7 +5697,7 @@ async fn handle_approve_all_draft_chunks_inner( Status::failed_precondition("bulk approval has no reviewed policy snapshot") })?; match apply_merge_operations_with_retry( - state.store.as_ref(), + state, &sandbox_id, &workspace, Some(&final_base), @@ -6758,6 +6797,31 @@ fn stage_validated_merge_operation( #[allow(clippy::too_many_arguments)] async fn apply_merge_operations_with_retry( + state: &ServerState, + sandbox_id: &str, + workspace: &str, + baseline_policy: Option<&ProtoSandboxPolicy>, + operations: &[PolicyMergeOp], + validation_context: PolicyMergeValidationContext<'_>, + expected_current_effective_hash: Option<&str>, + atomic_context: Option<&AtomicPolicyWriteContext<'_>>, +) -> Result<(i64, String, Option), Status> { + require_live_policy_update_support(state)?; + apply_merge_operations_with_retry_in_store( + state.store.as_ref(), + sandbox_id, + workspace, + baseline_policy, + operations, + validation_context, + expected_current_effective_hash, + atomic_context, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn apply_merge_operations_with_retry_in_store( store: &Store, sandbox_id: &str, workspace: &str, @@ -6916,7 +6980,7 @@ async fn apply_merge_operations_with_retry( } async fn merge_chunk_into_policy_with_validation( - store: &Store, + state: &ServerState, sandbox_id: &str, workspace: &str, chunk: &DraftChunkRecord, @@ -6935,7 +6999,7 @@ async fn merge_chunk_into_policy_with_validation( clear_provider_credentialed_markers(policy); } apply_merge_operations_with_retry( - store, + state, sandbox_id, workspace, baseline_policy.as_ref(), @@ -6957,17 +7021,34 @@ async fn merge_chunk_into_policy( chunk: &DraftChunkRecord, provider_layers: &[ProviderPolicyLayer], ) -> Result<(i64, String), Status> { - merge_chunk_into_policy_with_validation( + let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) + .map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?; + let operations = [PolicyMergeOp::AddRule { + rule_name: chunk.rule_name.clone(), + rule, + }]; + validate_merge_operations_for_server(&operations)?; + let mut baseline_policy = chunk.current_effective_policy.clone(); + if let Some(policy) = &mut baseline_policy { + strip_provider_rule_names(policy); + clear_provider_credentialed_markers(policy); + } + apply_merge_operations_with_retry_in_store( store, sandbox_id, workspace, - chunk, + baseline_policy.as_ref(), + &operations, PolicyMergeValidationContext { provider_layers, credential_binding: None, }, + (!chunk.current_effective_policy_hash.is_empty()) + .then_some(chunk.current_effective_policy_hash.as_str()), + None, ) .await + .map(|(version, hash, _)| (version, hash)) } async fn remove_chunk_from_policy( @@ -6977,7 +7058,7 @@ async fn remove_chunk_from_policy( chunk: &DraftChunkRecord, ) -> Result<(i64, String), Status> { apply_merge_operations_with_retry( - state.store.as_ref(), + state, sandbox_id, workspace, None, @@ -7307,7 +7388,9 @@ mod tests { Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; use crate::grpc::provider::ProviderEnvironment; - use crate::grpc::test_support::{authed_request, test_server_state}; + use crate::grpc::test_support::{ + authed_request, test_server_state, test_server_state_with_driver, + }; use crate::persistence::test_store; use std::collections::HashMap; use std::sync::Arc; @@ -8447,6 +8530,384 @@ mod tests { ); } + #[tokio::test] + async fn mxc_rejects_operator_policy_update_before_persistence() { + use openshell_core::proto::FilesystemPolicy; + + let state = test_server_state_with_driver("mxc").await; + let sandbox_id = "mxc-live-policy"; + let mut baseline = openshell_policy::restrictive_default_policy(); + baseline.filesystem = Some(FilesystemPolicy { + read_only: vec![r"C:\Windows".to_string()], + ..Default::default() + }); + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + baseline.clone(), + Vec::new(), + )) + .await + .expect("store MXC sandbox"); + + let mut additive = baseline; + additive + .filesystem + .as_mut() + .expect("filesystem policy") + .read_only + .push(r"C:\Program Files".to_string()); + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(additive), + ..Default::default() + })), + ) + .await + .expect_err("MXC cannot apply an operator policy update to a live sandbox"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("delete and recreate")); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .is_none(), + "a rejected update must not create a pending revision" + ); + } + + #[tokio::test] + async fn mxc_rejects_global_policy_changes_while_a_sandbox_exists() { + let state = test_server_state_with_driver("mxc").await; + let initial = test_policy_with_rule("initial", "initial.example.com"); + let created = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(initial.clone()), + ..Default::default() + })), + ) + .await + .expect("MXC may configure global policy before any sandbox exists") + .into_inner(); + assert_eq!(created.version, 1); + + state + .store + .put_message(&test_sandbox( + "mxc-global-policy", + "mxc-global-policy", + ProtoSandboxPolicy::default(), + Vec::new(), + )) + .await + .expect("store MXC sandbox"); + + let replace_error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(test_policy_with_rule("replacement", "new.example.com")), + ..Default::default() + })), + ) + .await + .expect_err("MXC must reject a global policy replacement for an existing sandbox"); + assert_eq!(replace_error.code(), Code::FailedPrecondition); + + let delete_error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + setting_key: POLICY_SETTING_KEY.to_string(), + delete_setting: true, + ..Default::default() + })), + ) + .await + .expect_err("MXC must reject deleting global policy for an existing sandbox"); + assert_eq!(delete_error.code(), Code::FailedPrecondition); + + let revisions = state + .store + .list_policies(GLOBAL_POLICY_SANDBOX_ID, 10, 0) + .await + .expect("list global policy revisions"); + assert_eq!(revisions.len(), 1); + assert_eq!(revisions[0].version, 1); + let settings = load_global_settings(state.store.as_ref()) + .await + .expect("load global settings"); + assert_eq!( + decode_policy_from_global_settings(&settings) + .expect("decode global policy") + .expect("global policy remains configured"), + initial + ); + } + + #[tokio::test] + async fn global_policy_transition_waits_for_sandbox_sync_guard() { + let state = test_server_state().await; + let guard = state.compute.sandbox_sync_guard().await; + + let update_state = state.clone(); + let update = tokio::spawn(async move { + handle_update_config( + &update_state, + with_user(Request::new(UpdateConfigRequest { + global: true, + policy: Some(test_policy_with_rule("global", "global.example.com")), + ..Default::default() + })), + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!( + !update.is_finished(), + "global update should wait for the guard" + ); + + drop(guard); + tokio::time::timeout(std::time::Duration::from_secs(5), update) + .await + .expect("global update should finish after guard release") + .expect("join global update") + .expect("global update should succeed"); + } + + #[tokio::test] + async fn mxc_rejects_all_advisor_policy_mutation_paths_before_persistence() { + let state = test_server_state_with_driver("mxc").await; + let sandbox_id = "mxc-advisor-policy"; + let sandbox_name = "mxc-advisor-policy"; + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_name, + ProtoSandboxPolicy::default(), + Vec::new(), + )) + .await + .expect("store MXC sandbox"); + + let proposal = |name: &str, host: &str| PolicyChunk { + rule_name: name.to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: name.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: r"C:\Windows\System32\curl.exe".to_string(), + }], + }), + ..Default::default() + }; + let submitted = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![proposal("manual", "manual.example.com")], + ..Default::default() + })), + ) + .await + .expect("store pending proposal") + .into_inner(); + let chunk_id = &submitted.accepted_chunk_ids[0]; + let chunk = state + .store + .get_draft_chunk(chunk_id) + .await + .expect("fetch proposal") + .expect("stored proposal"); + assert_eq!(chunk.status, "pending"); + + let manual_error = handle_approve_draft_chunk( + &state, + with_user(Request::new(ApproveDraftChunkRequest { + name: sandbox_name.to_string(), + chunk_id: chunk.id.clone(), + review_token: chunk.review_token.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .expect_err("manual approval must not mutate MXC policy"); + assert_eq!(manual_error.code(), Code::FailedPrecondition); + + let bulk_error = handle_approve_all_draft_chunks( + &state, + with_user(Request::new(ApproveAllDraftChunksRequest { + name: sandbox_name.to_string(), + include_security_flagged: true, + approvals: vec![openshell_core::proto::DraftChunkApproval { + chunk_id: chunk.id.clone(), + review_token: chunk.review_token.clone(), + }], + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .expect_err("bulk approval must not mutate MXC policy"); + assert_eq!(bulk_error.code(), Code::FailedPrecondition); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .is_none() + ); + + seed_sandbox_approval_mode(&state, sandbox_name, "auto").await; + let auto_submitted = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![proposal("automatic", "automatic.example.com")], + ..Default::default() + })), + ) + .await + .expect("automatic approval failure leaves the proposal pending") + .into_inner(); + let automatic = state + .store + .get_draft_chunk(&auto_submitted.accepted_chunk_ids[0]) + .await + .expect("fetch automatic proposal") + .expect("stored automatic proposal"); + assert_eq!(automatic.status, "pending"); + assert!(automatic.application_error.contains("delete and recreate")); + assert!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .is_none() + ); + + let approved_rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) + .expect("decode proposed rule"); + let mut approved_policy = ProtoSandboxPolicy::default(); + approved_policy + .network_policies + .insert(chunk.rule_name.clone(), approved_rule); + state + .store + .put_policy_revision( + "mxc-existing-policy", + sandbox_id, + "default", + 1, + &approved_policy.encode_to_vec(), + &deterministic_policy_hash(&approved_policy), + ) + .await + .expect("seed policy applied at sandbox startup"); + state + .store + .update_draft_chunk_status(chunk_id, "approved", Some(current_time_ms()), None) + .await + .expect("mark startup policy proposal approved"); + + let undo_error = handle_undo_draft_chunk( + &state, + with_user(Request::new(UndoDraftChunkRequest { + name: sandbox_name.to_string(), + chunk_id: chunk.id.clone(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .expect_err("undo must not mutate MXC policy"); + assert_eq!(undo_error.code(), Code::FailedPrecondition); + + let reject_error = handle_reject_draft_chunk( + &state, + with_user(Request::new(RejectDraftChunkRequest { + name: sandbox_name.to_string(), + chunk_id: chunk.id.clone(), + reason: "reject approved proposal".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + })), + ) + .await + .expect_err("rejecting an approved proposal must not mutate MXC policy"); + assert_eq!(reject_error.code(), Code::FailedPrecondition); + + let stored_chunk = state + .store + .get_draft_chunk(chunk_id) + .await + .expect("fetch approved proposal") + .expect("stored approved proposal"); + assert_eq!(stored_chunk.status, "approved"); + assert_eq!( + state + .store + .get_latest_policy(sandbox_id) + .await + .expect("policy history lookup") + .expect("startup policy revision") + .version, + 1 + ); + } + + #[tokio::test] + async fn mxc_allows_sandbox_authored_policy_sync() { + let state = test_server_state_with_driver("mxc").await; + let sandbox_id = "mxc-policy-sync"; + let policy = openshell_policy::restrictive_default_policy(); + state + .store + .put_message(&test_sandbox( + sandbox_id, + sandbox_id, + policy.clone(), + Vec::new(), + )) + .await + .expect("store MXC sandbox"); + + let response = handle_update_config( + &state, + with_sandbox( + Request::new(UpdateConfigRequest { + name: sandbox_id.to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + policy: Some(policy), + ..Default::default() + }), + sandbox_id, + ), + ) + .await + .expect("sandbox-authored startup sync remains supported") + .into_inner(); + + assert_eq!(response.version, 1); + } + #[tokio::test] async fn policy_record_identity_global_deduplicates_defaulted_mcp_history() { for (case, legacy_policy) in defaulted_mcp_policy_cases() { @@ -8592,7 +9053,7 @@ mod tests { .await .expect("store legacy merge base"); - let (version, hash, _) = apply_merge_operations_with_retry( + let (version, hash, _) = apply_merge_operations_with_retry_in_store( &store, &sandbox_id, "default", @@ -11179,7 +11640,7 @@ mod tests { .collect::>(); let error = apply_merge_operations_with_retry( - state.store.as_ref(), + state.as_ref(), "sb-ambiguous-merge", "default", None, @@ -13561,7 +14022,7 @@ mod tests { }, ]; let error = apply_merge_operations_with_retry( - state.store.as_ref(), + state.as_ref(), sandbox_id, "default", Some(&reviewed_policy), @@ -18196,7 +18657,7 @@ mod tests { }]; let (left, right) = tokio::join!( - apply_merge_operations_with_retry( + apply_merge_operations_with_retry_in_store( &store, sandbox_id, "default", @@ -18209,7 +18670,7 @@ mod tests { None, None ), - apply_merge_operations_with_retry( + apply_merge_operations_with_retry_in_store( &store, sandbox_id, "default", diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e0a83890c4..a226680171 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -2729,16 +2729,15 @@ pub(super) async fn handle_import_provider_profiles( ); diagnostics.extend(validate_profile_set(&profiles)); if !has_errors(&diagnostics) { - diagnostics.extend( - profile_attached_sandbox_diagnostics( - state.store.as_ref(), - &catalog, - &workspace, - &profiles, - "import", - ) - .await?, - ); + let (attached_diagnostics, _) = profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &catalog, + &workspace, + &profiles, + "import", + ) + .await?; + diagnostics.extend(attached_diagnostics); } if has_errors(&diagnostics) { @@ -2843,18 +2842,20 @@ pub(super) async fn handle_update_provider_profiles( severity: "error".to_string(), }); } - if !has_errors(&diagnostics) { - diagnostics.extend( - profile_attached_sandbox_diagnostics( - state.store.as_ref(), - &catalog, - &workspace, - &profiles, - "update", - ) - .await?, - ); - } + let affects_attached_sandbox = if has_errors(&diagnostics) { + false + } else { + let (attached_diagnostics, affects_attached) = profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &catalog, + &workspace, + &profiles, + "update", + ) + .await?; + diagnostics.extend(attached_diagnostics); + affects_attached + }; if has_errors(&diagnostics) { return Ok(Response::new(UpdateProviderProfilesResponse { @@ -2864,6 +2865,10 @@ pub(super) async fn handle_update_provider_profiles( })); } + if affects_attached_sandbox { + super::policy::require_live_policy_update_support(state)?; + } + let expected_resource_version = expected_resource_version.unwrap_or_default(); let (_, profile) = profiles .into_iter() @@ -3489,7 +3494,7 @@ async fn profile_attached_sandbox_diagnostics( workspace: &str, profiles: &[(String, ProviderTypeProfile)], operation: &str, -) -> Result, Status> { +) -> Result<(Vec, bool), Status> { let mut candidate_profiles = HashMap::::new(); for (source, profile) in profiles { let Some(id) = normalize_profile_id(&profile.id) else { @@ -3498,7 +3503,7 @@ async fn profile_attached_sandbox_diagnostics( candidate_profiles.insert(id, (source.clone(), profile.clone())); } if candidate_profiles.is_empty() { - return Ok(Vec::new()); + return Ok((Vec::new(), false)); } let is_platform_scope = workspace.is_empty(); @@ -3523,6 +3528,7 @@ async fn profile_attached_sandbox_diagnostics( .await? }; let mut diagnostics = Vec::new(); + let mut affects_attached_sandbox = false; let validate_policy_composition = super::policy::provider_policy_composition_enabled(store).await?; for sandbox in sandboxes { @@ -3650,6 +3656,7 @@ async fn profile_attached_sandbox_diagnostics( if imported_profiles_used.is_empty() { continue; } + affects_attached_sandbox = true; if let Err(err) = validate_dynamic_token_grant_bindings_unambiguous(&bindings) { for (source, profile_id) in &imported_profiles_used { diagnostics.push(ProfileValidationDiagnostic { @@ -3684,7 +3691,7 @@ async fn profile_attached_sandbox_diagnostics( } } - Ok(diagnostics) + Ok((diagnostics, affects_attached_sandbox)) } fn stored_provider_profile_for_workspace( @@ -4966,7 +4973,9 @@ mod tests { use super::*; use crate::auth::identity::{Identity, IdentityProvider}; use crate::auth::principal::{Principal, UserPrincipal}; - use crate::grpc::test_support::{authed_request, test_server_state}; + use crate::grpc::test_support::{ + authed_request, test_server_state, test_server_state_with_driver, + }; use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; use crate::persistence::test_store; use openshell_core::proto::{ @@ -5435,6 +5444,86 @@ mod tests { ); } + #[tokio::test] + async fn mxc_rejects_attached_provider_profile_update_before_persistence() { + let state = test_server_state_with_driver("mxc").await; + let mut original = custom_profile("custom-api"); + original.endpoints = vec![NetworkEndpoint { + host: "api.before.example".to_string(), + port: 443, + ..Default::default() + }]; + state + .store + .put_message(&stored_provider_profile(original)) + .await + .unwrap(); + state + .store + .put_message(&provider_with_values("work-custom", "custom-api")) + .await + .unwrap(); + state + .store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "mxc-profile-sandbox-id".to_string(), + name: "mxc-profile-sandbox".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + spec: Some(SandboxSpec { + providers: vec!["work-custom".to_string()], + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let stored_before = state + .store + .get_message_by_name::("default", "custom-api") + .await + .unwrap() + .unwrap(); + let mut updated = custom_profile("custom-api"); + updated.resource_version = stored_before.metadata.as_ref().unwrap().resource_version; + updated.endpoints = vec![NetworkEndpoint { + host: "api.after.example".to_string(), + port: 443, + ..Default::default() + }]; + + let error = handle_update_provider_profiles( + &state, + authed_request(UpdateProviderProfilesRequest { + profile: Some(ProviderProfileImportItem { + profile: Some(updated), + source: "custom-api.yaml".to_string(), + }), + expected_resource_version: 0, + id: "custom-api".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .expect_err("MXC cannot apply an attached provider profile update"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("delete and recreate")); + + let stored_after = state + .store + .get_message_by_name::("default", "custom-api") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored_after.profile.unwrap().endpoints[0].host, + "api.before.example" + ); + } + #[tokio::test] async fn update_provider_profile_rejects_built_in_and_missing_profiles() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index cc16a141db..73bdd75299 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -394,11 +394,16 @@ async fn handle_create_sandbox_inner( .authorize(&token, &workspace, &subject)?; } - let _sandbox_sync_guard = if spec.providers.is_empty() { - None - } else { - Some(state.compute.sandbox_sync_guard().await) - }; + // Drivers without live policy updates need an atomic boundary between + // create-time policy resolution and mutations that affect existing sandboxes. + // Provider-backed creates also serialize with profile mutation so the initial + // policy snapshot cannot miss a concurrent profile update before persistence. + let _sandbox_sync_guard = + if !state.compute.supports_live_policy_updates() || !spec.providers.is_empty() { + Some(state.compute.sandbox_sync_guard().await) + } else { + None + }; // Validate provider names exist (fail fast). for name in &spec.providers { @@ -1119,6 +1124,14 @@ pub(super) async fn handle_attach_sandbox_provider( .as_ref() .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + if !spec + .providers + .iter() + .any(|name| name == &request.provider_name) + { + super::policy::require_live_policy_update_support(state)?; + } + // Pre-check: fail fast if already at MAX_PROVIDERS limit (avoid spurious CAS conflicts) // Note: This is an optimization; the CAS closure rechecks after dedupe in case of races if spec.providers.len() >= MAX_PROVIDERS @@ -1256,6 +1269,14 @@ pub(super) async fn handle_detach_sandbox_provider( .spec .as_ref() .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + + if spec + .providers + .iter() + .any(|name| name == &request.provider_name) + { + super::policy::require_live_policy_update_support(state)?; + } let mut candidate_spec = spec.clone(); candidate_spec .providers @@ -3876,6 +3897,74 @@ mod tests { assert_eq!(spec.log_level, "debug"); } + #[tokio::test] + async fn mxc_rejects_provider_attachment_and_detachment_before_persistence() { + let state = test_server_state_with_driver("mxc").await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox("attach-target", Vec::new())) + .await + .unwrap(); + + let attach_error = handle_attach_sandbox_provider( + &state, + authed_request(AttachSandboxProviderRequest { + sandbox_name: "attach-target".to_string(), + provider_name: "work-github".to_string(), + expected_resource_version: 0, + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + ) + .await + .expect_err("MXC cannot apply provider attachment to an existing sandbox"); + assert_eq!(attach_error.code(), tonic::Code::FailedPrecondition); + assert!(attach_error.message().contains("delete and recreate")); + let attach_target = state + .store + .get_message_by_name::("default", "attach-target") + .await + .unwrap() + .unwrap(); + assert!(attach_target.spec.unwrap().providers.is_empty()); + + state + .store + .put_message(&test_sandbox( + "detach-target", + vec!["work-github".to_string()], + )) + .await + .unwrap(); + let detach_error = handle_detach_sandbox_provider( + &state, + authed_request(DetachSandboxProviderRequest { + sandbox_name: "detach-target".to_string(), + provider_name: "work-github".to_string(), + expected_resource_version: 0, + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + }), + ) + .await + .expect_err("MXC cannot apply provider detachment to an existing sandbox"); + assert_eq!(detach_error.code(), tonic::Code::FailedPrecondition); + assert!(detach_error.message().contains("delete and recreate")); + let detach_target = state + .store + .get_message_by_name::("default", "detach-target") + .await + .unwrap() + .unwrap(); + assert_eq!( + detach_target.spec.unwrap().providers, + vec!["work-github".to_string()] + ); + } + #[tokio::test] async fn attach_sandbox_provider_uses_configured_provider_profile_sources() { let state = test_server_state_with_user_only_github_profile().await; @@ -4896,6 +4985,83 @@ mod tests { assert!(err.message().contains("label value exceeds")); } + #[tokio::test] + async fn mxc_provider_free_create_waits_for_sandbox_sync_guard() { + let state = test_server_state_with_driver("mxc").await; + + let guard = state.compute.sandbox_sync_guard().await; + let task_state = state.clone(); + let task = tokio::spawn(async move { + handle_create_sandbox( + &task_state, + authed_request(CreateSandboxRequest { + name: "guarded-create".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!( + !task.is_finished(), + "provider-free sandbox create should wait for sandbox sync guard" + ); + drop(guard); + + let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("create should finish after guard release") + .expect("join create task") + .expect("create should succeed") + .into_inner(); + assert!( + response.sandbox.unwrap().spec.unwrap().providers.is_empty(), + "the synchronization test must exercise a provider-free create" + ); + } + + #[tokio::test] + async fn live_update_driver_create_does_not_wait_for_sandbox_sync_guard() { + let state = test_server_state().await; + + let guard = state.compute.sandbox_sync_guard().await; + let task_state = state.clone(); + let task = tokio::spawn(async move { + handle_create_sandbox( + &task_state, + authed_request(CreateSandboxRequest { + name: "concurrent-create".to_string(), + spec: Some(SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::new(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + await_main_process_attachment: false, + workload_template_name: String::new(), + }), + ) + .await + }); + + let response = tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("live-update-capable create must not wait for the sync guard") + .expect("join create task") + .expect("create should succeed") + .into_inner(); + drop(guard); + + assert!( + response.sandbox.unwrap().spec.unwrap().providers.is_empty(), + "the concurrency test must exercise a provider-free create" + ); + } + #[tokio::test] async fn create_sandbox_with_providers_waits_for_sandbox_sync_guard() { let state = test_server_state().await; @@ -4911,7 +5077,7 @@ mod tests { handle_create_sandbox( &task_state, authed_request(CreateSandboxRequest { - name: "guarded-create".to_string(), + name: "provider-create".to_string(), spec: Some(SandboxSpec { providers: vec!["work-github".to_string()], ..Default::default() diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index 62487d6bdb..4261f8ab6b 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -101,6 +101,7 @@ impl FakeComputeDriver { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, supports_ui_policy: false, + supports_live_policy_updates: None, }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 1d3685bf39..c83cb66f00 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -12,7 +12,7 @@ Use this page to apply and iterate policy changes on running sandboxes. For a fu ## Policy Structure -A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation. `network_policies` and `network_middlewares` are dynamic schema sections, but live updates work only on compute drivers that support runtime policy reload. MXC rejects live policy replacement and merge updates, so recreate the sandbox there. +A policy has static sections `filesystem_policy`, `landlock`, `process`, and `ui` that are locked at sandbox creation. `network_policies` and `network_middlewares` are dynamic schema sections, but live updates work only on compute drivers that support runtime policy reload. MXC rejects every operation that would change a running sandbox's effective policy: direct replacement or merge, global policy changes, policy-advisor approval or undo, provider attach or detach, and updates to an attached provider's profile. The gateway rejects these operations before saving anything; delete and recreate the MXC sandbox instead. ```yaml wordWrap showLineNumbers={false} version: 1 diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 1d781bd6ea..16dc25091f 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -104,6 +104,11 @@ message GetCapabilitiesResponse { // portable SandboxPolicy.ui contract. Partial support must report false so // the gateway rejects every explicit UI section before provisioning. bool supports_ui_policy = 12; + // Whether the driver can apply policy changes to an already-created sandbox. + // Omit this field to preserve the legacy gateway behavior. Drivers that + // cannot update a live sandbox must explicitly report false so the gateway + // rejects changes instead of persisting a policy that is not enforced. + optional bool supports_live_policy_updates = 13; } message AuthenticateSandboxRequest { diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index b68cb3ca58..b44b748e50 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -443,7 +443,7 @@ the operation that removes retained state. 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 creation: `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. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. UI capabilities are enforced only by a configured driver/backend advertising complete UI-policy support. Today that is the MXC driver's OpenShell `process_container` backend, which emits MXC's `processcontainer` containment value. `isolation_session` and non-Windows drivers reject any explicit UI section before provisioning; omit it to preserve their existing behavior. +**Key concept**: Policies have static fields (immutable after creation: `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. MXC rejects every operation that would change a running sandbox's effective policy: direct replacement or merge, global policy changes, policy-advisor approval or undo, provider attach or detach, and updates to an attached provider's profile. The gateway rejects these operations before saving anything; delete and recreate the MXC sandbox instead. UI capabilities are enforced only by a configured driver/backend advertising complete UI-policy support. Today that is the MXC driver's OpenShell `process_container` backend, which emits MXC's `processcontainer` containment value. `isolation_session` and non-Windows drivers reject any explicit UI section before provisioning; omit it to preserve their existing behavior. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -513,7 +513,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - Binary matching patterns - Ordered `network_middlewares`, host selection, HTTP 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 every operation that would change a running sandbox's effective policy: direct replacement or merge, global policy changes, policy-advisor approval or undo, provider attach or detach, and updates to an attached provider's profile. The gateway rejects these operations before saving anything; delete and recreate the 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 parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. From 2d5c9bfaab3c59bd7e5462784ec4515e0f3f726d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:22:40 -0700 Subject: [PATCH 18/39] fix(network): scope Windows egress by socket owner (#3491) * fix(ocsf): attribute MXC proxy events to sandboxes NVBug 6783086 Signed-off-by: Prekshi Vyas * fix(network): scope Windows egress by socket owner Resolve each accepted Windows proxy connection to its unique owning PID and executable before evaluating binary-scoped network policy. Fail closed when ownership or process identity cannot be established, and cover allowed and undeclared child processes with a real MXC regression. * fix(network): preserve sandbox context with socket owners Signed-off-by: Prekshi Vyas (cherry picked from commit 32ea316deb6c83c157fdf2de4a2cdc3a7d2da1e3) * fix(network): harden Windows process identity Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- Cargo.toml | 4 +- crates/openshell-driver-mxc/src/driver.rs | 9 - .../tests/wxc_exec_real.rs | 165 ++++++++ .../openshell-supervisor-network/src/host.rs | 55 +-- .../src/identity.rs | 44 +- .../openshell-supervisor-network/src/lib.rs | 2 + .../openshell-supervisor-network/src/proxy.rs | 157 +++++++- .../src/proxy/relay.rs | 10 +- .../openshell-supervisor-network/src/run.rs | 4 +- .../src/windows_process.rs | 381 ++++++++++++++++++ 10 files changed, 760 insertions(+), 71 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/windows_process.rs diff --git a/Cargo.toml b/Cargo.toml index 8f8a2efcc6..b8e6f599d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,8 +56,8 @@ terminal-colorsaurus = "1.0" 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_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } +# Windows platform APIs (MXC audit and host-proxy process identity; Windows-only) +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 10411258cb..6df81a09e3 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -668,14 +668,6 @@ fn allocate_sandbox_proxy_addr( const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; -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", @@ -1622,7 +1614,6 @@ async fn run_lifecycle( openshell_supervisor_network::host::HostProxyConfig { bind_addr: addr, policy: proxy_policy, - binary_path: host_proxy_binary_path(&sandbox_config), client_auth: proxy_auth.host_client_auth(), sandbox_id: Some(sandbox_id.clone()), sandbox_name: Some(sandbox_name.clone()), diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index e5e7dbd399..d54a9b8cb6 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -1125,6 +1125,171 @@ async fn pc_https_egress_reads_injected_ca_bundle() { ); } +/// Prove that host-proxy binary policy follows the process that owns each TCP +/// connection, rather than the sandbox entry command. This deliberately uses +/// L4 CONNECT policy so the assertion is independent of TLS/L7 enforcement. +#[tokio::test] +#[ignore = "requires real wxc-exec and outbound HTTPS"] +async fn pc_proxy_scopes_network_policy_to_socket_owner() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + if let Err(reason) = probe_processcontainer(&wxc) { + eprintln!("SKIP: processcontainer not live: {reason}"); + return; + } + + // QueryFullProcessImageNameW returns this Win32 spelling on the Windows + // test image. Keep the spelling exact here; path and case normalization + // are covered separately. + let cmd = PathBuf::from(r"C:\Windows\System32\cmd.exe"); + let curl = PathBuf::from(r"C:\Windows\System32\curl.exe"); + if !cmd.exists() || !curl.exists() { + eprintln!( + "SKIP: expected Windows binaries are absent (cmd={}, curl={})", + cmd.display(), + curl.display() + ); + return; + } + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + run_proxy_binary_scope_case(&wxc, "pc-owner-allow-child", &cmd, &curl, &curl, true).await; + run_proxy_binary_scope_case(&wxc, "pc-owner-deny-child", &cmd, &curl, &cmd, false).await; +} + +async fn run_proxy_binary_scope_case( + wxc: &Path, + sandbox_id: &str, + cmd: &Path, + curl: &Path, + allowed_binary: &Path, + expect_allowed: bool, +) { + let output_dir = tempfile::tempdir().expect("proxy scope output directory"); + let output_path = output_dir.path().join("example.html"); + let diagnostic_path = output_dir.path().join("curl-diagnostic.txt"); + let output_dir_string = output_dir.path().to_string_lossy().into_owned(); + let command = vec![ + cmd.to_string_lossy().into_owned(), + "/d".to_string(), + "/c".to_string(), + format!( + "echo proxy-scope 1>\"{}\" && \"{}\" --fail --silent --show-error --ssl-no-revoke --cacert \"%CURL_CA_BUNDLE%\" https://example.com/ --output \"{}\" 2>>\"{}\"", + diagnostic_path.display(), + curl.display(), + output_path.display(), + diagnostic_path.display() + ), + ]; + 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], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: allowed_binary.to_string_lossy().into_owned(), + }], + }, + )]), + ..Default::default() + }; + let sandbox = DriverSandbox { + id: sandbox_id.to_string(), + name: sandbox_id.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 backend = MxcComputeBackend::new(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() + }); + backend + .create_sandbox(&sandbox) + .await + .expect("real proxy-scope sandbox create accepted"); + + let mut terminal_condition = None; + for _ in 0..600 { + if let Some(observed) = backend.get_sandbox(sandbox_id).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("proxy-scope sandbox should terminate"); + let diagnostic = std::fs::read_to_string(&diagnostic_path) + .unwrap_or_else(|error| format!("failed to read curl diagnostic: {error}")); + backend + .delete_sandbox(sandbox_id, sandbox_id) + .await + .expect("delete completed proxy-scope sandbox"); + + if expect_allowed { + assert_eq!( + condition.reason, "AgentCompleted", + "declared child binary must be allowed: {}; diagnostic: {diagnostic}", + condition.message + ); + assert!( + std::fs::metadata(&output_path).is_ok_and(|metadata| metadata.len() > 0), + "allowed curl response should be non-empty; diagnostic: {diagnostic}" + ); + } else { + assert_eq!( + condition.reason, "ExecFailed", + "entry-command grant must not be inherited by curl: {}; diagnostic: {diagnostic}", + condition.message + ); + assert!( + diagnostic.contains("403"), + "undeclared curl child should receive proxy 403; diagnostic: {diagnostic}" + ); + assert!( + !output_path.exists(), + "denied curl child must not write an HTTPS response" + ); + } +} + /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. /// This is the genuine OS default-deny proof — the `AppContainer` blocks the write /// without requiring any host ACL lockdown. The mock can only fake this. diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index ea4082b9fa..e2a020ffb5 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -61,10 +61,6 @@ pub struct HostProxyConfig { 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, /// Per-sandbox client authentication. Host-side MXC proxies must set this /// so another sandbox cannot borrow this proxy's identity and policy. pub client_auth: HostProxyClientAuth, @@ -250,10 +246,9 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result HostProxyConfig { + fn test_config(bind_addr: SocketAddr) -> HostProxyConfig { HostProxyConfig { bind_addr, policy: ProtoSandboxPolicy { version: 1, ..Default::default() }, - binary_path, client_auth: HostProxyClientAuth::basic("openshell", "test-secret"), sandbox_id: Some("sandbox-123".to_string()), sandbox_name: Some("agent-box".to_string()), @@ -314,7 +308,7 @@ mod tests { #[test] fn host_proxy_event_context_uses_configured_sandbox_identity() { let bind_addr = "127.0.0.1:18080".parse().unwrap(); - let config = test_config(bind_addr, PathBuf::from("agent.exe")); + let config = test_config(bind_addr); let context = host_proxy_event_context(&config).unwrap(); @@ -326,10 +320,7 @@ mod tests { #[test] fn host_proxy_event_context_rejects_missing_sandbox_identity() { - let mut config = test_config( - "127.0.0.1:18080".parse().unwrap(), - PathBuf::from("agent.exe"), - ); + let mut config = test_config("127.0.0.1:18080".parse().unwrap()); config.sandbox_id = Some(" ".to_string()); let error = host_proxy_event_context(&config).unwrap_err(); @@ -369,7 +360,9 @@ mod tests { let mut client = TcpStream::connect(addr).await.unwrap(); client.write_all(request.as_bytes()).await.unwrap(); let mut response = Vec::new(); - tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + // The first authenticated CONNECT performs a full executable hash for + // TOFU identity binding; debug test binaries can be hundreds of MB. + tokio::time::timeout(Duration::from_secs(10), client.read_to_end(&mut response)) .await .unwrap() .unwrap(); @@ -378,11 +371,7 @@ mod tests { #[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 result = start_host_proxy(test_config(([192, 0, 2, 1], 0).into())).await; let Err(err) = result else { panic!("host proxy should reject non-loopback bind addresses"); @@ -395,10 +384,7 @@ mod tests { #[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"), - ); + let mut config = test_config(([127, 0, 0, 1], 0).into()); config.policy.network_middlewares.insert( "redactor".into(), NetworkMiddlewareConfig { @@ -427,15 +413,9 @@ mod tests { #[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 handle = start_host_proxy(test_config(([127, 0, 0, 1], 0).into())) + .await + .unwrap(); let addr = handle.http_addr().expect("proxy should report bound addr"); assert!(addr.ip().is_loopback()); @@ -475,9 +455,6 @@ mod tests { #[tokio::test] async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() { - let binary = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(binary.path(), b"agent").unwrap(); - let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret"); let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret"); // Node's EnvHttpProxyAgent currently emits the field name in lower @@ -491,11 +468,11 @@ mod tests { auth_b.expected_proxy_authorization ); - let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + let mut config_a = test_config(([127, 0, 0, 1], 0).into()); config_a.client_auth = auth_a; let proxy_a = start_host_proxy(config_a).await.unwrap(); - let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf()); + let mut config_b = test_config(([127, 0, 0, 1], 0).into()); config_b.client_auth = auth_b; let proxy_b = start_host_proxy(config_b).await.unwrap(); diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index b824c58d26..cefa955cea 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -11,6 +11,7 @@ use crate::procfs; use miette::Result; use std::collections::HashMap; +#[cfg(not(target_os = "windows"))] use std::fs::Metadata; #[cfg(unix)] use std::os::unix::fs::MetadataExt; @@ -18,6 +19,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use tracing::debug; +#[cfg(not(target_os = "windows"))] #[derive(Clone)] struct FileFingerprint { len: u64, @@ -29,6 +31,7 @@ struct FileFingerprint { ino: u64, } +#[cfg(not(target_os = "windows"))] impl FileFingerprint { fn from_metadata(metadata: &Metadata) -> Self { #[cfg(unix)] @@ -53,7 +56,7 @@ impl FileFingerprint { } } -#[cfg(not(unix))] +#[cfg(all(not(unix), not(target_os = "windows")))] fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { let duration = time.duration_since(std::time::UNIX_EPOCH).ok()?; Some(( @@ -62,6 +65,7 @@ fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { )) } +#[cfg(not(target_os = "windows"))] impl PartialEq for FileFingerprint { fn eq(&self, other: &Self) -> bool { self.len == other.len @@ -87,6 +91,7 @@ impl PartialEq for FileFingerprint { #[derive(Clone)] struct CachedBinary { hash: String, + #[cfg(not(target_os = "windows"))] fingerprint: FileFingerprint, } @@ -138,8 +143,10 @@ impl BinaryIdentityCache { let start = std::time::Instant::now(); let metadata = std::fs::metadata(access_path) .map_err(|error| miette::miette!("Failed to stat {}: {error}", cache_path.display()))?; + #[cfg(not(target_os = "windows"))] let fingerprint = FileFingerprint::from_metadata(&metadata); + #[cfg(not(target_os = "windows"))] let cached = self .hashes .lock() @@ -147,6 +154,10 @@ impl BinaryIdentityCache { .get(cache_path) .cloned(); + // Windows creation/modification timestamps and length can be restored + // after an in-place same-length rewrite. Rehash every Windows request + // so writable executables cannot reuse a spoofed cache fingerprint. + #[cfg(not(target_os = "windows"))] if let Some(cached_binary) = &cached && cached_binary.fingerprint == fingerprint { @@ -186,6 +197,7 @@ impl BinaryIdentityCache { cache_path.to_path_buf(), CachedBinary { hash: current_hash.clone(), + #[cfg(not(target_os = "windows"))] fingerprint, }, ); @@ -253,7 +265,7 @@ mod tests { .unwrap(); assert_eq!(hash1, hash2); - assert_eq!(hash_calls, 1); + assert_eq!(hash_calls, if cfg!(target_os = "windows") { 2 } else { 1 }); } #[test] @@ -329,6 +341,34 @@ mod tests { assert_eq!(hash_calls, 2); } + #[cfg(target_os = "windows")] + #[test] + fn same_length_rewrite_with_restored_mtime_is_rehashed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("binary.exe"); + let original = b"trusted-content!"; + let tampered = b"tampered-content"; + assert_eq!(original.len(), tampered.len()); + std::fs::write(&path, original).unwrap(); + + let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap(); + let cache = BinaryIdentityCache::new(); + cache.verify_or_cache(&path).unwrap(); + + std::fs::write(&path, tampered).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_modified(original_mtime) + .unwrap(); + + let error = cache + .verify_or_cache(&path) + .expect_err("Windows must rehash despite a restored metadata fingerprint"); + assert!(error.to_string().contains("integrity violation")); + } + #[test] fn display_path_can_differ_from_access_path() { let mut tmp = tempfile::NamedTempFile::new().unwrap(); diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index b815e826c4..2a3ae8824c 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -21,6 +21,8 @@ pub mod run; pub mod sigv4; mod token_grant; pub mod upstream_proxy; +#[cfg(target_os = "windows")] +pub(crate) mod windows_process; #[cfg(test)] pub(crate) mod test_alloc { diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index fb722ad4da..4f3d9af566 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7,7 +7,7 @@ pub(crate) mod destination; mod egress; mod relay; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows"))] use crate::identity::BinaryIdentityCache; use crate::l7::EndpointObserver; use crate::l7::tls::ProxyTlsState; @@ -175,11 +175,19 @@ pub(crate) enum ProxyIdentityMode { identity_cache: Arc, entrypoint_pid: Arc, }, - /// Host-side mode for platforms where procfs socket ownership is - /// unavailable. MXC uses this on Windows: every connection redirected to - /// the per-sandbox listener is evaluated as the configured sandbox agent - /// identity. - #[cfg(any(not(target_os = "linux"), test))] + /// Windows host-side mode: bind each request to the process that owns the + /// workload side of the accepted TCP connection. + #[cfg(target_os = "windows")] + Windows { + identity_cache: Arc, + required_proxy_authorization: Option>, + /// Per-sandbox context for host-side proxies. The process-wide OCSF + /// context cannot identify one sandbox when a gateway hosts many. + event_context: Option>, + }, + /// Static fallback for platforms without socket-owner resolution and for + /// tests that need to inject a deterministic identity. + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Static { binary_path: PathBuf, binary_sha256: String, @@ -202,12 +210,21 @@ impl ProxyIdentityMode { } } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + pub(crate) fn windows_with_client_auth(required_proxy_authorization: Option>) -> Self { + Self::Windows { + identity_cache: Arc::new(BinaryIdentityCache::new()), + required_proxy_authorization, + event_context: None, + } + } + + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] pub(crate) fn static_binary(path: impl Into) -> Result { Self::static_binary_with_client_auth(path, None) } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] pub(crate) fn static_binary_with_client_auth( path: impl Into, required_proxy_authorization: Option>, @@ -225,8 +242,10 @@ impl ProxyIdentityMode { #[cfg(target_os = "windows")] pub(super) fn with_event_context(mut self, context: EventContext) -> Self { match &mut self { - #[cfg(target_os = "linux")] - Self::Procfs { .. } => {} + Self::Windows { event_context, .. } => { + *event_context = Some(Arc::new(context)); + } + #[cfg(test)] Self::Static { event_context, .. } => { *event_context = Some(Arc::new(context)); } @@ -236,7 +255,12 @@ impl ProxyIdentityMode { fn event_context(&self) -> &EventContext { match self { - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { + event_context: Some(context), + .. + } => context, + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { event_context: Some(context), .. @@ -249,7 +273,12 @@ impl ProxyIdentityMode { match self { #[cfg(target_os = "linux")] Self::Procfs { .. } => None, - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { + required_proxy_authorization, + .. + } => required_proxy_authorization.as_deref(), + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { required_proxy_authorization, .. @@ -263,7 +292,9 @@ impl ProxyIdentityMode { Self::Procfs { entrypoint_pid, .. } => { entrypoint_pid.load(std::sync::atomic::Ordering::Acquire) } - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + Self::Windows { .. } => 0, + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] Self::Static { .. } => 0, } } @@ -2989,6 +3020,86 @@ fn sidecar_topology_enabled() -> bool { .is_ok_and(|value| value == SIDECAR_SUPERVISOR_TOPOLOGY) } +#[cfg(target_os = "windows")] +fn authorize_egress_intent_windows( + connection: crate::procfs::WorkloadProxyTcpConnection, + engine: &OpaEngine, + identity_cache: &BinaryIdentityCache, + intent: EgressIntent, +) -> EgressDecision { + let deny = |reason: String, binary: Option, binary_pid: Option| EgressDecision { + intent: intent.clone(), + action: NetworkAction::Deny { reason }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::LookupFailed), + endpoint: EndpointDecision::default(), + binary, + binary_pid, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + binary_match_paths: Vec::new(), + }; + + let (binary_path, binary_pid) = + match crate::windows_process::resolve_tcp_peer_identity(connection) { + Ok(identity) => identity, + Err(error) => { + return deny( + format!("failed to resolve Windows proxy peer identity: {error}"), + None, + None, + ); + } + }; + let binary_sha256 = match identity_cache.verify_or_cache(&binary_path) { + Ok(hash) => hash, + Err(error) => { + return deny( + format!("binary integrity check failed: {error}"), + Some(binary_path), + Some(binary_pid), + ); + } + }; + let input = crate::opa::NetworkInput { + host: intent.destination.host.clone(), + port: intent.destination.port, + binary_path: binary_path.clone(), + binary_sha256, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + + match engine.authorize_egress(&input) { + Ok(authorization) => EgressDecision { + intent, + action: authorization.action.clone(), + policy_generation: authorization.generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::from_authorization(&authorization), + binary: Some(binary_path), + binary_pid: Some(binary_pid), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + binary_match_paths: authorization.binary_match_paths.clone(), + }, + Err(error) => EgressDecision { + intent, + action: NetworkAction::Deny { + reason: format!("policy evaluation error: {error}"), + }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::default(), + binary: Some(binary_path), + binary_pid: Some(binary_pid), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + binary_match_paths: Vec::new(), + }, + } +} + fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> EgressDecision { let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), @@ -3039,7 +3150,7 @@ fn authorize_egress_intent( identity_mode: &ProxyIdentityMode, intent: EgressIntent, ) -> EgressDecision { - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "windows")))] let _ = &connection; if !crate::opa::network_binary_identity_required() { @@ -3058,7 +3169,11 @@ fn authorize_egress_intent( entrypoint_pid, intent, ), - #[cfg(any(not(target_os = "linux"), test))] + #[cfg(target_os = "windows")] + ProxyIdentityMode::Windows { identity_cache, .. } => { + authorize_egress_intent_windows(connection, engine, identity_cache, intent) + } + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] ProxyIdentityMode::Static { binary_path, binary_sha256, @@ -7399,6 +7514,16 @@ network_policies: validate_required_fields(&sandbox_b, &schema); } + #[cfg(target_os = "windows")] + #[test] + fn windows_socket_owner_identity_keeps_per_proxy_sandbox_attribution() { + let identity = ProxyIdentityMode::windows_with_client_auth(None) + .with_event_context(proxy_event_context("sandbox-a-id", "sandbox-a")); + + assert_eq!(identity.event_context().sandbox_id, "sandbox-a-id"); + assert_eq!(identity.event_context().sandbox_name, "sandbox-a"); + } + #[test] fn forward_l7_parse_rejection_ocsf_includes_denial_context() { let event = build_forward_l7_parse_rejection_ocsf_event( @@ -7768,6 +7893,8 @@ network_policies: } #[cfg(target_os = "linux")] ProxyIdentityMode::Procfs { .. } => panic!("expected static identity mode"), + #[cfg(target_os = "windows")] + ProxyIdentityMode::Windows { .. } => panic!("expected static identity mode"), } } diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 9c5cfb1af8..423d63c7cf 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -471,8 +471,14 @@ network_policies: }, ); request.request_default_port = Some(80); - let context = prepare_http_relay(Some(&route), &engine, &decision, &request) - .expect("current policy generation should prepare the relay"); + let context = prepare_http_relay( + Some(&route), + &engine, + &decision, + &request, + openshell_ocsf::ctx::ctx(), + ) + .expect("current policy generation should prepare the relay"); relay_http_stream(&mut relay_client, &mut relay_upstream, context).await }); diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index d1372d8432..622a5b31d3 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -163,7 +163,7 @@ pub struct Networking { _transparent_tcp: Option, } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "windows")))] fn current_exe_static_identity_path() -> Result { std::env::current_exe().map_err(|e| { miette::miette!("failed to resolve supervisor executable for static proxy identity: {e}") @@ -441,7 +441,7 @@ pub async fn run_networking( ProxyIdentityMode::procfs(cache, entrypoint_pid.clone()) }; #[cfg(target_os = "windows")] - let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; + let identity_mode = ProxyIdentityMode::windows_with_client_auth(None); #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; diff --git a/crates/openshell-supervisor-network/src/windows_process.rs b/crates/openshell-supervisor-network/src/windows_process.rs new file mode 100644 index 0000000000..15a1d828e5 --- /dev/null +++ b/crates/openshell-supervisor-network/src/windows_process.rs @@ -0,0 +1,381 @@ +// 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; + +use miette::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; + +use crate::procfs::WorkloadProxyTcpConnection; + +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. +pub 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(miette::miette!( + "TCP connection address families do not match: {connection}" + )); + } + }; + owners.sort_unstable(); + owners.dedup(); + + let pid = match owners.as_slice() { + [pid] => *pid, + [] => { + return Err(miette::miette!( + "No Windows process owns proxy connection {connection}" + )); + } + pids => { + return Err(miette::miette!( + "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(miette::miette!( + "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(|_| miette::miette!("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(miette::miette!( + "GetExtendedTcpTable failed with Win32 error {status}" + )); + } + byte_len = actual_len; + } + + Err(miette::miette!( + "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(miette::miette!("Windows TCP table is missing its header")); + } + if row_stride < size_of::() { + return Err(miette::miette!( + "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(|| miette::miette!("Windows TCP row count overflow"))?, + ) + .and_then(|last_row| last_row.checked_add(size_of::())) + .ok_or_else(|| miette::miette!("Windows TCP table size overflow"))? + }; + if required > byte_len || required > size_of_val(buffer) { + return Err(miette::miette!( + "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| miette::miette!("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| { + miette::miette!("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| miette::miette!("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", + "windows_process::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)); + } +} From ab64e84bfc874f40e07045ede6723b57c52b8808 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:43:32 +0530 Subject: [PATCH 19/39] fix(core): enforce owner-only Windows ACLs on sensitive files and dirs (#3495) * fix(core): enforce owner-only Windows ACLs on sensitive files and dirs set_dir_owner_only/set_file_owner_only were unconditional no-ops on Windows, so the CLI's mTLS client private key, OIDC/edge tokens, cached SSH keys, and the gateway's key-encryption key relied entirely on inherited NTFS ACLs with no OpenShell-applied restriction. Apply an owner-only DACL via SetEntriesInAclW/SetNamedSecurityInfoW with PROTECTED_DACL_SECURITY_INFORMATION to strip inherited ACEs, matching the 0700/0600 guarantee already provided on Unix. is_file_permissions_too_open now also works on Windows instead of being Unix-only, closing the detection gap alongside the prevention gap. Signed-off-by: Prashant Khodade (cherry picked from commit 71560e947f85819efbcddf70ddda94befab62b0b) * fix(core): treat a NULL DACL as too open in is_file_permissions_too_open has_foreign_trustee conflated a NULL DACL with an unreadable/invalid ACL and returned Some(false) (not too open) for both. Per the Win32 contract, a NULL DACL means the object grants full access to everyone -- the most permissive state possible -- so it must be flagged as too open. Split the null and invalid-ACL branches: null now returns Some(true), invalid ACL keeps the existing unreadable-ACL fallback (None, which the caller maps to false via unwrap_or). Adds a regression test that constructs a real NULL DACL via a SetNamedSecurityInfoW helper confined to the windows_acl module, consistent with the existing unsafe-FFI confinement in that module. Found by CodeRabbit review on MR !113. Signed-off-by: Prashant Khodade (cherry picked from commit 46e635a4ef1d6937cdb088f46aa85baee3d6ad28) * fix(core): close three false-negative gaps in the Windows ACL audit restrict_to_current_user() updated only the DACL, leaving a foreign owner's implicit WRITE_DAC right intact -- they could later replace the DACL we just set. Query OWNER_SECURITY_INFORMATION and take ownership in the same SetNamedSecurityInfoW call; if the caller can't (a genuinely foreign-owned object), the call now fails instead of silently leaving the object insecure. is_file_permissions_too_open() mapped every Win32 inspection failure (missing READ_CONTROL, an invalid ACL, a token-query failure) to "not too open" via unwrap_or(false). Fail closed instead: an inspection failure is a security false-negative risk, not a green light. has_foreign_trustee()'s ACE loop only recognized plain ACCESS_ALLOWED_ACE_TYPE and treated every other type as non-granting. Windows also defines access-allowed object, callback, and callback-object ACE variants that can grant rights to a foreign trustee; this audit doesn't parse their wider layouts, so their mere presence is now conservatively flagged as too open instead of silently skipped. Also updates architecture/gateway.md, which still described the SQLite file-tightening behavior only in terms of Unix mode 0o600, to distinguish it from the owner-only DACL behavior on Windows. Addresses review comments on PR #3495. Signed-off-by: Prashant Khodade * fix(core): conditional owner claim and audit owner in Windows ACL helpers restrict_to_current_user: query the current owner before calling SetNamedSecurityInfoW. Include OWNER_SECURITY_INFORMATION only when the path has a foreign owner -- requesting it unconditionally fails with ACCESS_DENIED (0x80070005) on standard credentials even when the current user is already the owner, because WRITE_OWNER is not implied by object ownership. A foreign-owned path still triggers an ownership claim and fails hard if the claim is denied, preserving the security contract. has_foreign_trustee: request OWNER_SECURITY_INFORMATION alongside DACL_SECURITY_INFORMATION and reject paths with a foreign owner immediately, before inspecting the DACL. A foreign owner has implicit WRITE_DAC rights and can replace any DACL we set, so a clean DACL is not sufficient evidence of safety on a foreign-owned object. architecture/gateway.md: clarify that the Windows path-hardening behavior sets mode 0o600 on Unix and applies a protected owner-only DACL on Windows, with conditional ownership claim and fail-hard semantics for foreign-owned objects. Signed-off-by: Prashant Khodade --------- Signed-off-by: Prashant Khodade --- Cargo.lock | 1 + Cargo.toml | 4 +- architecture/gateway.md | 10 +- crates/openshell-core/Cargo.toml | 3 + crates/openshell-core/src/paths.rs | 616 +++++++++++++++++- .../openshell-driver-db-credstore/src/lib.rs | 13 + .../src/persistence/sqlite.rs | 3 +- examples/governance-interceptor/Cargo.lock | 105 +++ .../Cargo.lock | 105 +++ 9 files changed, 839 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 182b278170..825cfc48e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4060,6 +4060,7 @@ dependencies = [ "tonic-types", "tracing", "url", + "windows", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b8e6f599d4..d6066067cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,8 +56,8 @@ terminal-colorsaurus = "1.0" miette = { version = "7", features = ["fancy"] } thiserror = "2" -# Windows platform APIs (MXC audit and host-proxy process identity; Windows-only) -windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] } +# Windows platform APIs (MXC audit, host-proxy process identity, ACL enforcement; Windows-only) +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Etw", "Win32_System_Memory", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/architecture/gateway.md b/architecture/gateway.md index bccebfd9f4..5f0e66da17 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -446,9 +446,13 @@ For in-memory SQLite, the adapter retains a dedicated keepalive connection for the store lifetime. Operational connection replacement therefore preserves the shared in-memory schema and objects instead of creating an empty database. -The SQLite adapter tightens the on-disk database file to mode `0o600` on every -connect so that provider API keys, SSH session tokens, and sandbox metadata are -not readable by other local users on shared hosts. The same restriction is +The SQLite adapter tightens the on-disk database file to owner-only access on +every connect so that provider API keys, SSH session tokens, and sandbox +metadata are not readable by other local users on shared hosts. On Unix the +file mode is set to `0o600`. On Windows a protected, owner-only DACL is +applied (inherited ACEs stripped; if the file has a foreign owner, ownership +is also claimed — the call fails hard if it cannot, rather than leaving the +foreign owner's implicit `WRITE_DAC` right intact). The same restriction is reapplied to the `-wal` and `-shm` sidecars (created by SQLite's default WAL journal mode), which mirror the same sensitive contents. diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 601232def8..ddf1750044 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -41,6 +41,9 @@ tempfile = { version = "3", optional = true } nix = { workspace = true } rustix = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } + [features] default = ["telemetry"] ## Compile in anonymous telemetry emission support. On by default; disable with diff --git a/crates/openshell-core/src/paths.rs b/crates/openshell-core/src/paths.rs index 2501958270..ff47fbc24a 100644 --- a/crates/openshell-core/src/paths.rs +++ b/crates/openshell-core/src/paths.rs @@ -74,8 +74,8 @@ pub fn xdg_data_dir() -> Result { Ok(PathBuf::from(home).join(".local").join("share")) } -/// Create a directory (and parents) with owner-only permissions (`0o700`) on -/// Unix. On non-Unix platforms, falls back to default permissions. +/// Create a directory (and parents) with owner-only permissions (`0o700` on +/// Unix; an owner-only DACL with inheritance disabled on Windows). /// /// This should be used for any directory that contains sensitive material /// (tokens, private keys, certificates). @@ -87,9 +87,9 @@ pub fn create_dir_restricted(path: &Path) -> Result<()> { Ok(()) } -/// Set a directory to owner-only access (`0o700`) on Unix. -/// -/// No-op on non-Unix platforms. +/// Restrict a directory to owner-only access: `0o700` on Unix, or an +/// owner-only DACL (with inherited ACEs stripped, and the ACE propagated to +/// children) on Windows. pub fn set_dir_owner_only(path: &Path) -> Result<()> { #[cfg(unix)] { @@ -98,14 +98,13 @@ pub fn set_dir_owner_only(path: &Path) -> Result<()> { .into_diagnostic() .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; } - #[cfg(not(unix))] - let _ = path; + #[cfg(windows)] + windows_acl::restrict_to_current_user(path, true)?; Ok(()) } -/// Set a file to owner-only read/write (`0o600`) on Unix. -/// -/// No-op on non-Unix platforms. +/// Restrict a file to owner-only read/write: `0o600` on Unix, or an +/// owner-only DACL (with inherited ACEs stripped) on Windows. pub fn set_file_owner_only(path: &Path) -> Result<()> { #[cfg(unix)] { @@ -114,8 +113,8 @@ pub fn set_file_owner_only(path: &Path) -> Result<()> { .into_diagnostic() .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; } - #[cfg(not(unix))] - let _ = path; + #[cfg(windows)] + windows_acl::restrict_to_current_user(path, false)?; Ok(()) } @@ -130,16 +129,478 @@ pub fn ensure_parent_dir_restricted(path: &Path) -> Result<()> { Ok(()) } -/// Check whether a file has permissions that are too open (group/other readable). +/// Check whether a file has permissions that are too open. /// -/// Returns `true` if the file has group or other read/write/execute bits set. -/// Always returns `false` on non-Unix platforms. +/// On Unix, returns `true` if the file has group or other read/write/execute +/// bits set, and `false` if the file's metadata cannot be read. On Windows, +/// returns `true` if the file's DACL grants access to any trustee other than +/// the current user, and also `true` (fails closed) if the ACL cannot be +/// inspected at all -- see the Windows doc comment below. #[cfg(unix)] pub fn is_file_permissions_too_open(path: &Path) -> bool { use std::os::unix::fs::PermissionsExt; std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o077 != 0) } +/// Check whether a file has permissions that are too open. +/// +/// See the Unix doc comment above for the cross-platform contract. A Win32 +/// inspection failure (missing `READ_CONTROL`, an invalid ACL, a token-query +/// failure, etc.) is treated as too open rather than safe: `unwrap_or(false)` +/// would turn every such failure into a security false negative, so this +/// fails closed instead. +#[cfg(windows)] +pub fn is_file_permissions_too_open(path: &Path) -> bool { + windows_acl::has_foreign_trustee(path).unwrap_or(true) +} + +/// Windows ACL/DACL implementation of the owner-only permission helpers +/// above. Confined to this submodule so the `unsafe` FFI surface stays out +/// of the rest of the crate, matching the precedent set by +/// `openshell-driver-mxc`'s ETW consumer. +#[cfg(windows)] +mod windows_acl { + #![allow(unsafe_code)] + + use miette::{IntoDiagnostic, Result, WrapErr}; + use std::path::Path; + use windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, LocalFree}; + use windows::Win32::Security::Authorization::{ + EXPLICIT_ACCESS_W, GetNamedSecurityInfoW, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, + SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + }; + use windows::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, + DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, GetTokenInformation, + IsValidAcl, NO_INHERITANCE, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + SUB_CONTAINERS_AND_OBJECTS_INHERIT, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows::Win32::Storage::FileSystem::FILE_ALL_ACCESS; + use windows::Win32::System::SystemServices::{ + ACCESS_ALLOWED_ACE_TYPE, ACCESS_ALLOWED_CALLBACK_ACE_TYPE, + ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE, ACCESS_ALLOWED_OBJECT_ACE_TYPE, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + use windows::core::{HSTRING, PWSTR}; + + /// An owned, `TOKEN_USER`-aligned buffer (backed by `Vec` purely for + /// its alignment guarantee; the contents are opaque bytes filled in by + /// `GetTokenInformation`). + struct TokenUserBuf(Vec); + + /// Fetch the current process's user token info as an aligned buffer. + /// + /// Callers get the `PSID` out via [`sid_from_token_info`], which borrows + /// from the returned buffer; keep it alive for as long as the `PSID` is + /// used. + fn current_user_token_info() -> Result { + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) + .into_diagnostic() + .wrap_err("failed to open process token")?; + let _guard = HandleGuard(token); + + let mut needed = 0u32; + // First call is expected to fail with ERROR_INSUFFICIENT_BUFFER; + // we only want the required buffer size out of it. + let _ = GetTokenInformation(token, TokenUser, None, 0, &raw mut needed); + if needed == 0 { + return Err(miette::miette!("GetTokenInformation returned no size")); + } + let words = (needed as usize).div_ceil(size_of::()); + let mut buf = TokenUserBuf(vec![0u64; words]); + GetTokenInformation( + token, + TokenUser, + Some(buf.0.as_mut_ptr().cast()), + needed, + &raw mut needed, + ) + .into_diagnostic() + .wrap_err("failed to read current process token user")?; + Ok(buf) + } + } + + /// Extract the `PSID` from a `TOKEN_USER` buffer produced by + /// [`current_user_token_info`]. The `PSID` borrows from `buf`. + fn sid_from_token_info(buf: &TokenUserBuf) -> PSID { + // SAFETY: `buf` was sized and filled by `GetTokenInformation` for + // `TokenUser` in `current_user_token_info`, and `Vec`'s 8-byte + // alignment satisfies `TOKEN_USER`'s alignment requirement. + let token_user = unsafe { &*buf.0.as_ptr().cast::() }; + token_user.User.Sid + } + + struct HandleGuard(HANDLE); + impl Drop for HandleGuard { + fn drop(&mut self) { + // SAFETY: `self.0` is a valid handle owned by this guard. + let _ = unsafe { CloseHandle(self.0) }; + } + } + + struct LocalFreeGuard(*mut core::ffi::c_void); + impl Drop for LocalFreeGuard { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: `self.0` was allocated by a Win32 API documented to + // return LocalAlloc-owned memory (e.g. `SetEntriesInAclW`). + let _ = unsafe { LocalFree(Some(HLOCAL(self.0))) }; + } + } + } + + /// Overwrite `path`'s DACL with a single, non-inherited ACE granting full + /// control to the current user, stripping any inherited ACEs. `is_dir` + /// controls whether the ACE propagates to children (directories only). + /// When `path` has a foreign owner, ownership is also claimed; an object + /// whose owner we cannot take produces an error rather than silently + /// leaving their implicit `WRITE_DAC` right intact. + pub(super) fn restrict_to_current_user(path: &Path, is_dir: bool) -> Result<()> { + let token_info = current_user_token_info()?; + let sid = sid_from_token_info(&token_info); + + let path_hstring = HSTRING::from(path.as_os_str()); + + // Query the current owner to decide whether to include + // OWNER_SECURITY_INFORMATION in the upcoming SetNamedSecurityInfoW + // call. Requesting it unconditionally fails with ACCESS_DENIED + // (0x80070005) when the current user already owns the file, because + // WRITE_OWNER is not implied by being the object's owner. + let mut current_owner = PSID::default(); + let mut owner_sd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `path_hstring` is valid for the call; out-params are simple + // pointers filled by the API on success. The security descriptor is + // LocalAlloc-owned and freed below. + unsafe { + GetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + Some(&raw mut current_owner), + None, + None, + None, + &raw mut owner_sd, + ) + } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to query owner of {}", path.display()))?; + let _owner_sd_guard = LocalFreeGuard(owner_sd.0); + + // SAFETY: both SIDs come from Windows APIs and are valid for this block. + let already_owned = unsafe { EqualSid(sid, current_owner) }.is_ok(); + + let trustee = TRUSTEE_W { + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR(sid.0.cast()), + ..Default::default() + }; + + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_ALL_ACCESS.0, + grfAccessMode: SET_ACCESS, + grfInheritance: if is_dir { + SUB_CONTAINERS_AND_OBJECTS_INHERIT + } else { + NO_INHERITANCE + }, + Trustee: trustee, + }; + + let mut new_acl: *mut ACL = core::ptr::null_mut(); + // SAFETY: `entry` is a valid, fully-initialized EXPLICIT_ACCESS_W + // whose Trustee SID borrows from `token_info`, kept alive for this + // call. `new_acl` receives a LocalAlloc-owned pointer on success. + unsafe { SetEntriesInAclW(Some(&[entry]), None, &raw mut new_acl) } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to build ACL for {}", path.display()))?; + let _acl_guard = LocalFreeGuard(new_acl.cast()); + + // Include OWNER_SECURITY_INFORMATION only when the path has a foreign + // owner. When the current user is already the owner the flag is + // unnecessary and fails with ACCESS_DENIED on standard credentials. + // When the owner is foreign we must either take ownership or reject + // the path: a DACL-only update leaves the foreign owner's implicit + // WRITE_DAC right intact, letting them later replace this DACL + // (https://learn.microsoft.com/en-us/windows/win32/secauthz/owner-of-a-new-object). + let security_info = if already_owned { + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION + } else { + OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + }; + let owner_arg = if already_owned { None } else { Some(sid) }; + + // SAFETY: `path_hstring` is a valid, NUL-terminated wide string for + // the lifetime of this call; `new_acl` is a valid ACL just built + // above; `sid` borrows from `token_info`, kept alive for this call. + // `PROTECTED_DACL_SECURITY_INFORMATION` strips inherited ACEs. + unsafe { + SetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + security_info, + owner_arg, + None, + Some(new_acl), + None, + ) + } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to set owner-only ACL on {}", path.display()))?; + + Ok(()) + } + + /// Test-only: explicitly set a NULL DACL on `path`, the Win32 API's own + /// documented "grant everyone full access" state. Used to regression-test + /// that [`has_foreign_trustee`] treats a NULL DACL as too open rather + /// than conflating it with an unreadable/invalid ACL. + #[cfg(test)] + pub(super) fn set_null_dacl_for_test(path: &Path) -> Result<()> { + let path_hstring = HSTRING::from(path.as_os_str()); + // SAFETY: `path_hstring` is valid for the duration of this call; + // passing `None` for pdacl with `DACL_SECURITY_INFORMATION` set + // explicitly requests a NULL DACL, per the documented Win32 contract. + unsafe { + SetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + None, + None, + ) + } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to set a NULL DACL on {}", path.display())) + } + + /// Test-only: set a DACL containing a single `ACCESS_ALLOWED_OBJECT_ACE` + /// (rather than the plain `ACCESS_ALLOWED_ACE` [`restrict_to_current_user`] + /// writes) granting the current user access. Used to regression-test that + /// [`has_foreign_trustee`] conservatively flags the non-basic + /// access-allow ACE layouts (object/callback/callback-object) it doesn't + /// parse, instead of silently skipping them as if they were a + /// non-granting type like deny/audit. + #[cfg(test)] + pub(super) fn set_object_ace_dacl_for_test(path: &Path) -> Result<()> { + use windows::Win32::Security::{ + ACE_FLAGS, ACL_REVISION, AddAccessAllowedObjectAce, InitializeAcl, + }; + + let token_info = current_user_token_info()?; + let sid = sid_from_token_info(&token_info); + + // Oversized fixed buffer: plenty of room for an ACL header plus one + // object ACE (which is wider than a plain ACE but still well under + // 1 KiB even with a SID). Backed by `Vec` purely for its 8-byte + // alignment guarantee, matching `TokenUserBuf` above -- `ACL` has a + // stricter alignment than a `Vec` buffer provides. + let mut acl_buf = vec![0u64; 128]; + let acl_len_bytes = size_of_val(acl_buf.as_slice()); + let acl_ptr = acl_buf.as_mut_ptr().cast::(); + let acl_len = u32::try_from(acl_len_bytes).expect("test buffer size fits in u32"); + // SAFETY: `acl_ptr` points at `acl_len` bytes of writable memory + // that outlives this call (owned by `acl_buf`, alive until this + // function returns). + unsafe { InitializeAcl(acl_ptr, acl_len, ACL_REVISION) } + .into_diagnostic() + .wrap_err("failed to initialize test ACL")?; + // SAFETY: `acl_ptr` was just initialized above and has room for one + // more ACE; `sid` borrows from `token_info`, kept alive for this + // call. Passing `None` for both GUIDs still produces an ACE typed + // `ACCESS_ALLOWED_OBJECT_ACE_TYPE` per the documented Win32 contract, + // which is exactly the non-basic layout under test. + unsafe { + AddAccessAllowedObjectAce( + acl_ptr, + ACL_REVISION, + ACE_FLAGS(0), + FILE_ALL_ACCESS.0, + None, + None, + sid, + ) + } + .into_diagnostic() + .wrap_err("failed to add object ACE to test ACL")?; + + let path_hstring = HSTRING::from(path.as_os_str()); + // SAFETY: `path_hstring` is valid for the duration of this call; + // `acl_ptr` is a valid, fully-built ACL from the calls above. + unsafe { + SetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(acl_ptr), + None, + ) + } + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to set an object-ACE DACL on {}", path.display())) + } + + /// Returns `true` if `path`'s owner is not the current user, or if its + /// DACL grants access to any trustee other than the current user. + /// Returns `None` if the owner or ACL could not be read. + pub(super) fn has_foreign_trustee(path: &Path) -> Option { + let token_info = current_user_token_info().ok()?; + let user_sid = sid_from_token_info(&token_info); + + let path_hstring = HSTRING::from(path.as_os_str()); + let mut owner: PSID = PSID::default(); + let mut dacl: *mut ACL = core::ptr::null_mut(); + let mut security_descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `path_hstring` is valid for the call; the out-params are + // simple pointers filled in by the API on success. The security + // descriptor (which both `owner` and `dacl` point into) is + // LocalAlloc-owned and freed below. + let status = unsafe { + GetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + Some(&raw mut owner), + None, + Some(&raw mut dacl), + None, + &raw mut security_descriptor, + ) + }; + status.ok().ok()?; + let _sd_guard = LocalFreeGuard(security_descriptor.0); + + // A foreign owner has implicit WRITE_DAC rights and can replace the + // DACL at any time, so reject the path regardless of what the DACL + // currently says. + // SAFETY: both SIDs come from Windows APIs and are valid for this call. + let owner_is_current_user = unsafe { EqualSid(user_sid, owner) }.is_ok(); + if !owner_is_current_user { + return Some(true); + } + + // A NULL DACL is a real, distinct state from "unreadable ACL": per + // the Win32 contract, it means the object grants full access to + // everyone -- the most permissive state possible -- so it must be + // flagged as too open, not treated as safe. + if dacl.is_null() { + return Some(true); + } + if unsafe { !IsValidAcl(dacl).as_bool() } { + return None; + } + + let mut size_info = ACL_SIZE_INFORMATION::default(); + // SAFETY: `dacl` was just validated above. + unsafe { + GetAclInformation( + dacl, + (&raw mut size_info).cast(), + u32::try_from(size_of::()) + .expect("ACL_SIZE_INFORMATION size fits in u32"), + AclSizeInformation, + ) + } + .ok()?; + + for index in 0..size_info.AceCount { + let mut ace_ptr: *mut core::ffi::c_void = core::ptr::null_mut(); + // SAFETY: `dacl` is valid and `index` is within `AceCount`. + if unsafe { GetAce(dacl, index, &raw mut ace_ptr) }.is_err() { + continue; + } + // SAFETY: `GetAce` returned a pointer to a valid ACE header. + let header = unsafe { &*ace_ptr.cast::() }; + let ace_type = u32::from(header.AceType); + if ace_type == ACCESS_ALLOWED_OBJECT_ACE_TYPE + || ace_type == ACCESS_ALLOWED_CALLBACK_ACE_TYPE + || ace_type == ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE + { + // Windows also defines access-allowed object, callback, and + // callback-object ACE variants (each with a different, wider + // layout than plain ACCESS_ALLOWED_ACE), any of which may + // grant rights to a foreign trustee. This audit doesn't parse + // their layouts, so treat their mere presence as too open + // rather than silently skip them -- a false positive here is + // an unnecessary re-tightening, but a false negative is a + // security hole. See the ACE type table: + // https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-strings + return Some(true); + } + if ace_type != ACCESS_ALLOWED_ACE_TYPE { + // Deny/audit/alarm ACE types don't grant access; skip them + // for this "is anyone but me granted access" check. + continue; + } + // SAFETY: header.AceType confirms this is an ACCESS_ALLOWED_ACE. + let ace = unsafe { &*ace_ptr.cast::() }; + let ace_sid = PSID((&raw const ace.SidStart).cast_mut().cast()); + // SAFETY: both SIDs come from Windows APIs (`GetTokenInformation` + // and `GetAce`) and are valid for the duration of this call. + let is_owner = unsafe { EqualSid(user_sid, ace_sid) }.is_ok(); + if !is_owner { + return Some(true); + } + } + Some(false) + } + + /// Test-only: returns `true` if `path`'s current owner SID equals the + /// current process's user SID. Used to regression-test that + /// [`restrict_to_current_user`] actually takes ownership of the object, + /// not just its DACL. + #[cfg(test)] + pub(super) fn owner_is_current_user_for_test(path: &Path) -> Result { + let token_info = current_user_token_info()?; + let expected_sid = sid_from_token_info(&token_info); + + let path_hstring = HSTRING::from(path.as_os_str()); + let mut owner = PSID::default(); + let mut security_descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `path_hstring` is valid for the call; the out-params are + // simple pointers filled in by the API on success. The security + // descriptor `owner` points into is LocalAlloc-owned and freed below. + let status = unsafe { + GetNamedSecurityInfoW( + PWSTR::from_raw(path_hstring.as_ptr().cast_mut()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + Some(&raw mut owner), + None, + None, + None, + &raw mut security_descriptor, + ) + }; + status + .ok() + .into_diagnostic() + .wrap_err_with(|| format!("failed to query owner of {}", path.display()))?; + let _sd_guard = LocalFreeGuard(security_descriptor.0); + + // SAFETY: both SIDs come from Windows APIs (`GetTokenInformation` and + // `GetNamedSecurityInfoW`) and are valid for the duration of this + // call. + Ok(unsafe { EqualSid(expected_sid, owner) }.is_ok()) + } +} + /// Normalize a filesystem path by collapsing redundant separators /// and removing trailing slashes, without requiring the path to exist on disk. /// @@ -252,6 +713,131 @@ mod tests { assert!(!is_file_permissions_too_open(&file)); } + #[cfg(windows)] + #[test] + fn create_dir_restricted_sets_owner_only_acl() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("restricted"); + create_dir_restricted(&dir).unwrap(); + assert!( + !is_file_permissions_too_open(&dir), + "expected owner-only ACL on {}", + dir.display() + ); + } + + #[cfg(windows)] + #[test] + fn set_file_owner_only_sets_owner_only_acl() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("secret"); + std::fs::write(&file, "secret-data").unwrap(); + set_file_owner_only(&file).unwrap(); + assert!( + !is_file_permissions_too_open(&file), + "expected owner-only ACL on {}", + file.display() + ); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_detects_world_readable() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("open-file"); + std::fs::write(&file, "data").unwrap(); + // Grant Everyone read access, mirroring the Unix 0o644 case: an ACE + // for a trustee other than the current user. + let status = std::process::Command::new("icacls") + .arg(&file) + .arg("/grant") + .arg("Everyone:(R)") + .status() + .unwrap(); + assert!(status.success(), "icacls grant failed"); + assert!(is_file_permissions_too_open(&file)); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_accepts_restricted() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("restricted-file"); + std::fs::write(&file, "data").unwrap(); + set_file_owner_only(&file).unwrap(); + assert!(!is_file_permissions_too_open(&file)); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_detects_null_dacl() { + // A NULL DACL is the Win32 API's own documented "grant everyone full + // access" state -- the most permissive possible -- and is a distinct + // condition from an unreadable/invalid ACL. Regression test for a + // CodeRabbit-flagged bug where the two were conflated and a NULL + // DACL was reported as safe. + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("null-dacl-file"); + std::fs::write(&file, "data").unwrap(); + + windows_acl::set_null_dacl_for_test(&file).unwrap(); + + assert!( + is_file_permissions_too_open(&file), + "a NULL DACL grants everyone full access and must be flagged as too open" + ); + } + + #[cfg(windows)] + #[test] + fn restrict_to_current_user_also_takes_ownership() { + // A DACL-only update leaves a foreign owner's implicit WRITE_DAC + // right intact, letting them later replace the DACL we just set. + // Regression test for a review comment on the owner-only ACL PR. + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("owned-file"); + std::fs::write(&file, "data").unwrap(); + + set_file_owner_only(&file).unwrap(); + + assert!( + windows_acl::owner_is_current_user_for_test(&file).unwrap(), + "restrict_to_current_user must take ownership, not just set the DACL" + ); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_fails_closed_on_inspection_error() { + // A nonexistent path can't have its ACL read, so GetNamedSecurityInfoW + // fails. `unwrap_or(false)` would turn that failure into "safe"; + // fail closed instead -- an inspection failure is a security false + // negative risk, not a green light. + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("does-not-exist"); + assert!(is_file_permissions_too_open(&missing)); + } + + #[cfg(windows)] + #[test] + fn is_file_permissions_too_open_detects_object_ace_type() { + // Windows also defines access-allowed object/callback/callback-object + // ACE types, each wider than the plain ACCESS_ALLOWED_ACE this audit + // parses. Any of them may grant rights to a foreign trustee, so their + // mere presence must be flagged conservatively rather than silently + // skipped as a non-granting (deny/audit) type would be. + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("object-ace-file"); + std::fs::write(&file, "data").unwrap(); + + windows_acl::set_object_ace_dacl_for_test(&file).unwrap(); + + assert!( + is_file_permissions_too_open(&file), + "an unparsed access-allow ACE layout must be conservatively flagged as too open" + ); + } + #[test] fn normalize_path_collapses_separators() { assert_eq!(normalize_path("/usr//lib"), "/usr/lib"); diff --git a/crates/openshell-driver-db-credstore/src/lib.rs b/crates/openshell-driver-db-credstore/src/lib.rs index c9813870b2..c5c29494b8 100644 --- a/crates/openshell-driver-db-credstore/src/lib.rs +++ b/crates/openshell-driver-db-credstore/src/lib.rs @@ -1261,4 +1261,17 @@ mod tests { & 0o777; assert_eq!(key_encryption_key_mode, 0o600); } + + #[cfg(windows)] + #[test] + fn generated_key_encryption_key_file_is_owner_only() { + let tmp = tempfile::tempdir().unwrap(); + let key_encryption_key_path = tmp.path().join(DEFAULT_KEY_ENCRYPTION_KEY_FILE); + let _crypto = crypto_for_key_encryption_key_path(&key_encryption_key_path); + assert!( + !openshell_core::paths::is_file_permissions_too_open(&key_encryption_key_path), + "expected owner-only ACL on {}", + key_encryption_key_path.display() + ); + } } diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 4c274386ca..93f0e6a318 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -1652,7 +1652,8 @@ WHERE "object_type" = ?1 AND "scope" = ?2 /// and `-shm` (shared memory index). They mirror the same sensitive data /// as the main file, so they get the same `0o600` treatment whenever they exist on disk. /// -/// Delegates to `set_file_owner_only`, which is a no-op on non-Unix platforms. +/// Delegates to `set_file_owner_only`, which restricts to an owner-only ACL +/// on Windows and `0o600` on Unix. pub(super) fn restrict_db_file_permissions(path: &Path) -> PersistenceResult<()> { set_file_owner_only(path).map_err(|err| PersistenceError::Database(err.to_string()))?; diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 958e37d814..bcf2e7baa6 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1085,6 +1085,7 @@ dependencies = [ "tonic-types", "tracing", "url", + "windows", ] [[package]] @@ -2365,12 +2366,107 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2405,6 +2501,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index f65d9a8318..8fb3844bd5 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -972,6 +972,7 @@ dependencies = [ "tonic-types", "tracing", "url", + "windows", ] [[package]] @@ -1948,12 +1949,107 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1988,6 +2084,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" From 9e397e8a439c1b0c3284db56c1ada53c65e752da Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:46:54 +0530 Subject: [PATCH 20/39] fix(mxc): reject cpu/memory limits instead of silently discarding them (#3548) * fix(mxc): reject cpu/memory limits instead of silently discarding them CreateSandbox accepted --cpu/--memory and reached Ready with no Job Object enforcement and no diagnostic, leaving the SDD's T11 host-exhaustion mitigation silently unmet. MXC's schema does not expose CPU rate control or memory limiting outside the WSLC backend, so reject requests carrying cpu/memory limits synchronously at CreateSandbox, matching the existing fail-closed GPU rejection. NVBug 6782894 Signed-off-by: Prashant Khodade * fix(server): validate sandbox resource quantities Signed-off-by: Shailendra Singh * docs(skill): clarify MXC resource limit behavior Signed-off-by: Shailendra Singh * chore(go): regenerate protobuf bindings Signed-off-by: Shailendra Singh * fix(go): align generated protobuf comments Signed-off-by: Shailendra Singh --------- Signed-off-by: Prashant Khodade Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- architecture/compute-runtimes.md | 5 +- crates/openshell-driver-mxc/README.md | 1 + crates/openshell-driver-mxc/src/driver.rs | 50 ++++- crates/openshell-server/src/compute/mod.rs | 37 ++++ .../openshell-server/src/grpc/validation.rs | 200 ++++++++++++++++++ docs/reference/sandbox-compute-drivers.mdx | 8 +- proto/openshell.proto | 3 +- sdk/go/proto/openshellv1/openshell.pb.go | 3 +- skills/openshell-cli/SKILL.md | 2 +- 9 files changed, 297 insertions(+), 12 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index e9320323be..fa4ce229b0 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -318,7 +318,10 @@ delete-and-cleanup path above. Per-sandbox CPU and memory values currently enter the driver layer through template resource limits. Docker and Podman apply them as runtime limits. Kubernetes mirrors each limit into the matching request. VM accepts the fields -but currently ignores them. +but currently ignores them. MXC rejects them because its current backends cannot +enforce them. At the public protobuf boundary, the gateway requires well-known +`cpu` and `memory` entries under `limits` or `requests` to be non-empty strings; +unknown resource entries remain available for platform-specific handling. Reusable sandbox workload templates are resolved before the compute-driver boundary. Drivers do not receive a separate template resource; the gateway diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 678632210f..7c9d5c08aa 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -26,6 +26,7 @@ it does not implement the Linux `ConnectSupervisor` protocol. | Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | | Process policy | Unsupported; MXC supplies OS isolation only. | +| Resource limits | Unsupported; MXC exposes no CPU rate control or memory limiting to non-WSLC backends. `CreateSandbox` rejects any request carrying `cpu_limit`, `cpu_request`, `memory_limit`, or `memory_request` synchronously rather than silently discarding them. | | Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | | Network middleware | Rejected before launch until the host proxy receives the gateway middleware registry. | | ETW/OCSF audit | Optional Windows Sandboxing ETW consumer attributes host events to OpenShell sandboxes and emits OCSF records. | diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 6df81a09e3..19a5d82790 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -1006,12 +1006,22 @@ impl MxcComputeBackend { "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)", - )); + if let Some(tmpl) = &spec.template { + if !tmpl.agent_socket_path.is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", + )); + } + if let Some(resources) = &tmpl.resources + && (!resources.cpu_limit.is_empty() + || !resources.cpu_request.is_empty() + || !resources.memory_limit.is_empty() + || !resources.memory_request.is_empty()) + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support cpu/memory resource limits (no Job Object enforcement); omit --cpu/--memory or use a driver that supports them", + )); + } } } sandbox_config(sandbox)?; @@ -2577,7 +2587,9 @@ fn make_sandbox_with_condition( mod lifecycle_tests { use super::*; use futures::StreamExt; - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_core::proto::compute::v1::{ + DriverResourceRequirements, DriverSandboxSpec, DriverSandboxTemplate, + }; use openshell_core::proto::{ FilesystemPolicy, MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, NetworkPolicyRule, SandboxPolicy, StaticCredentialBinding, @@ -3786,6 +3798,30 @@ mod lifecycle_tests { assert!(error.message().contains("unrestricted egress fallback")); } + #[test] + fn cpu_and_memory_limits_are_rejected_fail_closed() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let mut sandbox = driver_sandbox("sb-cpu-memory-limits"); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .resources = Some(DriverResourceRequirements { + cpu_limit: "1".into(), + memory_limit: "512Mi".into(), + ..Default::default() + }); + + let error = backend + .validate_sandbox_create(&sandbox) + .expect_err("cpu/memory limits are not enforceable on mxc and must fail closed"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("cpu/memory")); + } + #[tokio::test] async fn processcontainer_live_config_carries_explicit_ui_policy() { let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 5cc050f705..12ddac2e46 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -7191,6 +7191,43 @@ mod tests { assert!(build_platform_config(&template).is_none()); } + #[test] + fn driver_template_maps_string_cpu_and_memory_resources() { + let template = SandboxTemplate { + resources: Some(prost_types::Struct { + fields: [ + ( + "limits", + struct_value([("cpu", string_value("2")), ("memory", string_value("1Gi"))]), + ), + ( + "requests", + struct_value([ + ("cpu", string_value("500m")), + ("memory", string_value("512Mi")), + ]), + ), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(), + }), + ..Default::default() + }; + + let driver = driver_sandbox_template_from_public(&template, "mxc").unwrap(); + assert_eq!( + driver.resources, + Some(DriverResourceRequirements { + cpu_request: "500m".to_string(), + cpu_limit: "2".to_string(), + memory_request: "512Mi".to_string(), + memory_limit: "1Gi".to_string(), + }) + ); + assert!(driver.platform_config.is_none()); + } + #[test] fn build_platform_config_preserves_non_typed_resource_fields() { let template = SandboxTemplate { diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 2785b9b09b..7b3e7c4719 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -360,6 +360,7 @@ fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> { ), )); } + validate_template_resources(s)?; } if let Some(ref s) = tmpl.driver_config { let size = s.encoded_len(); @@ -377,6 +378,57 @@ fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> { Ok(()) } +/// Validate the well-known Kubernetes-style resource fields before the public +/// `Struct` is split into typed driver requirements and opaque platform data. +/// +/// Unknown resource names remain platform-specific, but CPU and memory must be +/// non-empty strings so malformed requests cannot be silently passed through +/// as opaque data and ignored by a compute driver. +fn validate_template_resources(resources: &prost_types::Struct) -> Result<(), Status> { + use prost_types::value::Kind; + + for section_name in ["limits", "requests"] { + let Some(section_value) = resources.fields.get(section_name) else { + continue; + }; + let field_prefix = format!("spec.template.resources.{section_name}"); + let Some(Kind::StructValue(section)) = section_value.kind.as_ref() else { + return Err(invalid_argument( + &field_prefix, + format!("template.resources.{section_name} must be an object"), + )); + }; + + for resource_name in ["cpu", "memory"] { + let Some(resource_value) = section.fields.get(resource_name) else { + continue; + }; + let field = format!("{field_prefix}.{resource_name}"); + match resource_value.kind.as_ref() { + Some(Kind::StringValue(value)) if !value.is_empty() => {} + Some(Kind::StringValue(_)) => { + return Err(invalid_argument( + &field, + format!( + "template.resources.{section_name}.{resource_name} must not be empty" + ), + )); + } + _ => { + return Err(invalid_argument( + &field, + format!( + "template.resources.{section_name}.{resource_name} must be a string" + ), + )); + } + } + } + } + + Ok(()) +} + /// `driver_config` fields the gateway resolves and writes itself. /// /// A caller who could set these would hand a raw host path straight to a @@ -1412,6 +1464,154 @@ mod tests { assert!(err.message().contains("template.resources")); } + #[test] + fn validate_sandbox_spec_accepts_string_cpu_and_memory_resources() { + use prost_types::{Struct, Value, value::Kind}; + + let section = |fields: [(&str, &str); 2]| Value { + kind: Some(Kind::StructValue(Struct { + fields: fields + .into_iter() + .map(|(name, value)| { + ( + name.to_string(), + Value { + kind: Some(Kind::StringValue(value.to_string())), + }, + ) + }) + .collect(), + })), + }; + let resources = Struct { + fields: [ + ( + "limits".to_string(), + section([("cpu", "1"), ("memory", "1Gi")]), + ), + ( + "requests".to_string(), + section([("cpu", "500m"), ("memory", "512Mi")]), + ), + ] + .into_iter() + .collect(), + }; + let spec = SandboxSpec { + template: Some(SandboxTemplate { + resources: Some(resources), + ..Default::default() + }), + ..Default::default() + }; + + validate_sandbox_spec("ok", &spec).unwrap(); + } + + #[test] + fn validate_sandbox_spec_rejects_non_string_cpu_resource() { + use prost_types::{Struct, Value, value::Kind}; + + let resources = Struct { + fields: std::iter::once(( + "limits".to_string(), + Value { + kind: Some(Kind::StructValue(Struct { + fields: std::iter::once(( + "cpu".to_string(), + Value { + kind: Some(Kind::NumberValue(1.0)), + }, + )) + .collect(), + })), + }, + )) + .collect(), + }; + let spec = SandboxSpec { + template: Some(SandboxTemplate { + resources: Some(resources), + ..Default::default() + }), + ..Default::default() + }; + + let err = validate_sandbox_spec("ok", &spec).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + let details = err.get_error_details(); + assert_eq!( + details.bad_request().unwrap().field_violations[0].field, + "spec.template.resources.limits.cpu" + ); + assert!(err.message().contains("must be a string")); + } + + #[test] + fn validate_sandbox_spec_rejects_non_object_resource_section() { + use prost_types::{Struct, Value, value::Kind}; + + let resources = Struct { + fields: std::iter::once(( + "requests".to_string(), + Value { + kind: Some(Kind::NumberValue(1.0)), + }, + )) + .collect(), + }; + let spec = SandboxSpec { + template: Some(SandboxTemplate { + resources: Some(resources), + ..Default::default() + }), + ..Default::default() + }; + + let err = validate_sandbox_spec("ok", &spec).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + let details = err.get_error_details(); + assert_eq!( + details.bad_request().unwrap().field_violations[0].field, + "spec.template.resources.requests" + ); + assert!(err.message().contains("must be an object")); + } + + #[test] + fn validate_sandbox_spec_rejects_empty_memory_resource() { + use prost_types::{Struct, Value, value::Kind}; + + let resources = Struct { + fields: std::iter::once(( + "limits".to_string(), + Value { + kind: Some(Kind::StructValue(Struct { + fields: std::iter::once(( + "memory".to_string(), + Value { + kind: Some(Kind::StringValue(String::new())), + }, + )) + .collect(), + })), + }, + )) + .collect(), + }; + let spec = SandboxSpec { + template: Some(SandboxTemplate { + resources: Some(resources), + ..Default::default() + }), + ..Default::default() + }; + + let err = validate_sandbox_spec("ok", &spec).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("must not be empty")); + } + #[test] fn validate_sandbox_spec_rejects_oversized_policy() { use openshell_core::proto::NetworkPolicyRule; diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index f328909ceb..92d4de0c47 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -121,7 +121,13 @@ the gateway uid can access it. Sandbox create supports `--cpu` and `--memory` for per-sandbox compute sizing. Docker and Podman apply them as runtime limits. Kubernetes applies them as both container requests and limits. The VM driver accepts the fields but currently -ignores them. +ignores them. The MXC driver rejects CPU and memory requests because its current +backends cannot enforce them. + +API clients that populate `SandboxTemplate.resources` directly must encode the +`cpu` and `memory` values under `limits` or `requests` as non-empty strings. +The gateway rejects malformed sections and non-string values with +`InvalidArgument` instead of forwarding them as opaque driver configuration. Sandbox create also accepts experimental driver-owned config through `--driver-config-json`. The value is a JSON object keyed by driver name. The diff --git a/proto/openshell.proto b/proto/openshell.proto index 458c87b026..fdccecd1bc 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -979,7 +979,8 @@ message SandboxTemplate { map annotations = 5; // Additional environment variables injected by the template. map environment = 6; - // Platform-specific compute resource requirements and limits. + // Platform-specific compute resource requirements and limits. Well-known + // cpu and memory entries under limits or requests must be non-empty strings. google.protobuf.Struct resources = 7; reserved 9; reserved "volume_claim_templates"; diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index 0a9455d742..a10c89f6a2 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -1746,7 +1746,8 @@ type SandboxTemplate struct { Annotations map[string]string `protobuf:"bytes,5,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Additional environment variables injected by the template. Environment map[string]string `protobuf:"bytes,6,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Platform-specific compute resource requirements and limits. + // Platform-specific compute resource requirements and limits. Well-known + // cpu and memory entries under limits or requests must be non-empty strings. Resources *structpb.Struct `protobuf:"bytes,7,opt,name=resources,proto3" json:"resources,omitempty"` // Enable Kubernetes user namespace isolation (hostUsers: false). // When true, container UID 0 maps to a non-root host UID and capabilities diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index b44b748e50..3e53e91ed0 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -248,7 +248,7 @@ Key flags: - `--provider`: Attach configured credential providers for API keys, tokens, and other secrets (repeatable) - `--policy`: Custom policy YAML (otherwise uses built-in default or `OPENSHELL_SANDBOX_POLICY` env var) - `--gpu [COUNT]`: Request the driver's default GPU selection or a specific GPU count -- `--cpu`, `--memory`: Set per-sandbox compute sizing. Docker/Podman apply limits; Kubernetes applies matching requests and limits. +- `--cpu`, `--memory`: Set per-sandbox compute sizing. Docker/Podman apply limits; Kubernetes applies matching requests and limits; VM currently accepts but ignores them; MXC rejects them because its current backends cannot enforce them. - `--driver-config-json`: Pass experimental driver-specific sandbox configuration - `--template NAME`: Create from a named sandbox workload template. Conflicts with inline workload flags such as `--from`, `--gpu`, `--cpu`, `--memory`, `--env`, and `--driver-config-json`. - `--label KEY=VALUE`: Add labels for later selection (repeatable) From a749bc88c82896b5efb64caf4da1f9c29b17396b Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Wed, 23 Sep 2026 13:52:34 -0700 Subject: [PATCH 21/39] docs(windows): add runtime architecture overview (#3575) * docs(windows): add runtime architecture overview Signed-off-by: Shailendra Singh * docs(windows): consolidate build documentation Signed-off-by: Shailendra Singh * docs(windows): address architecture review feedback Signed-off-by: Shailendra Singh --------- Signed-off-by: Shailendra Singh --- .../build-openshell-mxc-windows/SKILL.md | 8 +- .../build-openshell-mxc-windows/reference.md | 11 +- CONTRIBUTING.md | 51 +++- architecture/README.md | 46 +-- architecture/windows-msvc-build.md | 209 -------------- architecture/windows.md | 263 ++++++++++++++++++ crates/openshell-driver-mxc/README.md | 10 +- 7 files changed, 355 insertions(+), 243 deletions(-) delete mode 100644 architecture/windows-msvc-build.md create mode 100644 architecture/windows.md diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 13ae8f19f8..a7d30f63f7 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -32,7 +32,8 @@ The Windows build lane is implemented by these tracked files: | `tasks/rust.toml`, `tasks/test.toml`, and `tasks/markdown.toml` | Windows routing for compiler-bearing checks, explicit Unix-only test skips, and Markdown dependency setup. | | `tasks/scripts/windows-msvc.ps1` | PowerShell wrapper that enters the Visual Studio developer environment and invokes Cargo. | | `.github/workflows/windows-msvc.yml` | Opt-in PR lint and test plus advisory main/manual cache seeding and dependent binary builds on native x64 and ARM64 runners. | -| `architecture/windows-msvc-build.md` | Design notes and validation contract. | +| `CONTRIBUTING.md` | Human-facing Windows build prerequisites and command summary. | +| `architecture/windows.md` | Stable Windows/MXC runtime and cross-architecture boundaries. | | `.agents/skills/build-openshell-mxc-windows/` | This skill and companion reference material. | Use the code that is already in the repo. Do not generate a parallel Windows @@ -120,6 +121,7 @@ The lane targets a Windows host with Visual Studio Build Tools and rustup. | Windows SDK | `where.exe rc.exe` from a Developer PowerShell | Install an SDK containing target libraries and ARM64 tools. | | Rust via rustup | `rustc --version` | Add each target being validated: `x86_64-pc-windows-msvc` and/or `aarch64-pc-windows-msvc`. The wrapper also adds the selected target. | | mise | `mise --version` | Used as a task runner only. | +| cargo-nextest | `cargo nextest --version` | Required by `windows:test:*` and `windows:ci`. Install the pinned version with `mise install --locked github:nextest-rs/nextest` before running tasks with `--skip-tools`. | | Git | `git --version` | Needed for checkout and sync work. | | PowerShell | `$PSVersionTable.PSVersion` | Windows PowerShell 5.1 works; PowerShell 7 is quieter with mise shell hooks. | @@ -261,8 +263,8 @@ 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`, and `openshell-supervisor-relay.exe` for x64. | +| `windows:build:arm64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, and `openshell-supervisor-relay.exe` 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. | diff --git a/.agents/skills/build-openshell-mxc-windows/reference.md b/.agents/skills/build-openshell-mxc-windows/reference.md index bc278664dc..4ab0358d87 100644 --- a/.agents/skills/build-openshell-mxc-windows/reference.md +++ b/.agents/skills/build-openshell-mxc-windows/reference.md @@ -9,11 +9,18 @@ maintaining the existing build-only Windows MSVC lane. |---|---| | `tasks/windows.toml` | Mise task definitions for `windows:*`. | | `tasks/scripts/windows-msvc.ps1` | Visual Studio environment discovery, rustup target setup, Cargo invocation, logs, artifact report. | -| `.github/workflows/windows-msvc.yml` | PR/merge-queue lint and test plus main/manual cache seeding and dependent binary builds on native x64 and ARM64 runners. | -| `architecture/windows-msvc-build.md` | Human-readable design contract. | +| `.github/workflows/windows-msvc.yml` | Opt-in PR lint and test plus advisory main/manual cache seeding and dependent binary builds on native x64 and ARM64 runners. | +| `CONTRIBUTING.md` | Human-readable Windows build prerequisites and commands. | +| `architecture/windows.md` | Stable Windows/MXC runtime and cross-architecture boundaries. | ## Commands +Install the pinned test runner once before using test-bearing tasks: + +```powershell +mise install --locked github:nextest-rs/nextest +``` + Use `--skip-tools` for all Windows mise tasks: ```powershell diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 931d97500f..74beb5c6fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -361,14 +361,49 @@ just this crate does not require `LIBCLANG_PATH`: cargo build -p openshell-prover --target x86_64-pc-windows-msvc --features bundled-z3 ``` -### Windows full build - -To build the full set of Windows binaries, including `openshell-gateway.exe` -and `openshell.exe`, use the `windows:build:x64` mise task instead of a -single-crate `cargo build`. It downloads the pinned prebuilt Z3 release by default. A -full build also compiles crates that use `bindgen` (e.g. the MXC driver on -Windows), so it requires `libclang.dll`; if LLVM is not on the default search -path, set `LIBCLANG_PATH` to the directory containing `libclang.dll`: +### Windows build and validation + +Use the platform-native `windows:*` mise tasks for Windows MSVC development. +The lane supports x64 and ARM64 and builds `openshell-gateway.exe`, +`openshell.exe`, and `openshell-supervisor-relay.exe`. It does not enable +Docker, Kubernetes, Podman, or VM-backed execution on Windows. + +Install Visual Studio C++ Build Tools, a compatible Windows SDK, Rust through +rustup, mise, and the Visual Studio LLVM tools used by `bindgen`. Test-bearing +tasks also require the pinned `cargo-nextest` tool. Install it once through +mise: + +```powershell +mise install --locked github:nextest-rs/nextest +``` + +Run every task with `--skip-tools`; mise orchestrates the commands but does not +install the Windows compiler toolchain. + +| Task | Purpose | +|---|---| +| `windows:lint:` | Run Clippy for the Windows-supported workspace on the selected target architecture. | +| `windows:build:` | Build the three Windows release executables. | +| `windows:test:` | Run the workspace suite natively; the target must match the host architecture. | +| `windows:test:unsupported:` | Run the focused unsupported-driver contracts. | +| `windows:test:mxc-real:` | Run the probe-gated real-`wxc-exec` integration suite on the matching host. | +| `windows:artifacts` | Report sizes and SHA256 hashes for release artifacts. | +| `windows:ci` | Run the aggregate x64-host check, build, test, contract, and artifact workflow. | + +The task definitions in [`tasks/windows.toml`](tasks/windows.toml), the +PowerShell implementation in +[`tasks/scripts/windows-msvc.ps1`](tasks/scripts/windows-msvc.ps1), and the +hosted workflow in +[`.github/workflows/windows-msvc.yml`](.github/workflows/windows-msvc.yml) are +the executable sources of truth. See +[`architecture/windows.md`](architecture/windows.md) for the stable Windows/MXC +runtime and enforcement boundaries. + +To build x64 locally, use `windows:build:x64` instead of a single-crate Cargo +build. It downloads the pinned prebuilt Z3 release by default. A full build also +compiles crates that use `bindgen`, including the MXC driver, so it requires +`libclang.dll`. If LLVM is not on the default search path, set `LIBCLANG_PATH` +to the directory containing `libclang.dll`: ```powershell $env:LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\\VC\Tools\Llvm\x64\bin' diff --git a/architecture/README.md b/architecture/README.md index 706db67b1f..7f30c9a8b1 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -2,16 +2,17 @@ OpenShell runs autonomous AI agents in sandboxed environments with explicit policy, credential, identity, and network boundaries. The target architecture is -built around three stable runtime components: the **CLI**, the **Gateway**, and -the **Supervisor**. +built around stable user, control-plane, and runtime boundaries. Most runtimes +place the runtime boundary in the **Supervisor**; driver-controlled runtimes such +as Windows MXC enforce it through platform-native isolation and host services. The CLI, SDK, and TUI provide user-facing access. The gateway is the authenticated control plane: it owns API access, durable state, policy and settings delivery, provider configuration and attachments, and relay -coordination. The supervisor runs inside every sandbox workload and is the local -security boundary. It launches the agent as a restricted child process and -enforces policy where process identity, filesystem access, network egress, and -runtime credentials are visible. +coordination. In supervisor-based runtimes, the supervisor runs inside the +sandbox workload and launches the agent as a restricted child process. A +driver-controlled runtime owns the equivalent lifecycle and enforcement duties +without embedding the standard supervisor. Infrastructure-specific work sits behind integration boundaries. Compute, credentials, control-plane identity, and sandbox identity each have a driver or @@ -42,6 +43,7 @@ flowchart TB subgraph INFRA["Integrated Infrastructure"] RUNTIME["Docker / Podman / Kubernetes / VM"] + MXC["Windows MXC"] SECRETSTORE["Eg: Keychain / Secret Service / Vault / Kubernetes Secrets"] IDP["Eg: mTLS / OIDC / Local identity"] WORKLOADID["Eg: SPIFFE / Gateway-issued workload identity"] @@ -54,6 +56,11 @@ flowchart TB AGENT["Restricted agent process"] end + subgraph WINDP["Windows MXC Data Plane"] + MXCAGENT["MXC-isolated agent process"] + MXCPROXY["Host policy proxy (ProcessContainer only)"] + end + CLI -->|"gRPC / HTTP"| GW SDK -->|"gRPC / HTTP"| GW TUI -->|"gRPC / HTTP"| GW @@ -70,10 +77,13 @@ flowchart TB SIDENT -->|"gRPC / UDS"| SIDRV CDRV --> RUNTIME + CDRV --> MXC CRDRV --> SECRETSTORE CPIDRV --> IDP SIDRV --> WORKLOADID RUNTIME -->|"provisions workload"| SUP + MXC -->|"driver-controlled launch"| MXCAGENT + MXCAGENT -. "governed egress" .-> MXCPROXY SUP -->|"outbound control, config, logs, relay"| GW SUP -->|"spawn + restrict"| AGENT @@ -93,8 +103,8 @@ flowchart TB | Credentials subsystem | Logical provider and credential resolution. Secret storage and platform-native credential access belong to credentials drivers. | | Control-plane identity | Authentication and authorization for users, operators, and API clients. External identity verification belongs to identity drivers. | | Sandbox identity | Workload identity for supervisors and sandbox-to-sandbox authorization. Identity issuance or verification belongs to sandbox identity drivers. | -| Supervisor | Sandbox-local security boundary. It prepares isolation, fetches config, injects credentials, runs relay endpoints, starts the proxy, and launches restricted agent processes. | -| Policy proxy | Mandatory egress path for agent traffic. It enforces destination, binary identity, SSRF, TLS/L7, and endpoint-bound provider credential injection. | +| Supervisor | Sandbox-local security boundary for supervisor-based runtimes. It prepares isolation, fetches config, injects credentials, runs relay endpoints, starts the proxy, and launches restricted agent processes. Driver-controlled runtimes must enforce supported behavior and explicitly define or reject unsupported behavior. | +| Policy proxy | Mandatory path for policy-governed egress. It enforces destination, binary identity, SSRF, TLS/L7, and endpoint-bound provider credential injection. | ## Integrating with the Ecosystem @@ -111,10 +121,12 @@ operations. They should stay thin, preserve native behavior by default, and report platform lifecycle events back through the shared contracts. -The supervisor owns OpenShell sandbox semantics. Filesystem policy, process -privilege reduction, network proxying, endpoint-bound credential injection, -security logging, and gateway relay behavior should remain -consistent across runtimes. +The supervisor owns OpenShell sandbox semantics on supervisor-based runtimes. +Driver-controlled runtimes own the corresponding platform-specific semantics +and must explicitly define or reject policy they cannot enforce. Filesystem +policy, process privilege reduction, network proxying, endpoint-bound credential +injection, security logging, and gateway relay behavior should remain consistent +where the runtime advertises those capabilities. This keeps OpenShell usable in local single-player setups, Kubernetes deployments, VM-backed sandboxes, and future third-party environments. A new @@ -142,9 +154,11 @@ The compute-driver capability contract identifies whether a driver reports runtime readiness. Most drivers use the supervisor session model above. A driver that sets `driver_reports_runtime_readiness` may self-report readiness without a supervisor session. Every driver receives the canonical create-time -policy in `DriverSandboxSpec`; drivers without a supervisor use the existing -sandbox configuration API for later revisions. The Windows MXC driver reports -its own readiness and does not expose interactive connect or governed egress. +policy in `DriverSandboxSpec`; drivers without a supervisor may use the sandbox +configuration API when they advertise live-update support. The Windows MXC +driver reports its own readiness, does not support live updates, and does not +expose interactive connect. ProcessContainer can establish governed egress at +create time; IsolationSession rejects governed network policies. The gateway delivers desired state; the sandbox applies it locally. Policy, settings, provider attachments, and credential bindings flow from the gateway @@ -173,7 +187,7 @@ that crate's `README.md`. | [Compute Runtimes](compute-runtimes.md) | Docker, Podman, Kubernetes, VM, sandbox images, and runtime-specific responsibilities. | | [Build](build.md) | Build artifacts, CI/E2E, docs site validation, and release packaging. | | [Google Vertex AI Provider](google-vertex-ai-provider.md) | Implementation reference for the `google-vertex-ai` provider, from CLI through gateway to sandbox. | -| [Windows MSVC Build](windows-msvc-build.md) | Build-only native Windows MSVC lane (x64/ARM64) and unsupported-runtime behavior on Windows. | +| [Windows](windows.md) | Windows/MXC runtime architecture, policy enforcement, networking, relay, audit, and trust boundaries. | ## `rfc/` vs `architecture/` diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md deleted file mode 100644 index cd5fbd0615..0000000000 --- a/architecture/windows-msvc-build.md +++ /dev/null @@ -1,209 +0,0 @@ -# Windows MSVC Build Design - -This page records the design decisions for the native Windows MSVC build lane. -It provides the native build lane and validates the in-process MXC compute -driver. It does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. - -## Goals - -- Compile the OpenShell gateway and CLI for `x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`. -- Keep the Linux and macOS build paths unchanged. -- Preserve gateway configuration parsing for all existing compute driver names. -- Build and test the in-process MXC driver on supported Windows hosts. -- Use the ordinary in-process compute-driver composition path; MXC receives the - canonical sandbox policy through `DriverSandboxSpec` and advertises that it - reports runtime readiness. -- Return clear unsupported errors when a Windows gateway is configured to use Docker, Kubernetes, Podman, or VM. -- Keep dedicated `windows:*` validation tasks while allowing the repository-wide - `pre-commit` task to delegate compiler-bearing Rust checks to the native - Windows MSVC environment. - -## Non-Goals - -- Do not support Docker Desktop, WSL, Hyper-V, Podman machine, Podman Desktop, Kubernetes, or VM-backed sandbox execution on Windows. -- Do not ship Windows standalone binaries for Docker, Kubernetes, Podman, or VM drivers. -- Do not implement named-pipe driver IPC, Windows services, MSI packaging, Credential Manager integration, or DPAPI integration in this lane. - -## Unsupported Driver Strategy - -The gateway composition crate installs platform-specific registration stubs on -Windows. These registrations preserve config-file selection and reject -unsupported drivers with a clear error without depending on their runtime -crates. - -Each stub follows its corresponding `compute-driver-*` Cargo feature. -`compute-driver-mxc` independently links and registers MXC, so a gateway built -with only that feature has only the MXC registration. The default -`in-tree-compute-drivers` alias enables all five features and preserves the -existing MXC plus unsupported-driver registrations. The focused Windows -contract tasks cover default, protocol-only, MXC-only, Docker-stub-only, and -MXC plus Docker-stub compositions. - -The Windows lane does not build, release, package, or smoke-test standalone -driver binaries for Docker, Kubernetes, Podman, or VM. Those binaries are Linux -or macOS deliverables only. - -The Kubernetes Secrets and Vault packages are also excluded as top-level -Windows workspace targets because their standalone driver binaries use Unix -domain sockets. Their libraries remain in the gateway dependency graph, so the -gateway's credential-driver configuration and in-process behavior still compile -on Windows. - -| Driver | Windows build behavior | Runtime behavior | -|---|---|---| -| Docker | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| Kubernetes | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| Podman | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| VM | Driver crate excluded; gateway registration stub retained. | Gateway construction returns unsupported. | -| MXC | Driver links into the native gateway and runs in Windows validation. | `process_container` is default-deny; grant-only `isolation_session` requires explicit configuration. | - -This keeps Windows behavior explicit without carrying runtime dependencies or -creating misleading Windows driver artifacts. - -## Mise Lane - -The GitHub Actions workflow runs Clippy for the Windows-supported workspace and -e2e crates plus Rust tests for pull-request mirror branches labeled `test:windows`. -Merge queues do not run this workflow. On -pushes to `main`, a cache-seed job runs the same lint and test commands before a -dependent job builds the release binaries. Manual dispatches exercise the same -seed-then-build path. The binaries remain CI validation artifacts and are not -uploaded or published. - -Each job restores and saves a dedicated Rust cache containing the Cargo -registry and dependency build artifacts, including artifacts from failed runs. -The seed job and pull-request job use the same Cargo target and sccache -namespaces. The release build waits for the seed job, then restores its newly -warmed cache rather than compiling concurrently from a cold cache. - -Windows validation is exposed through `tasks/windows.toml`: - -| Task | Purpose | -|---|---| -| `windows:check:x64` | Check the x64 MSVC gateway/CLI build graph. | -| `windows:check:arm64` | Check the ARM64 MSVC gateway/CLI build graph. | -| `windows:lint:x64` | Run Clippy over the Windows-supported workspace for x64 MSVC. | -| `windows:lint:arm64` | Run Clippy over the Windows-supported workspace for ARM64 MSVC. | -| `windows:build:x64` | Build release x64 `openshell-gateway.exe` and `openshell.exe`. | -| `windows:build:arm64` | Build release ARM64 `openshell-gateway.exe` and `openshell.exe`. | -| `windows:test:x64` | Run native x64 workspace tests with the nextest CI profile and server test support, while excluding unsupported Windows packages as top-level test targets. | -| `windows:test:arm64` | Run the same suite natively on ARM64. | -| `windows:test:unsupported:x64` | Run focused gateway-composition tests for unsupported driver contracts. | -| `windows:test:unsupported:arm64` | Run the same focused contracts natively on ARM64. | -| `windows:test:mxc-real:arm64` | Run the native real-MXC developer suite; missing hardware remains skip-safe. | -| `windows:test:mxc-gb300:arm64` | Run the required real-MXC subset on native ARM64 and fail on any required skip. | -| `windows:qualify:mxc:gb300:contract` | Validate the static NVBug 6643699 scope matrix on any development host. | -| `windows:qualify:mxc:gb300` | Execute the complete native GB300 ARM64 gate and produce hash-bound evidence. | -| `windows:ci` | Run check, build, test, unsupported-contract tests, and artifact reporting. | - -The Windows tasks call `tasks/scripts/windows-msvc.ps1`. The wrapper discovers -Visual Studio's `VsDevCmd.bat` with `vswhere` or by enumerating installed -release directories, validates the requested compiler and ARM64 Spectre -libraries, adds rustup MSVC targets, preserves an inherited `RUSTC_WRAPPER` -when the command is available, and keeps build artifacts under the normal -Cargo target tree. If the wrapper command is unavailable, it warns and clears -the setting so local builds continue without compiler caching. -On Windows, the generic `rust:check`, `rust:lint`, and `test:rust` tasks call -the same wrapper with the host-native MSVC target. The wrapper preserves the -Unix Cargo commands on Linux and macOS, excludes unsupported Windows runtime -packages, and runs the server test-support suite separately. Windows Clippy -continues to deny all warnings except unused imports, dead code, and unused -async functions caused by cfg-gated Windows stubs. Repository-wide pre-commit -skips only Linux-specific installer, build-environment shell-helper, and -packaging-asset tests; its -cross-platform Python, Markdown, license, and documentation checks still run. -Test tasks require the Rust target architecture to match the Windows host, so -an ARM64 test result is native coverage rather than x64 emulation coverage. -By default it enables the `z3-sys` prebuilt-release feature and pins Z3 4.16.0. -On a clean target directory, `z3-sys` downloads the official static library for -the selected Windows architecture instead of compiling Z3 through -CMake/MSBuild. GitHub Actions supplies its read-only workflow token for the -release lookup, and the Cargo target cache preserves the extracted library for -subsequent runs. When `Z3_LIBRARY_PATH_OVERRIDE` points at a directory -containing `libz3.lib`, the wrapper uses that system Z3 instead and requires -`Z3_SYS_Z3_HEADER` to point at the full path to `z3.h`. Local clean builds use -the unauthenticated GitHub API unless `READ_ONLY_GITHUB_TOKEN` is set. - -GitHub Actions layers the Cargo target cache with sccache's GitHub Actions -backend. The target cache lets Cargo skip intact dependency builds; sccache -recovers cacheable Rust compiler outputs when source changes invalidate part of -that target tree. CI enables client-side mode and normalizes the checkout root -for stable compiler cache keys. The target-cache action runs its metadata step -with `RUSTC_WRAPPER` cleared so cache maintenance does not depend on sccache. -Hosted jobs use an isolated `RUSTUP_HOME` containing the pinned toolchain so -unused toolchains in runner images cannot change the target-cache restore key. - -The lane uses `mise run --skip-tools windows:*` because Windows Rust comes from -rustup and linking comes from Visual Studio Build Tools. Mise orchestrates the -tasks; it does not own the Windows toolchain. - -ARM64 validation requires the Visual Studio ARM64 MSVC tools, ARM64 -Spectre-mitigated libraries, host-native Clang tools, CMake tools, and an -ARM64-capable Windows SDK. Clang provides `libclang.dll` for `bindgen` and -`clang-cl.exe` for ARM64 crypto dependencies. During x64-to-ARM64 check/build, -the wrapper discovers and adds the Visual Studio-bundled Ninja to `PATH` for -native dependencies. Z3 uses the official prebuilt ARM64 static library, so it -does not inherit compiler settings from those native dependencies. Artifact -hashing uses .NET SHA256 directly because module autoloading in the -mise-launched Windows PowerShell process is not guaranteed. - -The wrapper defaults Cargo compilation to four jobs. Set -`OPENSHELL_WINDOWS_BUILD_JOBS` to a positive integer to override that limit. -A host-local mutex serializes wrapper-owned Cargo commands so concurrent -pre-commit tasks do not multiply the compiler process count. -The wrapper does not set `CL` or `_CL_`: those variables are also consumed by -`clang-cl`, where MSVC's `/MP` option can be interpreted as an input file and -break ARM64 crypto dependency builds. - -## CI Shape - -The x64 GitHub Actions jobs run on `windows-2025`; native ARM64 jobs run on -`windows-11-arm`. Pull-request mirrors labeled `test:windows` execute the matching -architecture-specific tasks: - -```powershell -mise run --skip-tools windows:lint: -mise run --skip-tools windows:test: -``` - -Pushes to `main` and manual dispatches first seed the shared caches with those -same lint and test commands. Both seed and build jobs use job-level -`continue-on-error: true`, so Windows job failures do not fail the main/manual -workflow. Opt-in PR jobs still report failures normally. After the seed job -finishes, a separate job executes: - -```powershell -mise run --skip-tools windows:build: -``` - -The server test-support suite includes the unsupported-driver contract test, so -CI does not run the focused test task a second time. The focused task remains -available for local diagnosis. - -The hosted workflow uses architecture-specific cache namespaces and does not -cache Cargo-installed binaries. - -The local aggregate `windows:ci` task can still cross-build ARM64 on an x64 -host. Hosted tests use architecture-matched runners, so ARM64 test results are -native rather than emulated coverage. - -## Validation Contract - -The generic real-MXC lanes are diagnostics and deliberately remain skip-safe. -They cannot establish hardware qualification when a prerequisite or backend is -absent. GB300 release evidence uses the separate fail-closed contract in -[`crates/openshell-driver-mxc/qualification/`](../crates/openshell-driver-mxc/qualification/README.md). -That matrix separates native Windows ARM64 evidence from x64-only NemoClaw and -Windows lanes, and records all hardware-dependent exclusions explicitly. - -A successful Windows build report should include: - -- x64 and ARM64 `cargo check` status. -- x64 and ARM64 release build status for `openshell-gateway.exe` and `openshell.exe`. -- x64 test summary. -- Native ARM64 test summary when validation runs on an ARM64 host. -- Focused unsupported-driver contract test status. -- Artifact size and SHA256 for each Windows binary. - -Warnings from Linux-only dead code are acceptable in the native Windows lane when -they come from code paths intentionally disabled on Windows. diff --git a/architecture/windows.md b/architecture/windows.md new file mode 100644 index 0000000000..bcbe9b6220 --- /dev/null +++ b/architecture/windows.md @@ -0,0 +1,263 @@ +# Windows + +OpenShell runs natively on Windows as an MSVC-built CLI and gateway with the +Microsoft MXC compute driver linked into the gateway process. MXC is a +driver-controlled runtime: it applies the canonical policy, launches the agent, +and reports readiness without running the standard `openshell-sandbox` +supervisor inside the workload. + +This document describes the stable Windows boundaries. See +[Gateway](gateway.md) for control-plane behavior, +[Compute Runtimes](compute-runtimes.md) for the shared driver contract, +[Security Policy](security-policy.md) for the canonical policy model, and the +[MXC driver README](../crates/openshell-driver-mxc/README.md) for configuration +and operational details. + +## Runtime Architecture + +The Windows CLI uses the same public gateway API as other platforms. The +gateway owns authentication, persistence, effective-policy composition, +provider attachment, and public sandbox status. The in-process +`MxcComputeBackend` owns MXC configuration, `wxc-exec` lifecycle, host-side +network enforcement, relay state, and runtime watch events. + +The following diagram shows the Windows-specific control and data paths. The +MXC isolation boundary contains the agent and optional supervisor relay; the +gateway-side proxy and ETW consumer remain on the host. + +```mermaid +flowchart LR + CLI["OpenShell CLI"] -->|"gRPC / HTTP"| GW["OpenShell gateway"] + + subgraph HOST["Windows host"] + GW -->|"in-process ComputeDriver"| DRV["MXC driver"] + DRV -->|"ContainerConfig + lifecycle commands"| WXC["wxc-exec"] + PROXY["Per-sandbox CONNECT proxy"] + ETW["Sandboxing ETW consumer"] -->|"attributed events"| OCSF["OCSF trail"] + GW -->|"dynamic forward request"| DRV + end + + subgraph MXC["MXC isolation boundary"] + PC["ProcessContainer"] + ISO["IsolationSession"] + RELAY["openshell-supervisor-relay"] + AGENT["Agent process"] + PC --> AGENT + PC -. "optional wrapper" .-> RELAY + RELAY --> AGENT + ISO --> AGENT + ISO -. "optional wrapper" .-> RELAY + end + + WXC --> PC + WXC --> ISO + PC -. "ProcessContainer governed egress only" .-> PROXY + DRV <-->|"inherited stdin/stdout control channel"| RELAY + WXC -. "OS Sandboxing events" .-> ETW +``` + +`crates/openshell-driver-mxc/src/grpc.rs` adapts the shared `ComputeDriver` +service to the in-process backend. This is a contract boundary, not a network +hop. MXC requests no additional gateway callback listener and does not +authenticate a sandbox principal because there is no standard supervisor +session. + +## Backend Semantics + +The selected backend changes both lifecycle and enforceable policy. Backend +selection belongs to gateway startup configuration, not to an individual +sandbox request. + +| Property | `process_container` | `isolation_session` | +|---|---|---| +| Runtime model | One-shot AppContainer process; default backend | Persistent MXC session used to run one configured process | +| Driver lifecycle | Launch and monitor `wxc-exec` | `provision` -> `start` -> `exec`; stop/delete issue `stop` and `deprovision` | +| Filesystem | Read-only/read-write grants with default-deny behavior | Explicit grant-only compatibility mode; not equivalent to ProcessContainer default deny | +| Portable UI policy | Supported completely | Every explicit `ui` section is rejected before provisioning | +| Governed network policy | Supported through the host proxy when enabled | Rejected because the backend cannot enforce the loopback-only proxy path; without an explicit network policy, the backend retains MXC's default-allow egress | +| Supervisor relay and dynamic forwarding | Optional | Optional | + +Neither backend implements interactive `sandbox connect` or interactive exec +through the standard supervisor protocol. The MXC `StartSandbox` operation is +also unsupported: create performs the initial launch, and a stopped workload is +not restarted in place. + +## Create and Policy Flow + +The gateway composes the effective `SandboxPolicy` before calling the driver and +includes it in `DriverSandboxSpec.policy`. The MXC driver validates and maps that +typed policy before it publishes a registry entry or invokes `wxc-exec`, so an +unsupported policy cannot leave a partially provisioned sandbox. + +The create path is: + +1. The gateway resolves the canonical command, environment, working directory, + effective policy, and attached provider state. +2. The gateway checks driver capabilities. In particular, it rejects an + explicit UI policy when the configured MXC backend does not advertise full + UI support. +3. `MxcComputeBackend::validate_sandbox_create` validates MXC-specific workload + inputs and calls `EmbeddedPolicyMapper`. +4. The mapper produces the MXC filesystem, UI, and network configuration. Any + loss item classified as an error rejects creation; warnings and informational + items do not block mapping. +5. The driver starts any required host proxy, stages public proxy CA material, + launches `wxc-exec`, and publishes lifecycle events through the shared watch + stream. + +The production mapping in +`crates/openshell-driver-mxc/src/policy.rs` and +`crates/openshell-driver-mxc/src/policy_map/` treats policy areas as follows: + +| Policy area | Windows enforcement | +|---|---| +| Filesystem | `read_only` and `read_write` become MXC path grants. `include_workdir` adds the resolved working directory as read-write. The mapper normalizes separators but does not translate Linux-rooted locations into Windows paths. ProcessContainer supplies the default-deny boundary; IsolationSession supplies only the requested grants. | +| Network | An explicit network policy requires governed egress on ProcessContainer. The mapper gives MXC loopback-only egress and returns the complete network policy to a per-sandbox host CONNECT proxy. MXC denies direct Internet access; the proxy evaluates destinations, ports, TLS/L7 rules, credential bindings, and binary rules against the configured agent command as its static process identity. Network middleware configuration is rejected because the host proxy does not receive the gateway middleware registry. IsolationSession rejects explicit network policy; without one, it retains MXC's default-allow egress. | +| UI | ProcessContainer maps graphical UI, directional clipboard access, and input injection into MXC's top-level `ui` object. An absent section maps to the restrictive UI posture. Within an explicit section, omitted fields deny. IsolationSession rejects even an empty explicit section. | +| Process | MXC supplies the Windows process-isolation boundary, but the mapper has no portable equivalent for `run_as_user` or `run_as_group`; callers must not treat those fields as enforced Windows identity controls. The canonical command, environment, and working directory are launch inputs rather than process-policy grants. | +| Landlock | MXC has no equivalent for the Linux Landlock compatibility mode, including `hard_requirement`. The mapper reports a non-blocking warning; Windows filesystem assurance comes from the selected MXC backend's native semantics, not Landlock. | + +The driver reports `supports_live_policy_updates = false`. The gateway therefore +rejects mutations that would change a running MXC sandbox's effective policy or +provider bindings before persistence. Filesystem, UI, network, and credential +changes require sandbox recreation. + +## Governed Egress and Credentials + +Governed egress uses a split enforcement model. MXC blocks direct Internet +traffic and permits host loopback; a unique authenticated proxy listener holds +the full OpenShell network policy. The driver injects standard proxy variables +for proxy-aware clients and stages only public CA certificates under an +authorized sandbox path. The ephemeral CA private key remains in host-proxy +memory. + +The loopback grant is broader than the proxy listener: a governed sandbox can +reach other host services bound to loopback. Per-sandbox proxy credentials +prevent another sandbox from using the OpenShell proxy, but they do not isolate +unrelated host-loopback services or authenticate individual processes within +one sandbox. + +Two gateway-wide ProcessContainer compatibility settings can deliberately +broaden this boundary. `pc_network_allow` permits unrestricted outbound TCP, and +`pc_allow_local_network` enables MXC local-network access. These are operator +configuration choices, not sandbox policy grants, and they must not be treated +as policy-governed egress. + +Provider credentials cross a narrower boundary than ordinary environment +variables: + +1. The gateway stages a create-scoped `ProviderCredentialState` in an + in-process handoff keyed by sandbox ID. +2. The driver consumes that state exactly once, including on validation + failure, and places only revision-scoped placeholders and non-secret provider + environment in the MXC child configuration. +3. The host proxy retains resolver state and substitutes credentials only for + endpoints authorized by the attached provider profile and policy. + +The gateway rejects expiring static provider credentials because MXC has no +live credential refresh channel. Dynamic token grants remain request-time +host-proxy operations. +Provider state that requires credential resolution also requires governed +egress; creation fails closed when the proxy path is unavailable. + +## Relay and Dynamic Forwarding + +Either MXC backend can launch `openshell-supervisor-relay` as a generic wrapper +around the configured agent. The driver computes this wrapping before it enters +the backend-specific lifecycle. The driver and relay exchange newline-delimited +JSON over inherited stdin and stdout, so control traffic does not require +sandbox network access. Startup validates the relay protocol version, transfers +the command and environment after the relay announces readiness, and waits for +the configured target port before publishing runtime readiness. + +Dynamic `openshell forward service` requests use a dedicated MXC path because +there is no `ConnectSupervisor` session. The gateway calls the driver's +`ForwardSink`; the driver creates an authenticated host-loopback listener and +multiplexes `forward_open`, `forward_read`, `forward_write`, and +`forward_close` operations over the inherited control channel. A fresh nonce +authenticates each host-side forward. This capability does not add interactive +shell or general exec support. + +The implementation boundary spans +`crates/openshell-driver-mxc/src/control_channel.rs`, +`crates/openshell-driver-mxc/src/relay.rs`, and +`crates/openshell-supervisor-relay/`. + +## Readiness, Lifecycle, and Persistence + +MXC advertises `driver_reports_runtime_readiness = true`. The gateway therefore +accepts the driver's `Ready=True` event without waiting for a supervisor +session. Direct launches on either backend become ready after the process +starts; relay-wrapped launches additionally require the relay and +target-readiness handshakes. + +The driver serializes startup against stop and delete with a per-sandbox +lifecycle gate. Stop and delete signal the owned process and wait for confirmed +termination before reporting success. IsolationSession delete also +deprovisions the MXC session. Process exit, relay failure, and MXC invocation +errors produce watch or platform events that the gateway folds into persisted +public status. Policy mapping failures reject the create request before the +driver publishes a registry entry, so they do not produce lifecycle events. + +Runtime ownership is not durable. The registry, process handles, proxy handles, +relay channels, and IsolationSession IDs live in gateway memory. A restarted +gateway cannot recover or reconcile an existing MXC workload, so Windows MXC +does not provide restart durability. + +## Audit Boundary + +When enabled, `crates/openshell-driver-mxc/src/etw_consumer.rs` starts a +gateway-owned real-time session for the Windows Sandboxing ETW provider. The +consumer attributes events to a sandbox using the driver-owned `wxc-exec` PID +and kernel process start key, then follows identity, activity, and correlation +links emitted by the provider. It never uses command text as ownership evidence; +records without matching generation evidence remain unattributed and are +dropped after the bounded late-event window. + +The ETW callback copies records into a bounded non-blocking queue. Overflow, +unattributed records, and a session that observes sandbox activity but no +provider events generate explicit coverage-gap warnings or findings. Structured +events flow through the shared OCSF builders. Command-line data and credentials +must not appear in OCSF fields, messages, or raw ETW diagnostics. The optional +gateway-local JSONL sink is specific to the Windows/MXC path; see +[Security Policy](security-policy.md#security-logging) for the logging contract. + +## Supported and Unsupported Runtime Surface + +Native Windows gateway builds link the MXC compute driver. Docker, Podman, +Kubernetes, and VM compute features install explicit Windows rejection stubs; +they do not silently fall back to an unisolated runtime. MXC also rejects GPU +requests and `agent_socket_path` because it has neither GPU integration nor the +standard in-sandbox supervisor. + +The Windows release-profile build lane produces `openshell-gateway.exe`, +`openshell.exe`, and `openshell-supervisor-relay.exe` for x64 and ARM64 as +validation artifacts; the hosted workflow does not publish them. An x64 host +can cross-check and cross-build ARM64, but native runtime tests and MXC +qualification must execute on the matching architecture. See +[Windows build and validation](../CONTRIBUTING.md#windows-build-and-validation) +for contributor prerequisites and commands. + +## Validation and Qualification + +Windows validation separates source correctness from host capability: + +- Mapper parity and matrix tests validate deterministic policy translation and + fail-closed loss handling without requiring MXC. +- The architecture-specific `windows:*` tasks check, lint, build, and run + workspace and unsupported-driver contract tests for x64 and ARM64. +- Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but + is not evidence of OS enforcement. +- Real-`wxc-exec` tests validate the installed schema and selected filesystem, + UI, network, and lifecycle behavior. A probe-gated skip is useful + diagnostic output, not qualification evidence. +- Strict platform qualification requires native architecture, a live required + backend, policy E2E scenarios, workload scenarios, and hash-bound artifacts. + The machine-validated contract lives in the + [MXC qualification directory](../crates/openshell-driver-mxc/qualification/README.md). +- Workload qualification scenarios exercise relay behavior, including dynamic + forwarding, on the backends selected by the scenario. + +The [MXC driver README](../crates/openshell-driver-mxc/README.md#real-mxc-test-lane) +defines the test tiers, host prerequisites, and qualification entry points. +These checks validate only the runtime available on the tested host. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 7c9d5c08aa..078157e724 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -49,11 +49,11 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] -# processContainer only: launch openshell-supervisor-relay instead of -# the per-sandbox command directly, giving the driver a control -# channel into the sandbox (launch handshake, dynamic `openshell forward -# service` bridging). target_port is the launched command's own listening -# port; 0 disables spawner wrapping (default -- the command runs directly). +# Either backend: launch openshell-supervisor-relay instead of the per-sandbox +# command directly, giving the driver a control channel into the sandbox +# (launch handshake, dynamic `openshell forward service` bridging). target_port +# is the launched command's own listening port; 0 disables spawner wrapping +# (default -- the command runs directly). pc_relay_spawner_path = "" pc_relay_target_port = 0 # processContainer only: env-inheritance tier for the launched process From 3451e72700fa585ba4cdb6edd3c61fc738d1a9b3 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:45:16 -0700 Subject: [PATCH 22/39] docs(mxc): correct isolation filesystem support (#3760) Signed-off-by: Prekshi Vyas --- architecture/windows.md | 4 ++-- crates/openshell-driver-mxc/README.md | 5 +++-- crates/openshell-driver-mxc/examples/mxc-gateway.toml | 4 ++-- crates/openshell-driver-mxc/src/driver.rs | 4 ++-- crates/openshell-driver-mxc/src/mxc.rs | 8 ++++---- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/architecture/windows.md b/architecture/windows.md index bcbe9b6220..7a14422302 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -72,7 +72,7 @@ sandbox request. |---|---|---| | Runtime model | One-shot AppContainer process; default backend | Persistent MXC session used to run one configured process | | Driver lifecycle | Launch and monitor `wxc-exec` | `provision` -> `start` -> `exec`; stop/delete issue `stop` and `deprovision` | -| Filesystem | Read-only/read-write grants with default-deny behavior | Explicit grant-only compatibility mode; not equivalent to ProcessContainer default deny | +| Filesystem | Read-only/read-write grants with default-deny behavior | OpenShell filesystem-policy grants are unsupported; MXC rejects non-empty read-only/read-write grants | | Portable UI policy | Supported completely | Every explicit `ui` section is rejected before provisioning | | Governed network policy | Supported through the host proxy when enabled | Rejected because the backend cannot enforce the loopback-only proxy path; without an explicit network policy, the backend retains MXC's default-allow egress | | Supervisor relay and dynamic forwarding | Optional | Optional | @@ -111,7 +111,7 @@ The production mapping in | Policy area | Windows enforcement | |---|---| -| Filesystem | `read_only` and `read_write` become MXC path grants. `include_workdir` adds the resolved working directory as read-write. The mapper normalizes separators but does not translate Linux-rooted locations into Windows paths. ProcessContainer supplies the default-deny boundary; IsolationSession supplies only the requested grants. | +| Filesystem | On ProcessContainer, `read_only` and `read_write` become MXC path grants, and `include_workdir` adds the resolved working directory as read-write. The mapper normalizes separators but does not translate Linux-rooted locations into Windows paths. ProcessContainer supplies the default-deny boundary. IsolationSession cannot accept non-empty filesystem grants; without them, its backend defaults determine filesystem visibility. | | Network | An explicit network policy requires governed egress on ProcessContainer. The mapper gives MXC loopback-only egress and returns the complete network policy to a per-sandbox host CONNECT proxy. MXC denies direct Internet access; the proxy evaluates destinations, ports, TLS/L7 rules, credential bindings, and binary rules against the configured agent command as its static process identity. Network middleware configuration is rejected because the host proxy does not receive the gateway middleware registry. IsolationSession rejects explicit network policy; without one, it retains MXC's default-allow egress. | | UI | ProcessContainer maps graphical UI, directional clipboard access, and input injection into MXC's top-level `ui` object. An absent section maps to the restrictive UI posture. Within an explicit section, omitted fields deny. IsolationSession rejects even an empty explicit section. | | Process | MXC supplies the Windows process-isolation boundary, but the mapper has no portable equivalent for `run_as_user` or `run_as_group`; callers must not treat those fields as enforced Windows identity controls. The canonical command, environment, and working directory are launch inputs rather than process-policy grants. | diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 078157e724..c4f1bfefdd 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -21,7 +21,7 @@ it does not implement the Linux `ConnectSupervisor` protocol. | Capability | MXC driver | |---|---| -| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces default-deny; `isolation_session` is an explicit grant-only compatibility mode. | +| Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces them with default-deny behavior. `isolation_session` does not support OpenShell filesystem-policy grants; MXC rejects non-empty grants during provisioning. | | UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | | Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | @@ -44,7 +44,8 @@ Gateway configuration contains only host runtime settings: ```toml [openshell.drivers.mxc] wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" -# Default: process_container. isolation_session is grant-only and opt-in. +# Default: process_container. isolation_session is opt-in and does not support +# OpenShell filesystem-policy grants. backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 601b66bec6..99e1780d50 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -16,8 +16,8 @@ version = 2 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. +# filesystem access. isolation_session does not support OpenShell filesystem- +# policy grants; MXC rejects non-empty grants during provisioning. backend = "process_container" # process_container only: request a Less-Privileged AppContainer. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 19a5d82790..cccb77bd47 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -150,8 +150,8 @@ async fn wait_for_target_listener(port: u16) -> std::io::Result<()> { /// 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. +/// (provision → start → exec → stop → deprovision). Does not support +/// OpenShell filesystem-policy grants; backend defaults determine visibility. /// - `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)] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index dd65b06358..e0e1598b1f 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -53,10 +53,10 @@ fn mock_grants() -> &'static Mutex>> { /// 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` and `denied_paths`. The +/// `AppContainer` backend is genuinely default-deny, so anything not granted is +/// already inaccessible. `isolation_session` rejects non-empty filesystem +/// grants; its backend defaults determine visibility when no grants are present. #[derive(Debug, Default)] #[allow(clippy::struct_field_names)] pub struct MxcFilesystem { From 7ba7a39d0979fea2b63ae8716a9b5cbf81926a0c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:39:23 -0700 Subject: [PATCH 23/39] ci(windows): exercise MXC inference demos with mock API (#3780) * ci(windows): exercise MXC Ollama demo with mock API Signed-off-by: Prekshi Vyas * ci(windows): cover both MXC inference demos Signed-off-by: Prekshi Vyas * fix(mxc): preserve executable extension resolution Signed-off-by: Prekshi Vyas * test(mxc): use absolute PowerShell in lifecycle checks Signed-off-by: Prekshi Vyas * test(mxc): make lifecycle write probes deterministic Signed-off-by: Prekshi Vyas * test(mxc): assert stable lifecycle completion Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- .../build-openshell-mxc-windows/SKILL.md | 39 ++- .github/workflows/windows-msvc.yml | 6 + architecture/windows.md | 5 + crates/openshell-driver-mxc/README.md | 12 +- .../examples/inference.yaml | 4 +- .../examples/run-inference-test.ps1 | 57 +++- .../examples/run-ollama-test.ps1 | 26 +- crates/openshell-driver-mxc/src/driver.rs | 112 ++++---- crates/openshell-driver-mxc/src/mxc.rs | 36 ++- .../tests/demo_examples.rs | 15 + .../windows-mxc-inference-examples-e2e.ps1 | 258 ++++++++++++++++++ tasks/scripts/windows-mxc-inference-stub.ps1 | 114 ++++++++ tasks/windows.toml | 10 + 13 files changed, 608 insertions(+), 86 deletions(-) create mode 100644 tasks/scripts/windows-mxc-inference-examples-e2e.ps1 create mode 100644 tasks/scripts/windows-mxc-inference-stub.ps1 diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index a7d30f63f7..18e69d943d 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 Windows MSVC lane for x64 and ARM64. Use when working on Windows compilation, `windows:*` mise tasks, unsupported Windows compute-driver contracts, MXC example wiring checks, 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. metadata: internal: true --- @@ -12,8 +12,8 @@ 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` @@ -51,6 +51,8 @@ In scope: `openshell`. - Running workspace tests on a native x64 or ARM64 host. - Running focused unsupported-driver contract tests. +- Running the shipped Ollama and cloud-inference MXC examples against the + in-process `wxc` mock and a local API stub. - Reporting test counts, skipped/gated areas, warnings, artifacts, and logs. - Keeping Linux and macOS build paths unchanged. - Keeping unsupported Windows compute drivers explicit and testable. @@ -158,8 +160,15 @@ mise run --skip-tools windows:build:x64 mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 +mise run --skip-tools windows:e2e:mxc:inference-mock:x64 ``` +Use the `arm64` form of the inference task on a native ARM64 host. It exercises +the real gateway, CLI, and shipped PowerShell runners with an in-process `wxc` +mock and local HTTP responses. It proves example wiring, request execution, and +sandbox-scoped credential propagation; it does not prove MXC, AppContainer, +filesystem, or network enforcement. + 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 @@ -220,14 +229,17 @@ order: The GitHub Actions jobs layer architecture-specific `Swatinem/rust-cache` entries for Cargo registry and dependency target artifacts with sccache's GHA backend for cacheable Rust compiler outputs. Failed runs also save their usable -dependency artifacts. Pull-request mirrors labeled `test:windows` run Clippy for the -Windows-supported workspace and e2e crates plus Rust tests. Pushes to `main` and -manual dispatches run the same lint and test commands in a cache-seed job, -followed by a dependent release-binary build job. The seed and PR jobs use the -same cache namespaces. Merge queues do not run this workflow. Main/manual seed -and build jobs use job-level `continue-on-error: true`; opt-in PR jobs report -failures normally. Applying the label alone does not start a run: re-run all -jobs in the current mirror push run, or push a new mirrored commit. The binaries are not uploaded or published. +dependency artifacts. Pull-request mirrors labeled `test:windows` run Clippy +for the Windows-supported workspace and e2e crates plus Rust tests, build +release binaries, and run both shipped MXC inference examples through the mock +task. Pushes to `main` and manual dispatches run the same lint and test commands +in a cache-seed job, followed by a dependent release-binary build and +mock-example job. The seed and PR jobs use the same cache namespaces. Merge +queues do not run this workflow. Main/manual seed and build jobs use job-level +`continue-on-error: true`; opt-in PR jobs report failures normally. Applying +the label alone does not start a run: re-run all jobs in the current mirror +push run, or push a new mirrored commit. The binaries are not uploaded or +published. 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 @@ -272,6 +284,8 @@ crypto dependency builds. | `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:test:mxc-gb300:arm64` | Runs the required native ARM64 ProcessContainer subset and fails when a test skips. | +| `windows:e2e:mxc:inference-mock:x64` | Runs the shipped Ollama and cloud-inference demos on native x64 through the real gateway and CLI, in-process `wxc` mock, and local API stub. This is wiring evidence, not MXC enforcement evidence. | +| `windows:e2e:mxc:inference-mock:arm64` | Runs the same mock-wiring checks on native ARM64 with ARM64 release binaries. | | `windows:qualify:mxc:gb300:contract` | Validates the required/optional/unsupported/architecture-constrained GB300 matrix. | | `windows:qualify:mxc:gb300` | Runs the fail-closed GB300 ARM64 gate and validates hash-bound evidence. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | @@ -324,6 +338,8 @@ When reporting `windows:ci`, distinguish these categories: - Passed tests from the full ARM64 workspace test log when run on a native ARM64 host. - The focused unsupported-contract re-run. +- The architecture-matched MXC inference-example mock task and its explicit + wiring-only limitation. - Explicit Cargo ignored tests, usually ignored doc examples. - Tests hidden by `#[cfg(not(target_os = "windows"))]`; these often appear as `running 0 tests`, not as ignored tests. @@ -385,6 +401,7 @@ Every substantial Windows build run should report: | ARM64 check/build | Pass/fail/skipped and log path. | | Native tests | Passed/failed/ignored/filtered counts and log path for the host architecture. | | Unsupported contracts | Which focused tests ran and their result. | +| MXC example mock E2E | Architecture, result, artifact directory on failure, and the wiring-only limitation. | | 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. | diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 00c41d9ca9..1b7964eb20 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -98,6 +98,10 @@ jobs: run: mise run --skip-tools windows:lint:${{ matrix.arch }} - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} + - name: Build MXC example E2E binaries + run: mise run --skip-tools windows:build:${{ matrix.arch }} + - name: Run shipped MXC inference examples with mock API + run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} - name: sccache stats if: always() run: sccache --show-stats @@ -211,6 +215,8 @@ jobs: cache-bin: "false" - name: Build release binaries run: mise run --skip-tools windows:build:${{ matrix.arch }} + - name: Run shipped MXC inference examples with mock API + run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} - name: sccache stats if: always() run: sccache --show-stats diff --git a/architecture/windows.md b/architecture/windows.md index 7a14422302..abf5043543 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -248,6 +248,11 @@ Windows validation separates source correctness from host capability: workspace and unsupported-driver contract tests for x64 and ARM64. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but is not evidence of OS enforcement. +- Hosted Windows CI runs the shipped Ollama and cloud-inference demos against + that mock and a local compatible API. This proves both complete demo scripts, + sandbox-scoped credential propagation, and response paths without a model or + external credential, but it does not prove MXC isolation, proxy substitution, + or network enforcement. - Real-`wxc-exec` tests validate the installed schema and selected filesystem, UI, network, and lifecycle behavior. A probe-gated skip is useful diagnostic output, not qualification evidence. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index c4f1bfefdd..7e88e24f79 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -59,7 +59,7 @@ pc_relay_spawner_path = "" pc_relay_target_port = 0 # processContainer only: env-inheritance tier for the launched process # (safest first): default is a minimal Windows CreateProcessW bootstrap set -# (SYSTEMROOT/WINDIR/PATH/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an +# (SYSTEMROOT/WINDIR/PATH/PATHEXT/COMSPEC/LOCALAPPDATA); pc_minimal_env starts from an # EMPTY env for runtimes that need a fully curated per-sandbox environment. pc_minimal_env = false # processContainer only: compatibility fallback for unrestricted outbound TCP. @@ -203,11 +203,11 @@ environment variable, so workloads using it must pass `--cacert %CURL_CA_BUNDLE%` explicitly. Clients that honor the injected trust variables consume the same per-sandbox bundle directly. -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 unless the gateway explicitly opts into another -environment mode. +The driver seeds only `SYSTEMROOT`, `WINDIR`, `PATH`, `PATHEXT`, `COMSPEC`, and +`LOCALAPPDATA` from the gateway host before applying sandbox and TLS overrides. +These values provide Windows process startup and command-resolution behavior +without exposing the gateway's full environment unless the gateway explicitly +opts into another environment mode. When governed egress is disabled, any network rule fails closed during sandbox creation. diff --git a/crates/openshell-driver-mxc/examples/inference.yaml b/crates/openshell-driver-mxc/examples/inference.yaml index e20f4020b8..820321c0be 100644 --- a/crates/openshell-driver-mxc/examples/inference.yaml +++ b/crates/openshell-driver-mxc/examples/inference.yaml @@ -14,8 +14,8 @@ network_policies: nvidia_inference: name: nvidia-inference endpoints: - - host: integrate.api.nvidia.com - port: 443 + - host: "__INFERENCE_HOST__" + port: __INFERENCE_PORT__ protocol: rest # Chat completions use POST. access: read-write diff --git a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 index 1dc08f5a5f..9b2a39e329 100644 --- a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 @@ -2,6 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 # Cloud inference (T1) demo for OpenShell on MXC. PowerShell 5.1 compatible. +# -Mock runs the workload on the host through the in-process wxc shim. It is for +# CI wiring coverage only and does not provide MXC or AppContainer isolation. [CmdletBinding()] param( @@ -11,8 +13,10 @@ param( [string] $ShareDir, [string] $Model = "nvidia/nemotron-3.5-lightning-30b-a3b", [string] $Prompt = "Say hello in exactly five words.", + [string] $ApiUrl = "https://integrate.api.nvidia.com/v1/chat/completions", [ValidateRange(0, 65535)] [int] $Port = 0, [string] $SandboxName, + [switch] $Mock, [switch] $KeepArtifacts ) @@ -133,6 +137,7 @@ $success = $false $failure = $null $oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG $oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER +$oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC $oldApiKey = $env:NV_API_KEY $apiKey = $env:NV_API_KEY @@ -142,12 +147,29 @@ try { } $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" $cli = Resolve-Executable $CliPath "openshell.exe" "" - $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + if ($Mock) { + # The gateway still validates that wxc_exec_path is absolute. The + # in-process mock never launches this placeholder. + $wxc = Join-Path $here "mock-wxc-exec.exe" + } else { + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + } foreach ($fixture in @("mxc-inference.toml", "inference.yaml")) { if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { throw "required demo fixture '$fixture' is missing beside the runner" } } + if ($ApiUrl.IndexOfAny([char[]]@('"', '%', '!', '&', '|', '<', '>', '^', [char] 13, [char] 10)) -ge 0) { + throw "ApiUrl contains a character that cannot be rendered safely into the sandbox command" + } + try { + $apiUri = [System.Uri] $ApiUrl + } catch { + throw "ApiUrl is not a valid absolute URI: $ApiUrl" + } + if (-not $apiUri.IsAbsoluteUri -or $apiUri.Scheme -notin @("http", "https")) { + throw "ApiUrl must be an absolute HTTP or HTTPS URI" + } if ($Port -eq 0) { $Port = Get-AvailablePort } $endpoint = "http://127.0.0.1:$Port" if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "inference-$PID" } @@ -163,8 +185,9 @@ try { Info "gateway: $gateway" Info "CLI: $cli" - Info "wxc-exec: $wxc" + Info "wxc-exec: $(if ($Mock) { 'in-process mock (no MXC isolation)' } else { $wxc })" Info "share: $ShareDir" + Info "inference API: $ApiUrl" Info "NV_API_KEY: present (value redacted)" $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" @@ -187,6 +210,8 @@ try { $policyText = [System.IO.File]::ReadAllText((Join-Path $here "inference.yaml")) $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + $policyText = $policyText.Replace("__INFERENCE_HOST__", $apiUri.DnsSafeHost) + $policyText = $policyText.Replace("__INFERENCE_PORT__", [string] $apiUri.Port) Write-Utf8 $policyUsed $policyText $requestPath = Join-Path $ShareDir "inference-request.json" @@ -198,11 +223,21 @@ try { Write-Utf8 $requestPath $requestJson $probePath = Join-Path $ShareDir "inference-probe.cmd" - $probeLines = @( - "@echo off", + $tlsArgs = if ($apiUri.Scheme -eq "https") { # Inbox curl uses Schannel and does not honor CURL_CA_BUNDLE by itself. # Point --cacert at the public bundle staged by the governed proxy. - "`"$curlExe`" --silent --show-error --fail-with-body --ssl-no-revoke --cacert `"%CURL_CA_BUNDLE%`" --max-time 120 -D `"$headersPath`" -H `"Authorization: Bearer %NV_API_KEY%`" -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"https://integrate.api.nvidia.com/v1/chat/completions`" 2> `"$errorPath`" || exit /b 31", + '--ssl-no-revoke --cacert "%CURL_CA_BUNDLE%"' + } else { + "" + } + $proxyBypassArgs = if ($apiUri.IsLoopback) { + "--noproxy `"$($apiUri.DnsSafeHost)`"" + } else { + "" + } + $probeLines = @( + "@echo off", + "`"$curlExe`" $proxyBypassArgs --silent --show-error --fail-with-body $tlsArgs --max-time 120 -D `"$headersPath`" -H `"Authorization: Bearer %NV_API_KEY%`" -H `"Content-Type: application/json`" --data-binary `"@$requestPath`" -o `"$responsePath`" `"$ApiUrl`" 2> `"$errorPath`" || exit /b 31", "`"$findStrExe`" /C:`"choices`" `"$responsePath`" >nul || exit /b 32", "echo PASS> `"$donePath`"" ) @@ -214,6 +249,11 @@ try { $gwErrLog = Join-Path $resultDir "gateway.err.log" $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } # The credential belongs to sandbox creation, not gateway configuration. Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue try { @@ -280,6 +320,11 @@ try { } $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if ([string]::IsNullOrWhiteSpace($oldMockWxc)) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc + } if ([string]::IsNullOrWhiteSpace($oldApiKey)) { Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue } else { $env:NV_API_KEY = $oldApiKey } if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue @@ -287,7 +332,7 @@ try { } $verdict = if ($success) { "PASS" } else { "FAIL" } -$summary = "verdict=$verdict`r`nbase=cloud-inference`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +$summary = "verdict=$verdict`r`nbase=cloud-inference`r`nmode=$(if ($Mock) { 'mock-wiring' } else { 'real-mxc' })`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" Write-Utf8 (Join-Path $resultDir "summary.txt") $summary Write-Host "`n$summary" Write-Host "Results: $resultDir" diff --git a/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 index 59c5bbcbb6..1dbfb77161 100644 --- a/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ollama-test.ps1 @@ -2,6 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 # Hello World local-inference demo for OpenShell on MXC. PowerShell 5.1 compatible. +# -Mock runs the workload on the host through the in-process wxc shim. It is for +# CI wiring coverage only and does not provide MXC or AppContainer isolation. [CmdletBinding()] param( @@ -15,6 +17,7 @@ param( [string] $Prompt = "Say hello in exactly five words.", [ValidateRange(0, 65535)] [int] $Port = 0, [string] $SandboxName, + [switch] $Mock, [switch] $KeepArtifacts ) @@ -135,11 +138,18 @@ $success = $false $failure = $null $oldGatewayConfig = $env:OPENSHELL_GATEWAY_CONFIG $oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER +$oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC try { $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" "" $cli = Resolve-Executable $CliPath "openshell.exe" "" - $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + if ($Mock) { + # The gateway still validates that wxc_exec_path is absolute. The + # in-process mock never launches this placeholder. + $wxc = Join-Path $here "mock-wxc-exec.exe" + } else { + $wxc = Resolve-Executable $WxcExecPath "wxc-exec.exe" "OPENSHELL_WXC_EXEC_PATH" + } foreach ($fixture in @("mxc-ollama.toml", "ollama.yaml")) { if (-not (Test-Path -LiteralPath (Join-Path $here $fixture) -PathType Leaf)) { throw "required demo fixture '$fixture' is missing beside the runner" @@ -163,7 +173,7 @@ try { Info "gateway: $gateway" Info "CLI: $cli" - Info "wxc-exec: $wxc" + Info "wxc-exec: $(if ($Mock) { 'in-process mock (no MXC isolation)' } else { $wxc })" Info "share: $ShareDir" Info "Ollama: http://${OllamaHost}:$OllamaPort" @@ -226,6 +236,11 @@ try { $gwErrLog = Join-Path $resultDir "gateway.err.log" $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info") -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog $deadline = (Get-Date).AddSeconds(30) while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { @@ -269,13 +284,18 @@ try { } $env:OPENSHELL_GATEWAY_CONFIG = $oldGatewayConfig $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if ([string]::IsNullOrWhiteSpace($oldMockWxc)) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc + } if (-not $KeepArtifacts -and $createdShare -and $ShareDir -and (Test-Path -LiteralPath $ShareDir)) { Remove-Item -LiteralPath $ShareDir -Recurse -Force -ErrorAction SilentlyContinue } } $verdict = if ($success) { "PASS" } else { "FAIL" } -$summary = "verdict=$verdict`r`nbase=local-ollama`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" +$summary = "verdict=$verdict`r`nbase=local-ollama`r`nmode=$(if ($Mock) { 'mock-wiring' } else { 'real-mxc' })`r`nsandbox=$SandboxName`r`ngateway=$endpoint`r`nbackend=process_container`r`nresult=$failure`r`n" Write-Utf8 (Join-Path $resultDir "summary.txt") $summary Write-Host "`n$summary" Write-Host "Results: $resultDir" diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index cccb77bd47..6c47753ce0 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -151,7 +151,7 @@ async fn wait_for_target_listener(port: u16) -> std::io::Result<()> { /// /// - `IsolationSession`: persistent, attachable session /// (provision → start → exec → stop → deprovision). Does not support -/// OpenShell filesystem-policy grants; backend defaults determine visibility. +/// `OpenShell` filesystem-policy grants; backend defaults determine visibility. /// - `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)] @@ -211,8 +211,8 @@ pub struct MxcComputeConfig { /// passed to the process. /// Use for agents like Node.js that fail with `STATUS_DLL_INIT_FAILED` /// when unrecognised host env vars are present; the caller is then - /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`COMSPEC`/ - /// `LOCALAPPDATA` through `sandbox create --env/--env-from` if needed + /// responsible for supplying `SYSTEMROOT`/`WINDIR`/`PATH`/`PATHEXT`/ + /// `COMSPEC`/`LOCALAPPDATA` through `sandbox create --env/--env-from` if needed /// (`CreateProcessW` itself won't succeed without `LOCALAPPDATA` at /// least -- see `MINIMAL_WINDOWS_BOOTSTRAP_ENV`). /// @@ -665,8 +665,14 @@ fn allocate_sandbox_proxy_addr( /// are secrets, so resolving them from the gateway host is safe; this is /// the per-sandbox environment layers on top of. See `pc_minimal_env` on /// `MxcComputeConfig` for the explicit empty-baseline option. -const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] = - ["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]; +const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 6] = [ + "SYSTEMROOT", + "WINDIR", + "PATH", + "PATHEXT", + "COMSPEC", + "LOCALAPPDATA", +]; const TLS_ENV_KEYS: [&str; 6] = [ "NODE_EXTRA_CA_CERTS", @@ -2612,9 +2618,19 @@ mod lifecycle_tests { } fn driver_sandbox(id: &str) -> DriverSandbox { - let shell = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - driver_sandbox_with_command(id, "", vec![shell, "/c".into(), "exit 0".into()]) + driver_sandbox_with_command(id, "", vec![inbox_cmd(), "/c".into(), "exit 0".into()]) + } + + fn inbox_cmd() -> String { + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()) + } + + fn inbox_powershell() -> String { + let system_root = std::env::var("SYSTEMROOT").unwrap_or_else(|_| r"C:\Windows".to_string()); + Path::new(&system_root) + .join(r"System32\WindowsPowerShell\v1.0\powershell.exe") + .to_string_lossy() + .into_owned() } #[tokio::test] @@ -2847,6 +2863,8 @@ mod lifecycle_tests { .replace("__OPENSHELL_DEMO_SHARE__", share) .replace("__OLLAMA_HOST__", "127.0.0.1") .replace("__OLLAMA_PORT__", "11434") + .replace("__INFERENCE_HOST__", "integrate.api.nvidia.com") + .replace("__INFERENCE_PORT__", "443") .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); parse_sandbox_policy(&rendered).expect("parse rendered shipped demo policy") } @@ -3458,10 +3476,10 @@ mod lifecycle_tests { 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"), + inbox_cmd(), + "/d".into(), + "/c".into(), + format!(r#"echo hi>"{hello}""#), ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); @@ -3469,36 +3487,23 @@ mod lifecycle_tests { 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 = 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") + ready_condition(s).is_some_and(|condition| { + condition.status == "True" && condition.reason == "AgentCompleted" + }) }) .await; assert!( completed.is_some(), "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" ); + assert!( + tmp.path().join("hello.txt").is_file(), + "hello.txt should appear in the granted share folder" + ); assert!( !backend .attribution @@ -3519,10 +3524,10 @@ mod lifecycle_tests { 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"), + inbox_cmd(), + "/d".into(), + "/c".into(), + format!(r#"echo hi>"{hello}""#), ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); @@ -3530,13 +3535,15 @@ mod lifecycle_tests { 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") + let completed = wait_for(&backend, "sb-pc", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| { + condition.status == "True" && condition.reason == "AgentCompleted" + }) }) .await; assert!( - ready.is_some(), - "processContainer sandbox should self-report Ready=True" + completed.is_some(), + "processContainer sandbox should report Ready=True/AgentCompleted" ); let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); assert!( @@ -3547,17 +3554,8 @@ mod lifecycle_tests { assert_eq!(recorded["ui"]["clipboard"], "none"); assert_eq!(recorded["ui"]["injection"], false); - let host_path = 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, + tmp.path().join("hello.txt").is_file(), "in-policy write should materialize under processContainer" ); } @@ -3878,10 +3876,10 @@ mod lifecycle_tests { out_tmp.path().to_string_lossy().replace('\\', "/") ); let cmd = vec![ - "powershell".into(), - "-NoProfile".into(), - "-Command".into(), - format!("Set-Content -LiteralPath {out_path} -Value hi"), + inbox_cmd(), + "/d".into(), + "/c".into(), + format!(r#"echo hi>"{out_path}""#), ]; let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); @@ -3937,7 +3935,7 @@ mod lifecycle_tests { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); let command = vec![ - "powershell".into(), + inbox_powershell(), "-NoProfile".into(), "-Command".into(), format!("$null = '{share}'; Start-Sleep -Seconds 60"), @@ -3980,7 +3978,7 @@ mod lifecycle_tests { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); let command = vec![ - "powershell".into(), + inbox_powershell(), "-NoProfile".into(), "-Command".into(), format!("$null = '{share}'; Start-Sleep -Seconds 60"), @@ -4031,7 +4029,7 @@ mod lifecycle_tests { let tmp = tempfile::tempdir().unwrap(); let share = tmp.path().to_string_lossy().replace('\\', "/"); let command = vec![ - "powershell".into(), + inbox_powershell(), "-NoProfile".into(), "-Command".into(), format!("$null = '{share}'; Start-Sleep -Seconds 60"), diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index e0e1598b1f..6f68dc5e3d 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -660,7 +660,15 @@ impl WxcExecInvoker { let cmd_norm = mock_normalize(&process.command_line); let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); - let mut cmd = Command::new("cmd"); + let command_shell = std::env::var_os("COMSPEC") + .unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows\System32\cmd.exe")); + let mut cmd = Command::new(command_shell); + cmd.env_clear(); + for entry in &process.env { + if let Some((key, value)) = entry.split_once('=') { + cmd.env(key, value); + } + } cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) @@ -975,6 +983,32 @@ mod tests { assert!(config.get("ui").is_none()); } + #[tokio::test] + async fn mock_exec_uses_only_the_mxc_process_environment() { + const KEY: &str = "OPENSHELL_MXC_MOCK_ENV_TEST"; + let workdir = tempfile::tempdir().expect("temporary workdir"); + let output = workdir.path().join("mock-env.txt"); + let process = MxcProcess { + command_line: format!("echo %{KEY}%,%SystemRoot% 1> \"{}\"", output.display()), + cwd: workdir.path().to_string_lossy().into_owned(), + env: vec![format!("{KEY}=process-value")], + timeout: 0, + }; + + let mut child = WxcExecInvoker::mock_spawn_with_grants( + &process, + &[mock_normalize(&workdir.path().to_string_lossy())], + ) + .expect("mock process should launch"); + let status = child.wait().await.expect("mock process should finish"); + + assert!(status.success()); + assert_eq!( + std::fs::read_to_string(output).expect("mock output").trim(), + "process-value,%SystemRoot%" + ); + } + #[test] fn oneshot_config_json_emits_typed_ui_policy() { let filesystem = MxcFilesystem::default(); diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index 11e72e688d..2b9ab52362 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -102,6 +102,8 @@ fn shipped_inference_policies_are_narrow_and_valid_after_rendering() { .replace("__OPENSHELL_DEMO_SHARE__", share) .replace("__OLLAMA_HOST__", "127.0.0.1") .replace("__OLLAMA_PORT__", "11434") + .replace("__INFERENCE_HOST__", "integrate.api.nvidia.com") + .replace("__INFERENCE_PORT__", "443") .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); let policy = parse_sandbox_policy(&rendered) .unwrap_or_else(|error| panic!("failed to parse rendered {name}: {error}")); @@ -156,6 +158,19 @@ fn shipped_runners_supply_sandbox_scoped_workload_configuration() { assert!(!cloud.contains("pass -ApiKey")); assert!(cloud.contains("nvidia/nemotron-3.5-lightning-30b-a3b")); assert!(!cloud.contains("nvidia/nvidia-nemotron-nano-9b-v2")); + + let local = read_example("run-ollama-test.ps1"); + assert!(local.contains("[switch] $Mock")); + assert!(local.contains("in-process wxc shim")); + assert!(local.contains("does not provide MXC or AppContainer isolation")); + + let cloud = read_example("run-inference-test.ps1"); + assert!(cloud.contains("[switch] $Mock")); + assert!(cloud.contains("[string] $ApiUrl")); + assert!(cloud.contains("$apiUri.IsLoopback")); + assert!(cloud.contains("--noproxy")); + assert!(cloud.contains("in-process wxc shim")); + assert!(cloud.contains("does not provide MXC or AppContainer isolation")); } #[cfg(target_os = "windows")] diff --git a/tasks/scripts/windows-mxc-inference-examples-e2e.ps1 b/tasks/scripts/windows-mxc-inference-examples-e2e.ps1 new file mode 100644 index 0000000000..f0f3f95b78 --- /dev/null +++ b/tasks/scripts/windows-mxc-inference-examples-e2e.ps1 @@ -0,0 +1,258 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs both shipped MXC inference demos through the real gateway and CLI +# against the in-process wxc mock and a local HTTP API stub. This proves demo +# wiring only; it is not evidence of MXC or AppContainer enforcement. + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc")] + [string] $Target, + + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-inference-examples-e2e.ps1 requires Windows." +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$ExamplesRoot = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$TargetDir = if ([string]::IsNullOrWhiteSpace($env:CARGO_TARGET_DIR)) { + Join-Path $RepoRoot "target" +} else { + [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) +} + +if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + $GatewayPath = Join-Path $TargetDir "$Target\release\openshell-gateway.exe" +} +if ([string]::IsNullOrWhiteSpace($CliPath)) { + $CliPath = Join-Path $TargetDir "$Target\release\openshell.exe" +} +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) + +foreach ($artifact in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "required release artifact is missing: $artifact" + } +} + +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$StageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-inference-examples-e2e-$Target")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $StageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "refusing to use staging path outside artifact root: $StageDir" +} +if (Test-Path -LiteralPath $StageDir) { + Remove-Item -LiteralPath $StageDir -Recurse -Force +} +New-Item -ItemType Directory -Path $StageDir | Out-Null + +foreach ($fixture in @( + "run-ollama-test.ps1", + "mxc-ollama.toml", + "ollama.yaml", + "run-inference-test.ps1", + "mxc-inference.toml", + "inference.yaml" +)) { + Copy-Item -LiteralPath (Join-Path $ExamplesRoot $fixture) -Destination $StageDir +} + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Listener([int] $Port) { + $client = [System.Net.Sockets.TcpClient]::new() + try { + $pending = $client.BeginConnect("127.0.0.1", $Port, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +$ApiPort = Get-AvailablePort +$MockToken = "openshell-ci-mock-token" +$RequestLog = Join-Path $StageDir "mock-inference-requests.log" +$ServerReady = Join-Path $StageDir "mock-inference.ready" +$ServerOutLog = Join-Path $StageDir "mock-inference.out.log" +$ServerErrLog = Join-Path $StageDir "mock-inference.err.log" +$ServerScript = Join-Path $PSScriptRoot "windows-mxc-inference-stub.ps1" +$serverProcess = $null +$passed = $false +$oldAppData = $env:APPDATA +$oldLocalAppData = $env:LOCALAPPDATA +$oldNvApiKey = $env:NV_API_KEY + +try { + $serverProcess = Start-Process -FilePath "powershell.exe" -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", "`"$ServerScript`"", + "-Port", "$ApiPort", + "-RequestLog", "`"$RequestLog`"", + "-ReadyPath", "`"$ServerReady`"", + "-ExpectedBearerToken", $MockToken + ) -PassThru -WindowStyle Hidden -RedirectStandardOutput $ServerOutLog -RedirectStandardError $ServerErrLog + + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $deadline -and (-not (Test-Path -LiteralPath $ServerReady) -or -not (Test-Listener $ApiPort))) { + if ($serverProcess.HasExited) { + $details = (Get-Content $ServerOutLog, $ServerErrLog -ErrorAction SilentlyContinue) -join [Environment]::NewLine + throw "mock inference server stopped before listening: $details" + } + Start-Sleep -Milliseconds 200 + } + if (-not (Test-Listener $ApiPort)) { + throw "mock inference server did not listen on port $ApiPort within 15 seconds" + } + + $env:APPDATA = Join-Path $StageDir "appdata" + $env:LOCALAPPDATA = Join-Path $StageDir "localappdata" + New-Item -ItemType Directory -Force -Path $env:APPDATA, $env:LOCALAPPDATA | Out-Null + + $ollamaRunner = Join-Path $StageDir "run-ollama-test.ps1" + $ollamaShare = Join-Path $StageDir "ollama-share" + $ollamaArgs = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $ollamaRunner, + "-Mock", + "-GatewayPath", $GatewayPath, + "-CliPath", $CliPath, + "-ShareDir", $ollamaShare, + "-OllamaPort", "$ApiPort", + "-Model", "openshell-ci-mock", + "-Prompt", "Return the CI mock response.", + "-KeepArtifacts" + ) + $output = & powershell.exe @ollamaArgs 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped Ollama demo failed in mock mode (exit $exitCode)" + } + + $ollamaResultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-ollama-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $ollamaResultDir) { throw "Ollama demo did not produce a results directory" } + + $ollamaSummary = Get-Content -LiteralPath (Join-Path $ollamaResultDir.FullName "summary.txt") -Raw + if ($ollamaSummary -notmatch '(?m)^verdict=PASS\s*$' -or $ollamaSummary -notmatch '(?m)^mode=mock-wiring\s*$') { + throw "Ollama demo summary did not report a mock-wiring PASS: $ollamaSummary" + } + $ollamaResponse = Get-Content -LiteralPath (Join-Path $ollamaResultDir.FullName "ollama-response.json") -Raw | ConvertFrom-Json + if ($ollamaResponse.response -ne "Hello from the CI mock.") { + throw "Ollama demo did not retain the expected mock completion" + } + + $env:NV_API_KEY = $MockToken + $cloudRunner = Join-Path $StageDir "run-inference-test.ps1" + $cloudShare = Join-Path $StageDir "cloud-share" + $cloudApiUrl = "http://127.0.0.1:$ApiPort/v1/chat/completions" + $cloudArgs = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $cloudRunner, + "-Mock", + "-GatewayPath", $GatewayPath, + "-CliPath", $CliPath, + "-ShareDir", $cloudShare, + "-ApiUrl", $cloudApiUrl, + "-Model", "openshell-ci-mock", + "-Prompt", "Return the CI mock response.", + "-KeepArtifacts" + ) + $output = & powershell.exe @cloudArgs 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped cloud-inference demo failed in mock mode (exit $exitCode)" + } + + $cloudResultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-inference-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $cloudResultDir) { throw "cloud-inference demo did not produce a results directory" } + + $cloudSummary = Get-Content -LiteralPath (Join-Path $cloudResultDir.FullName "summary.txt") -Raw + if ($cloudSummary -notmatch '(?m)^verdict=PASS\s*$' -or $cloudSummary -notmatch '(?m)^mode=mock-wiring\s*$') { + throw "cloud-inference demo summary did not report a mock-wiring PASS: $cloudSummary" + } + $cloudResponse = Get-Content -LiteralPath (Join-Path $cloudResultDir.FullName "inference-response.json") -Raw | ConvertFrom-Json + if ($cloudResponse.choices[0].message.content -ne "Hello from the CI mock.") { + throw "cloud-inference demo did not retain the expected mock completion" + } + + $requestLines = @([System.IO.File]::ReadAllLines($RequestLog)) + $expectedRequests = @( + @{ Pattern = '^GET /api/tags HTTP/\S+ authorization=absent$'; Count = 2; Description = 'host prerequisite and sandbox Ollama tag requests' }, + @{ Pattern = '^POST /api/generate HTTP/\S+ authorization=absent$'; Count = 1; Description = 'sandbox Ollama generation request' }, + @{ Pattern = '^POST /v1/chat/completions HTTP/\S+ authorization=synthetic$'; Count = 1; Description = 'sandbox cloud-inference request with the synthetic CI credential' } + ) + foreach ($expected in $expectedRequests) { + $actualCount = @($requestLines | Where-Object { $_ -match $expected.Pattern }).Count + if ($actualCount -ne $expected.Count) { + throw "mock inference server observed $actualCount $($expected.Description); expected $($expected.Count): $($requestLines -join '; ')" + } + } + + $passed = $true + Write-Host "MXC inference examples mock E2E passed for $Target" +} catch { + Write-Host "MXC inference examples mock E2E failed: $($_.Exception.Message)" -ForegroundColor Red + Get-ChildItem -LiteralPath $StageDir -File -Recurse -Include "*.log", "summary.txt" -ErrorAction SilentlyContinue | + ForEach-Object { + Write-Host "--- $($_.FullName) ---" + Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + throw +} finally { + $env:APPDATA = $oldAppData + $env:LOCALAPPDATA = $oldLocalAppData + if ([string]::IsNullOrWhiteSpace($oldNvApiKey)) { + Remove-Item Env:NV_API_KEY -ErrorAction SilentlyContinue + } else { + $env:NV_API_KEY = $oldNvApiKey + } + if ($serverProcess -and -not $serverProcess.HasExited) { + Stop-Process -Id $serverProcess.Id -Force -ErrorAction SilentlyContinue + try { [void] $serverProcess.WaitForExit(5000) } catch {} + } + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $StageDir)) { + Remove-Item -LiteralPath $StageDir -Recurse -Force + } else { + Write-Host "MXC inference example artifacts: $StageDir" + } +} diff --git a/tasks/scripts/windows-mxc-inference-stub.ps1 b/tasks/scripts/windows-mxc-inference-stub.ps1 new file mode 100644 index 0000000000..a62d881e9b --- /dev/null +++ b/tasks/scripts/windows-mxc-inference-stub.ps1 @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateRange(1, 65535)] + [int] $Port, + + [Parameter(Mandatory = $true)] + [string] $RequestLog, + + [Parameter(Mandatory = $true)] + [string] $ReadyPath, + + [Parameter(Mandatory = $true)] + [string] $ExpectedBearerToken +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$utf8 = [System.Text.UTF8Encoding]::new($false) +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) +$listener.Start() +[System.IO.File]::WriteAllText($ReadyPath, "$Port`r`n", $utf8) + +try { + while ($true) { + $client = $listener.AcceptTcpClient() + try { + $stream = $client.GetStream() + $reader = [System.IO.StreamReader]::new( + $stream, + [System.Text.Encoding]::ASCII, + $false, + 1024, + $true + ) + $requestLine = $reader.ReadLine() + if ([string]::IsNullOrWhiteSpace($requestLine)) { continue } + + $contentLength = 0 + $authorization = "" + while ($true) { + $line = $reader.ReadLine() + if ([string]::IsNullOrEmpty($line)) { break } + if ($line -match '^Content-Length:\s*(\d+)\s*$') { + $contentLength = [int] $Matches[1] + } elseif ($line -match '^Authorization:\s*(.+)\s*$') { + $authorization = $Matches[1] + } + } + if ($contentLength -gt 0) { + $buffer = New-Object char[] $contentLength + $read = 0 + while ($read -lt $contentLength) { + $count = $reader.Read($buffer, $read, $contentLength - $read) + if ($count -le 0) { break } + $read += $count + } + } + + $parts = $requestLine.Split(' ') + $path = if ($parts.Count -ge 2) { $parts[1] } else { "/" } + $authorizationStatus = if ([string]::IsNullOrWhiteSpace($authorization)) { + "absent" + } elseif ($authorization -ceq "Bearer $ExpectedBearerToken") { + "synthetic" + } else { + "mismatch" + } + [System.IO.File]::AppendAllText( + $RequestLog, + "$requestLine authorization=$authorizationStatus`r`n", + $utf8 + ) + switch ($path) { + "/api/tags" { + $status = "200 OK" + $body = '{"models":[{"name":"openshell-ci-mock"}]}' + } + "/api/generate" { + $status = "200 OK" + $body = '{"model":"openshell-ci-mock","response":"Hello from the CI mock.","done":true}' + } + "/v1/chat/completions" { + if ($authorizationStatus -eq "synthetic") { + $status = "200 OK" + $body = '{"choices":[{"message":{"role":"assistant","content":"Hello from the CI mock."}}]}' + } else { + $status = "401 Unauthorized" + $body = '{"error":{"message":"invalid mock credential"}}' + } + } + default { + $status = "404 Not Found" + $body = '{"error":"not found"}' + } + } + + $bodyBytes = $utf8.GetBytes($body) + $headers = "HTTP/1.1 $status`r`nContent-Type: application/json`r`nContent-Length: $($bodyBytes.Length)`r`nConnection: close`r`n`r`n" + $headerBytes = [System.Text.Encoding]::ASCII.GetBytes($headers) + $stream.Write($headerBytes, 0, $headerBytes.Length) + $stream.Write($bodyBytes, 0, $bodyBytes.Length) + $stream.Flush() + } finally { + $client.Dispose() + } + } +} finally { + $listener.Stop() +} diff --git a/tasks/windows.toml b/tasks/windows.toml index 5b8bab4008..c533dd53a9 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -108,3 +108,13 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/opensh description = "Run MXC Tier-3 e2e scenario runner in mock/wiring-only mode (no real wxc-exec required)" 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" + +["windows:e2e:mxc:inference-mock:x64"] +description = "Run the shipped MXC inference demos against the in-process wxc mock and a local API stub on Windows x64" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-inference-examples-e2e.ps1 -Target x86_64-pc-windows-msvc" + +["windows:e2e:mxc:inference-mock:arm64"] +description = "Run the shipped MXC inference demos against the in-process wxc mock and a local API stub on Windows ARM64" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-inference-examples-e2e.ps1 -Target aarch64-pc-windows-msvc" From d6fea62e762198c333a238d3fb2b045e0c888e74 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:48 -0700 Subject: [PATCH 24/39] fix(mxc): add schema version to test configs (#3891) Signed-off-by: Prekshi Vyas --- .../examples/mxc-ocsf-audit.toml | 3 +++ .../examples/mxc-ws-gateway.toml | 3 +++ .../tests/demo_examples.rs | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index a828264a5e..52b1fbf4e6 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -13,6 +13,9 @@ # egress-proxy switch into a 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] # Path to wxc-exec.exe on the box (patched by the run script; default is the # location observed on the MXC test boxes). diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml index 8a2aa4e9b8..5f534bc48c 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -32,6 +32,9 @@ # openshell-supervisor-relay kills the server directly and exits # driver also kills wxc-exec as a backstop regardless +[openshell] +version = 2 + [openshell.drivers.mxc] # Path to wxc-exec.exe. Patched at runtime by run-ws-agent-test.ps1. # Leave commented for mock-mode smoke tests (pass -Mock to the script). diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index 2b9ab52362..9fe867f4ba 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -38,6 +38,24 @@ fn read_example(name: &str) -> String { .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) } +#[test] +fn shipped_audit_and_websocket_configs_use_current_schema() { + for name in ["mxc-ocsf-audit.toml", "mxc-ws-gateway.toml"] { + let source = read_example(name); + let parsed: Value = toml::from_str(&source) + .unwrap_or_else(|error| panic!("failed to parse {name}: {error}")); + let openshell = parsed + .get("openshell") + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell]")); + assert_eq!( + openshell.get("version").and_then(Value::as_integer), + Some(2), + "{name} must use schema version 2" + ); + } +} + #[test] fn shipped_inference_configs_declare_required_mxc_settings() { for name in ["mxc-ollama.toml", "mxc-inference.toml"] { From 20b0ebdea496e1a8859ae3230661e7f5da4048f5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:10:52 -0700 Subject: [PATCH 25/39] docs(mxc): clarify mapper schema versions (#3894) Signed-off-by: Prekshi Vyas --- crates/openshell-driver-mxc/src/lib.rs | 6 ++--- crates/openshell-driver-mxc/src/policy.rs | 14 ++++++----- .../src/policy_map/config.rs | 14 +++++++++-- .../src/policy_map/map.rs | 9 +++---- .../src/policy_map/mod.rs | 14 ++++++++--- .../src/policy_map/report.rs | 5 +++- .../tests/policy_mapper_matrix.rs | 24 +++++++++++++++++-- 7 files changed, 65 insertions(+), 21 deletions(-) diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index dce22b3b49..fe12c4253c 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -51,7 +51,7 @@ pub use relay::RelayHandle; pub use policy::{EmbeddedPolicyMapper, MapCtx, MapError, MappedConfig, PolicyMapper}; #[cfg(target_os = "windows")] pub use policy_map::{ - DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, - MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, build_loss_report, map_to_mxc, - render_readme, split_policy, + DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, + LossItem, MxcMappingOptions, MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, + build_loss_report, map_to_mxc, render_readme, split_policy, }; diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 2a5c9883aa..3d823c3fc0 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -8,11 +8,12 @@ //! the standalone `openshell-policy-mapper` crate). This file defines the trait //! seam plus: //! -//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Calls -//! [`crate::policy_map::map_to_mxc`] directly on the typed `SandboxPolicy` -//! proto (no YAML bridge), extracts the MXC filesystem shares, normalizes -//! their paths to Windows form, and rejects the create on any `error`-severity -//! loss. +//! - [`EmbeddedPolicyMapper`] — the **primary** impl. Calls the coarse mapper or +//! governed-egress split directly on the typed `SandboxPolicy` proto (no YAML +//! bridge), extracts the MXC filesystem/UI fragment, normalizes paths, and +//! rejects the create on any `error`-severity loss. The generated mapping JSON +//! is an intermediate representation: live requests are rebuilt by +//! [`crate::mxc`] using its active schema version. //! //! **Rule: never silently drop policy.** Unmappable rules surface as //! `MapError::Unsupported` and are rejected by `CreateSandbox` before lifecycle side effects. @@ -181,7 +182,8 @@ impl PolicyMapper for EmbeddedPolicyMapper { // Map directly off the typed proto. The default MXC driver path runs // an isolation session, so use that containment: its network branch // yields an `error` loss for any host allowlist, which rejects - // network policy below. + // network policy below. This coarse JSON is never sent to wxc-exec; + // only the extracted filesystem/UI policy enters the live request. let opts = crate::policy_map::MxcMappingOptions { containment: ctx.containment.clone(), container_id: ctx.sandbox_id.clone(), diff --git a/crates/openshell-driver-mxc/src/policy_map/config.rs b/crates/openshell-driver-mxc/src/policy_map/config.rs index f64b64a54a..fba9593d0d 100644 --- a/crates/openshell-driver-mxc/src/policy_map/config.rs +++ b/crates/openshell-driver-mxc/src/policy_map/config.rs @@ -12,8 +12,18 @@ use super::loss::{LossItem, add_loss}; /// 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"; +/// Default schema for the standalone coarse mapper's host-list config. +/// +/// This is deliberately independent from [`crate::mxc::MXC_SCHEMA_VERSION`]: +/// the coarse artifact uses the MXC 0.7 `allowedHosts` shape, while live driver +/// requests and the governed-egress split use MXC 0.8 directional networking. +pub const DEFAULT_COARSE_MXC_VERSION: &str = "0.7.0-alpha"; + +/// Compatibility name for [`DEFAULT_COARSE_MXC_VERSION`]. +/// +/// This value belongs only to [`super::map_to_mxc`] output. It is not the +/// schema version used for live `wxc-exec` requests. +pub const DEFAULT_MXC_VERSION: &str = DEFAULT_COARSE_MXC_VERSION; /// Default MXC containment backend for the coarse mapping. pub const DEFAULT_CONTAINMENT: &str = "bubblewrap"; diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 4af3290715..47a159c33c 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -17,7 +17,7 @@ use openshell_core::proto::{ use serde_json::{Value, json}; use super::config::{ - DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_network_loss, + DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, add_backend_network_loss, add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, }; use super::loss::{LossItem, add_loss}; @@ -27,8 +27,9 @@ use crate::mxc::MXC_SCHEMA_VERSION; /// 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. + /// Caller-selectable schema version written only into standalone coarse-map + /// output. The governed-egress split and live driver requests instead use + /// [`MXC_SCHEMA_VERSION`]. pub mxc_version: String, /// MXC containment backend. pub containment: String, @@ -52,7 +53,7 @@ pub struct MxcMappingOptions { impl Default for MxcMappingOptions { fn default() -> Self { Self { - mxc_version: DEFAULT_MXC_VERSION.to_owned(), + mxc_version: DEFAULT_COARSE_MXC_VERSION.to_owned(), containment: DEFAULT_CONTAINMENT.to_owned(), command: DEFAULT_COMMAND.to_owned(), container_id: "openshell-policy".to_owned(), diff --git a/crates/openshell-driver-mxc/src/policy_map/mod.rs b/crates/openshell-driver-mxc/src/policy_map/mod.rs index 670e31a617..a184fb353e 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -17,11 +17,17 @@ //! 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. +//! own, with no `OpenShell` proxy in the loop. Its default schema is MXC 0.7, +//! matching that host-list shape; the caller can override the coarse target. //! - [`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. +//! enforced by the host CONNECT proxy. This path uses the live driver's MXC +//! 0.8 directional-network schema. +//! +//! The coarse mapper's schema version describes its generated artifact, not the +//! live driver's operating schema. 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; @@ -36,7 +42,9 @@ mod loss; mod map; mod report; -pub use config::{DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION}; +pub use config::{ + DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, +}; pub use loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS}; pub use map::{MxcMappingOptions, MxcMappingResult, SplitPolicyResult, map_to_mxc, split_policy}; pub use report::{build_loss_report, render_readme}; diff --git a/crates/openshell-driver-mxc/src/policy_map/report.rs b/crates/openshell-driver-mxc/src/policy_map/report.rs index 0b1ec37b77..fdea60efa7 100644 --- a/crates/openshell-driver-mxc/src/policy_map/report.rs +++ b/crates/openshell-driver-mxc/src/policy_map/report.rs @@ -7,7 +7,10 @@ use serde_json::{Value, json}; 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` describes that artifact's caller-selected schema. It +/// must not be interpreted as the live MXC driver's request schema. pub fn build_loss_report( source_policy: &str, generated_config: &str, diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 3d2b8647f8..2a82546773 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -27,8 +27,8 @@ use openshell_core::proto::{ NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy, }; use openshell_driver_mxc::{ - EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, - split_policy, + DEFAULT_COARSE_MXC_VERSION, 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; @@ -132,6 +132,26 @@ fn assert_single_loss( // ─── QUADRANT A: mappable fields, assert exact MXC output ─────────────────── +/// The standalone coarse mapper and live governed-egress path intentionally +/// target different schema shapes. Keep the compatibility constant scoped to +/// the coarse artifact and prevent either side from silently drifting. +#[test] +fn a_schema_versions_match_their_distinct_network_shapes() { + let policy = SandboxPolicy::default(); + let coarse = map_to_mxc(&policy, &default_opts()).config; + assert_eq!(DEFAULT_MXC_VERSION, DEFAULT_COARSE_MXC_VERSION); + assert_eq!(coarse["version"], DEFAULT_COARSE_MXC_VERSION); + assert!(coarse["network"].get("allowedHosts").is_some()); + assert!(coarse["network"].get("egress").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"], "0.8.0-alpha"); + 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() { From 4cb6054cfeaafb4c0b488293a76ad7d660705d02 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:11:01 -0700 Subject: [PATCH 26/39] fix(mxc): preserve JSON arguments in PowerShell 5.1 (#3893) Signed-off-by: Prekshi Vyas --- .../examples/run-openclaw-forward-test.ps1 | 70 ++++++++++++-- .../examples/run-ws-agent-test.ps1 | 71 ++++++++++++-- .../tests/powershell_native_arguments.rs | 95 +++++++++++++++++++ 3 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 crates/openshell-driver-mxc/tests/powershell_native_arguments.rs diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index 7c3a7c6e80..60d9fec9b6 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -137,6 +137,60 @@ function Copy-ItemRetry([string]$src, [string]$dst, [int]$attempts = 10, [int]$d function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 removes embedded quotes and can split JSON values at embedded spaces when +# invoking native commands through the call operator. +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) { + $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 + if (-not $process.Start()) { throw "failed to start $filePath" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $output = @($stdout.Result, $stderr.Result) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { $_ -split "`r?`n" } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + return @{ + ExitCode = $process.ExitCode + Output = @($output) + } +} + function Grant-AppContainerWritableDirectory([string]$Path) { # AppContainer access is a dual check: the generated package SID grant from # MXC is necessary, but OpenClaw's SQLite staging also needs the two built-in @@ -453,14 +507,14 @@ try { "--env", "NEMOCLAW_MXC_EGRESS_LOOPBACK_PORT=29999", "--no-tty", "--", "exit" ) - # Windows PowerShell 5.1 wraps native stderr as ErrorRecord objects. Keep - # warnings in the captured diagnostic without letting them terminate the - # command before its real exit code and output are collected. - $createPrevEAP = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { $createOut = & $cli @createArgs 2>&1; $createCode = $LASTEXITCODE } - catch { $createOut = $_.Exception.Message; $createCode = 1 } - finally { $ErrorActionPreference = $createPrevEAP } + try { + $createResult = Invoke-NativeCaptured $cli $createArgs + $createOut = $createResult.Output + $createCode = $createResult.ExitCode + } catch { + $createOut = $_.Exception.Message + $createCode = 1 + } $createBenign = Show-SandboxCreate $createOut $SandboxName if ($createCode -ne 0 -and -not $createBenign) { throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)" diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index 0372864928..7d1be2e5f6 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -112,6 +112,60 @@ function Esc([string]$p) { return $p.Replace('\', '\\') } # Convert Windows path to forward-slash form (TOML values). function Fwd([string]$p) { return $p.Replace('\', '/') } +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 removes embedded quotes and can split JSON values at embedded spaces when +# invoking native commands through the call operator. +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) { + $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 + if (-not $process.Start()) { throw "failed to start $filePath" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $output = @($stdout.Result, $stderr.Result) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { $_ -split "`r?`n" } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + return @{ + ExitCode = $process.ExitCode + Output = @($output) + } +} + # -WsPort is NOT actually wired through end to end: the in-sandbox server's # port is a compile-time const (WS_PORT = 22000 in mxc-ws-agent.rs) -- the # TOML generation below doesn't patch it. Rather than silently accept an @@ -485,14 +539,15 @@ try { # Use the same pattern as run-mxc-e2e.ps1: pass --no-tty with a no-op # command so the CLI fires the SSH attempt, fails quickly (connection # refused), and returns. Do NOT gate on exit code here. - $createOut = & $cli sandbox create ` - --name $sandboxName ` - --policy $policyUsed ` - --driver-config-json $driverConfigJson ` - --no-tty ` - -- cmd.exe /c exit 0 ` - 2>&1 - $createExitCode = $LASTEXITCODE + $createResult = Invoke-NativeCaptured $cli @( + "sandbox", "create", + "--name", $sandboxName, + "--policy", $policyUsed, + "--driver-config-json", $driverConfigJson, + "--no-tty", "--", "cmd.exe", "/c", "exit", "0" + ) + $createOut = $createResult.Output + $createExitCode = $createResult.ExitCode } catch { $createOut = $_.Exception.Message; $createExitCode = 1 } diff --git a/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs b/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs new file mode 100644 index 0000000000..27c33b3837 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/powershell_native_arguments.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(windows)] +#[test] +fn shipped_runners_preserve_driver_config_json_in_windows_powershell() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let directory = tempfile::tempdir().expect("create native-argument test directory"); + let receiver = directory.path().join("capture native arguments.ps1"); + std::fs::write( + &receiver, + r#" +param( + [Parameter(Mandatory = $true, Position = 0)] [string] $Before, + [Parameter(Mandatory = $true, Position = 1)] [string] $DriverConfigJson, + [Parameter(Mandatory = $true, Position = 2)] [string] $After +) + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +Write-Output "BEFORE=$Before" +Write-Output "JSON=$DriverConfigJson" +Write-Output "AFTER=$After" +"#, + ) + .expect("write native-argument receiver"); + + let verifier = r#" +$ErrorActionPreference = "Stop" +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $env:OPENSHELL_RUNNER_PATH, + [ref] $tokens, + [ref] $errors +) +if ($errors.Count -gt 0) { + throw "runner has PowerShell syntax errors: $($errors.Message -join '; ')" +} + +foreach ($name in @("Quote-NativeArgument", "Invoke-NativeCaptured")) { + $functionAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, $true) + if ($null -eq $functionAst) { throw "$name is missing" } + Invoke-Expression $functionAst.Extent.Text +} + +$expected = @{ + mxc = @{ + command = @("C:/Program Files/OpenShell/agent.exe", "server mode") + cwd = "C:/work/path with spaces" + } +} | ConvertTo-Json -Compress -Depth 4 +$powershell = Join-Path $env:SystemRoot "System32/WindowsPowerShell/v1.0/powershell.exe" +$result = Invoke-NativeCaptured $powershell @( + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", + $env:OPENSHELL_ARGUMENT_RECEIVER, + "before value", $expected, "after value" +) +if ($result.ExitCode -ne 0) { + throw "argument receiver exited $($result.ExitCode): $($result.Output -join [Environment]::NewLine)" +} + +$lines = @(($result.Output -join "`n") -split "`r?`n") +$before = $lines | Where-Object { $_ -like "BEFORE=*" } | Select-Object -First 1 +$json = $lines | Where-Object { $_ -like "JSON=*" } | Select-Object -First 1 +$after = $lines | Where-Object { $_ -like "AFTER=*" } | Select-Object -First 1 +if ($before -ne "BEFORE=before value") { throw "leading argument changed: $before" } +if ($null -eq $json -or $json.Substring(5) -cne $expected) { + throw "driver config JSON changed: expected '$expected', captured '$json'" +} +if ($after -ne "AFTER=after value") { throw "trailing argument changed: $after" } +"#; + + for runner in ["run-ws-agent-test.ps1", "run-openclaw-forward-test.ps1"] { + let runner_path = root.join("examples").join(runner); + let output = std::process::Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]) + .arg(verifier) + .env("OPENSHELL_RUNNER_PATH", &runner_path) + .env("OPENSHELL_ARGUMENT_RECEIVER", &receiver) + .output() + .unwrap_or_else(|error| { + panic!("failed to launch Windows PowerShell for {runner}: {error}") + }); + assert!( + output.status.success(), + "{runner} corrupted a native argument:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } +} From bd49d45cb4ce5bf86eb941e52b3dd2ee42572122 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:30:25 -0700 Subject: [PATCH 27/39] docs(mxc): document Windows host preparation (NVBug 6842834) (#3892) Document standalone MXC executable placement and condition system-drive ACL preparation on the AppContainer + DACL tier and probe recommendation. Explain the persistent metadata-only grant and link upstream verification and rollback guidance. NVBug: 6842834 Signed-off-by: Prekshi Vyas Signed-off-by: Shailendra Singh --- crates/openshell-driver-mxc/README.md | 32 ++++++++++++++++++ .../examples/README-ocsf-audit.txt | 21 +++++++++++- .../examples/README-openclaw-forward.txt | 21 +++++++++++- .../openshell-driver-mxc/examples/README.txt | 21 +++++++++++- docs/reference/gateway-config.mdx | 33 +++++++++++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 7e88e24f79..32b2908ad3 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -168,6 +168,38 @@ The MXC credential handoff is also fixed at sandbox creation. The gateway reject `openshell.exe`. Diagnose executable blocks with event 3077 in the `Microsoft-Windows-CodeIntegrity/Operational` log. +Place `wxc-exec.exe` in a regular directory outside any installed MSIX or +`WindowsApps` package directory. A package directory's ACLs can prevent the +gateway from starting the executable outside its package context, even when +the same binary runs after it is copied elsewhere. If sandbox creation fails +with `wxc-exec spawn failed: Access is denied. (os error 5)`, verify that +`wxc_exec_path` points to the standalone copy. + +Before the first live `process_container` run, check the selected isolation +tier and host-preparation warnings in PowerShell: + +```powershell +& "C:\path\to\wxc-exec.exe" --probe +``` + +If the probe selects AppContainer + DACL (`appcontainer-dacl`) and recommends +`prepare-system-drive`, obtain `wxc-host-prep.exe` from the MXC binaries +distribution and run this command once in an elevated PowerShell session. +BaseContainer and AppContainer + BFS do not require this preparation. + +```powershell +& "C:\path\to\wxc-host-prep.exe" prepare-system-drive +``` + +This persistent, host-wide change adds non-inheriting ACL entries that give +the AppContainer well-known SIDs metadata access to the system-drive root. It +does not grant directory listing or write access, or change descendant ACLs. +If `wxc-exec.exe` starts but the agent exits with +`wxc-exec stderr: Access is denied.` or exit code 1, check the probe warnings +before changing host ACLs; these errors alone do not identify missing host +preparation. See Microsoft's [MXC host-preparation guide](https://github.com/microsoft/mxc/blob/main/docs/host-prep.md) +for verification, other host prerequisites, and rollback instructions. + For off-box smoke tests against the in-process mock shim (no `wxc-exec`, no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt index 51bedd26a7..c81a047aed 100644 --- a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -21,7 +21,26 @@ WHAT THIS PROVES / PRODUCES [2004] Detection Finding - MXC setup activity errors (informational) PREREQUISITES (on this test box) - - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe). + Use a standalone copy in a regular directory, not a copy inside an + installed MSIX or WindowsApps package directory. If CreateSandbox reports + "wxc-exec spawn failed: Access is denied. (os error 5)", check this path. + - Before the first process_container run, check the selected isolation tier + and host-preparation warnings in PowerShell: + & "C:\path\to\wxc-exec.exe" --probe + Only if the probe selects AppContainer + DACL (appcontainer-dacl) and + recommends prepare-system-drive, obtain wxc-host-prep.exe from the MXC + binaries distribution and run this once in elevated PowerShell: + & "C:\path\to\wxc-host-prep.exe" prepare-system-drive + BaseContainer and AppContainer + BFS do not require this preparation. + The persistent, host-wide ACL change grants the AppContainer well-known + SIDs metadata access to the system-drive root only; it does not grant + directory listing or write access, or change descendant ACLs. + If wxc-exec starts but the agent exits with "wxc-exec stderr: Access is + denied." or exit code 1, check the probe warnings before changing host + ACLs; these errors alone do not identify missing host preparation. + For verification, other host prerequisites, and rollback, see: + https://github.com/microsoft/mxc/blob/main/docs/host-prep.md - process_container backend live (it was for our earlier runs) - Run ELEVATED (Run as administrator) OR from an account in the 'Performance Log Users' group. Opening the real-time ETW session needs this; diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt index c3aa149896..810cd2a9b8 100644 --- a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt +++ b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt @@ -40,7 +40,26 @@ PREREQUISITES (on this test box) ownership. Non-elevated fails both with ERROR_ACCESS_DENIED (0x5), the second as "Network proxy error: Failed to set loopback exemption: 0x00000005". -Backend isolation_session does not hit either path. - - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe). + Use a standalone copy in a regular directory, not a copy inside an + installed MSIX or WindowsApps package directory. If CreateSandbox reports + "wxc-exec spawn failed: Access is denied. (os error 5)", check this path. + - Before the first process_container run, check the selected isolation tier + and host-preparation warnings in PowerShell: + & "C:\path\to\wxc-exec.exe" --probe + Only if the probe selects AppContainer + DACL (appcontainer-dacl) and + recommends prepare-system-drive, obtain wxc-host-prep.exe from the MXC + binaries distribution and run this once in elevated PowerShell: + & "C:\path\to\wxc-host-prep.exe" prepare-system-drive + BaseContainer and AppContainer + BFS do not require this preparation. + The persistent, host-wide ACL change grants the AppContainer well-known + SIDs metadata access to the system-drive root only; it does not grant + directory listing or write access, or change descendant ACLs. + If wxc-exec starts but the agent exits with "wxc-exec stderr: Access is + denied." or exit code 1, check the probe warnings before changing host + ACLs; these errors alone do not identify missing host preparation. + For verification, other host prerequisites, and rollback, see: + https://github.com/microsoft/mxc/blob/main/docs/host-prep.md - process_container or isolation_session backend live (whichever -Backend you pass) - Your own OpenClaw install: a node.exe binary + the openclaw npm package diff --git a/crates/openshell-driver-mxc/examples/README.txt b/crates/openshell-driver-mxc/examples/README.txt index 659124f9e5..6e010dbd00 100644 --- a/crates/openshell-driver-mxc/examples/README.txt +++ b/crates/openshell-driver-mxc/examples/README.txt @@ -17,7 +17,26 @@ Prerequisites - openshell-gateway.exe and openshell.exe beside these files, or explicit -GatewayPath and -CliPath arguments. - wxc-exec.exe beside these files, on PATH, named by - OPENSHELL_WXC_EXEC_PATH, or passed with -WxcExecPath. + OPENSHELL_WXC_EXEC_PATH, or passed with -WxcExecPath. Use a standalone + copy in a regular directory. Do not use a copy from an installed MSIX or + WindowsApps package directory; its package ACLs can make CreateSandbox + fail with "wxc-exec spawn failed: Access is denied. (os error 5)". + - Before the first process_container run, check the selected isolation tier + and host-preparation warnings in PowerShell: + & "C:\path\to\wxc-exec.exe" --probe + Only if the probe selects AppContainer + DACL (appcontainer-dacl) and + recommends prepare-system-drive, obtain wxc-host-prep.exe from the MXC + binaries distribution and run this once in elevated PowerShell: + & "C:\path\to\wxc-host-prep.exe" prepare-system-drive + BaseContainer and AppContainer + BFS do not require this preparation. + The persistent, host-wide ACL change grants the AppContainer well-known + SIDs metadata access to the system-drive root only; it does not grant + directory listing or write access, or change descendant ACLs. + If wxc-exec starts but the agent exits with "wxc-exec stderr: Access is + denied." or exit code 1, check the probe warnings before changing host + ACLs; these errors alone do not identify missing host preparation. + For verification, other host prerequisites, and rollback, see: + https://github.com/microsoft/mxc/blob/main/docs/host-prep.md - Local demo: an Ollama-compatible service on 127.0.0.1:11434 by default. Override -OllamaHost, -OllamaPort, and -Model when needed. - Cloud demo: NV_API_KEY and outbound HTTPS to integrate.api.nvidia.com. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index dce1b313a5..ab1702a896 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -933,6 +933,39 @@ mount setup to bypass that profile. The MXC driver is Windows-only and opt-in. It links into the gateway, invokes Microsoft MXC through `wxc-exec.exe`, and runs each sandbox's configured command in-driver instead of using the Linux sandbox supervisor. +#### Prepare the Windows host + +Copy `wxc-exec.exe` to a regular directory outside any installed MSIX or +`WindowsApps` package directory. A package directory's ACLs can prevent the +gateway from starting the executable outside its package context. If sandbox +creation fails with `wxc-exec spawn failed: Access is denied. (os error 5)`, +verify that `wxc_exec_path` points to the standalone copy. + +Before the first live `process_container` run, check the selected isolation +tier and host-preparation warnings in PowerShell: + +```shell +& "C:\path\to\wxc-exec.exe" --probe +``` + +If the probe selects AppContainer + DACL (`appcontainer-dacl`) and recommends +`prepare-system-drive`, obtain `wxc-host-prep.exe` from the MXC binaries +distribution and run this command once in an elevated PowerShell session. +BaseContainer and AppContainer + BFS do not require this preparation. + +```shell +& "C:\path\to\wxc-host-prep.exe" prepare-system-drive +``` + +This persistent, host-wide change adds non-inheriting ACL entries that give +the AppContainer well-known SIDs metadata access to the system-drive root. It +does not grant directory listing or write access, or change descendant ACLs. +If `wxc-exec.exe` starts but the agent exits with +`wxc-exec stderr: Access is denied.` or exit code 1, check the probe warnings +before changing host ACLs; these errors alone do not identify missing host +preparation. See Microsoft's [MXC host-preparation guide](https://github.com/microsoft/mxc/blob/main/docs/host-prep.md) +for verification, other host prerequisites, and rollback instructions. + ```toml [openshell] version = 1 From d46a814141d8f2b19b5eaf6daa12c4c9680814f8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:50:53 -0700 Subject: [PATCH 28/39] ci(windows): exercise MXC credential, audit, and aggregate E2E flows (#3787) * ci(windows): exercise MXC provider credential example Signed-off-by: Prekshi Vyas * ci(windows): exercise MXC OCSF audit example Signed-off-by: Prekshi Vyas * ci(windows): enable aggregate MXC example E2E Signed-off-by: Prekshi Vyas * ci(windows): select native MXC mock target Signed-off-by: Prekshi Vyas * fix(windows): isolate MXC example CI harnesses Signed-off-by: Prekshi Vyas --------- Signed-off-by: Prekshi Vyas --- .../build-openshell-mxc-windows/SKILL.md | 28 ++- .github/workflows/windows-msvc.yml | 12 ++ architecture/windows.md | 11 ++ .../mxc-provider-credential-probe.ps1 | 8 +- .../examples/mxc-provider-credential.toml | 3 + .../examples/run-mxc-e2e.ps1 | 78 ++++++++- .../examples/run-ocsf-audit.ps1 | 161 ++++++++++++++--- .../examples/run-provider-credential-test.ps1 | 107 ++++++++---- crates/openshell-driver-mxc/src/mxc.rs | 55 +++++- .../tests/demo_examples.rs | 110 +++++++++++- .../tests/windows_e2e_harness.rs | 141 +++++++++++++++ tasks/scripts/windows-mxc-aggregate-e2e.ps1 | 150 ++++++++++++++++ tasks/scripts/windows-mxc-e2e-environment.ps1 | 76 ++++++++ tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 | 161 +++++++++++++++++ .../windows-mxc-provider-credential-e2e.ps1 | 164 ++++++++++++++++++ tasks/windows.toml | 15 ++ 16 files changed, 1200 insertions(+), 80 deletions(-) create mode 100644 crates/openshell-driver-mxc/tests/windows_e2e_harness.rs create mode 100644 tasks/scripts/windows-mxc-aggregate-e2e.ps1 create mode 100644 tasks/scripts/windows-mxc-e2e-environment.ps1 create mode 100644 tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 create mode 100644 tasks/scripts/windows-mxc-provider-credential-e2e.ps1 diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 18e69d943d..a6d86395bb 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -53,6 +53,8 @@ In scope: - Running focused unsupported-driver contract tests. - Running the shipped Ollama and cloud-inference MXC examples against the in-process `wxc` mock and a local API stub. +- Running the shipped provider-credential, OCSF audit, and aggregate MXC + examples against the in-process `wxc` mock. - Reporting test counts, skipped/gated areas, warnings, artifacts, and logs. - Keeping Linux and macOS build paths unchanged. - Keeping unsupported Windows compute drivers explicit and testable. @@ -161,6 +163,9 @@ mise run --skip-tools windows:build:arm64 mise run --skip-tools windows:test:x64 mise run --skip-tools windows:test:unsupported:x64 mise run --skip-tools windows:e2e:mxc:inference-mock:x64 +mise run --skip-tools windows:e2e:mxc:provider-mock +mise run --skip-tools windows:e2e:mxc:ocsf-mock +mise run --skip-tools windows:e2e:mxc:aggregate-mock ``` Use the `arm64` form of the inference task on a native ARM64 host. It exercises @@ -169,6 +174,13 @@ mock and local HTTP responses. It proves example wiring, request execution, and sandbox-scoped credential propagation; it does not prove MXC, AppContainer, filesystem, or network enforcement. +The provider mock proves provider/profile/policy attachment and placeholder +propagation without exposing the synthetic credential. The OCSF mock proves +the durable zero-provider-events diagnostic path. The aggregate mock exercises +the read-write, read-only, default-deny, and unsupported-network-policy +scenarios, including a staging path containing spaces. These remain wiring +checks; native MXC qualification is required for enforcement evidence. + 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 @@ -231,10 +243,11 @@ entries for Cargo registry and dependency target artifacts with sccache's GHA backend for cacheable Rust compiler outputs. Failed runs also save their usable dependency artifacts. Pull-request mirrors labeled `test:windows` run Clippy for the Windows-supported workspace and e2e crates plus Rust tests, build -release binaries, and run both shipped MXC inference examples through the mock -task. Pushes to `main` and manual dispatches run the same lint and test commands -in a cache-seed job, followed by a dependent release-binary build and -mock-example job. The seed and PR jobs use the same cache namespaces. Merge +release binaries, and run the inference, provider-credential, OCSF audit, and +aggregate shipped examples through their mock tasks. Pushes to `main` and +manual dispatches run the same lint and test commands in a cache-seed job, +followed by a dependent release-binary build and mock-example job. The seed and +PR jobs use the same cache namespaces. Merge queues do not run this workflow. Main/manual seed and build jobs use job-level `continue-on-error: true`; opt-in PR jobs report failures normally. Applying the label alone does not start a run: re-run all jobs in the current mirror @@ -286,6 +299,9 @@ crypto dependency builds. | `windows:test:mxc-gb300:arm64` | Runs the required native ARM64 ProcessContainer subset and fails when a test skips. | | `windows:e2e:mxc:inference-mock:x64` | Runs the shipped Ollama and cloud-inference demos on native x64 through the real gateway and CLI, in-process `wxc` mock, and local API stub. This is wiring evidence, not MXC enforcement evidence. | | `windows:e2e:mxc:inference-mock:arm64` | Runs the same mock-wiring checks on native ARM64 with ARM64 release binaries. | +| `windows:e2e:mxc:provider-mock` | Runs the shipped provider-credential demo with a synthetic credential and verifies placeholder propagation without leaking the credential. | +| `windows:e2e:mxc:ocsf-mock` | Runs the shipped OCSF audit demo and verifies the durable zero-provider-events finding. | +| `windows:e2e:mxc:aggregate-mock` | Runs the four aggregate MXC scenarios through the mock, including a staging path containing spaces. | | `windows:qualify:mxc:gb300:contract` | Validates the required/optional/unsupported/architecture-constrained GB300 matrix. | | `windows:qualify:mxc:gb300` | Runs the fail-closed GB300 ARM64 gate and validates hash-bound evidence. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | @@ -338,8 +354,8 @@ When reporting `windows:ci`, distinguish these categories: - Passed tests from the full ARM64 workspace test log when run on a native ARM64 host. - The focused unsupported-contract re-run. -- The architecture-matched MXC inference-example mock task and its explicit - wiring-only limitation. +- The architecture-matched MXC inference, provider-credential, OCSF audit, and + aggregate mock tasks and their explicit wiring-only limitation. - Explicit Cargo ignored tests, usually ignored doc examples. - Tests hidden by `#[cfg(not(target_os = "windows"))]`; these often appear as `running 0 tests`, not as ignored tests. diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 1b7964eb20..34ebd9499f 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -100,6 +100,12 @@ jobs: run: mise run --skip-tools windows:test:${{ matrix.arch }} - name: Build MXC example E2E binaries run: mise run --skip-tools windows:build:${{ matrix.arch }} + - name: Run shipped MXC provider-credential example in mock mode + run: mise run --skip-tools windows:e2e:mxc:provider-mock + - name: Run shipped MXC OCSF audit example in mock mode + run: mise run --skip-tools windows:e2e:mxc:ocsf-mock + - name: Run shipped aggregate MXC scenarios in mock mode + run: mise run --skip-tools windows:e2e:mxc:aggregate-mock - name: Run shipped MXC inference examples with mock API run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} - name: sccache stats @@ -215,6 +221,12 @@ jobs: cache-bin: "false" - name: Build release binaries run: mise run --skip-tools windows:build:${{ matrix.arch }} + - name: Run shipped MXC provider-credential example in mock mode + run: mise run --skip-tools windows:e2e:mxc:provider-mock + - name: Run shipped MXC OCSF audit example in mock mode + run: mise run --skip-tools windows:e2e:mxc:ocsf-mock + - name: Run shipped aggregate MXC scenarios in mock mode + run: mise run --skip-tools windows:e2e:mxc:aggregate-mock - name: Run shipped MXC inference examples with mock API run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} - name: sccache stats diff --git a/architecture/windows.md b/architecture/windows.md index abf5043543..8180f25762 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -248,6 +248,17 @@ Windows validation separates source correctness from host capability: workspace and unsupported-driver contract tests for x64 and ARM64. - Mock MXC E2E validates gateway, CLI, driver, lifecycle, and policy wiring but is not evidence of OS enforcement. +- Hosted Windows CI runs the shipped provider-credential example with a + synthetic value. It verifies provider/profile/policy attachment and that the + mock workload receives only a revision-scoped placeholder. It makes no + external request and does not prove proxy substitution or MXC enforcement. +- The shipped OCSF audit example also runs with mock MXC. It verifies ETW + consumer startup, mock workload wiring, durable JSONL output, and the + zero-provider-events finding. Real provider events and attribution still + require native MXC qualification. +- The aggregate example runs its read-write, read-only, default-deny, and + unsupported-network-policy scenarios in mock mode. This validates the + shipped runner and expected policy paths without claiming native enforcement. - Hosted Windows CI runs the shipped Ollama and cloud-inference demos against that mock and a local compatible API. This proves both complete demo scripts, sandbox-scoped credential propagation, and response paths without a model or diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 index 56477c2342..eaa37d4e5e 100644 --- a/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential-probe.ps1 @@ -7,7 +7,8 @@ [CmdletBinding()] param( - [string] $OutputDir = "C:\work\openshell-mxc-provider" + [string] $OutputDir = "C:\work\openshell-mxc-provider", + [switch] $Mock ) $ErrorActionPreference = "Stop" @@ -127,6 +128,11 @@ try { } $checks += "[PASS] MXC received only a revision-scoped GITHUB_TOKEN placeholder" + if ($Mock) { + $checks += "[PASS] no external network request was made in mock mode" + Complete-Probe $checks + } + $curlPath = Join-Path $env:SystemRoot "System32\curl.exe" if (-not (Test-Path $curlPath)) { $checks += "[FAIL] inbox curl.exe is unavailable" diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml index 65cd4a21c1..150064cc65 100644 --- a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml @@ -4,6 +4,9 @@ # Gateway configuration for run-provider-credential-test.ps1. The runner writes # a disposable copy with the requested wxc-exec path. +[openshell] +version = 2 + [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" backend = "process_container" diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 0cefca119b..060b7c6f79 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -40,6 +40,8 @@ param( [string] $DemoDir = "C:\work\openshell-mxc-e2e", [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + [string] $GatewayPath, + [string] $CliPath, [ValidateSet("isolation_session", "process_container")] [string] $Backend = "process_container", [string] $Scenario, @@ -105,6 +107,14 @@ function Quote-NativeArgument([string]$value) { return $quoted.ToString() } +function New-CmdWriteCommand([string]$value, [string]$path) { + if ($path.Contains('"')) { + throw "cmd.exe write target contains an invalid quote: $path" + } + $normalizedPath = $path.Replace('\', '/') + return "echo $value 1> `"$normalizedPath`"" +} + function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $filePath @@ -139,8 +149,16 @@ function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { # --- Path variables ----------------------------------------------------------- -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" +$gateway = if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + Join-Path $here "openshell-gateway.exe" +} else { + [System.IO.Path]::GetFullPath($GatewayPath) +} +$cli = if ([string]::IsNullOrWhiteSpace($CliPath)) { + Join-Path $here "openshell.exe" +} else { + [System.IO.Path]::GetFullPath($CliPath) +} $toml = Join-Path $here "mxc-gateway.toml" $policyDir = Join-Path $here "e2e-policies" @@ -534,18 +552,27 @@ try { $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" # Build the per-sandbox workload command and clean prior artifacts. + $mockDenySandboxName = $null + $mockDenyAttempted = $true if ($sc.Kind -eq "positive") { Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue - $command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))") + $command = @($cmdExe, "/c", (New-CmdWriteCommand "ok" $sc.PosTarget)) } elseif ($sc.Kind -eq "deny") { Remove-Item $sc.DenyTarget -Force -ErrorAction SilentlyContinue - $denied = $sc.DenyTarget.Replace('\', '/') + $deniedCommand = New-CmdWriteCommand "denied" $sc.DenyTarget 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") + $controlCommand = New-CmdWriteCommand "ok" $sc.ControlTarget + if ($Mock) { + # The in-process shim authorizes or denies a whole command. + # Use a separate denied-only launch below so mock CI exercises + # both branches instead of allowing both redirects together. + $command = @($cmdExe, "/c", $controlCommand) + } else { + $command = @($cmdExe, "/c", "$controlCommand & $deniedCommand") + } } else { - $command = @($cmdExe, "/c", "echo denied 1> $denied") + $command = @($cmdExe, "/c", $deniedCommand) } } else { $command = @($cmdExe, "/c", "exit 0") @@ -592,6 +619,34 @@ try { $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + if ($Mock -and $sc.Kind -eq "deny" -and $sc.ControlTarget) { + # Run the negative half separately because the mock shim makes a + # whole-command decision. A new launch message proves the denied + # command reached the shim; absence of its artifact scores denial. + $controlReady = Wait-File $sc.ControlTarget 30 + $launchCountBefore = ([regex]::Matches($gwText, 'MXC agent launched')).Count + $denyDriverConfig = @{ + mxc = @{ + command = @($cmdExe, "/c", $deniedCommand) + cwd = $demoDirFwd + } + } | ConvertTo-Json -Compress -Depth 4 + $mockDenySandboxName = "mxc-rd-$runId" + $denyCreateResult = Invoke-NativeCaptured $cli @( + "sandbox", "create", "--name", $mockDenySandboxName, + "--policy", [string]$policyUsed, + "--driver-config-json", $denyDriverConfig, + "--no-tty" + ) + Start-Sleep -Seconds 3 + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + $launchCountAfter = ([regex]::Matches($gwText, 'MXC agent launched')).Count + $mockDenyAttempted = $controlReady -and + $denyCreateResult.ExitCode -eq 0 -and + $launchCountAfter -gt $launchCountBefore + Info "mock deny create exit: $($denyCreateResult.ExitCode); launch observed: $($launchCountAfter -gt $launchCountBefore)" + } + # Evaluate. if ($sc.Kind -eq "create-fail") { # A non-zero exit alone is NOT sufficient: gateway-registration, @@ -638,7 +693,7 @@ try { # late denied write (enforcement regression racing the control write) # cannot be recorded as PASS. $denyPresent = Test-Path $sc.DenyTarget - if ($controlPresent -and -not $denyPresent) { + if ($controlPresent -and $mockDenyAttempted -and -not $denyPresent) { Ok "$($sc.Name): control write succeeded; denied write correctly blocked" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "control present, deny absent" } } elseif (-not $controlPresent) { @@ -646,6 +701,9 @@ try { Info "createOut: $createOutStr" if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "control absent (agent did not run)" } + } elseif (-not $mockDenyAttempted) { + Bad "$($sc.Name): mock deny launch was not observed - denial is inconclusive" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "mock deny launch not confirmed" } } else { Bad "$($sc.Name): denied write was NOT blocked (artifact present)" $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } @@ -683,6 +741,9 @@ try { # Real MXC startup is asynchronous and can otherwise be canceled # before the workload writes its positive/control proof. try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} + if ($mockDenySandboxName) { + try { Invoke-NativeCaptured $cli @("sandbox", "delete", $mockDenySandboxName) | Out-Null } catch {} + } Stop-Gw $gw $gw = $null } @@ -735,6 +796,7 @@ Files in this bundle ($resultDir): What PASS means: every non-skipped scenario 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. +$(if ($Mock) { 'Mock mode validates runner/gateway/driver wiring against the in-process shim; it is not evidence of native MXC or OS enforcement.' }) "@ 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/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 00d32e10ef..c410b1df8e 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -23,6 +23,11 @@ # powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 ` # -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe # +# Hosted CI can pass -Mock. That mode runs the complete gateway/CLI/script path +# with the in-process wxc shim, then requires the ETW consumer's durable +# mxc-etw-zero-events OCSF finding. It does not prove real provider events, +# attribution, or MXC enforcement. +# # 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. # @@ -34,6 +39,8 @@ param( # Real wxc-exec on the test box. [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + [string] $GatewayPath, + [string] $CliPath, # Host folder granted read-write in the disposable sandbox policy. [string] $ShareDir = "C:\work\openshell-mxc-demo", # How many sandboxes to create (each drives a full event burst). @@ -48,6 +55,10 @@ param( # Optional: copy the results bundle to this path (e.g. a shared drive) for # pickup. Empty by default (no copy); pass -ShareOut '\\server\share' to enable. [string] $ShareOut = "", + # Run with the in-process wxc shim and score the zero-event audit diagnostic. + [switch] $Mock, + [ValidateRange(31, 120)] + [int] $MockAuditWaitSeconds = 35, # Leave the gateway running afterward (for inspection). [switch] $KeepRunning ) @@ -71,6 +82,63 @@ 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 } +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-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $cli + $startInfo.Arguments = (($CommandArgs | 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 + if (-not $process.Start()) { throw "failed to start $cli" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $text = (@($stdout.Result, $stderr.Result) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + }) -join [Environment]::NewLine + $text = $text.Trim() + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" + } + return @{ ExitCode = $process.ExitCode; Text = $text } +} + +function Resolve-Artifact([string]$explicit, [string]$leaf) { + if (-not [string]::IsNullOrWhiteSpace($explicit)) { + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($explicit) + } + return (Join-Path $here $leaf) +} + function Get-MxcEtwSessions { $pattern = [regex]::Escape($SessionName) + '-(?[0-9]+)-[0-9a-fA-F]{32}-[0-9a-fA-F]{8}' foreach ($line in @(logman query -ets 2>$null)) { @@ -84,8 +152,8 @@ function Get-MxcEtwSessions { } } -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" +$gateway = Resolve-Artifact $GatewayPath "openshell-gateway.exe" +$cli = Resolve-Artifact $CliPath "openshell.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" @@ -96,6 +164,7 @@ $gw = $null $gatewayEtwSessions = @() $passed = $true $proxyOn = -not $NoProxy +$oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC try { # 1. Validate artifacts + privilege. @@ -116,7 +185,10 @@ try { throw "This run must open a real-time ETW session, which needs elevation. Re-run from an elevated shell (Run as administrator) or add this account to 'Performance Log Users'." } - if (-not (Test-Path $WxcExecPath)) { + if ($Mock) { + $WxcExecPath = Join-Path $here "mock-wxc-exec.exe" + Info "mock mode: using the in-process wxc shim" + } elseif (-not (Test-Path $WxcExecPath)) { throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath pointing at the real binary." } Info "wxc-exec: $WxcExecPath" @@ -163,14 +235,6 @@ try { 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 egress_proxy=$proxyVal" Info "workload cwd=$shareDirPolicy policy grant=$shareDirPolicy" @@ -218,14 +282,18 @@ try { # --config token: Start-Process -ArgumentList does not quote array elements, so a # config path containing a space gets split and the gateway's arg parser rejects it. $env:OPENSHELL_GATEWAY_CONFIG = $toml - Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } # 7. Start the gateway (background, TLS disabled on the loopback control plane). Step "Start gateway (OCSF audit on)" $gwLog = Join-Path $resultDir "gateway.log" $gwErrLog = Join-Path $resultDir "gateway.err.log" $gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--port", $Port, "--log-level", "info") ` + -ArgumentList @("--disable-tls", "--port", $Port, "--db-url", "sqlite::memory:", "--log-level", "info") ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog Info "gateway pid $($gw.Id); logs -> $(Split-Path $gwLog -Leaf) (+ .err)" @@ -252,10 +320,13 @@ 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)" } + $gatewayAdd = Invoke-Cli @("gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName) -AllowFailure + if ($gatewayAdd.Text) { Info $gatewayAdd.Text } + if ($gatewayAdd.ExitCode -ne 0 -and $gatewayAdd.Text -notmatch '(?i)already exists') { + throw "gateway registration failed (exit $($gatewayAdd.ExitCode)): $($gatewayAdd.Text)" + } + $gatewaySelect = Invoke-Cli @("gateway", "select", $GatewayName) + if ($gatewaySelect.Text) { Info $gatewaySelect.Text } Ok "selected gateway '$GatewayName'" # 10. Create N sandboxes. Each drives the Sandboxing provider -> a full OCSF @@ -265,10 +336,21 @@ try { 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)" } + $create = Invoke-Cli @( + "sandbox", "create", "--name", $name, "--policy", $policy, + "--driver-config-json", $driverConfig, "--no-tty" + ) -AllowFailure + if ($create.Text) { Info $create.Text } + if ($create.ExitCode -ne 0) { + Info "sandbox create returned exit $($create.ExitCode); workload and audit evidence will determine the verdict" + } Start-Sleep -Seconds 3 - try { & $cli sandbox delete $name 2>&1 | Out-Null } catch {} + try { Invoke-Cli @("sandbox", "delete", $name) -AllowFailure | Out-Null } catch {} + } + if ($Mock) { + Step "Wait for the ETW zero-event audit diagnostic" + Info "waiting $MockAuditWaitSeconds seconds for mxc-etw-zero-events" + Start-Sleep -Seconds $MockAuditWaitSeconds } } catch { @@ -295,6 +377,11 @@ finally { logman stop $session.Name -ets 2>&1 | Out-Null } } + if ($null -eq $oldMockWxc) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc + } # ---- summarise the OCSF audit trail -------------------------------------- $logText = @() @@ -314,7 +401,7 @@ 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 = @(); $zeroEventFinding = $false if ($jsonlPath) { $raw = @(Get-Content $jsonlPath -ErrorAction SilentlyContinue | Where-Object { $_.Trim() -ne "" }) $jsonlCount = $raw.Count @@ -322,8 +409,9 @@ finally { try { $o = $line | ConvertFrom-Json 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.container -and $o.container.uid) { $sids += [string]$o.container.uid } if ($o.device -and $o.device.hostname) { $hosts += [string]$o.device.hostname } + if ($o.finding_info -and $o.finding_info.uid -eq "mxc-etw-zero-events") { $zeroEventFinding = $true } } catch { $jsonlBad++ } } $sids = @($sids | Select-Object -Unique) @@ -359,7 +447,17 @@ finally { $findingsObserved = @($findingEvents.Values | Where-Object { $_ }).Count $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count $workloadCompleted = Test-Path $helloPath -PathType Leaf - if ($passed) { $passed = $consumerStarted -and (-not $consumerOverloaded) -and $workloadCompleted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($coreObserved -eq $coreExpected) } + $zeroEventWarning = Seen "(?i)MXC ETW->OCSF consumer has received zero events" + if ($passed) { + if ($Mock) { + $passed = $consumerStarted -and (-not $consumerFailed) -and (-not $consumerOverloaded) -and + $workloadCompleted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and + $zeroEventWarning -and $zeroEventFinding + } else { + $passed = $consumerStarted -and (-not $consumerOverloaded) -and $workloadCompleted -and + ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($coreObserved -eq $coreExpected) + } + } $verdict = if ($passed) { "PASS" } else { "FAIL" } $classLines = foreach ($uid in @(6002, 5019, 1007, 2004)) { " [{0}] {1,-28} : {2}" -f $uid, $classNames[$uid], $classCounts[$uid] } @@ -374,7 +472,9 @@ timestamp : $stamp machine : $env:COMPUTERNAME user : $env:USERNAME (admin=$admin perfLogUsers=$plu) verdict : $verdict +mode : $(if ($Mock) { 'mock-wiring' } else { 'real-mxc' }) event coverage : $coreObserved of $coreExpected expected event types fired (+ $findingsObserved anomaly finding(s)) +zero-event audit : $(if ($zeroEventWarning -and $zeroEventFinding) { 'warning + durable finding observed' } else { 'not observed' }) 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)' }) workload output : $(if ($workloadCompleted) { $helloPath } else { '(missing)' }) @@ -403,12 +503,19 @@ Files in this bundle ($resultDir): mxc-ocsf-audit.used.toml the exact gateway config used (wxc path patched) ocsf-audit.used.yaml the exact sandbox policy used -What PASS means: the gateway launched sandbox(es), the in-process ETW consumer +$(if ($Mock) { +"What PASS means: the gateway launched the mock workload, the ETW consumer +started, detected that sandbox activity produced zero provider events, and +wrote the mxc-etw-zero-events finding to durable OCSF JSONL. This does not +validate real Sandboxing-provider events, sandbox attribution, or MXC enforcement." +} else { +"What PASS means: the gateway launched sandbox(es), the in-process ETW consumer 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. +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." +}) "@ 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/examples/run-provider-credential-test.ps1 b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 index daffcbe424..7738737317 100644 --- a/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-provider-credential-test.ps1 @@ -24,6 +24,10 @@ # -GatewayPath .\target\x86_64-pc-windows-msvc\release\openshell-gateway.exe ` # -CliPath .\target\x86_64-pc-windows-msvc\release\openshell.exe # +# Hosted CI can pass -Mock with a synthetic GITHUB_TOKEN. That mode proves the +# provider/profile/policy wiring and placeholder-only child environment without +# making an external request. It is not proxy-substitution or MXC evidence. +# # PowerShell 5.1-compatible. The script never prints GITHUB_TOKEN and scans all # result artifacts for accidental raw-token leakage before creating the bundle. @@ -34,7 +38,8 @@ param( [string] $GatewayPath, [string] $CliPath, [int] $Port = 17670, - [string] $GatewayName = "openshell-mxc-provider-e2e" + [string] $GatewayName = "openshell-mxc-provider-e2e", + [switch] $Mock ) $ErrorActionPreference = "Stop" @@ -212,13 +217,16 @@ $artifactScanFailed = $false $failureReason = "" $failureStage = "initialization" $githubToken = $env:GITHUB_TOKEN +$oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC try { Step "Validate prerequisites" if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { throw "GITHUB_TOKEN is not set. Set it in this PowerShell session; do not pass it on the command line." } - foreach ($file in @($gateway, $cli, $powerShellExe, $probeTemplate, $tomlTemplate, $policyTemplate, $profileTemplate, $WxcExecPath)) { + $requiredFiles = @($gateway, $cli, $powerShellExe, $probeTemplate, $tomlTemplate, $policyTemplate, $profileTemplate) + if (-not $Mock) { $requiredFiles += $WxcExecPath } + foreach ($file in $requiredFiles) { if (-not (Test-Path $file)) { throw "missing artifact: $file" } Info "found $file" } @@ -227,24 +235,30 @@ try { if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { throw "gateway port $Port is already in use" } - $previous = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - $wxcProbeLines = & $WxcExecPath --probe 2>&1 - $wxcProbeExit = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previous - } - $wxcProbeText = (($wxcProbeLines | ForEach-Object { - if ($_ -is [System.Management.Automation.ErrorRecord]) { - $_.Exception.Message - } else { - $_.ToString() + if ($Mock) { + $WxcExecPath = Join-Path $here "mock-wxc-exec.exe" + [System.IO.File]::WriteAllText($wxcProbeFile, "mock mode: wxc-exec was not invoked`r`n", $utf8NoBom) + Info "mock mode: using the in-process wxc shim" + } else { + $previous = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $wxcProbeLines = & $WxcExecPath --probe 2>&1 + $wxcProbeExit = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + $wxcProbeText = (($wxcProbeLines | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.Exception.Message + } else { + $_.ToString() + } + }) -join [Environment]::NewLine).Trim() + [System.IO.File]::WriteAllText($wxcProbeFile, $wxcProbeText, $utf8NoBom) + if ($wxcProbeExit -ne 0) { + throw "wxc-exec --probe failed (exit $wxcProbeExit); inspect $wxcProbeFile" } - }) -join [Environment]::NewLine).Trim() - [System.IO.File]::WriteAllText($wxcProbeFile, $wxcProbeText, $utf8NoBom) - if ($wxcProbeExit -ne 0) { - throw "wxc-exec --probe failed (exit $wxcProbeExit); inspect $wxcProbeFile" } Ok "prerequisites available; token value was not printed" @@ -276,18 +290,20 @@ try { $profileText = [System.IO.File]::ReadAllText($profileTemplate, [System.Text.Encoding]::UTF8) $profileText = $profileText.Replace("C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", $powerShellFwd) [System.IO.File]::WriteAllText($profileUsed, $profileText, $utf8NoBom) + $probeCommand = @( + $powerShellFwd, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "$shareFwd/mxc-provider-credential-probe.ps1", + $shareFwd + ) + if ($Mock) { $probeCommand += "-Mock" } $driverConfig = @{ mxc = @{ - command = @( - $powerShellFwd, - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - "$shareFwd/mxc-provider-credential-probe.ps1", - $shareFwd - ) + command = $probeCommand cwd = $shareFwd } } | ConvertTo-Json -Compress -Depth 4 @@ -296,7 +312,11 @@ try { Step "Start gateway" $env:OPENSHELL_DRIVERS = "mxc" $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed - Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + } else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } # The CLI, not the gateway process environment, supplies the provider # credential. Temporarily remove GITHUB_TOKEN while spawning the gateway so # a successful test cannot be attributed to gateway environment inheritance. @@ -304,7 +324,7 @@ try { try { try { $gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info") ` + -ArgumentList @("--disable-tls", "--port", "$Port", "--db-url", "sqlite::memory:", "--log-level", "info") ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog } catch { @@ -380,7 +400,11 @@ try { throw "effective policy did not contain the attached provider's GitHub rule" } $passed = $true - Ok "placeholder isolation, authorized rewrite, and endpoint mismatch all passed" + if ($Mock) { + Ok "provider attachment and placeholder-only child environment passed" + } else { + Ok "placeholder isolation, authorized rewrite, and endpoint mismatch all passed" + } } catch { $failureReason = ($_.Exception.Message -replace '\r?\n', ' | ').Trim() @@ -399,6 +423,12 @@ finally { try { $gw.WaitForExit(5000) | Out-Null } catch {} } + if ($null -eq $oldMockWxc) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc + } + # Preserve probe output on both success and failure. These files contain # response bodies and redacted diagnostics, never the raw provider token. try { Copy-ProbeArtifacts } catch { Info "could not collect probe artifacts: $($_.Exception.GetType().Name)" } @@ -431,6 +461,7 @@ finally { OpenShell MXC provider credential example ========================================= verdict : $verdict +mode : $(if ($Mock) { "mock-wiring" } else { "real-mxc" }) sandbox : $sandboxName backend : process_container provider : $providerName @@ -438,11 +469,19 @@ share_path : $ShareDir stage : $failureStage failure : $(if ([string]::IsNullOrWhiteSpace($failureReason)) { "none" } else { $failureReason }) -PASS proves: +$(if ($Mock) { +"PASS proves: + - provider/profile/policy attachment produced a revision-scoped placeholder. + - the mock child received the placeholder instead of the raw token. + - the raw synthetic token did not appear in collected result artifacts. + - no external request was made; proxy substitution and MXC enforcement remain untested." +} else { +"PASS proves: - MXC received a revision-scoped GITHUB_TOKEN placeholder, not the token. - api.github.com accepted the credential after host-proxy substitution. - policy-allowed github.com could not resolve the api.github.com-bound placeholder. - - the raw token did not appear in collected result artifacts. + - the raw token did not appear in collected result artifacts." +}) "@ [System.IO.File]::WriteAllText((Join-Path $resultDir "summary.txt"), $summary, $utf8NoBom) Write-Host "`n$summary" -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 6f68dc5e3d..2de7d26fa5 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -36,11 +36,33 @@ fn mock_enabled() -> bool { } /// Normalize a path/command fragment to lowercase backslash form for the mock's -/// in-policy substring check. +/// in-policy path check. fn mock_normalize(s: &str) -> String { s.replace('/', "\\").to_lowercase() } +fn mock_command_references_grant(command: &str, grant: &str) -> bool { + if grant.is_empty() { + return false; + } + + command.match_indices(grant).any(|(start, _)| { + let before = command[..start].chars().next_back(); + let after = command[start + grant.len()..].chars().next(); + let is_shell_boundary = |ch: char| { + ch.is_whitespace() + || matches!( + ch, + '"' | '\'' | '=' | '>' | '<' | '(' | ')' | '&' | '|' | ';' + ) + }; + let starts_at_boundary = before.is_none_or(is_shell_boundary); + let ends_at_boundary = + grant.ends_with('\\') || after.is_none_or(|ch| ch == '\\' || is_shell_boundary(ch)); + starts_at_boundary && ends_at_boundary + }) +} + /// 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. @@ -658,7 +680,9 @@ impl WxcExecInvoker { grants: &[String], ) -> Result { let cmd_norm = mock_normalize(&process.command_line); - let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g)); + let in_policy = grants + .iter() + .any(|grant| mock_command_references_grant(&cmd_norm, grant)); let command_shell = std::env::var_os("COMSPEC") .unwrap_or_else(|| std::ffi::OsString::from(r"C:\Windows\System32\cmd.exe")); @@ -1009,6 +1033,33 @@ mod tests { ); } + #[test] + fn mock_grant_matching_respects_path_component_boundaries() { + let grant = mock_normalize(r"C:\work\demo"); + + for command in [ + r"echo ok > C:\work\demo", + r"echo ok > C:\work\demo\result.txt", + r#"echo ok > "C:/work/demo/result.txt""#, + ] { + assert!(mock_command_references_grant( + &mock_normalize(command), + &grant + )); + } + + for command in [ + r"echo denied > C:\work\demo-ro-src\result.txt", + r"echo denied > C:\work\demonstration\result.txt", + r"echo denied > XC:\work\demo\result.txt", + ] { + assert!(!mock_command_references_grant( + &mock_normalize(command), + &grant + )); + } + } + #[test] fn oneshot_config_json_emits_typed_ui_policy() { let filesystem = MxcFilesystem::default(); diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index 9fe867f4ba..5fb7f33d69 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Drift guards for the two shipped Windows inference demos. +//! Drift guards for the shipped Windows MXC demos. use std::path::{Path, PathBuf}; @@ -38,6 +38,105 @@ fn read_example(name: &str) -> String { .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) } +#[test] +fn shipped_provider_credential_assets_support_mock_wiring_validation() { + for name in [ + "mxc-provider-credential.toml", + "mxc-provider-credential-policy.yaml", + "mxc-github-provider-profile.yml", + "mxc-provider-credential-probe.ps1", + "run-provider-credential-test.ps1", + ] { + assert!( + examples_root().join(name).is_file(), + "shipped provider-credential asset is missing: {name}" + ); + } + + let runner = read_example("run-provider-credential-test.ps1"); + let probe = read_example("mxc-provider-credential-probe.ps1"); + let config: Value = toml::from_str(&read_example("mxc-provider-credential.toml")) + .expect("provider-credential config must parse"); + assert_eq!( + config + .get("openshell") + .and_then(Value::as_table) + .and_then(|openshell| openshell.get("version")) + .and_then(Value::as_integer), + Some(2) + ); + assert!(runner.contains("[switch] $Mock")); + assert!(runner.contains("OPENSHELL_MXC_MOCK_WXC")); + assert!(runner.contains("--provider")); + assert!(runner.contains("--credential")); + assert!(probe.contains("revision-scoped GITHUB_TOKEN placeholder")); + assert!(probe.contains("no external network request")); +} + +#[test] +fn shipped_ocsf_audit_assets_support_mock_diagnostic_validation() { + for name in [ + "mxc-ocsf-audit.toml", + "ocsf-audit.yaml", + "run-ocsf-audit.ps1", + ] { + assert!( + examples_root().join(name).is_file(), + "shipped OCSF audit asset is missing: {name}" + ); + } + + let config: Value = + toml::from_str(&read_example("mxc-ocsf-audit.toml")).expect("OCSF audit config must parse"); + assert_eq!( + config + .get("openshell") + .and_then(Value::as_table) + .and_then(|openshell| openshell.get("version")) + .and_then(Value::as_integer), + Some(2) + ); + let runner = read_example("run-ocsf-audit.ps1"); + assert!(runner.contains("[switch] $Mock")); + assert!(runner.contains("mxc-etw-zero-events")); + assert!(runner.contains("OPENSHELL_MXC_MOCK_WXC")); +} + +#[test] +fn shipped_aggregate_e2e_assets_support_mock_wiring_validation() { + for name in [ + "mxc-gateway.toml", + "run-mxc-e2e.ps1", + "e2e-policies/fs-rw.yaml", + "e2e-policies/fs-readonly.yaml", + "e2e-policies/fs-empty.yaml", + "e2e-policies/network-reject.yaml", + ] { + assert!( + examples_root().join(name).is_file(), + "shipped aggregate E2E asset is missing: {name}" + ); + } + + let config: Value = + toml::from_str(&read_example("mxc-gateway.toml")).expect("MXC E2E config must parse"); + assert_eq!( + config + .get("openshell") + .and_then(Value::as_table) + .and_then(|openshell| openshell.get("version")) + .and_then(Value::as_integer), + Some(2) + ); + let runner = read_example("run-mxc-e2e.ps1"); + assert!(runner.contains("[switch] $Mock")); + assert!(runner.contains("OPENSHELL_MXC_MOCK_WXC")); + for scenario in ["fs-rw", "fs-readonly", "fs-default-deny", "network-reject"] { + assert!(runner.contains(scenario)); + } + assert!(runner.contains("not evidence of native MXC or OS enforcement")); +} + #[test] fn shipped_audit_and_websocket_configs_use_current_schema() { for name in ["mxc-ocsf-audit.toml", "mxc-ws-gateway.toml"] { @@ -194,7 +293,14 @@ fn shipped_runners_supply_sandbox_scoped_workload_configuration() { #[cfg(target_os = "windows")] #[test] fn shipped_runners_parse_in_windows_powershell() { - for name in ["run-ollama-test.ps1", "run-inference-test.ps1"] { + for name in [ + "run-ollama-test.ps1", + "run-inference-test.ps1", + "run-provider-credential-test.ps1", + "mxc-provider-credential-probe.ps1", + "run-ocsf-audit.ps1", + "run-mxc-e2e.ps1", + ] { let path = examples_root().join(name); let script = r" $errors = $null diff --git a/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs b/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs new file mode 100644 index 0000000000..e9c4852bfe --- /dev/null +++ b/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(target_os = "windows")] + +use std::path::Path; + +#[test] +fn task_harnesses_isolate_and_restore_gateway_configuration() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let scripts_root = repo_root.join("tasks/scripts"); + for name in [ + "windows-mxc-provider-credential-e2e.ps1", + "windows-mxc-ocsf-audit-e2e.ps1", + "windows-mxc-aggregate-e2e.ps1", + ] { + let source = std::fs::read_to_string(scripts_root.join(name)) + .unwrap_or_else(|error| panic!("failed to read {name}: {error}")); + assert!(source.contains("Push-OpenShellMxcE2eEnvironment")); + assert!(source.contains("Pop-OpenShellMxcE2eEnvironment")); + } + + let helper = scripts_root.join("windows-mxc-e2e-environment.ps1"); + let temp = tempfile::tempdir().expect("temporary directory"); + let stage = temp.path().join("artifact root with spaces").join("stage"); + let script = r#" +$ErrorActionPreference = "Stop" +. $env:OPENSHELL_MXC_ENVIRONMENT_HELPER +$names = @( + "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" +) +foreach ($name in $names) { + [System.Environment]::SetEnvironmentVariable($name, "caller-$name", "Process") +} + +$snapshot = Push-OpenShellMxcE2eEnvironment -StageDir $env:OPENSHELL_MXC_ENVIRONMENT_STAGE +try { + foreach ($name in @("APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME")) { + $value = [System.Environment]::GetEnvironmentVariable($name, "Process") + if (-not $value.StartsWith($env:OPENSHELL_MXC_ENVIRONMENT_STAGE, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "$name escaped the staging directory: $value" + } + } + foreach ($name in @("OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_INSECURE", "OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY_NAME")) { + if ($null -ne [System.Environment]::GetEnvironmentVariable($name, "Process")) { + throw "$name was inherited by the isolated harness" + } + } +} finally { + Pop-OpenShellMxcE2eEnvironment -Snapshot $snapshot +} + +foreach ($name in $names) { + $value = [System.Environment]::GetEnvironmentVariable($name, "Process") + if ($value -ne "caller-$name") { + throw "$name was not restored: $value" + } +} +"#; + + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_MXC_ENVIRONMENT_HELPER", helper) + .env("OPENSHELL_MXC_ENVIRONMENT_STAGE", stage) + .output() + .expect("failed to launch Windows PowerShell"); + assert!( + output.status.success(), + "environment isolation check failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn aggregate_harness_exercises_a_staging_path_with_spaces() { + let harness = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tasks/scripts/windows-mxc-aggregate-e2e.ps1"); + let source = std::fs::read_to_string(harness).expect("aggregate harness source"); + assert!(source.contains("openshell mxc aggregate e2e-$Target")); + + let runner_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/run-mxc-e2e.ps1"); + let runner = std::fs::read_to_string(&runner_path).expect("aggregate example runner source"); + assert!(runner.contains("function New-CmdWriteCommand")); + + let temp = tempfile::tempdir().expect("temporary directory"); + let stage = temp.path().join("artifact root with spaces"); + std::fs::create_dir_all(&stage).expect("create staging directory"); + 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 -gt 0) { + throw "runner has PowerShell syntax errors: $($errors.Message -join '; ')" +} +$function = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq "New-CmdWriteCommand" +}, $true) +if ($null -eq $function) { throw "New-CmdWriteCommand was not found" } +Invoke-Expression $function.Extent.Text + +$target = Join-Path $env:OPENSHELL_MXC_SPACE_STAGE "write proof.txt" +$command = New-CmdWriteCommand "ok" $target +& $env:ComSpec /d /s /c $command +if ($LASTEXITCODE -ne 0) { throw "cmd.exe failed with exit $LASTEXITCODE" } +if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + throw "quoted command did not create the expected artifact: $target" +} +if ((Get-Content -LiteralPath $target -Raw).Trim() -ne "ok") { + throw "quoted command wrote unexpected contents" +} +"#; + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_MXC_RUNNER", runner_path) + .env("OPENSHELL_MXC_SPACE_STAGE", stage) + .output() + .expect("failed to launch Windows PowerShell"); + assert!( + output.status.success(), + "space-path command check failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tasks/scripts/windows-mxc-aggregate-e2e.ps1 b/tasks/scripts/windows-mxc-aggregate-e2e.ps1 new file mode 100644 index 0000000000..6ffaa6d37b --- /dev/null +++ b/tasks/scripts/windows-mxc-aggregate-e2e.ps1 @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs the shipped aggregate MXC scenario runner with the in-process wxc mock. +# This validates example, CLI, gateway, and driver wiring; it does not claim +# native MXC or Windows isolation enforcement coverage. + +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $Mock, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +. (Join-Path $PSScriptRoot "windows-mxc-e2e-environment.ps1") + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-aggregate-e2e.ps1 requires Windows." +} +if (-not $Mock) { + throw "windows-mxc-aggregate-e2e.ps1 only supports mock mode; pass -Mock." +} + +$Target = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "unsupported native Windows architecture: $($_)" } +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$ExamplesRoot = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$TargetDir = if ([string]::IsNullOrWhiteSpace($env:CARGO_TARGET_DIR)) { + Join-Path $RepoRoot "target" +} else { + [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) +} + +if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + $GatewayPath = Join-Path $TargetDir "$Target\release\openshell-gateway.exe" +} +if ([string]::IsNullOrWhiteSpace($CliPath)) { + $CliPath = Join-Path $TargetDir "$Target\release\openshell.exe" +} +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) + +foreach ($artifact in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "required release artifact is missing: $artifact" + } +} + +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$StageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell mxc aggregate e2e-$Target")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $StageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "refusing to use staging path outside artifact root: $StageDir" +} +if (Test-Path -LiteralPath $StageDir) { + Remove-Item -LiteralPath $StageDir -Recurse -Force +} +New-Item -ItemType Directory -Path $StageDir | Out-Null + +foreach ($fixture in @("run-mxc-e2e.ps1", "mxc-gateway.toml")) { + Copy-Item -LiteralPath (Join-Path $ExamplesRoot $fixture) -Destination $StageDir +} +Copy-Item -LiteralPath (Join-Path $ExamplesRoot "e2e-policies") -Destination $StageDir -Recurse + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +$passed = $false +$environmentSnapshot = $null + +try { + $environmentSnapshot = Push-OpenShellMxcE2eEnvironment -StageDir $StageDir + + $runner = Join-Path $StageDir "run-mxc-e2e.ps1" + $demoDir = Join-Path $StageDir "demo" + $port = Get-AvailablePort + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $runner ` + -Mock ` + -GatewayPath $GatewayPath ` + -CliPath $CliPath ` + -DemoDir $demoDir ` + -Port $port ` + -GatewayName "openshell-mxc-aggregate-ci" 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped aggregate MXC example failed in mock mode (exit $exitCode)" + } + + $resultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-e2e-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $resultDir) { throw "aggregate MXC example did not produce a results directory" } + + $summary = Get-Content -LiteralPath (Join-Path $resultDir.FullName "summary.txt") -Raw + if ($summary -notmatch '(?m)^verdict\s+: PASS\s*$' -or + $summary -notmatch '(?m)^mode\s+: MOCK\s*$' -or + $summary -notmatch '(?m)^totals\s+: PASS=4\s+FAIL=0\s+SKIP=0\s*$' -or + $summary -notmatch '(?m)^Mock mode validates runner/gateway/driver wiring') { + throw "aggregate MXC summary did not report the expected four-scenario mock PASS: $summary" + } + + if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { + throw "aggregate MXC example left gateway port $port listening" + } + + $passed = $true + Write-Host "MXC aggregate example mock E2E passed for $Target" +} catch { + Write-Host "MXC aggregate example mock E2E failed: $($_.Exception.Message)" -ForegroundColor Red + Get-ChildItem -LiteralPath $StageDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in @(".log", ".txt") } | + ForEach-Object { + Write-Host "--- $($_.FullName) ---" + Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + throw +} finally { + if ($null -ne $environmentSnapshot) { + Pop-OpenShellMxcE2eEnvironment -Snapshot $environmentSnapshot + } + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $StageDir)) { + Remove-Item -LiteralPath $StageDir -Recurse -Force + } else { + Write-Host "MXC aggregate example artifacts: $StageDir" + } +} diff --git a/tasks/scripts/windows-mxc-e2e-environment.ps1 b/tasks/scripts/windows-mxc-e2e-environment.ps1 new file mode 100644 index 0000000000..e4d3b2eb44 --- /dev/null +++ b/tasks/scripts/windows-mxc-e2e-environment.ps1 @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +function Push-OpenShellMxcE2eEnvironment { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $StageDir + ) + + $isolatedValues = [ordered]@{ + APPDATA = Join-Path $StageDir "appdata" + LOCALAPPDATA = Join-Path $StageDir "localappdata" + XDG_CONFIG_HOME = Join-Path $StageDir "xdg-config" + XDG_STATE_HOME = Join-Path $StageDir "xdg-state" + XDG_DATA_HOME = Join-Path $StageDir "xdg-data" + OPENSHELL_GATEWAY = $null + OPENSHELL_GATEWAY_ENDPOINT = $null + OPENSHELL_GATEWAY_INSECURE = $null + OPENSHELL_GATEWAY_CONFIG = $null + OPENSHELL_GATEWAY_NAME = $null + } + + New-Item -ItemType Directory -Force -Path @( + $isolatedValues.APPDATA + $isolatedValues.LOCALAPPDATA + $isolatedValues.XDG_CONFIG_HOME + $isolatedValues.XDG_STATE_HOME + $isolatedValues.XDG_DATA_HOME + ) | Out-Null + + $savedValues = @{} + foreach ($name in $isolatedValues.Keys) { + $savedValues[$name] = [System.Environment]::GetEnvironmentVariable( + $name, + [System.EnvironmentVariableTarget]::Process + ) + } + + try { + foreach ($entry in $isolatedValues.GetEnumerator()) { + [System.Environment]::SetEnvironmentVariable( + $entry.Key, + $entry.Value, + [System.EnvironmentVariableTarget]::Process + ) + } + } catch { + foreach ($entry in $savedValues.GetEnumerator()) { + [System.Environment]::SetEnvironmentVariable( + $entry.Key, + $entry.Value, + [System.EnvironmentVariableTarget]::Process + ) + } + throw + } + + return [pscustomobject]@{ Values = $savedValues } +} + +function Pop-OpenShellMxcE2eEnvironment { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [psobject] $Snapshot + ) + + foreach ($entry in $Snapshot.Values.GetEnumerator()) { + [System.Environment]::SetEnvironmentVariable( + $entry.Key, + $entry.Value, + [System.EnvironmentVariableTarget]::Process + ) + } +} diff --git a/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 b/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 new file mode 100644 index 0000000000..e94681fd1a --- /dev/null +++ b/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs the shipped MXC OCSF audit example with the in-process wxc mock. The test +# requires the ETW consumer's durable zero-provider-events finding; it does not +# claim real Sandboxing-provider coverage or MXC enforcement. + +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $Mock, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +. (Join-Path $PSScriptRoot "windows-mxc-e2e-environment.ps1") + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-ocsf-audit-e2e.ps1 requires Windows." +} +if (-not $Mock) { + throw "windows-mxc-ocsf-audit-e2e.ps1 only supports mock mode; pass -Mock." +} + +$Target = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "unsupported native Windows architecture: $($_)" } +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$ExamplesRoot = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$TargetDir = if ([string]::IsNullOrWhiteSpace($env:CARGO_TARGET_DIR)) { + Join-Path $RepoRoot "target" +} else { + [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) +} + +if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + $GatewayPath = Join-Path $TargetDir "$Target\release\openshell-gateway.exe" +} +if ([string]::IsNullOrWhiteSpace($CliPath)) { + $CliPath = Join-Path $TargetDir "$Target\release\openshell.exe" +} +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) + +foreach ($artifact in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "required release artifact is missing: $artifact" + } +} + +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$StageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-ocsf-audit-e2e-$Target")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $StageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "refusing to use staging path outside artifact root: $StageDir" +} +if (Test-Path -LiteralPath $StageDir) { + Remove-Item -LiteralPath $StageDir -Recurse -Force +} +New-Item -ItemType Directory -Path $StageDir | Out-Null + +foreach ($fixture in @("run-ocsf-audit.ps1", "mxc-ocsf-audit.toml", "ocsf-audit.yaml")) { + Copy-Item -LiteralPath (Join-Path $ExamplesRoot $fixture) -Destination $StageDir +} + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +$passed = $false +$environmentSnapshot = $null + +try { + $environmentSnapshot = Push-OpenShellMxcE2eEnvironment -StageDir $StageDir + + $runner = Join-Path $StageDir "run-ocsf-audit.ps1" + $share = Join-Path $StageDir "share" + $port = Get-AvailablePort + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $runner ` + -Mock ` + -GatewayPath $GatewayPath ` + -CliPath $CliPath ` + -ShareDir $share ` + -SandboxCount 1 ` + -Port $port ` + -GatewayName "openshell-mxc-ocsf-ci" 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped OCSF audit example failed in mock mode (exit $exitCode)" + } + + $resultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $resultDir) { throw "OCSF audit example did not produce a results directory" } + + $summary = Get-Content -LiteralPath (Join-Path $resultDir.FullName "summary.txt") -Raw + if ($summary -notmatch '(?m)^verdict\s+: PASS\s*$' -or + $summary -notmatch '(?m)^mode\s+: mock-wiring\s*$' -or + $summary -notmatch '(?m)^zero-event audit\s+: warning \+ durable finding observed\s*$') { + throw "OCSF audit summary did not report the expected mock-wiring PASS: $summary" + } + + $jsonl = Get-ChildItem -LiteralPath $resultDir.FullName -File -Filter "openshell-ocsf*.log" | + Select-Object -First 1 + if ($null -eq $jsonl) { throw "OCSF audit example did not produce a JSONL audit log" } + $findingObserved = $false + foreach ($line in Get-Content -LiteralPath $jsonl.FullName) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $event = $line | ConvertFrom-Json + $findingInfo = $event.PSObject.Properties["finding_info"] + if ($findingInfo -and $findingInfo.Value.uid -eq "mxc-etw-zero-events") { + $findingObserved = $true + } + } + if (-not $findingObserved) { + throw "OCSF JSONL did not contain the mxc-etw-zero-events finding" + } + + $passed = $true + Write-Host "MXC OCSF audit example mock E2E passed for $Target" +} catch { + Write-Host "MXC OCSF audit example mock E2E failed: $($_.Exception.Message)" -ForegroundColor Red + Get-ChildItem -LiteralPath $StageDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in @(".log", ".txt") } | + ForEach-Object { + Write-Host "--- $($_.FullName) ---" + Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + throw +} finally { + if ($null -ne $environmentSnapshot) { + Pop-OpenShellMxcE2eEnvironment -Snapshot $environmentSnapshot + } + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $StageDir)) { + Remove-Item -LiteralPath $StageDir -Recurse -Force + } else { + Write-Host "MXC OCSF audit example artifacts: $StageDir" + } +} diff --git a/tasks/scripts/windows-mxc-provider-credential-e2e.ps1 b/tasks/scripts/windows-mxc-provider-credential-e2e.ps1 new file mode 100644 index 0000000000..c76f6d5bf1 --- /dev/null +++ b/tasks/scripts/windows-mxc-provider-credential-e2e.ps1 @@ -0,0 +1,164 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Runs the shipped MXC provider-credential example with a synthetic credential +# and the in-process wxc mock. This proves wiring and placeholder propagation; +# it does not prove host-proxy substitution or MXC enforcement. + +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $Mock, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +. (Join-Path $PSScriptRoot "windows-mxc-e2e-environment.ps1") + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-provider-credential-e2e.ps1 requires Windows." +} +if (-not $Mock) { + throw "windows-mxc-provider-credential-e2e.ps1 only supports mock mode; pass -Mock." +} + +$Target = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "unsupported native Windows architecture: $($_)" } +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$ExamplesRoot = Join-Path $RepoRoot "crates\openshell-driver-mxc\examples" +$TargetDir = if ([string]::IsNullOrWhiteSpace($env:CARGO_TARGET_DIR)) { + Join-Path $RepoRoot "target" +} else { + [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) +} + +if ([string]::IsNullOrWhiteSpace($GatewayPath)) { + $GatewayPath = Join-Path $TargetDir "$Target\release\openshell-gateway.exe" +} +if ([string]::IsNullOrWhiteSpace($CliPath)) { + $CliPath = Join-Path $TargetDir "$Target\release\openshell.exe" +} +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) + +foreach ($artifact in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "required release artifact is missing: $artifact" + } +} + +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { + [System.IO.Path]::GetTempPath() + } else { + $env:RUNNER_TEMP + } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$StageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-provider-credential-e2e-$Target")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $StageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "refusing to use staging path outside artifact root: $StageDir" +} +if (Test-Path -LiteralPath $StageDir) { + Remove-Item -LiteralPath $StageDir -Recurse -Force +} +New-Item -ItemType Directory -Path $StageDir | Out-Null + +$fixtures = @( + "run-provider-credential-test.ps1", + "mxc-provider-credential-probe.ps1", + "mxc-provider-credential.toml", + "mxc-provider-credential-policy.yaml", + "mxc-github-provider-profile.yml" +) +foreach ($fixture in $fixtures) { + Copy-Item -LiteralPath (Join-Path $ExamplesRoot $fixture) -Destination $StageDir +} + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +$SyntheticToken = "openshell-ci-synthetic-provider-value" +$passed = $false +$environmentSnapshot = $null +$oldGitHubToken = $env:GITHUB_TOKEN + +try { + $environmentSnapshot = Push-OpenShellMxcE2eEnvironment -StageDir $StageDir + $env:GITHUB_TOKEN = $SyntheticToken + + $runner = Join-Path $StageDir "run-provider-credential-test.ps1" + $share = Join-Path $StageDir "share" + $port = Get-AvailablePort + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $runner ` + -Mock ` + -GatewayPath $GatewayPath ` + -CliPath $CliPath ` + -ShareDir $share ` + -Port $port ` + -GatewayName "openshell-mxc-provider-ci" 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped provider-credential example failed in mock mode (exit $exitCode)" + } + + $resultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-provider-credential-*" | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $resultDir) { throw "provider-credential example did not produce a results directory" } + + $summary = Get-Content -LiteralPath (Join-Path $resultDir.FullName "summary.txt") -Raw + if ($summary -notmatch '(?m)^verdict\s+: PASS\s*$' -or $summary -notmatch '(?m)^mode\s+: mock-wiring\s*$') { + throw "provider-credential summary did not report a mock-wiring PASS: $summary" + } + $probe = Get-Content -LiteralPath (Join-Path $resultDir.FullName "mxc-provider-credential-result.txt") -Raw + if ($probe -notmatch 'revision-scoped GITHUB_TOKEN placeholder' -or $probe -notmatch 'no external network request') { + throw "provider-credential probe did not prove the expected mock path: $probe" + } + + foreach ($file in Get-ChildItem -LiteralPath $StageDir -File -Recurse) { + $contents = [System.IO.File]::ReadAllText($file.FullName) + if ($contents.Contains($SyntheticToken)) { + throw "synthetic credential leaked into test artifact: $($file.FullName)" + } + } + + $passed = $true + Write-Host "MXC provider-credential example mock E2E passed for $Target" +} catch { + Write-Host "MXC provider-credential example mock E2E failed: $($_.Exception.Message)" -ForegroundColor Red + Get-ChildItem -LiteralPath $StageDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in @(".log", ".txt") } | + ForEach-Object { + Write-Host "--- $($_.FullName) ---" + Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } + } + throw +} finally { + $env:GITHUB_TOKEN = $oldGitHubToken + if ($null -ne $environmentSnapshot) { + Pop-OpenShellMxcE2eEnvironment -Snapshot $environmentSnapshot + } + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $StageDir)) { + Remove-Item -LiteralPath $StageDir -Recurse -Force + } else { + Write-Host "MXC provider-credential example artifacts: $StageDir" + } +} diff --git a/tasks/windows.toml b/tasks/windows.toml index c533dd53a9..2db94c9c97 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -109,6 +109,21 @@ description = "Run MXC Tier-3 e2e scenario runner in mock/wiring-only mode (no r 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" +["windows:e2e:mxc:provider-mock"] +description = "Run the shipped MXC provider-credential example with a synthetic credential and the in-process wxc mock on native Windows" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-provider-credential-e2e.ps1 -Mock" + +["windows:e2e:mxc:ocsf-mock"] +description = "Run the shipped MXC OCSF audit example and validate the zero-provider-events finding with the in-process wxc mock on native Windows" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 -Mock" + +["windows:e2e:mxc:aggregate-mock"] +description = "Run all four shipped aggregate MXC scenarios with the in-process wxc mock on native Windows" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-aggregate-e2e.ps1 -Mock" + ["windows:e2e:mxc:inference-mock:x64"] description = "Run the shipped MXC inference demos against the in-process wxc mock and a local API stub on Windows x64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" From 8f22fe84f617faea56a4db3adb7ed445ccda7022 Mon Sep 17 00:00:00 2001 From: araza008 <159492532+araza008@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:13:26 -0500 Subject: [PATCH 29/39] =?UTF-8?q?ci(windows):=20run=20MXC=20host=20probe,?= =?UTF-8?q?=20WebSocket=20agent,=20and=20OpenClaw=20forwar=E2=80=A6=20(#38?= =?UTF-8?q?26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(windows): run MXC host probe, WebSocket agent, and OpenClaw forward examples checks Add separate hosted CI tasks for the MXC host probe, WebSocket agent, and OpenClaw forward examples. Use mock workloads to verify gateway, CLI, driver, and sandbox lifecycle wiring without requiring wxc-exec. Document that mock passes do not validate forwarding or MXC enforcement. * fix(tests): enhance environment isolation for WebSocket and OpenClaw mock tests * fix(mxc): enhance OpenClaw mock validation to require 'Ready' sandbox state * fix(mxc): restore native process helper in WebSocket example Restore Invoke-NativeCaptured for the PowerShell argument regression test and delegate CLI execution through it while preserving explicit gateway endpoint selection. Signed-off-by: Akber Raza --------- Signed-off-by: Akber Raza --- .../build-openshell-mxc-windows/SKILL.md | 14 ++ .github/workflows/windows-msvc.yml | 18 ++ architecture/windows.md | 11 + crates/openshell-driver-mxc/README.md | 3 + .../examples/README-openclaw-forward.txt | 4 + .../examples/run-openclaw-forward-mock.ps1 | 185 +++++++++++++++ .../examples/run-openclaw-forward-test.ps1 | 30 ++- .../examples/run-ws-agent-test.ps1 | 220 +++++++++++------- tasks/scripts/windows-mxc-host-probe-e2e.ps1 | 55 +++++ .../windows-mxc-openclaw-forward-mock-e2e.ps1 | 104 +++++++++ .../scripts/windows-mxc-ws-agent-mock-e2e.ps1 | 107 +++++++++ tasks/windows.toml | 15 ++ 12 files changed, 673 insertions(+), 93 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/run-openclaw-forward-mock.ps1 create mode 100644 tasks/scripts/windows-mxc-host-probe-e2e.ps1 create mode 100644 tasks/scripts/windows-mxc-openclaw-forward-mock-e2e.ps1 create mode 100644 tasks/scripts/windows-mxc-ws-agent-mock-e2e.ps1 diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index a6d86395bb..6b6820e1ea 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -254,6 +254,17 @@ the label alone does not start a run: re-run all jobs in the current mirror push run, or push a new mirrored commit. The binaries are not uploaded or published. +Native x64 and ARM64 jobs run `windows:e2e:mxc:host-probe` separately, then +build release binaries and run `windows:e2e:mxc:ws-agent-mock` and +`windows:e2e:mxc:openclaw-forward-mock` as independent steps. The probe +reports an explicitly absent `wxc-exec`; the other runners use the in-process +mock to verify gateway, CLI, driver, and workload-proof wiring. A pass is not +evidence of WebSocket forwarding, OpenClaw behavior, proxy substitution, or +MXC enforcement. The mock runners stage isolated XDG config, state, and data +directories and pin sandbox calls to their disposable gateway endpoints. The +OpenClaw mock requires a successful CLI create and a `Ready` sandbox after the +workload proof. + 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 @@ -302,6 +313,9 @@ crypto dependency builds. | `windows:e2e:mxc:provider-mock` | Runs the shipped provider-credential demo with a synthetic credential and verifies placeholder propagation without leaking the credential. | | `windows:e2e:mxc:ocsf-mock` | Runs the shipped OCSF audit demo and verifies the durable zero-provider-events finding. | | `windows:e2e:mxc:aggregate-mock` | Runs the four aggregate MXC scenarios through the mock, including a staging path containing spaces. | +| `windows:e2e:mxc:host-probe` | Runs the shipped MXC host probe against an explicitly absent `wxc-exec`. | +| `windows:e2e:mxc:ws-agent-mock` | Runs the shipped WebSocket agent example in wiring-only mode on the native Windows host. | +| `windows:e2e:mxc:openclaw-forward-mock` | Runs the shipped OpenClaw forward example in wiring-only mode on the native Windows host. | | `windows:qualify:mxc:gb300:contract` | Validates the required/optional/unsupported/architecture-constrained GB300 matrix. | | `windows:qualify:mxc:gb300` | Runs the fail-closed GB300 ARM64 gate and validates hash-bound evidence. | | `windows:artifacts` | Reports size and SHA256 for release artifacts that exist. | diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml index 34ebd9499f..00b761a04c 100644 --- a/.github/workflows/windows-msvc.yml +++ b/.github/workflows/windows-msvc.yml @@ -98,7 +98,10 @@ jobs: run: mise run --skip-tools windows:lint:${{ matrix.arch }} - name: Test run: mise run --skip-tools windows:test:${{ matrix.arch }} + - name: Probe MXC host capabilities + run: mise run --skip-tools windows:e2e:mxc:host-probe - name: Build MXC example E2E binaries + id: mxc_build run: mise run --skip-tools windows:build:${{ matrix.arch }} - name: Run shipped MXC provider-credential example in mock mode run: mise run --skip-tools windows:e2e:mxc:provider-mock @@ -108,6 +111,12 @@ jobs: run: mise run --skip-tools windows:e2e:mxc:aggregate-mock - name: Run shipped MXC inference examples with mock API run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} + - name: Run MXC WebSocket agent example in mock mode + if: ${{ !cancelled() && steps.mxc_build.outcome == 'success' }} + run: mise run --skip-tools windows:e2e:mxc:ws-agent-mock + - name: Run MXC OpenClaw forward example in mock mode + if: ${{ !cancelled() && steps.mxc_build.outcome == 'success' }} + run: mise run --skip-tools windows:e2e:mxc:openclaw-forward-mock - name: sccache stats if: always() run: sccache --show-stats @@ -219,7 +228,10 @@ jobs: cache-targets: "true" cache-on-failure: "true" cache-bin: "false" + - name: Probe MXC host capabilities + run: mise run --skip-tools windows:e2e:mxc:host-probe - name: Build release binaries + id: mxc_build run: mise run --skip-tools windows:build:${{ matrix.arch }} - name: Run shipped MXC provider-credential example in mock mode run: mise run --skip-tools windows:e2e:mxc:provider-mock @@ -229,6 +241,12 @@ jobs: run: mise run --skip-tools windows:e2e:mxc:aggregate-mock - name: Run shipped MXC inference examples with mock API run: mise run --skip-tools windows:e2e:mxc:inference-mock:${{ matrix.arch }} + - name: Run MXC WebSocket agent example in mock mode + if: ${{ !cancelled() && steps.mxc_build.outcome == 'success' }} + run: mise run --skip-tools windows:e2e:mxc:ws-agent-mock + - name: Run MXC OpenClaw forward example in mock mode + if: ${{ !cancelled() && steps.mxc_build.outcome == 'success' }} + run: mise run --skip-tools windows:e2e:mxc:openclaw-forward-mock - name: sccache stats if: always() run: sccache --show-stats diff --git a/architecture/windows.md b/architecture/windows.md index 8180f25762..2dc5f5a805 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -264,6 +264,17 @@ Windows validation separates source correctness from host capability: sandbox-scoped credential propagation, and response paths without a model or external credential, but it does not prove MXC isolation, proxy substitution, or network enforcement. +- Hosted Windows CI runs the shipped MXC host probe, WebSocket example, and + OpenClaw example as separate steps. The probe uses an explicitly absent + `wxc-exec`; the other two use the in-process mock. They check the + unavailable-backend report, sandbox lifecycle, and workload proof files. + The OpenClaw mock requires a successful create, a workload proof, and a + `Ready` sandbox before reporting a pass. + The wrappers isolate CLI config, state, and data directories, and direct + sandbox calls to their temporary loopback gateways. The mock disables relay + wrapping because it has no control channel. These runs do not validate + WebSocket connectivity, OpenClaw, dynamic forwarding, proxy behavior, or MXC + isolation. - Real-`wxc-exec` tests validate the installed schema and selected filesystem, UI, network, and lifecycle behavior. A probe-gated skip is useful diagnostic output, not qualification evidence. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 32b2908ad3..7cc86409eb 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -276,6 +276,9 @@ is deliberately strict and fails on every required skip. | `windows:test:mxc-gb300:arm64` | Required ARM64 ProcessContainer cases from `tests/wxc_exec_real.rs`; rejects x64 and every required `SKIP` | GB300 qualification only; requires a live backend and all prerequisites | | `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 | +| `windows:e2e:mxc:host-probe` | Runs the shipped host probe with an absent `wxc-exec` | Hosted Windows x64 and ARM64; validates unavailable-backend diagnostics | +| `windows:e2e:mxc:ws-agent-mock` | Runs the shipped WebSocket agent script in mock mode | Hosted Windows x64 and ARM64; validates gateway/CLI/driver lifecycle and a workload proof, not WebSocket forwarding or MXC enforcement | +| `windows:e2e:mxc:openclaw-forward-mock` | Runs the shipped OpenClaw forward script in mock mode | Hosted Windows x64 and ARM64; validates gateway/CLI/driver lifecycle and a workload proof, not OpenClaw forwarding or MXC enforcement | | `windows:qualify:mxc:gb300` | Complete source, host, ARM64 build/test, strict MXC, policy E2E, OpenClaw, and hash-bound evidence contract | Review/release evidence on a native GB300 Windows ARM64 host | The exact required, optional, unsupported, and architecture-constrained GB300 diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt index 810cd2a9b8..84d41bbf73 100644 --- a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt +++ b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt @@ -139,11 +139,15 @@ FILES IN THIS PACKAGE openclaw-gateway.yaml sandbox policy (read-write grant to share_dir only -- see the comment at its top for why) run-openclaw-forward-test.ps1 the orchestrator you run + run-openclaw-forward-mock.ps1 helper used by -Mock for hosted CI wiring install-nodejs-openclaw.ps1 optional prerequisite: fetches Node.js + OpenClaw if you don't already have them README-openclaw-forward.txt this file NOTES + - -Mock runs the gateway, CLI, policy, and an in-policy proof command with + the in-process wxc shim. It needs no Node.js or OpenClaw install and does + not test the relay, WebSocket forwarding, OpenClaw, proxy behavior, or MXC. - The control plane between CLI and gateway runs with --disable-tls on loopback (that's a separate test point, T2). This test's relay traffic (host <-> sandbox) is a separate, unrelated WebSocket tunnel. diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-mock.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-mock.ps1 new file mode 100644 index 0000000000..4ccc91032a --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-mock.ps1 @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Called by run-openclaw-forward-test.ps1 -Mock. Exercise its shipped gateway +# config and policy with a proof command; the wxc shim has no relay channel. +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [Parameter(Mandatory = $true)] [string] $ShareDir, + [int] $Port = 17670, + [string] $SandboxName +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +$here = $PSScriptRoot +$gateway = if ($GatewayPath) { [System.IO.Path]::GetFullPath($GatewayPath) } else { Join-Path $here "openshell-gateway.exe" } +$cli = if ($CliPath) { [System.IO.Path]::GetFullPath($CliPath) } else { Join-Path $here "openshell.exe" } +$ShareDir = [System.IO.Path]::GetFullPath($ShareDir) +if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "oc-mock-$PID" } +$resultDir = Join-Path $here "results-openclaw-forward-mock-$PID" +$proof = Join-Path $ShareDir "mock-workload-pass.txt" +$gatewayProcess = $null +$created = $false +$passed = $false +$failure = "" +$oldConfig = $env:OPENSHELL_GATEWAY_CONFIG +$oldComputeDriver = $env:OPENSHELL_COMPUTE_DRIVER +$oldMock = $env:OPENSHELL_MXC_MOCK_WXC + +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('"') + $slashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { $slashes++; continue } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $slashes + 1))) + [void]$quoted.Append('"') + } else { + if ($slashes -gt 0) { [void]$quoted.Append(('\' * $slashes)) } + [void]$quoted.Append($ch) + } + $slashes = 0 + } + if ($slashes -gt 0) { [void]$quoted.Append(('\' * (2 * $slashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} + +function Invoke-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $cli + $start.Arguments = ((@("--gateway-endpoint", "http://127.0.0.1:$Port") + $CommandArgs | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $start + if (-not $process.Start()) { throw "failed to start OpenShell CLI" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $output = (@($stdout.Result, $stderr.Result) | Where-Object { $_ }) -join [Environment]::NewLine + if (-not $AllowFailure -and $process.ExitCode -ne 0) { + throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $output" + } + return @{ ExitCode = $process.ExitCode; Output = $output; Stdout = $stdout.Result } +} + +function Test-Port([int]$Candidate) { + $client = New-Object System.Net.Sockets.TcpClient + try { + $pending = $client.BeginConnect("127.0.0.1", $Candidate, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { return $false } finally { $client.Dispose() } +} + +try { + foreach ($file in @($gateway, $cli, (Join-Path $here "mxc-openclaw-gateway.toml"), + (Join-Path $here "e2e-policies\openclaw-gateway.yaml"))) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "missing mock demo asset: $file" } + } + if (Test-Port $Port) { throw "gateway port $Port is already in use" } + New-Item -ItemType Directory -Force -Path $resultDir, $ShareDir | Out-Null + Remove-Item -LiteralPath $proof -Force -ErrorAction SilentlyContinue + + $shareFwd = $ShareDir.Replace('\', '/') + $toml = Get-Content -LiteralPath (Join-Path $here "mxc-openclaw-gateway.toml") -Raw + $mockWxc = (Join-Path $here "mock-wxc-exec.exe").Replace('\', '\\') + $toml = [regex]::Replace($toml, '(?m)^wxc_exec_path\s*=.*$', "wxc_exec_path = `"$mockWxc`"") + $toml = $toml.Replace('C:/openshell-openclaw', $shareFwd) + $toml = [regex]::Replace($toml, '(?m)^pc_relay_spawner_path\s*=.*$', 'pc_relay_spawner_path = ""') + $tomlUsed = Join-Path $resultDir "mxc-openclaw-gateway.used.toml" + [System.IO.File]::WriteAllText($tomlUsed, $toml, [System.Text.UTF8Encoding]::new($false)) + + $policy = Get-Content -LiteralPath (Join-Path $here "e2e-policies\openclaw-gateway.yaml") -Raw + $policy = $policy.Replace('C:/openshell-openclaw', $shareFwd) + $policyUsed = Join-Path $resultDir "openclaw-gateway.used.yaml" + [System.IO.File]::WriteAllText($policyUsed, $policy, [System.Text.UTF8Encoding]::new($false)) + + $env:OPENSHELL_GATEWAY_CONFIG = $tomlUsed + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + $env:OPENSHELL_MXC_MOCK_WXC = "1" + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $gatewayProcess = Start-Process -FilePath $gateway -ArgumentList @( + "--disable-tls", "--db-url", "sqlite::memory:", "--port", "$Port", "--log-level", "info" + ) -WorkingDirectory $here -PassThru -WindowStyle Hidden -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { + if ($gatewayProcess.HasExited) { throw "gateway exited before listening" } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Port $Port)) { throw "gateway did not listen within 30 seconds" } + + $cmd = Join-Path $env:SystemRoot "System32\cmd.exe" + $driverConfig = @{ mxc = @{ + command = @($cmd, "/d", "/s", "/c", "echo PASS 1> `"$proof`"") + cwd = $shareFwd + } } | ConvertTo-Json -Compress -Depth 5 + $created = $true + $create = Invoke-Cli @("sandbox", "create", "--name", $SandboxName, + "--policy", $policyUsed, "--driver-config-json", $driverConfig, + "--no-tty", "--output", "json") + $deadline = (Get-Date).AddSeconds(30) + $proofText = "" + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $proof) { + $proofText = Get-Content -LiteralPath $proof -Raw + if ($null -ne $proofText -and $proofText.Trim() -eq "PASS") { break } + } + Start-Sleep -Milliseconds 250 + } + if ($null -eq $proofText -or $proofText.Trim() -ne "PASS") { + throw "mock workload did not finish proof (create exit $($create.ExitCode)): $($create.Output)" + } + $get = Invoke-Cli @("sandbox", "get", $SandboxName, "--output", "json") + $sandbox = ConvertFrom-Json -InputObject $get.Stdout -ErrorAction Stop + if ($sandbox.name -cne $SandboxName) { + throw "sandbox get returned '$($sandbox.name)' instead of '$SandboxName'" + } + if ($sandbox.phase -cne "Ready") { + throw "sandbox '$SandboxName' is not Ready (phase: $($sandbox.phase))" + } + $delete = Invoke-Cli @("sandbox", "delete", $SandboxName) + $created = $false + if ($delete.ExitCode -ne 0) { throw "sandbox deletion failed" } + $passed = $true + Write-Host "OpenClaw example mock wiring passed for $SandboxName" +} catch { + $failure = $_.Exception.Message + Write-Host "OpenClaw example mock wiring failed: $failure ($($_.ScriptStackTrace))" -ForegroundColor Red +} finally { + if ($created) { + try { [void](Invoke-Cli @("sandbox", "delete", $SandboxName) -AllowFailure) } catch {} + } + if ($gatewayProcess -and -not $gatewayProcess.HasExited) { + Stop-Process -Id $gatewayProcess.Id -Force -ErrorAction SilentlyContinue + try { [void]$gatewayProcess.WaitForExit(5000) } catch {} + } + $env:OPENSHELL_GATEWAY_CONFIG = $oldConfig + $env:OPENSHELL_COMPUTE_DRIVER = $oldComputeDriver + if ($null -eq $oldMock) { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + } else { + $env:OPENSHELL_MXC_MOCK_WXC = $oldMock + } + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $summary = "verdict=$verdict`r`nmode=mock-wiring`r`nsandbox=$SandboxName`r`nresult=$failure`r`n" + + "Mock mode validates the shipped config, policy, gateway, CLI, driver, and proof command. " + + "It does not validate OpenClaw, WebSocket forwarding, proxy behavior, or MXC enforcement.`r`n" + [System.IO.File]::WriteAllText((Join-Path $resultDir "summary.txt"), $summary, [System.Text.UTF8Encoding]::new($false)) + Write-Host $summary + Write-Host "Results: $resultDir" +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index 60d9fec9b6..73edf045af 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -48,13 +48,13 @@ param( [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", # Your existing Node.js binary. Copied (not run in place) into share_dir -- # the AppContainer cannot read paths outside it. - [Parameter(Mandatory = $true)] [string] $NodeExePath, # Root directory of your OpenClaw npm package install -- the directory that # directly contains openclaw.mjs and its own node_modules. Copied # (recursively, via robocopy) into share_dir\runtime\node_modules\openclaw. - [Parameter(Mandatory = $true)] [string] $OpenClawInstallDir, + [string] $GatewayPath, + [string] $CliPath, # Must be a DIRECT CHILD of a drive root (e.g. C:\openshell-openclaw, not # C:\work\openshell-openclaw). The staged Node invocation below uses # --preserve-symlinks-main so Node does not realpath the main module before @@ -92,7 +92,10 @@ param( # this switch was meant to test around. Left in for whoever investigates # next (a different wxc-exec build may behave differently), but don't # expect it to work today. - [switch] $UseLocalNetwork + [switch] $UseLocalNetwork, + # CI wiring mode uses the in-process wxc shim and an in-policy proof command. + # It does not launch OpenClaw or exercise forwarding or OS enforcement. + [switch] $Mock ) $ErrorActionPreference = "Stop" @@ -106,6 +109,23 @@ $OutputEncoding = [System.Text.Encoding]::UTF8 $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +if ($Mock) { + if ($Backend -ne "process_container" -or $UseLocalNetwork) { + throw "-Mock supports only the default process_container configuration" + } + if (-not $PSBoundParameters.ContainsKey("ShareDir")) { + $ShareDir = Join-Path ([System.IO.Path]::GetTempPath()) "openshell-openclaw-mock-$PID" + } + & (Join-Path $here "run-openclaw-forward-mock.ps1") ` + -GatewayPath $GatewayPath -CliPath $CliPath -ShareDir $ShareDir ` + -Port $Port -SandboxName $SandboxName + exit $LASTEXITCODE +} + +if ([string]::IsNullOrWhiteSpace($NodeExePath) -or [string]::IsNullOrWhiteSpace($OpenClawInstallDir)) { + throw "-NodeExePath and -OpenClawInstallDir are required for real MXC runs" +} + if ([string]::IsNullOrWhiteSpace($SandboxName)) { $SandboxName = "openclaw-$PID" } @@ -233,8 +253,8 @@ function Show-SandboxCreate([object]$out, [string]$name) { } } -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" +$gateway = if ($GatewayPath) { [System.IO.Path]::GetFullPath($GatewayPath) } else { Join-Path $here "openshell-gateway.exe" } +$cli = if ($CliPath) { [System.IO.Path]::GetFullPath($CliPath) } else { Join-Path $here "openshell.exe" } $relayExe = Join-Path $here "openshell-supervisor-relay.exe" $policy = Join-Path $here "e2e-policies\openclaw-gateway.yaml" if ($UseLocalNetwork -and $Backend -eq "isolation_session") { diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index 7d1be2e5f6..3d2254c496 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -25,9 +25,9 @@ # freed. # 6. Verify port 22000 is freed within the drain timeout. # -# In -Mock mode: steps 3-4 and 6 are skipped because wxc-exec is not invoked -# and the server never starts. The test validates gateway startup, sandbox -# create, and sandbox delete only. +# In -Mock mode: the disposable policy permits a proof write and the runner +# verifies it. WebSocket connectivity, relay forwarding, and OS isolation are +# not exercised because wxc-exec is not invoked. # # PowerShell 5.1-compatible (no && / || / ternary operators). ASCII only. # @@ -49,6 +49,10 @@ param( # Path to wxc-exec.exe. Required for real runs; ignored in mock mode. [string] $WxcExecPath = "", + # Optional paths to CI-built binaries; defaults to binaries beside this script. + [string] $GatewayPath, + [string] $CliPath, + # Working directory the AppContainer can read/write. mxc-ws-agent.exe is # expected alongside this script; # the script copies it here if needed. @@ -106,51 +110,37 @@ function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } -# Escape backslashes for TOML basic strings. -function Esc([string]$p) { return $p.Replace('\', '\\') } - -# Convert Windows path to forward-slash form (TOML values). -function Fwd([string]$p) { return $p.Replace('\', '/') } - -# Build one CreateProcess-compatible command-line argument. Windows PowerShell -# 5.1 removes embedded quotes and can split JSON values at embedded spaces when -# invoking native commands through the call operator. 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 + $slashes = 0 foreach ($ch in $value.ToCharArray()) { - if ($ch -eq '\') { - $backslashes++ - continue - } + if ($ch -eq '\') { $slashes++; continue } if ($ch -eq '"') { - [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append(('\' * (2 * $slashes + 1))) [void]$quoted.Append('"') } else { - if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + if ($slashes -gt 0) { [void]$quoted.Append(('\' * $slashes)) } [void]$quoted.Append($ch) } - $backslashes = 0 + $slashes = 0 } - if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + if ($slashes -gt 0) { [void]$quoted.Append(('\' * (2 * $slashes))) } [void]$quoted.Append('"') return $quoted.ToString() } function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { - $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 - + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $filePath + $start.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true $process = New-Object System.Diagnostics.Process - $process.StartInfo = $startInfo + $process.StartInfo = $start if (-not $process.Start()) { throw "failed to start $filePath" } $stdout = $process.StandardOutput.ReadToEndAsync() $stderr = $process.StandardError.ReadToEndAsync() @@ -159,13 +149,20 @@ function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_ -split "`r?`n" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + return @{ ExitCode = $process.ExitCode; Output = @($output) } +} - return @{ - ExitCode = $process.ExitCode - Output = @($output) - } +function Invoke-Cli([string[]]$CommandArgs) { + $result = Invoke-NativeCaptured $cli (@("--gateway-endpoint", $gatewayEndpoint) + $CommandArgs) + return @{ ExitCode = $result.ExitCode; Text = ($result.Output -join [Environment]::NewLine).Trim() } } +# Escape backslashes for TOML basic strings. +function Esc([string]$p) { return $p.Replace('\', '\\') } + +# Convert Windows path to forward-slash form (TOML values). +function Fwd([string]$p) { return $p.Replace('\', '/') } + # -WsPort is NOT actually wired through end to end: the in-sandbox server's # port is a compile-time const (WS_PORT = 22000 in mxc-ws-agent.rs) -- the # TOML generation below doesn't patch it. Rather than silently accept an @@ -179,8 +176,9 @@ if ($WsPort -ne 22000) { # --- Path variables ----------------------------------------------------------- -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" +$gateway = if ($GatewayPath) { [System.IO.Path]::GetFullPath($GatewayPath) } else { Join-Path $here "openshell-gateway.exe" } +$cli = if ($CliPath) { [System.IO.Path]::GetFullPath($CliPath) } else { Join-Path $here "openshell.exe" } +$gatewayEndpoint = "http://127.0.0.1:$Port" $tomlSrc = Join-Path $here "mxc-ws-gateway.toml" $toml = Join-Path $resultDir "mxc-ws-gateway.toml" $policyFile = Join-Path $here "e2e-policies\ws-agent.yaml" @@ -188,6 +186,7 @@ $policyUsed = Join-Path $resultDir "ws-agent.yaml" $agentExeSrc = Join-Path $here "mxc-ws-agent.exe" $agentExe = Join-Path $AgentDir "mxc-ws-agent.exe" +$mockProof = Join-Path $AgentDir "mock-workload-pass.txt" # openshell-supervisor-relay.exe wraps the per-sandbox command (see mxc-ws-gateway.toml's # pc_relay_spawner_path) so the driver has a control channel into the sandbox, @@ -210,7 +209,7 @@ $sandboxName = "mxc-ws-$runId" function Start-Gw { Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue $env:OPENSHELL_GATEWAY_CONFIG = $toml - $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" $env:OPENSHELL_MXC_SHARE_DIR = $AgentDir $p = Start-Process -FilePath $gateway ` -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info", "--port", $Port) ` @@ -245,7 +244,7 @@ function Register-Cli { $env:OPENSHELL_GATEWAY = "" $addMsg = "" try { - & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | + & $cli --gateway-endpoint $gatewayEndpoint gateway add $gatewayEndpoint --local --name $GatewayName 2>&1 | ForEach-Object { $addMsg += "$_`n"; Info $_ } } catch { $addMsg = $_.Exception.Message @@ -254,13 +253,13 @@ function Register-Cli { if ($addMsg -match 'different endpoint') { # Registered at a stale port; remove and re-add. Info "removing stale gateway registration and re-adding at port $Port" - try { & $cli gateway remove $GatewayName 2>&1 | Out-Null } catch {} + try { & $cli --gateway-endpoint $gatewayEndpoint gateway remove $GatewayName 2>&1 | Out-Null } catch {} try { - & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | + & $cli --gateway-endpoint $gatewayEndpoint gateway add $gatewayEndpoint --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } } catch { Info "gateway add retry: $($_.Exception.Message) (continuing)" } } - try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + try { & $cli --gateway-endpoint $gatewayEndpoint gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } catch { Info "gateway select: $($_.Exception.Message) (continuing)" } } @@ -357,6 +356,12 @@ function Render-Toml { $t = [regex]::Replace($t, '(?m)^\s*#?\s*pc_relay_spawner_path\s*=.*$', "pc_relay_spawner_path = `"$relayExeFwd`"") + if ($Mock) { + # The wxc shim has no control-channel handshake. Run the proof command + # directly while still parsing the shipped MXC settings. + $t = [regex]::Replace($t, '(?m)^\s*pc_relay_spawner_path\s*=.*$', 'pc_relay_spawner_path = ""') + } + Set-Content $toml -Value $t -Encoding UTF8 } @@ -376,6 +381,12 @@ function Render-Policy { if ($agentDirPolicy -ne $defaultAgentDirPolicy) { $p = $p.Replace($defaultAgentDirPolicy, $agentDirPolicy) } + if ($Mock) { + # The proof is a host file; this disposable mock policy grants only + # that directory so the shim can materialize it. + $p = [regex]::Replace($p, '(?m)^ read_only:\r?\n - "[^"]+"\s*$', ' read_only: []') + $p = $p.Replace('read_write: []', "read_write: [`"$agentDirPolicy`"]") + } Set-Content $policyUsed -Value $p -Encoding UTF8 } @@ -439,7 +450,10 @@ try { Remove-Item (Join-Path $AgentDir "appcontainer-sid.txt") -Force -ErrorAction SilentlyContinue Remove-Item (Join-Path $AgentDir "outbound-probe.txt") -Force -ErrorAction SilentlyContinue Remove-Item (Join-Path $AgentDir "outbound-probe-addr.txt") -Force -ErrorAction SilentlyContinue - if (Test-Path $agentExeSrc) { + if ($Mock) { + Remove-Item -LiteralPath $mockProof -Force -ErrorAction SilentlyContinue + Info "mock workload will write $mockProof; WS server binary is not needed" + } elseif (Test-Path $agentExeSrc) { try { Copy-Item $agentExeSrc $agentExe -Force Ok "mxc-ws-agent.exe copied from release build" @@ -462,7 +476,9 @@ try { Info "mxc-ws-agent.exe already in $AgentDir (using existing)" } - if (Test-Path $relayExeSrc) { + if ($Mock) { + Info "mock mode does not launch openshell-supervisor-relay.exe" + } elseif (Test-Path $relayExeSrc) { try { Copy-Item $relayExeSrc $relayExe -Force Ok "openshell-supervisor-relay.exe copied from release build" @@ -491,17 +507,19 @@ try { } Ok "gateway port $Port is free" - $busyWs = Get-NetTCPConnection -State Listen -LocalPort $WsPort -ErrorAction SilentlyContinue - if ($busyWs) { - throw "WebSocket port $WsPort already in use (pid $($busyWs.OwningProcess)). Free it before running this test." - } - Ok "WebSocket port $WsPort is free" + if (-not $Mock) { + $busyWs = Get-NetTCPConnection -State Listen -LocalPort $WsPort -ErrorAction SilentlyContinue + if ($busyWs) { + throw "WebSocket port $WsPort already in use (pid $($busyWs.OwningProcess)). Free it before running this test." + } + Ok "WebSocket port $WsPort is free" - $busyRelay = Get-NetTCPConnection -State Listen -LocalPort $RelayPort -ErrorAction SilentlyContinue - if ($busyRelay) { - throw "relay port $RelayPort already in use (pid $($busyRelay.OwningProcess)). Free it before running this test." + $busyRelay = Get-NetTCPConnection -State Listen -LocalPort $RelayPort -ErrorAction SilentlyContinue + if ($busyRelay) { + throw "relay port $RelayPort already in use (pid $($busyRelay.OwningProcess)). Free it before running this test." + } + Ok "relay port $RelayPort is free" } - Ok "relay port $RelayPort is free" # --- Render TOML + start gateway ------------------------------------------ @@ -527,27 +545,25 @@ try { Step "Create sandbox '$sandboxName'" $createOut = $null; $createExitCode = 0 + $workloadCommand = if ($Mock) { + @((Join-Path $env:SystemRoot "System32\cmd.exe"), "/d", "/s", "/c", "echo PASS 1> `"$mockProof`"") + } else { + @((Fwd $agentExe), "server") + } $driverConfigJson = @{ mxc = @{ - command = @((Fwd $agentExe), "server") + command = $workloadCommand cwd = (Fwd $AgentDir) } } | ConvertTo-Json -Compress -Depth 4 try { - # MXC exec-in-driver has no SSH server, so any `sandbox create` invocation - # that attempts SSH will fail with connection-refused and exit non-zero. - # Use the same pattern as run-mxc-e2e.ps1: pass --no-tty with a no-op - # command so the CLI fires the SSH attempt, fails quickly (connection - # refused), and returns. Do NOT gate on exit code here. - $createResult = Invoke-NativeCaptured $cli @( - "sandbox", "create", - "--name", $sandboxName, - "--policy", $policyUsed, - "--driver-config-json", $driverConfigJson, - "--no-tty", "--", "cmd.exe", "/c", "exit", "0" - ) - $createOut = $createResult.Output - $createExitCode = $createResult.ExitCode + # ProcessStartInfo preserves JSON quoting under Windows PowerShell 5.1. + # MXC has no SSH server, so the CLI can still exit non-zero after create. + $create = Invoke-Cli @("sandbox", "create", "--name", $sandboxName, + "--policy", $policyUsed, "--driver-config-json", $driverConfigJson, + "--no-tty", "--", "cmd.exe", "/c", "exit", "0") + $createOut = $create.Text + $createExitCode = $create.ExitCode } catch { $createOut = $_.Exception.Message; $createExitCode = 1 } @@ -558,8 +574,9 @@ try { Start-Sleep -Milliseconds 500 $getOut = $null; $getExitCode = 0 try { - $getOut = & $cli sandbox get $sandboxName 2>&1 - $getExitCode = $LASTEXITCODE + $get = Invoke-Cli @("sandbox", "get", $sandboxName) + $getOut = $get.Text + $getExitCode = $get.ExitCode } catch { $getOut = $_.Exception.Message; $getExitCode = 1 } @@ -576,6 +593,20 @@ try { throw "sandbox create failed: sandbox does not exist or is not Ready after create" } + if ($Mock) { + $proofDeadline = (Get-Date).AddSeconds(15) + $proofText = "" + while ((Get-Date) -lt $proofDeadline) { + if (Test-Path -LiteralPath $mockProof) { + $proofText = Get-Content -LiteralPath $mockProof -Raw + if ($null -ne $proofText -and $proofText.Trim() -eq "PASS") { break } + } + Start-Sleep -Milliseconds 200 + } + $proofOk = $null -ne $proofText -and $proofText.Trim() -eq "PASS" + Record "mock-workload" $proofOk "in-policy proof command completed in the shared directory" + } + # --- WebSocket connectivity (real mode only) ------------------------------ if (-not $Mock) { @@ -634,7 +665,7 @@ try { if ($serverUp) { Step "openshell forward service --target-port $WsPort --local $RelayPort" $script:fwdProc = Start-Process -FilePath $cli ` - -ArgumentList @("forward", "service", "--target-port", "$WsPort", "--local", "$RelayPort", $sandboxName) ` + -ArgumentList @("--gateway-endpoint", $gatewayEndpoint, "forward", "service", "--target-port", "$WsPort", "--local", "$RelayPort", $sandboxName) ` -WorkingDirectory $here -PassThru -NoNewWindow ` -RedirectStandardOutput $fwdLog -RedirectStandardError $fwdErrLog Info "forward pid $($script:fwdProc.Id)" @@ -678,8 +709,9 @@ try { Step "Delete sandbox '$sandboxName'" $deleteOut = $null; $deleteExitCode = 0 try { - $deleteOut = & $cli sandbox delete $sandboxName 2>&1 - $deleteExitCode = $LASTEXITCODE + $delete = Invoke-Cli @("sandbox", "delete", $sandboxName) + $deleteOut = $delete.Text + $deleteExitCode = $delete.ExitCode } catch { $deleteOut = $_.Exception.Message; $deleteExitCode = 1 } @@ -716,7 +748,7 @@ try { if ($script:fwdProc -and -not $script:fwdProc.HasExited) { try { Stop-Process -Id $script:fwdProc.Id -Force -ErrorAction SilentlyContinue } catch {} } - try { & $cli sandbox delete $sandboxName 2>&1 | Out-Null } catch {} + try { [void](Invoke-Cli @("sandbox", "delete", $sandboxName)) } catch {} if (-not $KeepRunning) { Stop-Gw $script:gwProc @@ -736,10 +768,16 @@ try { $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } $checkLines = ($checks | ForEach-Object { " " + $_.Result + " " + $_.Check + ": " + $_.Detail }) -join "`n" - $modeStr = if ($Mock) { "MOCK (no wxc-exec, no WS connectivity)" } else { "REAL" } + $modeStr = if ($Mock) { "mock-wiring" } else { "real-mxc" } $wxcStr = if ($Mock) { "(mock)" } else { $WxcExecPath } $errStr = if ($harnessError) { "harness_error: $harnessError" } else { "" } + $agentDescription = if ($Mock) { "(not launched in mock mode)" } else { $agentExe } + $relayDescription = if ($Mock) { "(not launched in mock mode)" } else { $relayExe } + $wsDescription = if ($Mock) { "(not exercised in mock mode)" } else { "$WsPort" } + $relayPortDescription = if ($Mock) { "(not exercised in mock mode)" } else { "$RelayPort (on this host)" } + $messageDescription = if ($Mock) { "(not sent in mock mode)" } else { $WsMessage } + $forwardArtifactLine = if ($Mock) { "" } else { " forward.log / forward.err.log 'openshell forward service' stdout / stderr`n" } $summary = "OpenShell MXC WebSocket agent test`n" + "====================================`n" + "timestamp : $stamp`n" + @@ -748,13 +786,13 @@ try { "mode : $modeStr`n" + "gateway : $gateway (port $Port)`n" + "agent_dir : $AgentDir`n" + - "agent_exe : $agentExe`n" + - "relay_exe : $relayExe`n" + + "agent_exe : $agentDescription`n" + + "relay_exe : $relayDescription`n" + "policy : $policyUsed`n" + "sandbox : $sandboxName`n" + - "ws_port : $WsPort`n" + - "relay_port : $RelayPort (on this host)`n" + - "ws_message : $WsMessage`n" + + "ws_port : $wsDescription`n" + + "relay_port : $relayPortDescription`n" + + "ws_message : $messageDescription`n" + "wxc_exec : $wxcStr`n" + "totals : PASS=$passCount FAIL=$failCount`n" + "$errStr`n" + @@ -762,18 +800,24 @@ try { "`nFiles in this bundle ($resultDir):`n" + " transcript.txt full console transcript`n" + " gateway.log / gateway.err.log gateway stdout / stderr`n" + - " forward.log / forward.err.log 'openshell forward service' stdout / stderr`n" + + $forwardArtifactLine + " mxc-ws-gateway.rendered.toml exact gateway config used`n" + " ws-agent.yaml sandbox policy used`n" + "`nWhat PASS means:`n" + " gateway-start gateway bound port $Port within 30 s`n" + - " sandbox-create sandbox reached Ready after create (CLI exit may be non-zero on MXC without SSH)`n" + - " server-port-open WS server bound port $WsPort within 30 s`n" + - " ws-server-marker spawner logged its own port-ready confirmation in the gateway log`n" + - " ws-echo '$WsMessage' echoed via a dynamic 'openshell forward service' relay at 127.0.0.1:$RelayPort`n" + - " (fresh, on-demand relay for this one call -- no static bridge, nothing pre-declared)`n" + - " sandbox-delete CLI returned exit 0 for sandbox delete`n" + - " port-freed port $WsPort released within 30 s of sandbox delete`n" + " sandbox-create sandbox reached Ready after create (CLI exit may be non-zero on MXC without SSH)`n" + if ($Mock) { + $summary += " mock-workload an in-policy proof command wrote its file`n" + + " sandbox-delete CLI returned exit 0 for sandbox delete`n" + + "Mock mode does not validate WebSocket connectivity, dynamic forwarding, or MXC enforcement.`n" + } else { + $summary += " server-port-open WS server bound port $WsPort within 30 s`n" + + " ws-server-marker spawner logged its own port-ready confirmation in the gateway log`n" + + " ws-echo '$WsMessage' echoed via a dynamic 'openshell forward service' relay at 127.0.0.1:$RelayPort`n" + + " (fresh, on-demand relay for this one call -- no static bridge, nothing pre-declared)`n" + + " sandbox-delete CLI returned exit 0 for sandbox delete`n" + + " port-freed port $WsPort released within 30 s of sandbox delete`n" + } Set-Content (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 $color = if ($verdict -eq "PASS") { "Green" } else { "Red" } diff --git a/tasks/scripts/windows-mxc-host-probe-e2e.ps1 b/tasks/scripts/windows-mxc-host-probe-e2e.ps1 new file mode 100644 index 0000000000..4013546d29 --- /dev/null +++ b/tasks/scripts/windows-mxc-host-probe-e2e.ps1 @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Run the shipped host probe against an explicitly absent wxc-exec. This +# verifies the hosted-runner diagnostic path, not real MXC availability. +[CmdletBinding()] +param( + [string] $ArtifactRoot, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-host-probe-e2e.ps1 requires Windows" +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$examplesRoot = Join-Path $repoRoot "crates\openshell-driver-mxc\examples" +if (-not $ArtifactRoot) { + $ArtifactRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$stageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-host-probe-$PID")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $stageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "staging directory is outside artifact root: $stageDir" +} +New-Item -ItemType Directory -Path $stageDir | Out-Null +$passed = $false + +try { + $probe = Join-Path $stageDir "probe-mxc-host.ps1" + Copy-Item -LiteralPath (Join-Path $examplesRoot "probe-mxc-host.ps1") -Destination $probe + $report = Join-Path $stageDir "capabilities.json" + $absentWxc = Join-Path $stageDir "absent-wxc-exec.exe" + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $probe -WxcExecPath $absentWxc -OutFile $report + if ($LASTEXITCODE -ne 0) { throw "MXC host probe failed (exit $LASTEXITCODE)" } + + $capabilities = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json + if ($capabilities.wxcExec.exists -ne $false -or + $capabilities.processcontainerTrial.result -ne "absent" -or + $capabilities.isolationSessionTrial.result -ne "absent" -or + $capabilities.host.osBuildNumber -le 0) { + throw "host probe did not report the expected absent-backend diagnostic" + } + $passed = $true + Write-Host "MXC host probe CI passed" +} finally { + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $stageDir)) { + Remove-Item -LiteralPath $stageDir -Recurse -Force + } else { + Write-Host "MXC host probe artifacts: $stageDir" + } +} diff --git a/tasks/scripts/windows-mxc-openclaw-forward-mock-e2e.ps1 b/tasks/scripts/windows-mxc-openclaw-forward-mock-e2e.ps1 new file mode 100644 index 0000000000..32f5bbb5ac --- /dev/null +++ b/tasks/scripts/windows-mxc-openclaw-forward-mock-e2e.ps1 @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Run the shipped OpenClaw forward example with the in-process wxc shim and +# an in-policy proof command. This does not launch OpenClaw or a relay. +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-openclaw-forward-mock-e2e.ps1 requires Windows" +} +$target = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "unsupported native Windows architecture: $($_)" } +} +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$examplesRoot = Join-Path $repoRoot "crates\openshell-driver-mxc\examples" +$targetDir = if ($env:CARGO_TARGET_DIR) { [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) } else { Join-Path $repoRoot "target" } +if (-not $GatewayPath) { $GatewayPath = Join-Path $targetDir "$target\release\openshell-gateway.exe" } +if (-not $CliPath) { $CliPath = Join-Path $targetDir "$target\release\openshell.exe" } +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) +foreach ($binary in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { throw "required release binary is missing: $binary" } +} + +if (-not $ArtifactRoot) { + $ArtifactRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$stageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-openclaw-forward-$target-$PID")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $stageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "staging directory is outside artifact root: $stageDir" +} +New-Item -ItemType Directory -Path $stageDir | Out-Null +$passed = $false +$oldAppData = $env:APPDATA +$oldLocalAppData = $env:LOCALAPPDATA +$oldXdgConfigHome = $env:XDG_CONFIG_HOME +$oldXdgStateHome = $env:XDG_STATE_HOME +$oldXdgDataHome = $env:XDG_DATA_HOME + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { $listener.Stop() } +} + +try { + $env:APPDATA = Join-Path $stageDir "appdata" + $env:LOCALAPPDATA = Join-Path $stageDir "localappdata" + $env:XDG_CONFIG_HOME = Join-Path $stageDir "xdg-config" + $env:XDG_STATE_HOME = Join-Path $stageDir "xdg-state" + $env:XDG_DATA_HOME = Join-Path $stageDir "xdg-data" + New-Item -ItemType Directory -Force -Path @( + $env:APPDATA, $env:LOCALAPPDATA, + $env:XDG_CONFIG_HOME, $env:XDG_STATE_HOME, $env:XDG_DATA_HOME + ) | Out-Null + + New-Item -ItemType Directory -Path (Join-Path $stageDir "e2e-policies") | Out-Null + foreach ($fixture in @("run-openclaw-forward-test.ps1", "run-openclaw-forward-mock.ps1", "mxc-openclaw-gateway.toml")) { + Copy-Item -LiteralPath (Join-Path $examplesRoot $fixture) -Destination $stageDir + } + Copy-Item -LiteralPath (Join-Path $examplesRoot "e2e-policies\openclaw-gateway.yaml") -Destination (Join-Path $stageDir "e2e-policies") + + $port = Get-AvailablePort + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $stageDir "run-openclaw-forward-test.ps1") ` + -Mock -GatewayPath $GatewayPath -CliPath $CliPath -ShareDir (Join-Path $stageDir "share") -Port $port + if ($LASTEXITCODE -ne 0) { throw "MXC OpenClaw forward example failed (exit $LASTEXITCODE)" } + + $result = Get-ChildItem -LiteralPath $stageDir -Directory -Filter "results-openclaw-forward-mock-*" | + Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 + if ($null -eq $result) { throw "OpenClaw example produced no result bundle" } + $summary = Get-Content -LiteralPath (Join-Path $result.FullName "summary.txt") -Raw + if ($summary -notmatch '(?m)^verdict=PASS\s*$' -or + $summary -notmatch '(?m)^mode=mock-wiring\s*$') { + throw "OpenClaw example did not report a mock-wiring PASS" + } + + $passed = $true + Write-Host "MXC OpenClaw forward example mock CI passed for $target" +} finally { + $env:APPDATA = $oldAppData + $env:LOCALAPPDATA = $oldLocalAppData + $env:XDG_CONFIG_HOME = $oldXdgConfigHome + $env:XDG_STATE_HOME = $oldXdgStateHome + $env:XDG_DATA_HOME = $oldXdgDataHome + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $stageDir)) { + Remove-Item -LiteralPath $stageDir -Recurse -Force + } else { + Write-Host "MXC OpenClaw forward example artifacts: $stageDir" + } +} diff --git a/tasks/scripts/windows-mxc-ws-agent-mock-e2e.ps1 b/tasks/scripts/windows-mxc-ws-agent-mock-e2e.ps1 new file mode 100644 index 0000000000..7ae54365e4 --- /dev/null +++ b/tasks/scripts/windows-mxc-ws-agent-mock-e2e.ps1 @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Run the shipped WebSocket example with the in-process wxc shim. The proof +# command checks gateway/CLI/driver wiring; no WebSocket server is started. +[CmdletBinding()] +param( + [string] $GatewayPath, + [string] $CliPath, + [string] $ArtifactRoot, + [switch] $KeepArtifacts +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + throw "windows-mxc-ws-agent-mock-e2e.ps1 requires Windows" +} +$target = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "unsupported native Windows architecture: $($_)" } +} +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$examplesRoot = Join-Path $repoRoot "crates\openshell-driver-mxc\examples" +$targetDir = if ($env:CARGO_TARGET_DIR) { [System.IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) } else { Join-Path $repoRoot "target" } +if (-not $GatewayPath) { $GatewayPath = Join-Path $targetDir "$target\release\openshell-gateway.exe" } +if (-not $CliPath) { $CliPath = Join-Path $targetDir "$target\release\openshell.exe" } +$GatewayPath = [System.IO.Path]::GetFullPath($GatewayPath) +$CliPath = [System.IO.Path]::GetFullPath($CliPath) +foreach ($binary in @($GatewayPath, $CliPath)) { + if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { throw "required release binary is missing: $binary" } +} + +if (-not $ArtifactRoot) { + $ArtifactRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } +} +$ArtifactRoot = [System.IO.Path]::GetFullPath($ArtifactRoot) +$stageDir = [System.IO.Path]::GetFullPath((Join-Path $ArtifactRoot "openshell-mxc-ws-agent-$target-$PID")) +$expectedPrefix = $ArtifactRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $stageDir.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "staging directory is outside artifact root: $stageDir" +} +New-Item -ItemType Directory -Path $stageDir | Out-Null +$passed = $false +$oldAppData = $env:APPDATA +$oldLocalAppData = $env:LOCALAPPDATA +$oldXdgConfigHome = $env:XDG_CONFIG_HOME +$oldXdgStateHome = $env:XDG_STATE_HOME +$oldXdgDataHome = $env:XDG_DATA_HOME + +function Get-AvailablePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { $listener.Stop() } +} + +try { + $env:APPDATA = Join-Path $stageDir "appdata" + $env:LOCALAPPDATA = Join-Path $stageDir "localappdata" + $env:XDG_CONFIG_HOME = Join-Path $stageDir "xdg-config" + $env:XDG_STATE_HOME = Join-Path $stageDir "xdg-state" + $env:XDG_DATA_HOME = Join-Path $stageDir "xdg-data" + New-Item -ItemType Directory -Force -Path @( + $env:APPDATA, $env:LOCALAPPDATA, + $env:XDG_CONFIG_HOME, $env:XDG_STATE_HOME, $env:XDG_DATA_HOME + ) | Out-Null + + New-Item -ItemType Directory -Path (Join-Path $stageDir "e2e-policies") | Out-Null + foreach ($fixture in @("run-ws-agent-test.ps1", "mxc-ws-gateway.toml")) { + Copy-Item -LiteralPath (Join-Path $examplesRoot $fixture) -Destination $stageDir + } + Copy-Item -LiteralPath (Join-Path $examplesRoot "e2e-policies\ws-agent.yaml") -Destination (Join-Path $stageDir "e2e-policies") + + $port = Get-AvailablePort + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $stageDir "run-ws-agent-test.ps1") ` + -Mock -GatewayPath $GatewayPath -CliPath $CliPath -AgentDir (Join-Path $stageDir "agent") ` + -Port $port -GatewayName "mxc-ws-ci-$PID" + if ($LASTEXITCODE -ne 0) { throw "MXC WebSocket example failed (exit $LASTEXITCODE)" } + + $result = Get-ChildItem -LiteralPath $stageDir -Directory -Filter "results-ws-*" | + Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 + if ($null -eq $result) { throw "WebSocket example produced no result bundle" } + $summary = Get-Content -LiteralPath (Join-Path $result.FullName "summary.txt") -Raw + if ($summary -notmatch '(?m)^verdict\s+: PASS\s*$' -or + $summary -notmatch '(?m)^mode\s+: mock-wiring\s*$' -or + $summary -notmatch 'PASS\s+mock-workload:' -or + $summary -notmatch 'PASS\s+sandbox-delete:') { + throw "WebSocket example did not report a complete mock-wiring PASS" + } + + $passed = $true + Write-Host "MXC WebSocket example mock CI passed for $target" +} finally { + $env:APPDATA = $oldAppData + $env:LOCALAPPDATA = $oldLocalAppData + $env:XDG_CONFIG_HOME = $oldXdgConfigHome + $env:XDG_STATE_HOME = $oldXdgStateHome + $env:XDG_DATA_HOME = $oldXdgDataHome + if ($passed -and -not $KeepArtifacts -and (Test-Path -LiteralPath $stageDir)) { + Remove-Item -LiteralPath $stageDir -Recurse -Force + } else { + Write-Host "MXC WebSocket example artifacts: $stageDir" + } +} diff --git a/tasks/windows.toml b/tasks/windows.toml index 2db94c9c97..a0178fcae0 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -133,3 +133,18 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts description = "Run the shipped MXC inference demos against the in-process wxc mock and a local API stub on Windows ARM64" run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-inference-examples-e2e.ps1 -Target aarch64-pc-windows-msvc" + +["windows:e2e:mxc:host-probe"] +description = "Run the shipped MXC host probe against an explicitly absent wxc-exec on hosted Windows" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-host-probe-e2e.ps1" + +["windows:e2e:mxc:ws-agent-mock"] +description = "Run the shipped MXC WebSocket agent example in hosted Windows mock mode" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-ws-agent-mock-e2e.ps1" + +["windows:e2e:mxc:openclaw-forward-mock"] +description = "Run the shipped MXC OpenClaw forward example in hosted Windows mock mode" +run = "echo 'windows:* tasks require a Windows MSVC host' && exit 1" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-mxc-openclaw-forward-mock-e2e.ps1" From 9ceec26d42f922cc108c0f86ca3016ae6088a57a Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:32:55 +0530 Subject: [PATCH 30/39] fix(mxc): redact injected secrets from gateway diagnostics (#3853) Redact injected environment values and the per-sandbox proxy password in captured MXC output and decoded relay launch-failure diagnostics before logging or publishing sandbox failure status. Match the original text and redact the union of overlapping occurrences. Preserve control-channel payloads and avoid allocating for unmatched text. Add regression coverage and document exact-match and length limits. Signed-off-by: Prashant Khodade Signed-off-by: Shailendra Singh --- Cargo.lock | 1 + architecture/windows.md | 8 + crates/openshell-driver-mxc/Cargo.toml | 1 + crates/openshell-driver-mxc/README.md | 14 + crates/openshell-driver-mxc/src/driver.rs | 316 ++++++++++++++++++++- docs/reference/sandbox-compute-drivers.mdx | 13 + 6 files changed, 349 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 825cfc48e3..e7f63c78a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4191,6 +4191,7 @@ dependencies = [ "toml", "tonic", "tracing", + "tracing-subscriber", "uuid", "windows", ] diff --git a/architecture/windows.md b/architecture/windows.md index 2dc5f5a805..ec0bf3da62 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -206,6 +206,14 @@ does not provide restart durability. ## Audit Boundary +The driver redacts literal, case-sensitive matches of injected environment +values and the per-sandbox proxy password in captured stdout/stderr and decoded +relay launch-failure diagnostics before logging or publishing failure status. +Overlapping matches are redacted together. Values shorter than four UTF-8 bytes +are excluded to preserve diagnostic usefulness. This is best-effort log hygiene; +transformed values are outside its scope. Control-channel protocol payloads are +unchanged, and provider secrets remain in the host proxy. + When enabled, `crates/openshell-driver-mxc/src/etw_consumer.rs` starts a gateway-owned real-time session for the Windows Sandboxing ETW provider. The consumer attributes events to a sandbox using the driver-owned `wxc-exec` PID diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index bfead4f202..31fb9e4233 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -43,6 +43,7 @@ rand = { workspace = true } [dev-dependencies] anyhow = { workspace = true } +tracing-subscriber = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } futures = { workspace = true } diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 7cc86409eb..9e6f80b332 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -159,6 +159,20 @@ Each sandbox receives a distinct proxy listener and a random per-sandbox credent The MXC credential handoff is also fixed at sandbox creation. The gateway rejects expiring static provider credentials because the in-process MXC driver has no live credential-refresh channel. Dynamic token grants remain request-time operations in the host proxy. Recreate the sandbox after rotating or revoking a non-expiring static credential. +### Captured output and failure diagnostics + +Before writing captured stdout/stderr or relay launch-failure diagnostics to +gateway logs, the driver replaces literal, case-sensitive matches of injected +environment values and the per-sandbox proxy password with `[REDACTED]`. +Failure status uses the same scrubbed diagnostic. Overlapping occurrences are +redacted together; successful control-channel payloads are unchanged. + +The redaction set includes all injected values of at least four UTF-8 bytes, +including non-secret settings. Shorter values and transformed or encoded +representations are outside its scope. This is best-effort log hygiene; +workloads should avoid printing credentials. Provider secrets remain in the +host proxy and reach the child only as placeholders. + ## Prerequisites (live runs) - Windows 11 Insider build ≥ 26300.8553 diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 6c47753ce0..07e9fee713 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -523,6 +523,88 @@ fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { environment } +/// Values to redact from gateway diagnostics: the +/// caller-injected per-sandbox environment (`--env`/`--env-from`/template +/// environment) and the generated egress-proxy password. Both reach the +/// sandboxed process's own environment for its own legitimate use, but the +/// whole point of injecting them as environment variables rather than baking +/// them into a logged command line is that they must never surface in +/// gateway output either. `cmd.exe` batch files without `@echo off` echo +/// their expanded command lines to stdout, so a workload that +/// merely references an injected variable (e.g. `echo %SECRET%`) leaks it +/// straight into the `wxc-exec stdout:` log line -- twice, once in the +/// echoed command and once in the command's own output -- unless the value +/// is redacted before logging. See nvbugs 6843156. +/// +/// Matching is literal and case-sensitive. Values shorter than four UTF-8 +/// bytes are excluded to preserve useful diagnostics for common flag values. +/// This is best-effort log hygiene, not protection against transformed output. +/// +/// Provider credentials are deliberately excluded here: per +/// `append_provider_child_env`'s doc comment, those values are +/// revision-scoped placeholders by the time they reach `process.env`, not +/// the raw secret, so redacting them here would be pointless. +fn secret_redaction_values( + injected_environment: &[String], + proxy_auth: Option<&SandboxProxyAuth>, +) -> Vec { + // Short flag values are too common to suppress in every diagnostic. + const MIN_SECRET_LEN: usize = 4; + let mut values: Vec = injected_environment + .iter() + .filter_map(|entry| entry.split_once('=').map(|(_, v)| v.to_string())) + .collect(); + if let Some(auth) = proxy_auth { + values.push(auth.password.clone()); + } + values.retain(|v| v.len() >= MIN_SECRET_LEN); + values.sort_unstable(); + values.dedup(); + values +} + +/// Redact the union of all matching ranges in the original text. Include +/// overlapping occurrences, even of the same value, and never scan replacement +/// markers. The deduplicated values are prepared once per sandbox; a line with +/// no matches needs neither an allocation nor a sort. +fn redact_secrets<'a>(line: &'a str, secrets: &[String]) -> std::borrow::Cow<'a, str> { + let mut ranges = Vec::new(); + for secret in secrets { + let Some(first) = secret.chars().next() else { + continue; + }; + let mut offset = 0; + while let Some(relative_start) = line[offset..].find(secret.as_str()) { + let start = offset + relative_start; + ranges.push((start, start + secret.len())); + // Advance one character, not the full match, to retain self-overlap. + offset = start + first.len_utf8(); + } + } + if ranges.is_empty() { + return std::borrow::Cow::Borrowed(line); + } + + ranges.sort_unstable(); + let mut result = String::new(); + let (mut start, mut end) = ranges[0]; + let mut copied_until = 0; + for &(next_start, next_end) in &ranges[1..] { + if next_start <= end { + end = end.max(next_end); + } else { + result.push_str(&line[copied_until..start]); + result.push_str("[REDACTED]"); + copied_until = end; + (start, end) = (next_start, next_end); + } + } + result.push_str(&line[copied_until..start]); + result.push_str("[REDACTED]"); + result.push_str(&line[end..]); + std::borrow::Cow::Owned(result) +} + /// Merge provider-owned child environment values into MXC `process.env`. /// /// Provider entries win case-insensitively, matching Windows environment @@ -1727,7 +1809,8 @@ async fn run_lifecycle( .collect() }; - for entry in sandbox_environment(&sandbox) { + let injected_environment = sandbox_environment(&sandbox); + for entry in &injected_environment { if let Some(pos) = entry.find('=') { env_map.insert(entry[..pos].to_string(), entry[pos + 1..].to_string()); } @@ -1915,6 +1998,17 @@ async fn run_lifecycle( // unread in the OS pipe until the process exits. let mut child = child; + // A shell entry point (e.g. `cmd.exe /c script.cmd` with no `@echo off`) + // echoes its own expanded command line to its stdout by default, so an + // injected secret referenced by the workload's command surfaces in the + // captured stdout below even though it was never explicitly logged by + // name -- redact known secret values from every captured line before + // they reach the gateway log. See nvbugs 6843156. + let secret_values = Arc::new(secret_redaction_values( + &injected_environment, + proxy_auth.as_ref(), + )); + // Control channel: correlate JSON responses in the stdout stream with // pending requests sent over stdin (see control_channel.rs). Only // meaningful when the process on the other end is @@ -1974,6 +2068,7 @@ async fn run_lifecycle( let sandbox_name_out = sandbox_name.clone(); let ready_slot = ready_slot.clone(); let target_ready_slot = target_ready_slot.clone(); + let secret_values_out = secret_values.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stdout).lines(); loop { @@ -1998,6 +2093,7 @@ async fn run_lifecycle( None => false, }; if !routed { + let line = redact_secrets(&line, &secret_values_out); info!(sandbox = %sandbox_name_out, "wxc-exec stdout: {line}"); } } @@ -2027,11 +2123,15 @@ async fn run_lifecycle( drop(target_ready_slot); if let Some(stderr) = child.stderr.take() { let sandbox_name_err = sandbox_name.clone(); + let secret_values_err = secret_values.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); loop { match lines.next_line().await { - Ok(Some(line)) => warn!(sandbox = %sandbox_name_err, "wxc-exec stderr: {line}"), + Ok(Some(line)) => { + let line = redact_secrets(&line, &secret_values_err); + warn!(sandbox = %sandbox_name_err, "wxc-exec stderr: {line}"); + } Ok(None) => break, Err(e) => { warn!(sandbox = %sandbox_name_err, "wxc-exec stderr read error: {e}"); @@ -2274,8 +2374,15 @@ async fn run_lifecycle( } }; if let Some(err) = launch_err { - warn!(sandbox = %sandbox_name, "control-channel launch failed: {err}"); - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, &err).await; + set_launch_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &err, + &secret_values, + ) + .await; // `child` was already moved into the registry (and possibly // already claimed by monitor_exec, spawned above) once a // cancellable handle was published. ProcessContainer has @@ -2516,6 +2623,23 @@ async fn monitor_exec( } } } + +/// Control-channel diagnostics can contain the relay's captured workload +/// stderr. Scrub the decoded text at the diagnostic boundary, leaving protocol +/// envelopes and successful control-channel payloads untouched. +async fn set_launch_failed( + registry: &Arc>>, + watch_tx: &Arc>, + sandbox: &DriverSandbox, + sandbox_id: &str, + message: &str, + secrets: &[String], +) { + let message = redact_secrets(message, secrets); + warn!(sandbox = %sandbox.name, "control-channel launch failed: {message}"); + set_failed(registry, watch_tx, sandbox, sandbox_id, &message).await; +} + async fn set_failed( registry: &Arc>>, watch_tx: &Arc>, @@ -3410,6 +3534,190 @@ mod lifecycle_tests { })); } + #[test] + fn redact_secrets_covers_both_echoed_command_and_command_output() { + // Mirrors nvbugs 6843156: cmd.exe with no `@echo off` writes the + // literal secret twice -- once as part of the echoed, expanded + // command line, once again as the command's own output. + let secrets = vec!["SuperSecretValue12345".to_owned()]; + let echoed_command = + "C:\\work\\share>echo checking secret SuperSecretValue12345".to_owned(); + let command_output = "checking secret SuperSecretValue12345".to_owned(); + + assert_eq!( + redact_secrets(&echoed_command, &secrets), + "C:\\work\\share>echo checking secret [REDACTED]" + ); + assert_eq!( + redact_secrets(&command_output, &secrets), + "checking secret [REDACTED]" + ); + } + + #[test] + fn redact_secrets_is_a_no_op_when_nothing_matches() { + let secrets = vec!["SuperSecretValue12345".to_owned()]; + let line = "nothing sensitive here".to_owned(); + assert_eq!(redact_secrets(&line, &secrets), line); + } + + #[test] + fn redact_secrets_covers_contained_values() { + let secrets = vec!["abcd".to_owned(), "abcdef".to_owned()]; + let line = "value=abcdef".to_owned(); + assert_eq!(redact_secrets(&line, &secrets), "value=[REDACTED]"); + } + + #[test] + fn secret_redaction_values_collects_injected_env_and_proxy_password_only() { + let injected_environment = vec![ + "SECRET_VAR=SuperSecretValue12345".to_owned(), + "SHORT=abc".to_owned(), // below MIN_SECRET_LEN, must be dropped + ]; + let proxy_auth = SandboxProxyAuth { + password: "sandbox-secret".to_owned(), + }; + + let values = secret_redaction_values(&injected_environment, Some(&proxy_auth)); + + assert!(values.contains(&"SuperSecretValue12345".to_owned())); + assert!(values.contains(&"sandbox-secret".to_owned())); + assert!(!values.iter().any(|v| v == "abc")); + } + + #[test] + fn redact_secrets_merges_overlapping_and_adjacent_matches() { + for values in [["abcdef", "defghi"], ["defghi", "abcdef"]] { + let secrets = values.map(str::to_owned); + assert_eq!( + redact_secrets("value=abcdefghi!", &secrets), + "value=[REDACTED]!" + ); + assert_eq!(redact_secrets("abcdefdefghi", &secrets), "[REDACTED]"); + assert_eq!( + redact_secrets("abcdef / defghi", &secrets), + "[REDACTED] / [REDACTED]" + ); + } + } + + #[test] + fn redact_secrets_handles_self_overlap_and_unicode_boundaries() { + let secrets = ["éé".to_owned()]; + assert_eq!(redact_secrets("前ééé後", &secrets), "前[REDACTED]後"); + let secrets = ["abab".to_owned()]; + assert_eq!(redact_secrets("ababab", &secrets), "[REDACTED]"); + } + + #[test] + fn redact_secrets_does_not_rescan_replacement_markers() { + let secrets = ["example-value".to_owned(), "REDACTED".to_owned()]; + assert_eq!(redact_secrets("example-value", &secrets), "[REDACTED]"); + } + + #[test] + fn redact_secrets_borrows_when_no_nonempty_pattern_matches() { + for secrets in [vec![], vec![String::new()], vec!["absent".to_owned()]] { + assert!(matches!( + redact_secrets("ordinary output", &secrets), + std::borrow::Cow::Borrowed(_) + )); + } + } + + async fn assert_launch_failure_is_redacted(message: &str, secret: &str) { + use tracing::instrument::WithSubscriber; + + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let sandbox = driver_sandbox("redacted-failure"); + backend.registry.lock().await.insert( + sandbox.id.clone(), + SandboxEntry { + sandbox: sandbox.clone(), + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Starting, + lifecycle_gate: Arc::new(Mutex::new(())), + exec_child: None, + shutdown_tx: None, + terminated_rx: None, + signal_file: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, + control_channel: None, + }, + ); + let mut events = backend.watch_tx.subscribe(); + let log = tempfile::NamedTempFile::new().unwrap(); + let writer = log.reopen().unwrap(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_writer(move || writer.try_clone().unwrap()) + .finish(); + let secrets = secret_redaction_values(&[format!("TOKEN={secret}")], None); + set_launch_failed( + &backend.registry, + &backend.watch_tx, + &sandbox, + &sandbox.id, + message, + &secrets, + ) + .with_subscriber(subscriber) + .await; + + let expected = "target failed; stderr: [REDACTED]"; + let registry = backend.registry.lock().await; + let entry = registry.get(&sandbox.id).unwrap(); + assert_eq!(entry.phase_state, PhaseState::Failed(expected.to_owned())); + assert_eq!(ready_condition(&entry.sandbox).unwrap().message, expected); + let event = events.try_recv().expect("failure watch event"); + let Some(watch_sandboxes_event::Payload::Sandbox(event)) = event.payload else { + panic!("expected sandbox status"); + }; + assert_eq!( + ready_condition(&event.sandbox.unwrap()).unwrap().message, + expected + ); + let captured = std::fs::read_to_string(log.path()).unwrap(); + assert!(captured.contains("control-channel launch failed")); + assert!(captured.contains("MXC lifecycle failed")); + assert!(!captured.contains(secret)); + assert_eq!(captured.matches("[REDACTED]").count(), 2); + } + + #[tokio::test] + async fn routed_target_failure_redacts_logs_status_and_watch_event() { + let secret = r#"test-credential"with\escapes"#; + let message = format!("target failed; stderr: {secret}"); + let line = serde_json::json!({"event": "target_failed", "error": message}).to_string(); + let (tx, rx) = oneshot::channel(); + let slot = Mutex::new(Some(tx)); + assert!(ControlChannel::try_route_target_status(&slot, &line).await); + let decoded = rx.await.unwrap().unwrap_err(); + assert_eq!(decoded, message, "routing preserves decoded protocol data"); + assert_launch_failure_is_redacted(&decoded, secret).await; + } + + #[tokio::test] + async fn routed_response_redacts_failure_without_rewriting_success_payload() { + let secret = "test-response-credential"; + for ok in [false, true] { + let message = format!("target failed; stderr: {secret}"); + let response = serde_json::json!({"id": 1, "ok": ok, "error": message, "data": secret}); + let (tx, rx) = oneshot::channel(); + let pending = Mutex::new(HashMap::from([(1, tx)])); + assert!(ControlChannel::try_route_response(&pending, &response.to_string()).await); + let decoded = rx.await.unwrap(); + assert_eq!(decoded, response, "protocol payload must remain unchanged"); + if !ok { + assert_launch_failure_is_redacted(decoded["error"].as_str().unwrap(), secret).await; + } + } + } + #[test] fn tls_env_vars_replace_user_trust_overrides() { let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 92d4de0c47..3875e0da73 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -40,6 +40,19 @@ an exited canonical process remains a terminal sandbox result. Exit code zero produces `Completed`; a nonzero or signal-normalized exit produces `Error` with the exact exit code. Driver and supervisor failures remain `Error`. +## Windows MXC Diagnostics + +The MXC driver redacts literal, case-sensitive matches of injected environment +values and its per-sandbox proxy password in captured stdout/stderr and relay +launch-failure diagnostics. Gateway logs and sandbox failure status use the +scrubbed diagnostics. Overlapping matches are redacted together. + +Only values of at least four UTF-8 bytes are included, and non-secret injected +settings can also be redacted. Shorter values and transformed or encoded values +are outside this protection. Avoid printing credentials in workloads. +Provider credentials remain in the host proxy and reach the child as +placeholders. + ## Build with Selected Compute Drivers Source builds of `openshell-gateway` can include any subset of the Docker, From d1ae20a0b6718a8a78d18397e5ccd175471ff388 Mon Sep 17 00:00:00 2001 From: pkhodade-NV <84895267+pkhodade-NV@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:36:38 +0530 Subject: [PATCH 31/39] docs(mxc): add missing demo examples for runbook and mTLS scenario (#3659) * docs(mxc): add missing demo examples for runbook and mTLS scenario Add three files present in the internal mirror but absent from the Windows branch: - mxc-demo-runbook.md: operator runbook for the MXC demo kit - README-mtls.txt: usage notes for the mTLS scenario - run-mtls-test.ps1: PowerShell test runner for the mTLS scenario These are required by the package-demo.ps1 packager script and are referenced by the mxc-kit documentation. No issue required: mechanical sync of missing demo assets. Signed-off-by: Prashant Khodade * fix(mxc): align demo workflows with current contracts Signed-off-by: Shailendra Singh --------- Signed-off-by: Prashant Khodade Signed-off-by: Shailendra Singh Co-authored-by: Shailendra Singh --- .../examples/README-mtls.txt | 61 ++++ .../examples/mxc-demo-runbook.md | 232 ++++++++++++++ .../examples/run-mtls-test.ps1 | 294 ++++++++++++++++++ .../tests/mtls_examples.rs | 114 +++++++ 4 files changed, 701 insertions(+) create mode 100644 crates/openshell-driver-mxc/examples/README-mtls.txt create mode 100644 crates/openshell-driver-mxc/examples/mxc-demo-runbook.md create mode 100644 crates/openshell-driver-mxc/examples/run-mtls-test.ps1 create mode 100644 crates/openshell-driver-mxc/tests/mtls_examples.rs diff --git a/crates/openshell-driver-mxc/examples/README-mtls.txt b/crates/openshell-driver-mxc/examples/README-mtls.txt new file mode 100644 index 0000000000..d5487a03f6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README-mtls.txt @@ -0,0 +1,61 @@ +OpenShell CLI <-> gateway mTLS test (T2) +========================================= + +WHAT THIS PROVES + T2 covers the control channel, not inference. It verifies that: + + - the gateway enables TLS, client-certificate verification, and mTLS auth; + - the CLI can register the gateway and complete a real `sandbox list` RPC; + - a client that presents no certificate is rejected during the handshake. + + The gateway starts with OpenShell's in-process MXC mock only to satisfy the + compute-driver startup contract. The test never creates a sandbox and does + not claim MXC or AppContainer isolation coverage. + +PREREQUISITES + Put this script in a folder containing: + + openshell-gateway.exe + openshell.exe + + No API key, wxc-exec binary, MXC backend, or network egress is required. + The gateway uses an in-memory database and the test runs on loopback. + +RUN + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mtls-test.ps1 + + Optional parameters: + + -TlsDir C:\work\openshell-mtls + Directory for throwaway certificates. Private keys remain here and are + never copied into the result bundle. + + -Port 17670 + Explicit gateway port. The default, 0, chooses an available loopback + port. If an explicit port is busy, the test fails without stopping the + process that owns it. + + -GatewayPath C:\path\to\openshell-gateway.exe + -CliPath C:\path\to\openshell.exe + Override the packaged binary paths for local development. + + -OutputDir D:\results\openshell-mtls + Write the result directory and zip to another location. The default is + the script folder. + + -KeepRunning + Leave only the gateway process started by this invocation running. + The script prints the PID and preserves its isolated CLI state path. + +STATE SAFETY + The script redirects XDG_CONFIG_HOME, XDG_STATE_HOME, and the system gateway + overlay to a unique test-owned directory beneath TlsDir before generating + certificates or invoking the CLI. Existing gateway registrations, active + selection, certificates, and CLI state are never read, overwritten, or + removed. The original environment is restored before the script exits. + +OUTPUT + results-mtls--.zip in OutputDir (the script folder by + default). The bundle contains gateway logs, transcript.txt, summary.txt, the + exact non-secret gateway configuration, and the public server certificate. + It contains no private keys. diff --git a/crates/openshell-driver-mxc/examples/mxc-demo-runbook.md b/crates/openshell-driver-mxc/examples/mxc-demo-runbook.md new file mode 100644 index 0000000000..1a14532619 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-demo-runbook.md @@ -0,0 +1,232 @@ + + + +# OpenShell MXC filesystem enforcement runbook + +Use this runbook on a Windows 11 host with a live Microsoft MXC installation. +The proof launches a workload through OpenShell's MXC driver, writes inside a +granted directory, and verifies that Windows denies a write outside it. + +This workflow uses the current MXC contract: + +- Gateway TOML contains host runtime settings only. +- `wxc_exec_path` is required and must be absolute. +- `process_container` is the default and enforces filesystem grants with a + default-deny AppContainer boundary. +- Each sandbox receives its `command` and `cwd` through + `--driver-config-json`; gateway fields such as `agent_command`, `agent_cwd`, + `agent_env`, and `share_dir` no longer exist. +- `isolation_session` is opt-in and rejects policies with non-empty filesystem + grants. Do not use it for this enforcement proof. + +## Required files + +Place these files in one writable directory: + +| File | Purpose | +|---|---| +| `openshell-gateway.exe` | Gateway with the in-process MXC driver. | +| `openshell.exe` | OpenShell CLI. | +| `libz3.dll` | Runtime dependency of the CLI package. | +| `mxc-gateway.toml` | MXC host runtime configuration. | +| `demo.yaml` | Filesystem policy granting `C:/work/openshell-mxc-demo`. | +| `mxc-demo-runbook.md` | This runbook. | + +The workload is a generated `.cmd` file in the granted directory. This +runbook does not depend on a separately packaged demo agent or orchestration +script. + +## Preflight + +Open PowerShell in the package directory and set the path to the MXC binary: + +```powershell +$wxc = "C:\mxc\wxc-exec.exe" +if (-not (Test-Path -LiteralPath $wxc -PathType Leaf)) { + throw "wxc-exec.exe was not found at $wxc" +} +& $wxc --version +``` + +Resolve any `wxc-exec` failure before starting OpenShell. The OpenShell package +does not install or repair MXC. + +Create the directory granted by `demo.yaml`: + +```powershell +$share = "C:\work\openshell-mxc-demo" +New-Item -ItemType Directory -Path $share -Force | Out-Null +``` + +If you change `$share`, update the `read_write` entry in `demo.yaml` to the same +absolute path with forward slashes. + +## Render the gateway configuration + +Create a per-run configuration rather than editing the shipped template: + +```powershell +$configPath = Join-Path $PWD "mxc-gateway.used.toml" +$config = [System.IO.File]::ReadAllText((Join-Path $PWD "mxc-gateway.toml")) +$escapedWxc = $wxc.Replace('\', '\\').Replace('"', '\"') +$config = [regex]::Replace( + $config, + '(?m)^\s*wxc_exec_path\s*=.*$', + "wxc_exec_path = `"$escapedWxc`"" +) +$config = [regex]::Replace( + $config, + '(?m)^\s*backend\s*=.*$', + 'backend = "process_container"' +) +[System.IO.File]::WriteAllText( + $configPath, + $config, + (New-Object System.Text.UTF8Encoding($false)) +) +``` + +The rendered `[openshell.drivers.mxc]` table should contain at least: + +```toml +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +backend = "process_container" +``` + +## Start the gateway + +In the first PowerShell window, isolate demo state and start the gateway: + +```powershell +$demoState = Join-Path $PWD ".openshell-mxc-demo-state" +$env:XDG_CONFIG_HOME = Join-Path $demoState "config" +$env:XDG_STATE_HOME = Join-Path $demoState "state" +$env:OPENSHELL_SYSTEM_GATEWAY_DIR = Join-Path $demoState "system-config" +$env:OPENSHELL_GATEWAY_CONFIG = $configPath +$env:OPENSHELL_COMPUTE_DRIVER = "mxc" + +.\openshell-gateway.exe --disable-tls --db-url sqlite::memory: ` + --port 17670 --log-level info +``` + +Healthy startup logs report `driver=mxc` and a listener on +`127.0.0.1:17670`. If startup rejects `wxc_exec_path`, verify that the rendered +value is absolute and points at the intended binary. + +## Register the gateway + +In a second PowerShell window, set the same isolated state paths and register +the endpoint: + +```powershell +$demoState = Join-Path $PWD ".openshell-mxc-demo-state" +$env:XDG_CONFIG_HOME = Join-Path $demoState "config" +$env:XDG_STATE_HOME = Join-Path $demoState "state" +$env:OPENSHELL_SYSTEM_GATEWAY_DIR = Join-Path $demoState "system-config" + +.\openshell.exe gateway add http://127.0.0.1:17670 --local --name openshell-mxc +.\openshell.exe gateway select openshell-mxc +``` + +## Positive proof: a granted write succeeds + +Create the workload inside the granted directory: + +```powershell +$share = "C:\work\openshell-mxc-demo" +$cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" +$positiveScript = Join-Path $share "positive.cmd" +$positiveResult = Join-Path $share "hello.txt" +Remove-Item -LiteralPath $positiveResult -Force -ErrorAction SilentlyContinue + +@" +@echo off +echo OpenShell MXC>"$positiveResult" +"@ | Set-Content -LiteralPath $positiveScript -Encoding Ascii + +$positiveConfig = @{ + mxc = @{ + command = @($cmdExe, "/d", "/s", "/c", $positiveScript) + cwd = $share + } +} | ConvertTo-Json -Compress -Depth 8 + +.\openshell.exe sandbox create --name mxc-positive --policy .\demo.yaml ` + --driver-config-json $positiveConfig --no-tty +``` + +Wait for the one-shot workload to complete, then verify the host artifact: + +```powershell +Start-Sleep -Seconds 3 +.\openshell.exe sandbox get mxc-positive --output json +Get-Content -LiteralPath $positiveResult +``` + +PASS requires a `Ready`/`AgentCompleted` sandbox and `hello.txt` containing +`OpenShell MXC`. + +## Negative proof: an ungranted write is denied + +Choose a fresh target outside the granted directory and create another +workload script inside the granted directory: + +```powershell +$deniedTarget = Join-Path ([System.IO.Path]::GetTempPath()) ` + "openshell-mxc-denied-$([Guid]::NewGuid().ToString('N')).txt" +$negativeScript = Join-Path $share "negative.cmd" + +@" +@echo off +echo should-not-exist>"$deniedTarget" +"@ | Set-Content -LiteralPath $negativeScript -Encoding Ascii + +$negativeConfig = @{ + mxc = @{ + command = @($cmdExe, "/d", "/s", "/c", $negativeScript) + cwd = $share + } +} | ConvertTo-Json -Compress -Depth 8 + +.\openshell.exe sandbox create --name mxc-negative --policy .\demo.yaml ` + --driver-config-json $negativeConfig --no-tty +``` + +Inspect the terminal state and the host path: + +```powershell +Start-Sleep -Seconds 3 +.\openshell.exe sandbox get mxc-negative --output json +Test-Path -LiteralPath $deniedTarget +``` + +PASS requires the workload to report an error caused by access denial and +`Test-Path` to return `False`. A successful write is a failed enforcement proof. + +## Cleanup + +Delete both sandbox records, stop the gateway with Ctrl+C, and remove the +test-owned artifacts: + +```powershell +.\openshell.exe sandbox delete mxc-positive +.\openshell.exe sandbox delete mxc-negative +.\openshell.exe gateway remove openshell-mxc + +Remove-Item -LiteralPath $share -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath (Join-Path $PWD ".openshell-mxc-demo-state") ` + -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath (Join-Path $PWD "mxc-gateway.used.toml") ` + -Force -ErrorAction SilentlyContinue +``` + +## Troubleshooting + +| Symptom | Action | +|---|---| +| Gateway rejects `wxc_exec_path` | Use an absolute path and confirm the rendered TOML contains doubled backslashes. | +| Gateway reports an unknown MXC field | Start from the shipped `mxc-gateway.toml`; do not add removed workload fields to gateway configuration. | +| Sandbox says the command is missing | Ensure `--driver-config-json` contains `mxc.command` and that the executable/path values are absolute. | +| Positive write is denied | Ensure the `cwd`, scripts, and result file are under the path granted by `demo.yaml`. | +| Negative write succeeds | Confirm the gateway uses `backend = "process_container"`; stop and investigate before treating the host as qualified. | +| `isolation_session` rejects the policy | Expected: this backend does not accept non-empty OpenShell filesystem grants. Use `process_container` for this proof. | diff --git a/crates/openshell-driver-mxc/examples/run-mtls-test.ps1 b/crates/openshell-driver-mxc/examples/run-mtls-test.ps1 new file mode 100644 index 0000000000..116cfcf861 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-mtls-test.ps1 @@ -0,0 +1,294 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Validate the CLI <-> gateway mTLS control channel without creating an MXC +# sandbox. The gateway uses the in-process MXC mock only to satisfy compute- +# driver startup; the test itself exercises gateway registration, a live list +# RPC, and rejection of a client that presents no certificate. + +[CmdletBinding()] +param( + [string] $TlsDir = "C:\work\openshell-mtls", + [ValidateRange(0, 65535)] [int] $Port = 0, + [ValidateSet("openshell")] [string] $GatewayName = "openshell", + [string] $GatewayPath, + [string] $CliPath, + [string] $OutputDir, + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$OutputDir = if ([string]::IsNullOrWhiteSpace($OutputDir)) { $here } else { [System.IO.Path]::GetFullPath($OutputDir) } +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +$resultDir = Join-Path $OutputDir "results-mtls-$stamp-$PID" +New-Item -ItemType Directory -Path $resultDir | Out-Null +Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + +function Step([string]$message) { Write-Host "`n=== $message ===" -ForegroundColor Cyan } +function Info([string]$message) { Write-Host " $message" } +function Ok([string]$message) { Write-Host "[OK] $message" -ForegroundColor Green } +function Bad([string]$message) { Write-Host "[FAIL] $message" -ForegroundColor Red } + +function Resolve-Executable([string]$explicit, [string]$leaf) { + $candidates = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($explicit)) { [void]$candidates.Add($explicit) } + [void]$candidates.Add((Join-Path $here $leaf)) + [void]$candidates.Add((Join-Path (Join-Path $here "bin") $leaf)) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [System.IO.Path]::GetFullPath($candidate) + } + } + throw "$leaf was not found. Pass its path explicitly or place it beside this script." +} + +function Get-AvailablePort { + $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Test-Port([int]$candidate) { + $client = New-Object System.Net.Sockets.TcpClient + try { + $pending = $client.BeginConnect("127.0.0.1", $candidate, $null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(250)) { return $false } + $client.EndConnect($pending) + return $true + } catch { + return $false + } finally { + $client.Dispose() + } +} + +function Invoke-Native { + param([string]$File, [string[]]$Arguments) + $old = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $raw = & $File @Arguments 2>&1 + $code = $LASTEXITCODE + $out = @($raw | ForEach-Object { [string] $_ }) + return [pscustomobject]@{ Out = $out; Code = $code } + } finally { + $ErrorActionPreference = $old + } +} + +function Restore-ProcessEnvironment([hashtable]$snapshot) { + foreach ($name in $snapshot.Keys) { + [Environment]::SetEnvironmentVariable($name, $snapshot[$name], "Process") + } +} + +$environmentNames = @( + "OPENSHELL_COMPUTE_DRIVER", + "OPENSHELL_GATEWAY", + "OPENSHELL_GATEWAY_CONFIG", + "OPENSHELL_LOCAL_TLS_DIR", + "OPENSHELL_MXC_MOCK_WXC", + "OPENSHELL_SYSTEM_GATEWAY_DIR", + "XDG_CONFIG_HOME", + "XDG_STATE_HOME" +) +$environmentSnapshot = @{} +foreach ($name in $environmentNames) { + $environmentSnapshot[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} + +$gateway = $null +$cli = $null +$gw = $null +$gwLog = Join-Path $resultDir "gateway.log" +$gwErrLog = Join-Path $resultDir "gateway.err.log" +$stateRoot = $null +$leftRunning = $false +$passed = $true +$tlsEnabled = $false +$clientVerify = $false +$mtlsAuth = $false +$addOk = $false +$listOk = $false +$noCertRefused = $false + +try { + Step "Validate artifacts" + $gateway = Resolve-Executable $GatewayPath "openshell-gateway.exe" + $cli = Resolve-Executable $CliPath "openshell.exe" + Info "found $gateway" + Info "found $cli" + Info "machine: $env:COMPUTERNAME user: $env:USERNAME PS: $($PSVersionTable.PSVersion)" + + $TlsDir = [System.IO.Path]::GetFullPath($TlsDir) + New-Item -ItemType Directory -Path $TlsDir -Force | Out-Null + $stateRoot = Join-Path $TlsDir ".openshell-mtls-state-$PID-$([Guid]::NewGuid().ToString('N'))" + $xdgConfigHome = Join-Path $stateRoot "config" + $xdgStateHome = Join-Path $stateRoot "state" + $systemConfigHome = Join-Path $stateRoot "system-config" + foreach ($directory in @($xdgConfigHome, $xdgStateHome, $systemConfigHome)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + # Keep gateway registration, active selection, generated client materials, + # and CLI state entirely inside this test-owned directory. + $env:XDG_CONFIG_HOME = $xdgConfigHome + $env:XDG_STATE_HOME = $xdgStateHome + $env:OPENSHELL_SYSTEM_GATEWAY_DIR = $systemConfigHome + [Environment]::SetEnvironmentVariable("OPENSHELL_GATEWAY", $null, "Process") + + Step "Generate isolated mTLS cert bundle -> $TlsDir" + $certgen = Invoke-Native $gateway @("generate-certs", "--output-dir", $TlsDir) + $certgen.Out | ForEach-Object { Info $_ } + if ($certgen.Code -ne 0) { throw "generate-certs failed (exit $($certgen.Code))" } + $bundle = @("ca.crt", "server\tls.crt", "server\tls.key", "client\tls.crt", "client\tls.key") + $missing = $bundle | Where-Object { -not (Test-Path -LiteralPath (Join-Path $TlsDir $_) -PathType Leaf) } + if ($missing) { throw "incomplete bundle, missing: $($missing -join ', ')" } + Ok "5-file TLS bundle present (+ JWT material)" + Copy-Item -LiteralPath (Join-Path $TlsDir "server\tls.crt") -Destination (Join-Path $resultDir "server-tls.crt") -Force + + if ($Port -eq 0) { $Port = Get-AvailablePort } + Step "Check gateway port $Port is free" + if (Test-Port $Port) { + throw "port $Port is already accepting connections; choose another port with -Port. No process was stopped." + } + Ok "port $Port is free" + + # The control-channel test never creates a sandbox. A schema-v2 MXC config + # plus the in-process mock provides a valid, dependency-free gateway startup. + $mockWxc = Join-Path $stateRoot "mock-wxc-exec.exe" + $escapedMockWxc = $mockWxc.Replace('\', '\\').Replace('"', '\"') + $gatewayConfig = Join-Path $resultDir "gateway.used.toml" + $gatewayConfigText = @" +[openshell] +version = 2 + +[openshell.drivers.mxc] +wxc_exec_path = "$escapedMockWxc" +backend = "process_container" +"@ + [System.IO.File]::WriteAllText($gatewayConfig, $gatewayConfigText, $utf8NoBom) + + Step "Start gateway with isolated state and mock MXC driver" + $env:OPENSHELL_LOCAL_TLS_DIR = $TlsDir + $env:OPENSHELL_GATEWAY_CONFIG = $gatewayConfig + $env:OPENSHELL_COMPUTE_DRIVER = "mxc" + $env:OPENSHELL_MXC_MOCK_WXC = "1" + $gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--db-url", "sqlite::memory:", "--port", "$Port", "--enable-mtls-auth", "true", "--log-level", "info") ` + -WorkingDirectory $here -PassThru -WindowStyle Hidden ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + Info "gateway pid $($gw.Id)" + + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Port $Port)) { + if ($gw.HasExited) { + $details = (Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue) -join "`n" + throw "gateway exited before listening (code $($gw.ExitCode)): $details" + } + Start-Sleep -Milliseconds 250 + } + if (-not (Test-Port $Port)) { throw "gateway did not start listening on $Port within 30 seconds" } + + Step "CLI over mTLS with isolated registration" + $add = Invoke-Native $cli @("gateway", "add", "https://127.0.0.1:$Port", "--local", "--name", $GatewayName) + $add.Out | ForEach-Object { Info $_ } + $addOk = ($add.Code -eq 0) + if (-not $addOk) { throw "gateway add failed (exit $($add.Code))" } + $select = Invoke-Native $cli @("gateway", "select", $GatewayName) + $select.Out | ForEach-Object { Info $_ } + if ($select.Code -ne 0) { throw "gateway select failed (exit $($select.Code))" } + Ok "gateway add/select over HTTPS succeeded" + + $list = Invoke-Native $cli @("sandbox", "list") + $list.Out | ForEach-Object { Info $_ } + $listOk = ($list.Code -eq 0) + $tlsEnabled = $listOk + if ($listOk) { Ok "sandbox list over mTLS succeeded (decisive round trip)" } else { Bad "sandbox list failed"; $passed = $false } + + Step "Negative: client with NO certificate must be refused" + $curl = Invoke-Native "curl.exe" @("-sS", "--insecure", "--max-time", "6", "https://127.0.0.1:$Port/") + $curlExit = $curl.Code + $curl.Out | ForEach-Object { Info $_ } + Start-Sleep -Milliseconds 500 + $logNow = (Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue) -join "`n" + $noCertRefused = ($curlExit -ne 0) -and ($logNow -match 'handshake failed.*no certificates|peer sent no certificates|certificate required|bad certificate') + if ($noCertRefused) { Ok "no-cert client refused (curl exit $curlExit + gateway logged the rejection)" } else { Bad "no-cert client was NOT clearly refused (curl exit $curlExit)"; $passed = $false } + $clientVerify = $noCertRefused + $mtlsAuth = $tlsEnabled -and $clientVerify + if ($tlsEnabled) { Ok "TLS enabled: CLI completed a trusted HTTPS RPC" } + if ($clientVerify) { Ok "client certificate verification enabled: no-cert handshake rejected" } + if ($mtlsAuth) { Ok "mTLS authentication enabled: certified CLI accepted and no-cert client refused" } +} catch { + Bad $_.Exception.Message + $passed = $false +} finally { + if ($KeepRunning -and $gw -and -not $gw.HasExited) { + $leftRunning = $true + Info "leaving gateway pid $($gw.Id) running (-KeepRunning)" + Info "isolated CLI state is preserved at $stateRoot" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup" + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + try { [void] $gw.WaitForExit(5000) } catch {} + Info "stopped test-owned gateway pid $($gw.Id)" + } + + Restore-ProcessEnvironment $environmentSnapshot + if (-not $leftRunning -and $stateRoot -and (Test-Path -LiteralPath $stateRoot)) { + Remove-Item -LiteralPath $stateRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + Step "Gateway log (tail)" + Get-Content $gwLog, $gwErrLog -Tail 25 -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + + Step "RESULT" + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $summary = @" +OpenShell CLI<->gateway mTLS test (T2) +====================================== +timestamp : $stamp +machine : $env:COMPUTERNAME +verdict : $verdict +tls_enabled : $tlsEnabled +client_cert_verification : $clientVerify +mtls_user_auth : $mtlsAuth +cli_gateway_add_ok : $addOk +cli_sandbox_list_ok : $listOk +no_cert_client_refused : $noCertRefused +gateway_port : $Port +tls_bundle_dir : $TlsDir +isolated_cli_state : $stateRoot +gateway_left_running : $leftRunning + +PASS means the gateway served its management API over mutual TLS, the CLI +completed a real list RPC over that channel, and a client with no certificate +was refused. The in-process MXC mock is startup plumbing only; no sandbox or +inference endpoint is exercised. Existing OpenShell CLI state is not modified. +Throwaway private keys are not included in the result bundle. +"@ + [System.IO.File]::WriteAllText((Join-Path $resultDir "summary.txt"), $summary, $utf8NoBom) + Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) + + try { Stop-Transcript | Out-Null } catch {} + try { + $zip = Join-Path $OutputDir "results-mtls-$stamp-$PID.zip" + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow + } catch { + Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red + } +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/tests/mtls_examples.rs b/crates/openshell-driver-mxc/tests/mtls_examples.rs new file mode 100644 index 0000000000..249f9ae287 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/mtls_examples.rs @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Drift guards for the shipped mTLS control-channel scenario and MXC runbook. + +use std::path::{Path, PathBuf}; + +fn examples_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("examples") +} + +fn read_example(name: &str) -> String { + let path = examples_root().join(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())) +} + +#[test] +fn shipped_mtls_and_runbook_assets_are_present() { + for name in [ + "README-mtls.txt", + "run-mtls-test.ps1", + "mxc-demo-runbook.md", + ] { + let path = examples_root().join(name); + assert!( + path.is_file(), + "shipped example asset is missing: {}", + path.display() + ); + } +} + +#[test] +fn mtls_runner_uses_isolated_state_and_valid_mock_mxc_startup() { + let source = read_example("run-mtls-test.ps1"); + + for required in [ + "$env:XDG_CONFIG_HOME = $xdgConfigHome", + "$env:XDG_STATE_HOME = $xdgStateHome", + "$env:OPENSHELL_SYSTEM_GATEWAY_DIR = $systemConfigHome", + "$env:OPENSHELL_GATEWAY_CONFIG = $gatewayConfig", + "$env:OPENSHELL_COMPUTE_DRIVER = \"mxc\"", + "$env:OPENSHELL_MXC_MOCK_WXC = \"1\"", + "version = 2", + "wxc_exec_path = \"$escapedMockWxc\"", + "sqlite::memory:", + "Restore-ProcessEnvironment $environmentSnapshot", + ] { + assert!(source.contains(required), "runner is missing: {required}"); + } + + assert!(!source.contains("OPENSHELL_DRIVERS")); + assert!(!source.contains("Get-Process -Id")); + assert!(!source.contains("@(\"gateway\", \"remove\"")); + assert!(source.contains("Stop-Process -Id $gw.Id")); + assert!(source.contains("No process was stopped")); +} + +#[test] +fn runbook_uses_current_mxc_configuration_contract() { + let source = read_example("mxc-demo-runbook.md"); + + for required in [ + "`process_container` is the default", + "`wxc_exec_path` is required and must be absolute", + "`--driver-config-json`", + "mxc.command", + "command = @($cmdExe", + "cwd = $share", + "rejects policies with non-empty filesystem", + ] { + assert!(source.contains(required), "runbook is missing: {required}"); + } + + for obsolete in [ + "agent_command =", + "agent_cwd =", + "agent_env =", + "share_dir =", + "`isolation_session` (default)", + "run-demo.ps1", + "mxc-demo-agent.exe", + ] { + assert!( + !source.contains(obsolete), + "runbook still relies on obsolete content: {obsolete}" + ); + } +} + +#[cfg(target_os = "windows")] +#[test] +fn mtls_runner_parses_in_windows_powershell() { + let path = examples_root().join("run-mtls-test.ps1"); + let script = r" +$errors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile($env:OPENSHELL_MTLS_SCRIPT_TO_PARSE, [ref]$null, [ref]$errors) +if ($errors.Count -gt 0) { + $errors | ForEach-Object { [Console]::Error.WriteLine($_.Message) } + exit 1 +} +"; + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-Command", script]) + .env("OPENSHELL_MTLS_SCRIPT_TO_PARSE", &path) + .output() + .unwrap_or_else(|error| panic!("failed to launch PowerShell: {error}")); + assert!( + output.status.success(), + "run-mtls-test.ps1 has PowerShell syntax errors:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} From f6ecca2061976474c21c48e74208f01caa89f2a3 Mon Sep 17 00:00:00 2001 From: nv-vankit <162085734+nv-vankit@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:28:35 +0530 Subject: [PATCH 32/39] fix(mxc): authorize curl in inference policy (NVBug 6863783) (#4199) Signed-off-by: nv-vankit Co-authored-by: nv-vankit --- .../examples/inference.yaml | 2 +- .../examples/run-inference-test.ps1 | 2 +- crates/openshell-driver-mxc/src/driver.rs | 3 ++- .../tests/demo_examples.rs | 19 +++++++++++++++---- .../tests/wxc_exec_real.rs | 3 ++- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/inference.yaml b/crates/openshell-driver-mxc/examples/inference.yaml index 820321c0be..fe9c5b5ec3 100644 --- a/crates/openshell-driver-mxc/examples/inference.yaml +++ b/crates/openshell-driver-mxc/examples/inference.yaml @@ -21,4 +21,4 @@ network_policies: access: read-write enforcement: enforce binaries: - - path: '__CMD_EXE__' + - path: '__CURL_EXE__' diff --git a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 index 9b2a39e329..7033b3eb1c 100644 --- a/crates/openshell-driver-mxc/examples/run-inference-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-inference-test.ps1 @@ -209,7 +209,7 @@ try { $sharePolicy = $ShareDir.Replace('\', '/') $policyText = [System.IO.File]::ReadAllText((Join-Path $here "inference.yaml")) $policyText = $policyText.Replace("__OPENSHELL_DEMO_SHARE__", $sharePolicy) - $policyText = $policyText.Replace("__CMD_EXE__", $cmdExe) + $policyText = $policyText.Replace("__CURL_EXE__", $curlExe) $policyText = $policyText.Replace("__INFERENCE_HOST__", $apiUri.DnsSafeHost) $policyText = $policyText.Replace("__INFERENCE_PORT__", [string] $apiUri.Port) Write-Utf8 $policyUsed $policyText diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 07e9fee713..f20d001920 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -2989,7 +2989,8 @@ mod lifecycle_tests { .replace("__OLLAMA_PORT__", "11434") .replace("__INFERENCE_HOST__", "integrate.api.nvidia.com") .replace("__INFERENCE_PORT__", "443") - .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); + .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe") + .replace("__CURL_EXE__", r"C:\Windows\System32\curl.exe"); parse_sandbox_policy(&rendered).expect("parse rendered shipped demo policy") } diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index 5fb7f33d69..f16887955f 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -206,22 +206,31 @@ fn shipped_inference_policies_are_narrow_and_valid_after_rendering() { "/portable/demo" }; let fixtures = [ - ("ollama.yaml", "local_ollama", "127.0.0.1", share), + ( + "ollama.yaml", + "local_ollama", + "127.0.0.1", + share, + "__CMD_EXE__", + r"C:\Windows\System32\cmd.exe", + ), ( "inference.yaml", "nvidia_inference", "integrate.api.nvidia.com", share, + "__CURL_EXE__", + r"C:\Windows\System32\curl.exe", ), ]; - for (name, rule_name, endpoint, share) in fixtures { + for (name, rule_name, endpoint, share, binary_placeholder, binary_path) in fixtures { let rendered = read_example(name) .replace("__OPENSHELL_DEMO_SHARE__", share) .replace("__OLLAMA_HOST__", "127.0.0.1") .replace("__OLLAMA_PORT__", "11434") .replace("__INFERENCE_HOST__", "integrate.api.nvidia.com") .replace("__INFERENCE_PORT__", "443") - .replace("__CMD_EXE__", r"C:\Windows\System32\cmd.exe"); + .replace(binary_placeholder, binary_path); let policy = parse_sandbox_policy(&rendered) .unwrap_or_else(|error| panic!("failed to parse rendered {name}: {error}")); validate_sandbox_policy(&policy) @@ -241,7 +250,7 @@ fn shipped_inference_policies_are_narrow_and_valid_after_rendering() { assert_eq!(rule.binaries.len(), 1); assert_eq!( rule.binaries[0].path.to_ascii_lowercase(), - r"c:\windows\system32\cmd.exe" + binary_path.to_ascii_lowercase() ); } } @@ -288,6 +297,8 @@ fn shipped_runners_supply_sandbox_scoped_workload_configuration() { assert!(cloud.contains("--noproxy")); assert!(cloud.contains("in-process wxc shim")); assert!(cloud.contains("does not provide MXC or AppContainer isolation")); + assert!(cloud.contains("$policyText.Replace(\"__CURL_EXE__\", $curlExe)")); + assert!(!cloud.contains("$policyText.Replace(\"__CMD_EXE__\", $cmdExe)")); } #[cfg(target_os = "windows")] diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index d54a9b8cb6..ea42823c4b 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -982,6 +982,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { let post_status_path_string = post_status_path.to_string_lossy().into_owned(); let diagnostic_path_string = diagnostic_path.to_string_lossy().into_owned(); let cmd_string = cmd.to_string_lossy().into_owned(); + let curl_string = curl.to_string_lossy().into_owned(); // Schannel's revocation lookup targets are intentionally outside this // test's example.com-only policy. Disable that network lookup while still // requiring curl to validate the proxy-issued certificate against the @@ -1036,7 +1037,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { access: "read-only".to_string(), ..Default::default() }], - binaries: vec![NetworkBinary { path: cmd_string }], + binaries: vec![NetworkBinary { path: curl_string }], }, )]), ..Default::default() From ed35eb9f3b9ca9f03ac815321d4119480baefa43 Mon Sep 17 00:00:00 2001 From: nv-vankit <162085734+nv-vankit@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:30:20 +0530 Subject: [PATCH 33/39] fix(mxc): refresh stale demo gateway aliases (#4192) * fix(mxc): refresh stale demo gateway aliases (NVBug 6870039) Signed-off-by: nv-vankit * fix(mxc): isolate demo CLI gateway state Signed-off-by: nv-vankit * fix(mxc): remove empty gateway overrides Signed-off-by: nv-vankit * fix(mxc): preserve exact runner environment state Signed-off-by: Shailendra Singh --------- Signed-off-by: nv-vankit Signed-off-by: Shailendra Singh Co-authored-by: nv-vankit Co-authored-by: Shailendra Singh --- .../examples/run-mxc-e2e.ps1 | 111 +++++++++- .../examples/run-ocsf-audit.ps1 | 112 +++++++++- .../tests/demo_examples.rs | 19 ++ .../tests/windows_e2e_harness.rs | 194 +++++++++++++++--- tasks/scripts/windows-mxc-aggregate-e2e.ps1 | 50 +++-- tasks/scripts/windows-mxc-e2e-environment.ps1 | 80 ++++++-- tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 | 52 +++-- 7 files changed, 535 insertions(+), 83 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 060b7c6f79..f8853beeb6 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -176,9 +176,105 @@ $script:registered = $false $tomlBase = $null $gwLog = $null $gwErrLog = $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" +) # --- Helpers ------------------------------------------------------------------ +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) { + $snapshot = $script:cliEnvironmentSnapshot[$name] + Set-ProcessEnvironmentVariableExact -Name $name -Exists $snapshot.Exists -Value $snapshot.Value + } + if ($script:cliStateRoot -and (Test-Path -LiteralPath $script:cliStateRoot)) { + Remove-Item -LiteralPath $script:cliStateRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + # Render host-runtime settings from the pristine base. Sandbox workload # settings are create-time driver config, not gateway-wide TOML. function Render-Toml { @@ -233,14 +329,14 @@ function Stop-Gw($p) { function Register-Cli { if ($script:registered) { return } - $env:OPENSHELL_GATEWAY = "" + $expectedEndpoint = "http://127.0.0.1:$Port" $addResult = Invoke-NativeCaptured $cli @( - "gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName + "gateway", "add", $expectedEndpoint, "--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" } @@ -379,6 +475,8 @@ $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. @@ -750,7 +848,10 @@ try { } } finally { if ($gw -and -not $KeepRunning) { Stop-Gw $gw } - if ($KeepRunning -and $gw) { Info "gateway pid $($gw.Id) left running (-KeepRunning)" } + if ($KeepRunning -and $gw) { + Info "gateway pid $($gw.Id) left running (-KeepRunning)" + Info "CLI inspection endpoint: `$env:OPENSHELL_GATEWAY_ENDPOINT='http://127.0.0.1:$Port'" + } } } catch { @@ -758,6 +859,8 @@ catch { Bad "harness error: $harnessError" } finally { + Exit-IsolatedCliEnvironment + # --- Summary + results bundle --------------------------------------------- Step "Summary" $results | Format-Table -AutoSize diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index c410b1df8e..80dbe43de2 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -129,7 +129,10 @@ function Invoke-Cli([string[]]$CommandArgs, [switch]$AllowFailure) { if (-not $AllowFailure -and $process.ExitCode -ne 0) { throw "openshell $($CommandArgs -join ' ') failed (exit $($process.ExitCode)): $text" } - return @{ ExitCode = $process.ExitCode; Text = $text } + return @{ + ExitCode = $process.ExitCode + Text = $text + } } function Resolve-Artifact([string]$explicit, [string]$leaf) { @@ -152,6 +155,103 @@ function Get-MxcEtwSessions { } } +$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) { + $snapshot = $script:cliEnvironmentSnapshot[$name] + Set-ProcessEnvironmentVariableExact -Name $name -Exists $snapshot.Exists -Value $snapshot.Value + } + if ($script:cliStateRoot -and (Test-Path -LiteralPath $script:cliStateRoot)) { + Remove-Item -LiteralPath $script:cliStateRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + $gateway = Resolve-Artifact $GatewayPath "openshell-gateway.exe" $cli = Resolve-Artifact $CliPath "openshell.exe" $policySrc = Join-Path $here "ocsf-audit.yaml" @@ -167,6 +267,8 @@ $proxyOn = -not $NoProxy $oldMockWxc = $env:OPENSHELL_MXC_MOCK_WXC try { + Enter-IsolatedCliEnvironment + # 1. Validate artifacts + privilege. Step "Validate package artifacts" foreach ($f in @($gateway, $cli, $policySrc, $tomlSrc)) { @@ -319,10 +421,10 @@ try { # 9. Register CLI -> gateway. Step "Register CLI -> gateway" - $env:OPENSHELL_GATEWAY = "" - $gatewayAdd = Invoke-Cli @("gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName) -AllowFailure + $expectedEndpoint = "http://127.0.0.1:$Port" + $gatewayAdd = Invoke-Cli @("gateway", "add", $expectedEndpoint, "--local", "--name", $GatewayName) -AllowFailure if ($gatewayAdd.Text) { Info $gatewayAdd.Text } - if ($gatewayAdd.ExitCode -ne 0 -and $gatewayAdd.Text -notmatch '(?i)already exists') { + if ($gatewayAdd.ExitCode -ne 0) { throw "gateway registration failed (exit $($gatewayAdd.ExitCode)): $($gatewayAdd.Text)" } $gatewaySelect = Invoke-Cli @("gateway", "select", $GatewayName) @@ -364,6 +466,7 @@ finally { # Stop the gateway FIRST so it releases its log + JSONL file handles. if ($KeepRunning -and $gw -and -not $gw.HasExited) { Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop it with: Stop-Process -Id $($gw.Id) -Force" + Info "CLI inspection endpoint: `$env:OPENSHELL_GATEWAY_ENDPOINT='http://127.0.0.1:$Port'" } elseif ($gw -and -not $gw.HasExited) { Step "Cleanup" Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue @@ -382,6 +485,7 @@ finally { } else { $env:OPENSHELL_MXC_MOCK_WXC = $oldMockWxc } + Exit-IsolatedCliEnvironment # ---- summarise the OCSF audit trail -------------------------------------- $logText = @() diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index f16887955f..a6293f7220 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -137,6 +137,25 @@ fn shipped_aggregate_e2e_assets_support_mock_wiring_validation() { assert!(runner.contains("not evidence of native MXC or OS enforcement")); } +#[test] +fn shipped_runners_isolate_cli_state_and_gateway_overrides() { + for name in ["run-mxc-e2e.ps1", "run-ocsf-audit.ps1"] { + let runner = read_example(name); + for required in [ + "Enter-IsolatedCliEnvironment", + "Exit-IsolatedCliEnvironment", + "APPDATA", + "LOCALAPPDATA", + "OPENSHELL_GATEWAY", + "OPENSHELL_GATEWAY_ENDPOINT", + "Remove-Item \"Env:$name\"", + "CLI inspection endpoint:", + ] { + assert!(runner.contains(required), "{name} is missing {required}"); + } + } +} + #[test] fn shipped_audit_and_websocket_configs_use_current_schema() { for name in ["mxc-ocsf-audit.toml", "mxc-ws-gateway.toml"] { diff --git a/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs b/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs index e9c4852bfe..f360aee15c 100644 --- a/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs +++ b/crates/openshell-driver-mxc/tests/windows_e2e_harness.rs @@ -26,20 +26,25 @@ fn task_harnesses_isolate_and_restore_gateway_configuration() { let script = r#" $ErrorActionPreference = "Stop" . $env:OPENSHELL_MXC_ENVIRONMENT_HELPER -$names = @( - "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" -) -foreach ($name in $names) { - [System.Environment]::SetEnvironmentVariable($name, "caller-$name", "Process") +$nonEmptyValues = @{ + APPDATA = "caller-APPDATA" + LOCALAPPDATA = "caller-LOCALAPPDATA" + XDG_CONFIG_HOME = "caller-XDG_CONFIG_HOME" + XDG_STATE_HOME = "caller-XDG_STATE_HOME" + XDG_DATA_HOME = "caller-XDG_DATA_HOME" + OPENSHELL_GATEWAY_ENDPOINT = "http://127.0.0.1:1" + OPENSHELL_GATEWAY_INSECURE = "caller-OPENSHELL_GATEWAY_INSECURE" + OPENSHELL_GATEWAY_CONFIG = "caller-OPENSHELL_GATEWAY_CONFIG" +} +foreach ($entry in $nonEmptyValues.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") +} +$before = [Environment]::GetEnvironmentVariables("Process") +if (-not $before.Contains("OPENSHELL_GATEWAY") -or ([string] $before["OPENSHELL_GATEWAY"]).Length -ne 0) { + throw "OPENSHELL_GATEWAY did not start as an empty entry" +} +if ($before.Contains("OPENSHELL_GATEWAY_NAME")) { + throw "OPENSHELL_GATEWAY_NAME did not start absent" } $snapshot = Push-OpenShellMxcE2eEnvironment -StageDir $env:OPENSHELL_MXC_ENVIRONMENT_STAGE @@ -50,8 +55,9 @@ try { throw "$name escaped the staging directory: $value" } } + $isolatedEnvironment = [Environment]::GetEnvironmentVariables("Process") foreach ($name in @("OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_INSECURE", "OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY_NAME")) { - if ($null -ne [System.Environment]::GetEnvironmentVariable($name, "Process")) { + if ($isolatedEnvironment.Contains($name)) { throw "$name was inherited by the isolated harness" } } @@ -59,26 +65,152 @@ try { Pop-OpenShellMxcE2eEnvironment -Snapshot $snapshot } -foreach ($name in $names) { - $value = [System.Environment]::GetEnvironmentVariable($name, "Process") - if ($value -ne "caller-$name") { - throw "$name was not restored: $value" +foreach ($entry in $nonEmptyValues.GetEnumerator()) { + $value = [Environment]::GetEnvironmentVariable($entry.Key, "Process") + if ($value -ne $entry.Value) { + throw "$($entry.Key) was not restored: $value" } } +$restored = [Environment]::GetEnvironmentVariables("Process") +if (-not $restored.Contains("OPENSHELL_GATEWAY") -or ([string] $restored["OPENSHELL_GATEWAY"]).Length -ne 0) { + throw "empty OPENSHELL_GATEWAY was not restored exactly" +} +if ($restored.Contains("OPENSHELL_GATEWAY_NAME")) { + throw "absent OPENSHELL_GATEWAY_NAME was restored" +} "#; - let output = std::process::Command::new("powershell.exe") - .args(["-NoProfile", "-Command", script]) - .env("OPENSHELL_MXC_ENVIRONMENT_HELPER", helper) - .env("OPENSHELL_MXC_ENVIRONMENT_STAGE", stage) - .output() - .expect("failed to launch Windows PowerShell"); - assert!( - output.status.success(), - "environment isolation check failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + for shell in ["powershell.exe", "pwsh.exe"] { + let output = std::process::Command::new(shell) + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) + .env("OPENSHELL_MXC_ENVIRONMENT_HELPER", &helper) + .env("OPENSHELL_MXC_ENVIRONMENT_STAGE", &stage) + .env("OPENSHELL_GATEWAY", "") + .env_remove("OPENSHELL_GATEWAY_NAME") + .output() + .unwrap_or_else(|error| panic!("failed to launch {shell}: {error}")); + assert!( + output.status.success(), + "environment isolation check failed under {shell}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn shipped_runners_remove_empty_overrides_in_both_powershell_versions() { + let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples"); + 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 -gt 0) { throw ($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 "$functionName was not found" } + 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" +) +$env:OPENSHELL_GATEWAY_ENDPOINT = "http://127.0.0.1:1" +$before = [Environment]::GetEnvironmentVariables("Process") +if (-not $before.Contains("OPENSHELL_GATEWAY") -or ([string] $before["OPENSHELL_GATEWAY"]).Length -ne 0) { + throw "OPENSHELL_GATEWAY did not start as an empty entry" +} +if ($before.Contains("OPENSHELL_GATEWAY_NAME")) { + throw "OPENSHELL_GATEWAY_NAME did not start absent" +} + +Enter-IsolatedCliEnvironment +try { + $isolatedEnvironment = [Environment]::GetEnvironmentVariables("Process") + foreach ($name in @("OPENSHELL_GATEWAY", "OPENSHELL_GATEWAY_ENDPOINT", "OPENSHELL_GATEWAY_NAME")) { + if ($isolatedEnvironment.Contains($name)) { + throw "$name was not removed" + } + } +} finally { + Exit-IsolatedCliEnvironment +} + +if ($env:OPENSHELL_GATEWAY_ENDPOINT -ne "http://127.0.0.1:1") { + throw "OPENSHELL_GATEWAY_ENDPOINT was not restored" +} +$restored = [Environment]::GetEnvironmentVariables("Process") +if (-not $restored.Contains("OPENSHELL_GATEWAY") -or ([string] $restored["OPENSHELL_GATEWAY"]).Length -ne 0) { + throw "empty OPENSHELL_GATEWAY snapshot was not restored exactly" +} +if ($restored.Contains("OPENSHELL_GATEWAY_NAME")) { + throw "absent OPENSHELL_GATEWAY_NAME snapshot was restored" +} +"#; + + for runner in ["run-mxc-e2e.ps1", "run-ocsf-audit.ps1"] { + for shell in ["powershell.exe", "pwsh.exe"] { + let output = std::process::Command::new(shell) + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]) + .env("OPENSHELL_MXC_RUNNER", examples.join(runner)) + .env("OPENSHELL_GATEWAY", "") + .env_remove("OPENSHELL_GATEWAY_NAME") + .output() + .unwrap_or_else(|error| panic!("failed to launch {shell}: {error}")); + assert!( + output.status.success(), + "{runner} environment isolation failed under {shell}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } +} + +#[test] +fn aggregate_and_ocsf_harnesses_preserve_caller_gateway_state() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + for name in [ + "windows-mxc-aggregate-e2e.ps1", + "windows-mxc-ocsf-audit-e2e.ps1", + ] { + let source = std::fs::read_to_string(repo_root.join("tasks/scripts").join(name)) + .unwrap_or_else(|error| panic!("failed to read {name}: {error}")); + for required in [ + "$sentinelEndpoint", + "OPENSHELL_GATEWAY_ENDPOINT", + "gateway list -o json", + "$sentinel.active", + "pwsh.exe", + ] { + assert!(source.contains(required), "{name} is missing {required}"); + } + } } #[test] diff --git a/tasks/scripts/windows-mxc-aggregate-e2e.ps1 b/tasks/scripts/windows-mxc-aggregate-e2e.ps1 index 6ffaa6d37b..46da26bbf1 100644 --- a/tasks/scripts/windows-mxc-aggregate-e2e.ps1 +++ b/tasks/scripts/windows-mxc-aggregate-e2e.ps1 @@ -96,18 +96,44 @@ try { $runner = Join-Path $StageDir "run-mxc-e2e.ps1" $demoDir = Join-Path $StageDir "demo" - $port = Get-AvailablePort - $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $runner ` - -Mock ` - -GatewayPath $GatewayPath ` - -CliPath $CliPath ` - -DemoDir $demoDir ` - -Port $port ` - -GatewayName "openshell-mxc-aggregate-ci" 2>&1 - $exitCode = $LASTEXITCODE - $output | ForEach-Object { Write-Host $_ } - if ($exitCode -ne 0) { - throw "shipped aggregate MXC example failed in mock mode (exit $exitCode)" + $gatewayName = "openshell-mxc-aggregate-ci" + $sentinelEndpoint = "http://127.0.0.1:9" + $inheritedEndpoint = "http://127.0.0.1:1" + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $sentinelAdd = & $CliPath gateway add $sentinelEndpoint --local --name $gatewayName 2>&1 + $sentinelAddExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($sentinelAddExitCode -ne 0) { + throw "failed to seed sentinel gateway '$gatewayName': $($sentinelAdd -join [Environment]::NewLine)" + } + $env:OPENSHELL_GATEWAY_ENDPOINT = $inheritedEndpoint + foreach ($powerShell in @("powershell.exe", "pwsh.exe")) { + $port = Get-AvailablePort + $output = & $powerShell -NoProfile -ExecutionPolicy Bypass -File $runner ` + -Mock ` + -GatewayPath $GatewayPath ` + -CliPath $CliPath ` + -DemoDir $demoDir ` + -Port $port ` + -GatewayName $gatewayName 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped aggregate MXC example failed under $powerShell in mock mode (exit $exitCode)" + } + + $gateways = & $CliPath gateway list -o json | ConvertFrom-Json + $sentinel = $gateways | Where-Object { $_.name -eq $gatewayName } | Select-Object -First 1 + if ($null -eq $sentinel -or $sentinel.endpoint -ne $sentinelEndpoint -or -not $sentinel.active) { + throw "aggregate runner changed the caller's sentinel gateway registration or active selection under $powerShell" + } + if ($env:OPENSHELL_GATEWAY_ENDPOINT -ne $inheritedEndpoint) { + throw "aggregate runner changed the caller's OPENSHELL_GATEWAY_ENDPOINT under $powerShell" + } } $resultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-e2e-*" | diff --git a/tasks/scripts/windows-mxc-e2e-environment.ps1 b/tasks/scripts/windows-mxc-e2e-environment.ps1 index e4d3b2eb44..50068ce77e 100644 --- a/tasks/scripts/windows-mxc-e2e-environment.ps1 +++ b/tasks/scripts/windows-mxc-e2e-environment.ps1 @@ -1,6 +1,48 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +function Set-ProcessEnvironmentVariableExact { + [CmdletBinding()] + 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 Push-OpenShellMxcE2eEnvironment { [CmdletBinding()] param( @@ -29,29 +71,30 @@ function Push-OpenShellMxcE2eEnvironment { $isolatedValues.XDG_DATA_HOME ) | Out-Null + $processEnvironment = [Environment]::GetEnvironmentVariables("Process") $savedValues = @{} foreach ($name in $isolatedValues.Keys) { - $savedValues[$name] = [System.Environment]::GetEnvironmentVariable( - $name, - [System.EnvironmentVariableTarget]::Process - ) + $exists = $processEnvironment.Contains($name) + $savedValues[$name] = [pscustomobject]@{ + Exists = $exists + Value = if ($exists) { [string] $processEnvironment[$name] } else { $null } + } } try { foreach ($entry in $isolatedValues.GetEnumerator()) { - [System.Environment]::SetEnvironmentVariable( - $entry.Key, - $entry.Value, - [System.EnvironmentVariableTarget]::Process - ) + if ($null -eq $entry.Value) { + Remove-Item "Env:$($entry.Key)" -ErrorAction SilentlyContinue + } else { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") + } } } catch { foreach ($entry in $savedValues.GetEnumerator()) { - [System.Environment]::SetEnvironmentVariable( - $entry.Key, - $entry.Value, - [System.EnvironmentVariableTarget]::Process - ) + Set-ProcessEnvironmentVariableExact ` + -Name $entry.Key ` + -Exists $entry.Value.Exists ` + -Value $entry.Value.Value } throw } @@ -67,10 +110,9 @@ function Pop-OpenShellMxcE2eEnvironment { ) foreach ($entry in $Snapshot.Values.GetEnumerator()) { - [System.Environment]::SetEnvironmentVariable( - $entry.Key, - $entry.Value, - [System.EnvironmentVariableTarget]::Process - ) + Set-ProcessEnvironmentVariableExact ` + -Name $entry.Key ` + -Exists $entry.Value.Exists ` + -Value $entry.Value.Value } } diff --git a/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 b/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 index e94681fd1a..fc3f4f8cf8 100644 --- a/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 +++ b/tasks/scripts/windows-mxc-ocsf-audit-e2e.ps1 @@ -95,19 +95,45 @@ try { $runner = Join-Path $StageDir "run-ocsf-audit.ps1" $share = Join-Path $StageDir "share" - $port = Get-AvailablePort - $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $runner ` - -Mock ` - -GatewayPath $GatewayPath ` - -CliPath $CliPath ` - -ShareDir $share ` - -SandboxCount 1 ` - -Port $port ` - -GatewayName "openshell-mxc-ocsf-ci" 2>&1 - $exitCode = $LASTEXITCODE - $output | ForEach-Object { Write-Host $_ } - if ($exitCode -ne 0) { - throw "shipped OCSF audit example failed in mock mode (exit $exitCode)" + $gatewayName = "openshell-mxc-ocsf-ci" + $sentinelEndpoint = "http://127.0.0.1:9" + $inheritedEndpoint = "http://127.0.0.1:1" + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $sentinelAdd = & $CliPath gateway add $sentinelEndpoint --local --name $gatewayName 2>&1 + $sentinelAddExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($sentinelAddExitCode -ne 0) { + throw "failed to seed sentinel gateway '$gatewayName': $($sentinelAdd -join [Environment]::NewLine)" + } + $env:OPENSHELL_GATEWAY_ENDPOINT = $inheritedEndpoint + foreach ($powerShell in @("powershell.exe", "pwsh.exe")) { + $port = Get-AvailablePort + $output = & $powerShell -NoProfile -ExecutionPolicy Bypass -File $runner ` + -Mock ` + -GatewayPath $GatewayPath ` + -CliPath $CliPath ` + -ShareDir $share ` + -SandboxCount 1 ` + -Port $port ` + -GatewayName $gatewayName 2>&1 + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "shipped OCSF audit example failed under $powerShell in mock mode (exit $exitCode)" + } + + $gateways = & $CliPath gateway list -o json | ConvertFrom-Json + $sentinel = $gateways | Where-Object { $_.name -eq $gatewayName } | Select-Object -First 1 + if ($null -eq $sentinel -or $sentinel.endpoint -ne $sentinelEndpoint -or -not $sentinel.active) { + throw "OCSF runner changed the caller's sentinel gateway registration or active selection under $powerShell" + } + if ($env:OPENSHELL_GATEWAY_ENDPOINT -ne $inheritedEndpoint) { + throw "OCSF runner changed the caller's OPENSHELL_GATEWAY_ENDPOINT under $powerShell" + } } $resultDir = Get-ChildItem -LiteralPath $StageDir -Directory -Filter "results-*" | From d89e4359b810d3d7205bb7d4a706f3c773a59e1f Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Wed, 7 Oct 2026 23:02:02 -0700 Subject: [PATCH 34/39] feat(mxc): migrate Windows driver to schema 1.0.0 (#4252) * feat(mxc): migrate schema to 1.0.0 Signed-off-by: Shailendra Singh * test(mxc): gate governed egress by host capability Signed-off-by: Shailendra Singh * feat(mxc): enforce 1.0-only configuration Signed-off-by: Shailendra Singh * fix(mxc): align RC3 qualification contracts Signed-off-by: Shailendra Singh * docs(mxc): replace RC3 release references Signed-off-by: Shailendra Singh * docs(mxc): clarify 1.0 example evidence Signed-off-by: Shailendra Singh * fix(mxc): migrate standalone mapper to schema 1.0 Signed-off-by: Shailendra Singh * test(mxc): handle backend capability validation Signed-off-by: Shailendra Singh * fix(mxc): reject isolation filesystem grants Signed-off-by: Shailendra Singh --------- Signed-off-by: Shailendra Singh --- architecture/security-policy.md | 6 +- architecture/windows.md | 15 +- crates/openshell-driver-mxc/README.md | 32 +- .../e2e-policies/openclaw-gateway.yaml | 4 +- .../examples/mxc-gateway.toml | 3 - .../examples/mxc-inference.toml | 1 - .../examples/mxc-ocsf-audit.toml | 7 +- .../examples/mxc-ollama.toml | 1 - .../examples/mxc-openclaw-gateway.toml | 5 +- .../examples/mxc-openclaw-isolation.toml | 1 - .../examples/mxc-openclaw-localnet.toml | 12 +- .../examples/mxc-provider-credential.toml | 1 - .../examples/probe-mxc-host.ps1 | 101 ++--- .../examples/run-mxc-e2e.ps1 | 28 +- .../qualification/gb300-woa.json | 2 +- crates/openshell-driver-mxc/src/driver.rs | 94 +++-- .../openshell-driver-mxc/src/etw_consumer.rs | 9 +- crates/openshell-driver-mxc/src/lib.rs | 6 +- crates/openshell-driver-mxc/src/mxc.rs | 319 +++++++--------- crates/openshell-driver-mxc/src/policy.rs | 33 +- .../src/policy_map/config.rs | 111 +++--- .../src/policy_map/loss.rs | 8 +- .../src/policy_map/map.rs | 324 +++++++++++----- .../src/policy_map/mod.rs | 23 +- .../src/policy_map/report.rs | 27 +- crates/openshell-driver-mxc/src/relay.rs | 4 +- .../tests/policy_mapper_examples.rs | 91 +++-- .../tests/policy_mapper_matrix.rs | 331 ++++++++++------- .../tests/wxc_exec_real.rs | 350 ++++++++++-------- crates/openshell-supervisor-relay/src/imp.rs | 4 +- docs/reference/gateway-config.mdx | 24 +- rfc/0013-native-windows-mxc/README.md | 70 ++-- tasks/scripts/windows-msvc.ps1 | 2 - 33 files changed, 1157 insertions(+), 892 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 7503532ed8..df4092e687 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -39,9 +39,9 @@ contract. Any explicit section, including `{}`, is rejected before driver validation or provisioning when that capability is false. The MXC `process_container` mapper translates the fields to MXC's top-level `ui` object under its `processcontainer` containment value and treats omitted fields inside -the section as deny. That object is common to MXC's 0.8 stable and 0.9 -development schemas. Both schema lines reject it for `isolation_session`, so -that backend advertises no support and the mapper also rejects it in depth. +the section as deny. That object is part of MXC's stable 1.0 schema, which +rejects it for `isolation_session`, so that backend advertises no support and +the mapper also rejects it in depth. Linux, macOS, and other non-MXC paths advertise no support: explicit UI policy fails closed, while an absent section leaves their runtime behavior unchanged. diff --git a/architecture/windows.md b/architecture/windows.md index ec0bf3da62..17646b66ac 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -68,6 +68,13 @@ The selected backend changes both lifecycle and enforceable policy. Backend selection belongs to gateway startup configuration, not to an individual sandbox request. +The driver emits the stable MXC 1.0.0 schema. IsolationSession lifecycle +routing uses `wxc-exec --operation`; later phases pass the returned identity +with `--container-id`, while the JSON omits the legacy `phase`, `sandboxId`, and +`experimental.isolation_session` fields. Provisioning declares MXC's required +all-allow IsolationSession network posture, and exec layers OpenShell variables +onto the agent user's default environment. + | Property | `process_container` | `isolation_session` | |---|---|---| | Runtime model | One-shot AppContainer process; default backend | Persistent MXC session used to run one configured process | @@ -112,11 +119,17 @@ The production mapping in | Policy area | Windows enforcement | |---|---| | Filesystem | On ProcessContainer, `read_only` and `read_write` become MXC path grants, and `include_workdir` adds the resolved working directory as read-write. The mapper normalizes separators but does not translate Linux-rooted locations into Windows paths. ProcessContainer supplies the default-deny boundary. IsolationSession cannot accept non-empty filesystem grants; without them, its backend defaults determine filesystem visibility. | -| Network | An explicit network policy requires governed egress on ProcessContainer. The mapper gives MXC loopback-only egress and returns the complete network policy to a per-sandbox host CONNECT proxy. MXC denies direct Internet access; the proxy evaluates destinations, ports, TLS/L7 rules, credential bindings, and binary rules against the configured agent command as its static process identity. Network middleware configuration is rejected because the host proxy does not receive the gateway middleware registry. IsolationSession rejects explicit network policy; without one, it retains MXC's default-allow egress. | +| Network | An explicit network policy requires governed egress on ProcessContainer. The mapper gives MXC loopback-only egress and returns the complete network policy to a per-sandbox host CONNECT proxy. This path requires MXC's `baseContainerSupportsIngressHostLoopbackAllow` host capability; fallback tiers without it can run ordinary ProcessContainer workloads but cannot reach the host proxy. MXC denies direct Internet access; the proxy evaluates destinations, ports, TLS/L7 rules, credential bindings, and binary rules against the configured agent command as its static process identity. Network middleware configuration is rejected because the host proxy does not receive the gateway middleware registry. IsolationSession rejects explicit network policy; without one, it retains MXC's default-allow egress. | | UI | ProcessContainer maps graphical UI, directional clipboard access, and input injection into MXC's top-level `ui` object. An absent section maps to the restrictive UI posture. Within an explicit section, omitted fields deny. IsolationSession rejects even an empty explicit section. | | Process | MXC supplies the Windows process-isolation boundary, but the mapper has no portable equivalent for `run_as_user` or `run_as_group`; callers must not treat those fields as enforced Windows identity controls. The canonical command, environment, and working directory are launch inputs rather than process-policy grants. | | Landlock | MXC has no equivalent for the Linux Landlock compatibility mode, including `hard_requirement`. The mapper reports a non-blocking warning; Windows filesystem assurance comes from the selected MXC backend's native semantics, not Landlock. | +The exported standalone mapper also emits MXC 1.0.0 directional networking. +It maps numeric IP/CIDR destinations and TCP ports directly, but records +error-severity loss for DNS names, wildcards, binary scope, and L7 behavior. +Callers that need those semantics must use the governed-egress split; no +pre-1.0 host-list output mode remains. + The driver reports `supports_live_policy_updates = false`. The gateway therefore rejects mutations that would change a running MXC sandbox's effective policy or provider bindings before persistence. Filesystem, UI, network, and credential diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 9e6f80b332..77d87c8cd6 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -23,7 +23,7 @@ it does not implement the Linux `ConnectSupervisor` protocol. |---|---| | Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces them with default-deny behavior. `isolation_session` does not support OpenShell filesystem-policy grants; MXC rejects non-empty grants during provisioning. | | UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | -| Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 0.8 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | +| Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 1.0 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | | Process policy | Unsupported; MXC supplies OS isolation only. | | Resource limits | Unsupported; MXC exposes no CPU rate control or memory limiting to non-WSLC backends. `CreateSandbox` rejects any request carrying `cpu_limit`, `cpu_request`, `memory_limit`, or `memory_request` synchronously rather than silently discarding them. | @@ -47,7 +47,6 @@ wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" # Default: process_container. isolation_session is opt-in and does not support # OpenShell filesystem-policy grants. backend = "process_container" -default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] # Either backend: launch openshell-supervisor-relay instead of the per-sandbox @@ -66,9 +65,9 @@ pc_minimal_env = false # A sandbox with egress_proxy enabled but no explicit network rules rejects # this fallback instead of silently changing governed egress to allow-all. pc_network_allow = false -# processContainer only: include "allowLocalNetwork": true in the MXC -# network section. This compatibility setting broadens network access and is -# not required by the BaseContainer qualification profile. +# processContainer only: emit permissive MXC 1.0 ingress and host-loopback +# actions. This compatibility setting broadens network access and is not +# required by the BaseContainer qualification profile. pc_allow_local_network = false # Pattern C governed egress. Requires backend = "process_container". egress_proxy = false @@ -85,6 +84,10 @@ PATH-lookup or working-directory-relative resolution execute an unapproved binary with the gateway's identity instead of the approved `wxc-exec`. There is no usable default. +MXC 1.0 removed the IsolationSession `configurationId`. The driver implements +only the MXC 1.0 contract and rejects the legacy `default_configuration_id` +gateway setting. Remove that setting from older TOML files before upgrading. + When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback `IP:PORT` seed. For policies with explicit network rules, the driver preserves the configured IP and allocates a unique ephemeral port for that sandbox's @@ -93,7 +96,7 @@ authenticated host CONNECT proxy. `pc_network_allow = true` is an explicit unrestricted-egress compatibility fallback. If it is combined with `egress_proxy = true`, a sandbox policy without explicit network rules is rejected synchronously rather than falling -through from governed egress to `defaultPolicy = "allow"`. +through from governed egress to `network.egress.default = "allow"`. Supply workload settings for each sandbox. The public config is keyed by driver name; the gateway forwards only the inner `mxc` object to the driver: @@ -107,7 +110,7 @@ The `command` array is required and preserves Windows argument boundaries. `cwd` UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). -`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: for a sandbox policy with explicit network rules, the driver reserves a unique ephemeral loopback port. MXC 0.8 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. A policy without network rules keeps MXC's default network posture and receives neither a host listener nor proxy environment variables. The current governed-egress policy permits all loopback ports, so governed sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. +`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: for a sandbox policy with explicit network rules, the driver reserves a unique ephemeral loopback port. MXC 1.0 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. A policy without network rules keeps MXC's default network posture and receives neither a host listener nor proxy environment variables. The current governed-egress policy permits all loopback ports, so governed sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a @@ -175,7 +178,8 @@ host proxy and reach the child only as placeholders. ## Prerequisites (live runs) -- Windows 11 Insider build ≥ 26300.8553 +- Windows 11 Insider build ≥ 26340.9212 for `isolation_session` +- MXC 1.0.0 or a newer binary compatible with the stable 1.0.0 schema - `IsoSessionApp.dll` present and registered - `wxc-exec.exe` built with `--features isolation_session` - Any enforced App Control policy allows both `openshell-gateway.exe` and @@ -278,8 +282,8 @@ This example uses `process_container`. The `IsoSessionApp.dll` and The generic real-`wxc-exec.exe` tasks print a SKIP reason and exit 0 when the binary or requested backend is unavailable. Once ProcessContainer is live, -required capabilities are authoritative: rejection of `network.proxy` or -another enforcement failure fails the task. These tasks are useful developer +required capabilities are authoritative: rejection of the loopback-only +directional policy or another enforcement failure fails the task. These tasks are useful developer diagnostics, but a skipped run is not qualification evidence. The GB300 task is deliberately strict and fails on every required skip. @@ -301,9 +305,11 @@ matrix is documented and machine-validated in lanes are explicitly separate and cannot receive GB300 ARM64 credit. **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: +(OS build, wxc-exec path/version, isolation tier, host-loopback support, 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. Governed +egress tests require `ingressHostLoopbackAllow` to be `true`; basic +ProcessContainer tests do not. 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 diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml index a41ddb21d6..c3774eb2e0 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/openclaw-gateway.yaml @@ -19,8 +19,8 @@ # - Win32k syscall access (ui.allow_graphical_ui) even though it never # opens a real window: Node.js touches user32/gdi32 during its own # startup and dies with STATUS_DLL_INIT_FAILED under the Win32k -# lockdown that applies by default when a policy has no `ui:` section, -# confirmed empirically against mxc-release-binaries-v0.8.0. +# lockdown that applies by default under MXC 1.0.0 when a policy has no +# `ui:` section. # # The network policy is enforced by the OpenShell host CONNECT proxy. MXC's # default-deny egress independently blocks direct Internet bypass attempts. diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml index 99e1780d50..8305b01946 100644 --- a/crates/openshell-driver-mxc/examples/mxc-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -25,8 +25,5 @@ backend = "process_container" # 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-inference.toml b/crates/openshell-driver-mxc/examples/mxc-inference.toml index 1c0d0274fd..e235028966 100644 --- a/crates/openshell-driver-mxc/examples/mxc-inference.toml +++ b/crates/openshell-driver-mxc/examples/mxc-inference.toml @@ -11,7 +11,6 @@ version = 2 [openshell.drivers.mxc] wxc_exec_path = "wxc-exec.exe" backend = "process_container" -default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] egress_proxy = true diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml index 52b1fbf4e6..5c3f7d4e72 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -25,7 +25,6 @@ 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 @@ -33,9 +32,9 @@ debug = false 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 +# proxy and hand MXC a loopback-only directional policy. MXC emits the +# SandboxProxyConfigured event, which maps to OCSF CONFIG [5019] and 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. diff --git a/crates/openshell-driver-mxc/examples/mxc-ollama.toml b/crates/openshell-driver-mxc/examples/mxc-ollama.toml index 8b7a693dd5..8ff7e8b2e7 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ollama.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ollama.toml @@ -11,7 +11,6 @@ version = 2 [openshell.drivers.mxc] wxc_exec_path = "wxc-exec.exe" backend = "process_container" -default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] egress_proxy = true diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml index b49116fca7..f5885accda 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml @@ -28,9 +28,8 @@ pc_least_privilege = false # openclaw-gateway.yaml's `ui: { allow_graphical_ui: true, ... }`. Needed # because Node.js (OpenClaw's runtime) touches user32/gdi32 during its own # startup and dies with STATUS_DLL_INIT_FAILED under the Win32k lockdown -# that applies when a policy has no `ui:` section, confirmed empirically -# against mxc-release-binaries-v0.8.0, even though this target never -# actually opens a window. +# that applies under MXC 1.0.0 when a policy has no `ui:` section, even +# though this target never actually opens a window. # Egress proxy for outbound TCP connectivity. egress_proxy = true diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml index 2bab7c5655..c61d191341 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-isolation.toml @@ -33,7 +33,6 @@ version = 2 wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" backend = "isolation_session" -default_configuration_id = "composable" # The workload command, cwd, and environment are supplied per sandbox by # run-openclaw-forward-test.ps1 through `sandbox create`. diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml index 6fbaced702..29a1b98a76 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-localnet.toml @@ -2,9 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 # # mxc-openclaw-localnet.toml -# Like mxc-openclaw-gateway.toml but uses allowLocalNetwork instead of egress_proxy. -# allowLocalNetwork=true lets the AppContainer reach the host's loopback (the gateway -# relay) without routing through the egress proxy, which breaks node.js DLL init. +# Like mxc-openclaw-gateway.toml but uses permissive MXC 1.0 ingress and +# host-loopback actions instead of egress_proxy. This lets the AppContainer +# reach the host relay without routing through the egress proxy, which breaks +# node.js DLL initialization. [openshell] version = 2 @@ -20,9 +21,8 @@ pc_allow_local_network = true # openclaw-gateway.yaml's `ui: { allow_graphical_ui: true, ... }`. Needed # because Node.js (OpenClaw's runtime) touches user32/gdi32 during its own # startup and dies with STATUS_DLL_INIT_FAILED under the Win32k lockdown -# that applies when a policy has no `ui:` section, confirmed empirically -# against mxc-release-binaries-v0.8.0, even though this target never -# actually opens a window. +# that applies under MXC 1.0.0 when a policy has no `ui:` section, even +# though this target never actually opens a window. # The workload command, cwd, and environment are supplied per sandbox by # run-openclaw-forward-test.ps1 through `sandbox create`. diff --git a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml index 150064cc65..6c8d941078 100644 --- a/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml +++ b/crates/openshell-driver-mxc/examples/mxc-provider-credential.toml @@ -10,7 +10,6 @@ version = 2 [openshell.drivers.mxc] wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" backend = "process_container" -default_configuration_id = "composable" # The split mapper gives MXC a loopback redirect and the driver starts the # per-sandbox host CONNECT proxy that enforces L4/L7 policy and rewrites 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 f8853beeb6..b40103bf1b 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -389,7 +389,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) @@ -410,21 +410,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") { @@ -441,19 +437,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/qualification/gb300-woa.json b/crates/openshell-driver-mxc/qualification/gb300-woa.json index f6d4e6c72e..63ba49e8f4 100644 --- a/crates/openshell-driver-mxc/qualification/gb300-woa.json +++ b/crates/openshell-driver-mxc/qualification/gb300-woa.json @@ -326,7 +326,7 @@ "credits_gb300_arm64": false, "allow_skip": true, "prerequisites": [ - "Windows build 26300.8553 or newer", + "Windows build 26340.9212 or newer", "IsoSessionApp.dll and isolation_session support in wxc-exec" ], "commands": [ diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index f20d001920..cbee63d4c5 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -175,7 +175,7 @@ impl MxcBackend { /// /// Loaded from `[openshell.drivers.mxc]` in the gateway TOML file, or from /// environment variables / CLI flags via the standard gateway precedence chain. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] #[allow(clippy::struct_excessive_bools)] // Independent, existing gateway TOML options. pub struct MxcComputeConfig { @@ -193,17 +193,17 @@ pub struct MxcComputeConfig { /// `processContainer` only: `AppContainer` capabilities to grant. pub pc_capabilities: Vec, /// `processContainer` only: inject a network section with - /// `defaultPolicy: "allow"` so the `AppContainer` has unrestricted outbound + /// `egress.default = "allow"` so the `AppContainer` has unrestricted outbound /// TCP access. Required when `pc_capabilities` alone is insufficient to /// enable network access in the target wxc-exec build. When `egress_proxy` /// is also enabled, sandbox policies without explicit network rules are /// rejected instead of falling back from governed egress to unrestricted /// access. pub pc_network_allow: bool, - /// `processContainer` only: include `"allowLocalNetwork": true` in the - /// MXC network section. Required for node.js (and other runtimes that - /// need loopback during DLL initialization) to start inside a - /// processcontainer. + /// `processContainer` only: allow inbound/private-network and host-loopback + /// traffic through the MXC 1.0 directional ingress policy. Required for + /// node.js (and other runtimes that need loopback during DLL initialization) + /// to start inside a processcontainer. pub pc_allow_local_network: bool, /// `processContainer` only: when `true`, start with an EMPTY process env /// (not even `MINIMAL_WINDOWS_BOOTSTRAP_ENV`) instead of the safe @@ -236,17 +236,14 @@ pub struct MxcComputeConfig { /// Ignored unless `pc_relay_spawner_path` is set. `0` disables spawner /// wrapping (default) — the per-sandbox command runs directly. pub pc_relay_target_port: u16, - /// MXC `configurationId` for isolation session. Default: `"composable"`. - /// Never use `"small"` (known OS bug). - pub default_configuration_id: String, /// Enable Pattern-C governed egress for sandbox policies that contain /// explicit network rules. MXC permits loopback-only egress, the driver /// injects proxy environment variables, and the host CONNECT proxy receives /// the full network policy. Policies without network rules do not start a /// listener or receive proxy environment variables. pub egress_proxy: bool, - /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is - /// enabled. The driver preserves the loopback IP and allocates a unique + /// Loopback `IP:PORT` seed for the host CONNECT proxy used by governed + /// egress. The driver preserves the loopback IP and allocates a unique /// ephemeral port per sandbox. pub egress_proxy_addr: String, @@ -258,33 +255,6 @@ pub struct MxcComputeConfig { pub etw_audit: bool, } -impl Default for MxcComputeConfig { - fn default() -> Self { - Self { - // No usable default: `wxc_exec_path` must be explicitly set to an - // absolute path (see `validate_configuration` and the field doc - // comment above). Shipping a bare relative filename here would - // silently reintroduce the exact PATH/CWD-hijack risk the - // validation exists to reject. - wxc_exec_path: String::new(), - backend: MxcBackend::default(), - pc_least_privilege: false, - pc_capabilities: Vec::new(), - pc_network_allow: false, - pc_relay_spawner_path: String::new(), - pc_relay_target_port: 0, - pc_allow_local_network: false, - pc_minimal_env: false, - default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), - egress_proxy: false, - egress_proxy_addr: String::new(), - - debug: false, - etw_audit: false, - } - } -} - impl MxcComputeConfig { /// Validate startup configuration without touching `wxc-exec` or the /// filesystem beyond `Path::is_absolute`. @@ -844,10 +814,8 @@ fn resolve_agent_proxy_ca_paths( stage_tls_ca_files(host_proxy_ca_paths, share_dir, sandbox_id) } -/// PROTOTYPE (2026-09-10): env-var-based governed egress, as an alternative -/// to MXC's own `network.proxy`/`runtimeConfig.networkProxy` transparent -/// redirect (both confirmed broken for this driver's use case -- see -/// `network_json()` in mxc.rs for the elevation/loopback-block history). +/// PROTOTYPE (2026-09-10): env-var-based governed egress paired with MXC 1.0 +/// loopback-only directional egress (see `network_json()` in mxc.rs). /// `HTTP_PROXY`/`HTTPS_PROXY` are honored voluntarily by well-behaved HTTP /// clients (curl, most language HTTP libraries, Node fetch, git, etc.), not /// enforced by the OS -- but paired with the sandbox's own default-deny @@ -1866,17 +1834,14 @@ async fn run_lifecycle( timeout: 0, }; let network = proxy_addr.map(|addr| MxcNetwork { - default_policy: "block".into(), + egress_default: "deny".into(), proxy: Some(addr), allow_local_network: false, }); let child = match config.backend { MxcBackend::IsolationSession => { - let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem, network) - .await - { + let iso_sandbox_id = match invoker.provision(filesystem).await { Ok(id) => id, Err(error) => { set_failed( @@ -1934,7 +1899,7 @@ async fn run_lifecycle( // Build the effective network config: // - egress_proxy: use the proxy-based network (already in `network`) // - pc_network_allow: inject allow-all (fallback for builds without capability support) - // - pc_allow_local_network: block-default but with allowLocalNetwork=true so + // - pc_allow_local_network: deny-by-default egress plus permissive ingress so // intra-container loopback works and the spawner can reach the relay on the // host's route-selected private interface without a full egress proxy. let effective_network = if network.is_none() @@ -1943,13 +1908,13 @@ async fn run_lifecycle( // Both flags apply to the same no-proxy startup case and // aren't mutually exclusive -- honor both instead of letting // pc_allow_local_network's branch silently force - // default_policy back to "block" and drop pc_network_allow's + // egress_default back to "deny" and drop pc_network_allow's // unrestricted-egress intent. Some(MxcNetwork { - default_policy: if config.pc_network_allow { + egress_default: if config.pc_network_allow { "allow".into() } else { - "block".into() + "deny".into() }, proxy: None, allow_local_network: config.pc_allow_local_network, @@ -3062,6 +3027,15 @@ mod lifecycle_tests { } } + #[test] + fn gateway_config_rejects_pre_1_0_configuration_id() { + let error = serde_json::from_value::(serde_json::json!({ + "default_configuration_id": "composable" + })) + .expect_err("MXC pre-1.0 configurationId must not be accepted"); + assert!(error.to_string().contains("default_configuration_id")); + } + #[test] fn validate_configuration_rejects_unset_wxc_exec_path() { // Regression test: the shipped default used to be the bare relative @@ -4175,6 +4149,24 @@ mod lifecycle_tests { assert!(crate::mxc::mock_recorded_config("sb-iso-ui").is_none()); } + #[tokio::test] + async fn isolation_session_rejects_filesystem_before_lifecycle_side_effects() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig { + backend: MxcBackend::IsolationSession, + ..Default::default() + }); + let policy = fs_policy(&["C:/work/demo"]); + let sandbox = with_policy(driver_sandbox("sb-iso-filesystem"), policy); + let error = backend + .create_sandbox(&sandbox) + .await + .expect_err("isolation filesystem grants must be rejected synchronously"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("filesystem_policy")); + assert!(backend.list_sandboxes().await.is_empty()); + assert!(crate::mxc::mock_recorded_config("sb-iso-filesystem").is_none()); + } + #[tokio::test] async fn negative_out_of_policy_write_is_denied_with_event() { let share_tmp = tempfile::tempdir().unwrap(); diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 612412bf5f..b7c1a7853b 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -1819,11 +1819,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/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index fe12c4253c..dce22b3b49 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -51,7 +51,7 @@ pub use relay::RelayHandle; pub use policy::{EmbeddedPolicyMapper, MapCtx, MapError, MappedConfig, PolicyMapper}; #[cfg(target_os = "windows")] pub use policy_map::{ - DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, - LossItem, MxcMappingOptions, MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, - build_loss_report, map_to_mxc, render_readme, split_policy, + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, LossItem, MxcMappingOptions, + MxcMappingResult, OPEN_SHELL_SUPERSET_GAPS, SplitPolicyResult, build_loss_report, map_to_mxc, + render_readme, split_policy, }; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 2de7d26fa5..240885b84a 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -17,12 +17,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"; - -/// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). -pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; +/// Stable MXC config 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 the real `wxc-exec.exe`; instead it emits @@ -90,11 +86,13 @@ pub struct MxcFilesystem { /// Network redirect fragment emitted when governed egress is enabled. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MxcNetwork { - pub default_policy: String, + /// MXC 1.0 `network.egress.default` action. + pub egress_default: 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`. + /// When true, permits inbound/private-network and host-loopback traffic + /// through the MXC 1.0 directional `network.ingress` policy. 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, } @@ -181,13 +179,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 @@ -219,7 +210,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" }); @@ -227,30 +218,37 @@ fn network_json(network: &MxcNetwork) -> serde_json::Value { value } -fn provision_config_json( - configuration_id: &str, - filesystem: &MxcFilesystem, - network: Option<&MxcNetwork>, -) -> serde_json::Value { - let mut config = serde_json::json!({ +fn provision_config_json() -> serde_json::Value { + 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": {} - } + // IsolationSession cannot restrict networking. MXC 1.0.0 requires the + // provision request to declare that actual all-allow posture exactly. + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, } - }); - if let Some(network) = network { - config["network"] = network_json(network); - } - config + }) +} + +fn lifecycle_config_json() -> serde_json::Value { + serde_json::json!({ "version": MXC_SCHEMA_VERSION }) +} + +fn exec_config_json(process: &MxcProcess) -> serde_json::Value { + serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "process": { + "commandLine": process.command_line, + "cwd": process.cwd, + "env": process.env, + // IsolationSession always starts from the agent user's default + // environment. MXC 1.0.0 rejects a supplied env array unless + // callers explicitly request layering instead of replacement. + "inheritDefaultEnv": true, + "timeout": process.timeout, + } + }) } fn oneshot_config_json( @@ -441,28 +439,37 @@ impl WxcExecInvoker { } } - /// 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> { + /// Encode a lifecycle config as base64 and invoke `wxc-exec`. + /// + /// MXC 1.0.0 takes the operation and sandbox identity as command-line + /// routing arguments. The config must omit the legacy `phase` and + /// `sandboxId` fields because the executor injects them before validation. + async fn run_phase( + &self, + operation: &str, + sandbox_id: &str, + 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)"); + debug!(operation, sandbox_id, "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"); + cmd.arg("--config-base64") + .arg(&b64) + .arg("--operation") + .arg(operation) + .arg("--container-id") + .arg(sandbox_id); if self.debug { cmd.arg("--debug"); } - // `config` here never carries `process.env` today (provision/start/ - // stop/deprovision have no `process` field at all -- see run_phase's - // doc comment), but redact defensively rather than relying on that - // staying true. - debug!(config = %redact_env_for_debug(config), "wxc-exec phase"); + debug!(operation, sandbox_id, config = %redact_env_for_debug(config), "wxc-exec phase"); let output = cmd.output().await?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); @@ -498,15 +505,19 @@ impl WxcExecInvoker { } /// Run the provision phase and return the `sandboxId` from the response. - pub async fn provision( - &self, - configuration_id: &str, - filesystem: MxcFilesystem, - network: Option, - ) -> Result { + pub async fn provision(&self, filesystem: MxcFilesystem) -> Result { + if !filesystem.readwrite_paths.is_empty() + || !filesystem.readonly_paths.is_empty() + || !filesystem.denied_paths.is_empty() + { + return Err(InvokerError::Mxc { + code: "policy_validation".into(), + message: "MXC 1.0 isolation_session cannot enforce filesystem grants".into(), + }); + } 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. + // Mock provision: mint a synthetic `iso:` id. IsolationSession has + // no filesystem grants in MXC 1.0, so the recorded grant set is empty. let id = format!("iso:mock-{}", uuid::Uuid::new_v4()); let grants: Vec = filesystem .readwrite_paths @@ -516,19 +527,22 @@ impl WxcExecInvoker { mock_grants().lock().unwrap().insert(id.clone(), grants); #[cfg(test)] { - let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); + let config = provision_config_json(); 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 config = provision_config_json(); 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"); + cmd.arg("--config-base64") + .arg(&b64) + .arg("--operation") + .arg("provision"); if self.debug { cmd.arg("--debug"); } @@ -585,17 +599,8 @@ impl WxcExecInvoker { /// 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 + let config = lifecycle_config_json(); + self.run_phase("start", iso_sandbox_id, &config).await } /// Spawn the exec phase (agent command). Returns the child process handle. @@ -608,17 +613,7 @@ impl WxcExecInvoker { 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 config = exec_config_json(&process); let json = serde_json::to_string(&config)?; let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); @@ -626,7 +621,10 @@ impl WxcExecInvoker { let mut cmd = Command::new(&self.exec_path); cmd.arg("--config-base64") .arg(&b64) - .arg("--experimental") + .arg("--operation") + .arg("exec") + .arg("--container-id") + .arg(iso_sandbox_id) // Piped (not null): mirrors the ProcessContainer one-shot spawn // below -- with STDIO passthrough, wxc-exec forwards this handle // down to the exec'd child, giving the driver a control channel @@ -803,37 +801,17 @@ impl WxcExecInvoker { /// 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. + /// MXC 1.0.0 selects the operation and sandbox through CLI arguments, so + /// the config carries only the stable contract version. 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 + let config = lifecycle_config_json(); + self.run_phase("stop", iso_sandbox_id, &config).await } - /// Run the deprovision phase (unit variant — see [`Self::stop`]). + /// Run the deprovision phase. 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 + let config = lifecycle_config_json(); + self.run_phase("deprovision", iso_sandbox_id, &config).await } } @@ -877,29 +855,18 @@ mod tests { #[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"); + let config = provision_config_json(); + assert_eq!(config["version"], "1.0.0"); assert_eq!(config["containment"], "isolation_session"); - assert_eq!( - config["experimental"]["isolation_session"]["configurationId"], - "composable" - ); - assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + assert_eq!(config["network"]["egress"]["default"], "allow"); + assert_eq!(config["network"]["ingress"]["default"], "allow"); + assert_eq!(config["network"]["ingress"]["hostLoopback"], "allow"); + for legacy in ["phase", "sandboxId", "filesystem", "experimental"] { + assert!( + config.get(legacy).is_none(), + "legacy field {legacy} must be omitted" + ); + } } #[test] @@ -935,42 +902,27 @@ mod tests { } #[test] - fn provision_config_json_includes_network_loopback_when_proxy_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()), - allow_local_network: false, + fn isolation_exec_config_layers_environment_and_omits_cli_routing() { + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\Windows\\Temp".into(), + env: vec!["MODE=test".into()], + timeout: 0, }; - let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); - - // With proxy: use direct loopback egress (127.0.0.1/32 allow) instead of - // runtimeConfig.networkProxy proxy mode, so relay can reach its spawned - // target process via loopback without processmodel.dll proxy-redirect WFP - // interference. PSEC tier still selected via requires_psec_networking(). - assert_eq!(config["network"]["egress"]["default"], "deny"); - assert_eq!( - config["network"]["egress"]["allow"][0]["to"][0]["cidr"], - "127.0.0.1/32" - ); - assert_eq!(config["network"]["ingress"]["default"], "allow"); - assert_eq!(config["network"]["ingress"]["hostLoopback"], "allow"); - assert!(config["network"].get("defaultPolicy").is_none()); - assert!(config["network"].get("proxy").is_none()); - // No runtimeConfig.networkProxy — using direct loopback egress instead. - assert!(config.get("runtimeConfig").is_none()); + let config = exec_config_json(&process); + assert_eq!(config["version"], "1.0.0"); + assert_eq!(config["process"]["inheritDefaultEnv"], true); + assert_eq!(config["process"]["env"][0], "MODE=test"); + assert!(config.get("phase").is_none()); + assert!(config.get("sandboxId").is_none()); } #[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, }; @@ -1084,31 +1036,34 @@ mod tests { #[test] fn isolation_provision_config_never_synthesizes_ui() { - let config = - provision_config_json(DEFAULT_CONFIGURATION_ID, &MxcFilesystem::default(), None); + let config = provision_config_json(); 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)" - ); + fn lifecycle_config_omits_operation_and_sandbox_id() { + let config = lifecycle_config_json(); + assert_eq!(config["version"], "1.0.0"); + assert!(config.get("phase").is_none()); + assert!(config.get("sandboxId").is_none()); + } + + #[tokio::test] + async fn isolation_provision_rejects_filesystem_grants() { + let invoker = WxcExecInvoker::mocked("unused"); + let error = invoker + .provision(MxcFilesystem { + readwrite_paths: vec![r"C:\work\demo".into()], + ..Default::default() + }) + .await + .expect_err("isolation_session must not silently drop filesystem grants"); + match error { + InvokerError::Mxc { code, message } => { + assert_eq!(code, "policy_validation"); + assert!(message.contains("cannot enforce filesystem grants")); + } + other => panic!("expected MXC policy validation error, got {other}"), } } diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 3d823c3fc0..ef0d57a45d 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -158,6 +158,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. @@ -267,7 +281,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!( @@ -307,6 +321,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 fba9593d0d..3ba58085cb 100644 --- a/crates/openshell-driver-mxc/src/policy_map/config.rs +++ b/crates/openshell-driver-mxc/src/policy_map/config.rs @@ -7,44 +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 schema for the standalone coarse mapper's host-list config. -/// -/// This is deliberately independent from [`crate::mxc::MXC_SCHEMA_VERSION`]: -/// the coarse artifact uses the MXC 0.7 `allowedHosts` shape, while live driver -/// requests and the governed-egress split use MXC 0.8 directional networking. -pub const DEFAULT_COARSE_MXC_VERSION: &str = "0.7.0-alpha"; - -/// Compatibility name for [`DEFAULT_COARSE_MXC_VERSION`]. -/// -/// This value belongs only to [`super::map_to_mxc`] output. It is not the -/// schema version used for live `wxc-exec` requests. -pub const DEFAULT_MXC_VERSION: &str = DEFAULT_COARSE_MXC_VERSION; +/// 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 { @@ -71,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, @@ -116,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 47a159c33c..9c0fb72c00 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,20 +17,15 @@ use openshell_core::proto::{ use serde_json::{Value, json}; use super::config::{ - DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, 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 { - /// Caller-selectable schema version written only into standalone coarse-map - /// output. The governed-egress split and live driver requests instead use - /// [`MXC_SCHEMA_VERSION`]. - pub mxc_version: String, /// MXC containment backend. pub containment: String, /// `process.commandLine` value. @@ -43,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, @@ -53,14 +46,12 @@ pub struct MxcMappingOptions { impl Default for MxcMappingOptions { fn default() -> Self { Self { - mxc_version: DEFAULT_COARSE_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 +137,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 { @@ -190,7 +182,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", @@ -215,7 +207,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": { @@ -232,7 +224,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 } @@ -254,19 +246,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": { @@ -281,7 +277,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 } @@ -437,7 +433,7 @@ fn map_network( policy: &SandboxPolicy, opts: &MxcMappingOptions, items: &mut Vec, -) -> Vec { +) -> Vec { if !policy.network_middlewares.is_empty() { add_loss( items, @@ -461,7 +457,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}"); @@ -473,12 +469,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() { @@ -508,43 +504,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( @@ -552,61 +556,106 @@ 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"), "error", &format!( - "MXC has no protocol-aware policy equivalent for '{}'.", + "MXC 1.0 can preserve TCP transport but cannot enforce OpenShell protocol '{}'.", endpoint.protocol ), "protocol-aware proxy policy", - "MXC host filtering cannot enforce REST/WebSocket/GraphQL semantics.", + "The directional rule is TCP-only, but REST/WebSocket/GraphQL/SQL/JSON-RPC/MCP semantics require governed egress.", ); } @@ -625,7 +674,7 @@ fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut endpoint.tls ), "TLS inspection mode", - "MXC network policy is host-level only.", + "MXC 1.0 applies only the directional CIDR/port rule.", ); } @@ -644,9 +693,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.", ); } } @@ -727,6 +776,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) { @@ -740,6 +856,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, @@ -803,6 +930,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 a184fb353e..13cb5b861a 100644 --- a/crates/openshell-driver-mxc/src/policy_map/mod.rs +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -13,21 +13,18 @@ //! //! 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. Its default schema is MXC 0.7, -//! matching that host-list shape; the caller can override the coarse target. +//! - [`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. This path uses the live driver's MXC -//! 0.8 directional-network schema. +//! enforced by the host CONNECT proxy. //! -//! The coarse mapper's schema version describes its generated artifact, not the -//! live driver's operating schema. The embedded driver may use a coarse mapping -//! as an intermediate policy translation but does not send that JSON to MXC. +//! 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; @@ -42,9 +39,7 @@ mod loss; mod map; mod report; -pub use config::{ - DEFAULT_COARSE_MXC_VERSION, DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, -}; +pub use config::{DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION}; pub use loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS}; pub use map::{MxcMappingOptions, MxcMappingResult, SplitPolicyResult, map_to_mxc, split_policy}; pub use report::{build_loss_report, render_readme}; diff --git a/crates/openshell-driver-mxc/src/policy_map/report.rs b/crates/openshell-driver-mxc/src/policy_map/report.rs index fdea60efa7..8ca7e10a83 100644 --- a/crates/openshell-driver-mxc/src/policy_map/report.rs +++ b/crates/openshell-driver-mxc/src/policy_map/report.rs @@ -5,18 +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 for a generated mapper artifact. /// -/// `target.schemaVersion` describes that artifact's caller-selected schema. It -/// must not be interpreted as the live MXC driver's request schema. +/// `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 { @@ -26,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": { @@ -65,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"]); @@ -87,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(), @@ -134,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/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs index 45b0776290..2796c8be9e 100644 --- a/crates/openshell-driver-mxc/src/relay.rs +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -26,8 +26,8 @@ //! host, so a loopback listener is unreachable unless traffic is sent through //! the CONNECT proxy; that proxy can also capture the bridge's separate //! sandbox-local target connection. Binding one concrete host interface keeps -//! the target hop on sandbox loopback and lets `allowLocalNetwork` authorize -//! only the host callback. In principle another host or local process could +//! the target hop on sandbox loopback and lets the MXC 1.0 directional ingress +//! policy authorize only the host callback. In principle another host or local process could //! race to connect before the real Phase A/B peer does and //! hijack or inject traffic into the forward. Both phases are authenticated //! against a fresh, unguessable per-forward nonce (`ForwardSink:: diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index c7382afece..4f29bf8885 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"], @@ -252,24 +269,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" @@ -395,11 +410,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()); } @@ -414,16 +437,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"); @@ -436,7 +461,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 2a82546773..e0846d923c 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, NetworkBinary, NetworkCredentialBinding, NetworkEndpoint, + NetworkMiddlewareConfig, NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, + UiPolicy, }; use openshell_driver_mxc::{ - DEFAULT_COARSE_MXC_VERSION, DEFAULT_MXC_VERSION, 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,22 +144,23 @@ fn assert_single_loss( // ─── QUADRANT A: mappable fields, assert exact MXC output ─────────────────── -/// The standalone coarse mapper and live governed-egress path intentionally -/// target different schema shapes. Keep the compatibility constant scoped to -/// the coarse artifact and prevent either side from silently drifting. +/// The standalone mapper and governed-egress path both emit the stable MXC 1.0 +/// directional network schema. #[test] -fn a_schema_versions_match_their_distinct_network_shapes() { +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, DEFAULT_COARSE_MXC_VERSION); - assert_eq!(coarse["version"], DEFAULT_COARSE_MXC_VERSION); - assert!(coarse["network"].get("allowedHosts").is_some()); - assert!(coarse["network"].get("egress").is_none()); + 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"], "0.8.0-alpha"); + assert_eq!(governed["version"], DEFAULT_MXC_VERSION); assert!(governed["network"].get("allowedHosts").is_none()); assert!(governed["network"].get("egress").is_some()); } @@ -259,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 { @@ -289,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( @@ -339,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 ); } @@ -410,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"], @@ -431,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". @@ -705,9 +731,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 { @@ -715,13 +741,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() @@ -732,47 +752,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. @@ -928,12 +953,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()], @@ -965,6 +990,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 @@ -975,19 +1024,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"); @@ -1027,11 +1072,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. @@ -1218,15 +1259,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 @@ -1234,11 +1274,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", @@ -1257,6 +1293,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] @@ -1359,8 +1402,15 @@ fn handled_fields_inventory() { 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(), @@ -1388,6 +1438,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, @@ -1397,7 +1464,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 ea42823c4b..0d028b39c8 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 @@ -225,8 +225,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] @@ -239,7 +241,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": { @@ -273,7 +275,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": { @@ -299,10 +301,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() { @@ -314,20 +315,20 @@ fn dryrun_current_schema_rejects_isolation_session_ui() { 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"); + if (major, minor) < (1, 0) { + eprintln!("SKIP: {raw_version} predates the stable MXC schema"); return; } let base = serde_json::json!({ - "phase": "provision", + "version": "1.0.0", "containment": "isolation_session", "network": { - "defaultPolicy": "allow", - "allowLocalNetwork": true, + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, }, }); - let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, &["--experimental"]); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, &["--operation", "provision"]); let output = format!("{stdout} {stderr}").to_ascii_lowercase(); if code != 0 && output.contains("backend_unavailable") @@ -343,7 +344,7 @@ fn dryrun_current_schema_rejects_isolation_session_ui() { let mut with_ui = base; with_ui["ui"] = serde_json::json!({ "disable": true }); - let (code, stdout, stderr) = dry_run_with_args(&wxc, &with_ui, &["--experimental"]); + let (code, stdout, stderr) = dry_run_with_args(&wxc, &with_ui, &["--operation", "provision"]); assert_ne!( code, 0, "current isolation_session schema unexpectedly accepted UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}" @@ -356,7 +357,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() { @@ -367,7 +368,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": { @@ -379,7 +380,7 @@ fn dryrun_accepts_network_block_without_proxy() { "readwritePaths": [temp_path], }, "network": { - "defaultPolicy": "block", + "egress": { "default": "deny" }, }, }); @@ -390,82 +391,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"] @@ -477,7 +402,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": { @@ -495,7 +420,7 @@ fn dryrun_rejects_unknown_containment() { } /// The most important dry-run test: build a typed Windows policy, run -/// `split_policy` (native MXC 0.8 proxy redirect 127.0.0.1:18080, containment +/// `split_policy` (native MXC 1.0 proxy redirect 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 — @@ -507,6 +432,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 { @@ -544,7 +473,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"], @@ -563,6 +492,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; @@ -596,7 +608,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": { @@ -659,6 +671,52 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { Ok(()) } +/// Probe the MXC capability required by `OpenShell`'s loopback CONNECT proxy. +/// +/// MXC 1.0.0 reports this independently from basic `ProcessContainer` +/// availability. +/// AppContainer+DACL fallback hosts can run ordinary one-shot workloads while +/// correctly rejecting `ingress.hostLoopback = "allow"` during dry-run. +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 out = Command::new(wxc) + .arg("--probe") + .output() + .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), + )); + } + + 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!( + "wxc-exec --probe reports baseContainerSupportsIngressHostLoopbackAllow=false \ + for isolation tier {tier}" + )) +} + /// Probe the `isolation_session` backend. /// /// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or @@ -672,18 +730,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" }, } }); @@ -693,7 +744,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}"))?; @@ -704,7 +756,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(), ); } @@ -755,15 +807,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()); @@ -771,7 +815,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(); } } @@ -807,7 +854,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": { @@ -896,7 +943,7 @@ fn pc_oneshot_token_is_appcontainer_without_admin_access() { }), ); let config = serde_json::json!({ - "version": "0.8.0-alpha", + "version": "1.0.0", "containerId": "pc-token-identity", "containment": "processcontainer", "process": { @@ -961,6 +1008,10 @@ async fn pc_https_egress_reads_injected_ca_bundle() { eprintln!("SKIP: processcontainer not live: {reason}"); return; } + if let Err(reason) = probe_processcontainer_host_loopback(&wxc) { + eprintln!("SKIP: processcontainer governed egress unavailable: {reason}"); + return; + } 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"); @@ -1140,6 +1191,10 @@ async fn pc_proxy_scopes_network_policy_to_socket_owner() { eprintln!("SKIP: processcontainer not live: {reason}"); return; } + if let Err(reason) = probe_processcontainer_host_loopback(&wxc) { + eprintln!("SKIP: processcontainer governed egress unavailable: {reason}"); + return; + } // QueryFullProcessImageNameW returns this Win32 spelling on the Windows // test image. Keep the spelling exact here; path and case normalization @@ -1314,7 +1369,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": { @@ -1388,7 +1443,7 @@ fn pc_oneshot_unrelated_root_path_read_denied() { let diagnostic = granted_dir.path().join("root-read.txt"); let diagnostic_str = diagnostic.to_string_lossy().into_owned(); let config = serde_json::json!({ - "version": "0.6.0-alpha", + "version": "1.0.0", "containerId": "pc-root-read-denied", "containment": "processcontainer", "process": { @@ -1460,21 +1515,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!( @@ -1489,13 +1540,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, } }); @@ -1504,7 +1554,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!( @@ -1516,22 +1569,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/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs index 568696b96e..f399c00202 100644 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -55,8 +55,8 @@ //! `mxc.rs`'s `run_oneshot`) instead of the usual `null`/`piped`-for-logging //! split, giving the gateway a write channel straight into the `AppContainer`. //! This needs **no `AppContainer` network capability at all**: it's inherited -//! process handles, not network traffic, so none of `egress_proxy` / -//! `network.proxy` / `privateNetworkClientServer` are involved. +//! process handles, not network traffic, so neither `egress_proxy` nor +//! `privateNetworkClientServer` is involved. //! //! Protocol: newline-delimited JSON, mirroring MXC's own `pipe_server` tool: //! Request: `{"id": , "op": "", "data": }` diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ab1702a896..dfe0b03e78 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -676,7 +676,6 @@ compute_drivers = ["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 = [] # Pattern-C governed egress. The address is a loopback seed; the driver @@ -695,6 +694,10 @@ binary with the gateway's identity instead of the intended `wxc-exec`. There is no usable default; older configurations that relied on the bare relative filename `wxc-exec.exe` must set an absolute path before upgrading. +MXC 1.0 removed the IsolationSession `configurationId`. OpenShell implements +only the MXC 1.0 contract and rejects the legacy `default_configuration_id` +setting. Remove it from older gateway configurations before upgrading. + `etw_audit` defaults to `false`. When enabled, the gateway account must be an administrator or belong to the Windows Performance Log Users group. Workload commands and working directories remain sandbox-scoped. Supply the command with @@ -703,13 +706,14 @@ described below. Set `egress_proxy = true` with a loopback seed such as `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern-C split. -MXC redirects sandbox traffic to a sandbox-unique port on the configured -loopback IP, where the host CONNECT proxy enforces the trimmed network policy. +The driver injects the sandbox-unique host CONNECT proxy address while MXC 1.0 +allows only loopback egress and denies direct Internet traffic. The proxy +enforces the trimmed network policy. The driver exposes the proxy's public CA bundle through a sandbox-unique internal filesystem grant so HTTPS clients can read the injected trust path; the CA private key remains in gateway memory. -Governed egress requires `process_container` until MXC supports `network.proxy` -for isolation sessions. +Governed egress requires `process_container` until MXC supports an enforceable +loopback-only path for isolation sessions. MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not yet receive the gateway's built-in and remote middleware registry. @@ -948,6 +952,13 @@ tier and host-preparation warnings in PowerShell: & "C:\path\to\wxc-exec.exe" --probe ``` +Governed egress through OpenShell's host CONNECT proxy requires the probe field +`probes.baseContainerSupportsIngressHostLoopbackAllow` to be `true`. A host that +reports `false` can still run ordinary ProcessContainer workloads, but MXC +rejects the bidirectional host-loopback capability required to reach the proxy. +`prepare-system-drive` does not add this capability; use a Windows build and +isolation tier whose MXC probe reports it as supported. + If the probe selects AppContainer + DACL (`appcontainer-dacl`) and recommends `prepare-system-drive`, obtain `wxc-host-prep.exe` from the MXC binaries distribution and run this command once in an elevated PowerShell session. @@ -979,10 +990,9 @@ compute_drivers = ["mxc"] wxc_exec_path = "C:\\mxc\\wxc-exec.exe" # process_container (default) or isolation_session. backend = "process_container" -default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] -# Pattern C governed egress. MXC 0.8 denies direct Internet egress and permits +# Pattern C governed egress. MXC 1.0 denies direct Internet egress and permits # host loopback; proxy-aware clients receive HTTP_PROXY/HTTPS_PROXY and the host # CONNECT proxy enforces the trimmed network policy. Requires process_container. egress_proxy = false diff --git a/rfc/0013-native-windows-mxc/README.md b/rfc/0013-native-windows-mxc/README.md index 515e7387dd..1968f8935a 100644 --- a/rfc/0013-native-windows-mxc/README.md +++ b/rfc/0013-native-windows-mxc/README.md @@ -24,8 +24,8 @@ 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 +proxy inside the driver process and giving MXC a loopback-only directional +policy. 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. @@ -46,9 +46,9 @@ 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. +AppContainer + a Low Integrity token) as an OS-native sandbox primitive. MXC +1.0 directional policy can restrict egress to host loopback, making a +supervisor-free, host-enforced design feasible. 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, @@ -128,8 +128,8 @@ flowchart LR wxc -->|"creates and runs"| sandbox_a wxc -->|"creates and runs"| sandbox_n - sandbox_a -.->|"MXC network.proxy
localhost:port_A"| proxy_a - sandbox_n -.->|"MXC network.proxy
localhost:port_N"| proxy_n + sandbox_a -.->|"MXC loopback-only egress
proxy env: port_A"| proxy_a + sandbox_n -.->|"MXC loopback-only egress
proxy env: port_N"| proxy_n end clients -->|"gRPC + mTLS"| control @@ -272,9 +272,9 @@ 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 +sandbox's trimmed network-only policy, injects the allocated address into the +process proxy environment, and gives MXC loopback-only directional egress. +Loopback inside an AppContainer 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 @@ -291,7 +291,7 @@ 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. +backend enforces the MXC 1.0 loopback rule used by this design. ### Part 3 - Policy translation between OpenShell and MXC @@ -308,23 +308,23 @@ vs exec-time split. | 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 | +| network (OPA / L7 / inference / privacy) | host CONNECT proxy | MXC 1.0 loopback-only directional egress | 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, `inference.local`, privacy routing, and audit. +network policy into MXC network policy. MXC receives a fail-closed directional +layer: `network.egress.default = "deny"`, a loopback-only CIDR grant, and +host-loopback ingress. The original OpenShell `network_policies` are preserved +and handed to the host CONNECT proxy, which remains responsible for ports, +binaries, L7 rules, `inference.local`, 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"`. +where no proxy is in the loop. In that mode, MXC 1.0 can express numeric +IP/CIDR destinations and TCP ports, but it cannot encode DNS names, wildcards, +protocol/L7 behavior, per-binary scope, TLS inspection behavior, credential +rewrite, or inference routing. The mapper emits a structured loss report and +rejects error-severity losses rather than silently broadening access. Both +paths explicitly emit `network.egress.default = "deny"`; there is no pre-1.0 +host-list compatibility mode. Across the five OpenShell example policies, the MXC-only coarse mapping is schema-valid but lossy: an aggregate of 64 access-broadening errors, 32 @@ -335,11 +335,11 @@ 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 +kernel-enforceable additions such as a filesystem default posture, +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 +execution manifest (add portable kernel-enforceable fields such as a filesystem +default posture) 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 @@ -352,10 +352,10 @@ kernel-level defense-in-depth. 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 + MXC loopback-only directional policy 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 + backend depends on Microsoft adding an enforceable loopback-only path; 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 @@ -392,8 +392,8 @@ are never affected and the changes can land additively. 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. + adds an enforceable loopback-only path 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 @@ -407,13 +407,13 @@ the gateway config reference and the architecture docs. | 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 | +| MXC 1.0 directional rules cannot express DNS names or binary/L7 scope, so the host proxy remains required for full policy fidelity. | Keep MXC egress loopback-only and rely on the authenticated host proxy for DNS, binary, and L7 decisions. | | 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. | +| Policy fidelity: MXC cannot enforce DNS/binary/L7 policy, so the MXC-only tier is a coarse approximation. | Fail-safe mapper (always directional deny-by-default, 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 @@ -458,7 +458,7 @@ the gateway config reference and the architecture docs. - **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 + sandbox primitive and loopback-only directional policy 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 diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 54bb721eac..3fe5655285 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -601,8 +601,6 @@ function Invoke-MxcGb300Tests([string] $RustTarget) { "dryrun_accepts_minimal_processcontainer_config", "dryrun_accepts_processcontainer_ui_policy_matrix", "dryrun_accepts_network_block_without_proxy", - "dryrun_accepts_localhost_proxy_shape", - "dryrun_rejects_host_port_proxy_shape", "dryrun_rejects_unknown_containment", "dryrun_accepts_split_policy_output", "pc_oneshot_in_policy_write_succeeds", From fa9840940a8a8b3f704a586e3f33fffb9372ac4b Mon Sep 17 00:00:00 2001 From: Prashant S Khodade Date: Thu, 8 Oct 2026 05:22:19 -0700 Subject: [PATCH 35/39] =?UTF-8?q?=EF=BB=BFfeat(mxc):=20relay=20ingress=20a?= =?UTF-8?q?nd=20egress=20through=20an=20AppContainer=20proxy=20peer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in "proxy-peer" mode to the MXC ProcessContainer driver for sandboxes running with MXC networkProxy, where in-sandbox loopback dials are blocked. - Gateway spawns a per-sandbox AppContainer peer (openshell-mxc-peer) that listens on loopback for the MXC networkProxy / allowedProxyPeer and bridges to the gateway over ACL'd named pipes. - Ingress: forward listener backed by the peer (start_peer_relay) with the same nonce-authenticated contract as the control-channel relay. - Egress: peer tunnels proxy connections to the host egress proxy; tunnelled connections are aliased via ForwardedClients so identity resolution works and the per-sandbox password check is skipped. - Supervisor relay keeps the HTTP(S)_PROXY variables MXC injects when it clears the environment for the launched target. - New driver option pc_proxy_peer_path with validation; README section. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Prashant S Khodade --- Cargo.toml | 2 +- crates/openshell-driver-mxc/README.md | 52 ++ .../src/bin/openshell-mxc-peer.rs | 276 ++++++ crates/openshell-driver-mxc/src/driver.rs | 201 ++++- crates/openshell-driver-mxc/src/lib.rs | 2 + crates/openshell-driver-mxc/src/mxc.rs | 95 ++ crates/openshell-driver-mxc/src/peer.rs | 829 ++++++++++++++++++ crates/openshell-driver-mxc/src/relay.rs | 86 ++ .../openshell-supervisor-network/src/host.rs | 13 + .../openshell-supervisor-network/src/proxy.rs | 50 +- .../src/windows_process.rs | 101 +++ crates/openshell-supervisor-relay/src/imp.rs | 99 ++- 12 files changed, 1788 insertions(+), 18 deletions(-) create mode 100644 crates/openshell-driver-mxc/src/bin/openshell-mxc-peer.rs create mode 100644 crates/openshell-driver-mxc/src/peer.rs diff --git a/Cargo.toml b/Cargo.toml index d6066067cb..31a2c7289a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ miette = { version = "7", features = ["fancy"] } thiserror = "2" # Windows platform APIs (MXC audit, host-proxy process identity, ACL enforcement; Windows-only) -windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Etw", "Win32_System_Memory", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time"] } +windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_Etw", "Win32_System_Memory", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 9e6f80b332..241887dabf 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -73,6 +73,9 @@ pc_allow_local_network = false # Pattern C governed egress. Requires backend = "process_container". egress_proxy = false egress_proxy_addr = "" +# processContainer only: proxy-peer mode (see "Proxy-peer mode" below). Absolute +# path to openshell-mxc-peer.exe; empty (default) keeps the loopback-allow model. +pc_proxy_peer_path = "" debug = false etw_audit = false ``` @@ -95,6 +98,55 @@ fallback. If it is combined with `egress_proxy = true`, a sandbox policy without explicit network rules is rejected synchronously rather than falling through from governed egress to `defaultPolicy = "allow"`. +### Proxy-peer mode + +By default a `process_container` sandbox gets `hostLoopback: allow` plus an +`egress.allow` rule for `127.0.0.1/32`, so the sandbox can reach the host relay +and the host egress proxy, but also any other service bound to host loopback. + +Setting `pc_proxy_peer_path` switches the sandbox to MXC's strict model instead: +`runtimeConfig.networkProxy` + `processContainer.network.allowedProxyPeer` with +`hostLoopback: deny` (MXC schema `0.9.0-alpha`). The sandbox can then reach only +its proxy endpoint, and only one `AppContainer` identity may talk to its +listeners. The driver provides both from one helper process, `openshell-mxc-peer.exe`, +which it starts for every sandbox under a per-sandbox `AppContainer` profile +(`openshell-mxc-`); the profile is the `allowedProxyPeer`: + +```text +forward: host client -> gateway forward listener -> named pipe -> peer + -> 127.0.0.1: inside the sandbox +egress: sandbox (HTTP(S)_PROXY) -> peer proxy listener -> named pipe + -> gateway -> per-sandbox host egress proxy (OpenShell policy) +``` + +- In this mode the driver sets no `HTTP_PROXY`/`HTTPS_PROXY`. MXC puts its + `networkProxy` endpoint into the environment of the process it launches + (`openshell-supervisor-relay`), and the relay copies `HTTP_PROXY`, `HTTPS_PROXY` + and their lowercase forms to the target unless the launch request sets them + (`NO_PROXY` is never copied). Clients still have to honor the variables (for + Node.js, `NODE_OPTIONS=--use-env-proxy`); anything that ignores them is blocked + by MXC's per-container network rules. +- MXC's variables carry no credentials, so the host proxy accepts connections + that arrive through the peer tunnel without the per-sandbox proxy password. + Only the gateway can mark a connection as tunnelled, and only the peer can open + the pipe. Direct connections to the host proxy still need the password. + +- The pipes are created with a DACL that admits only the gateway's user and the + peer's profile SID. The peer is a byte tunnel only; policy decisions, TLS + interception and credential substitution stay in the gateway's host proxy. +- Per-process binary rules still apply: the peer reports the sandbox client's + source port, and the gateway registers it so the host proxy resolves the owner + of the original sandbox socket rather than the gateway's bridge socket. +- Without `egress_proxy`, or for a policy with no network rules, the peer's proxy + answers `403` and the sandbox has no egress. +- `openshell-mxc-peer.exe` must be readable and executable by "ALL APPLICATION + PACKAGES" (for example `icacls /grant *S-1-15-2-1:(OI)(CI)(RX)`). +- Requires `pc_relay_spawner_path` / `pc_relay_target_port` and the `process_container` + backend, and cannot be combined with `pc_network_allow` or `pc_allow_local_network`. +- The peer and its profile are removed when the sandbox is deleted. If the gateway + is killed, the peer exits when its control pipe closes, but its profile stays + registered until it is removed by hand (`DeleteAppContainerProfile`). + 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: ```powershell diff --git a/crates/openshell-driver-mxc/src/bin/openshell-mxc-peer.rs b/crates/openshell-driver-mxc/src/bin/openshell-mxc-peer.rs new file mode 100644 index 0000000000..fc2d8f32e1 --- /dev/null +++ b/crates/openshell-driver-mxc/src/bin/openshell-mxc-peer.rs @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Proxy-peer helper started by the OpenShell MXC driver under a per-sandbox +//! `AppContainer` profile (the sandbox's `processContainer.network. +//! allowedProxyPeer`). See `src/peer.rs` in this crate for the architecture and +//! the control protocol; this process is the other side of it. +//! +//! Usage: `openshell-mxc-peer.exe [log-file]` +//! +//! 1. Bind a proxy listener on `127.0.0.1:0`. It is the sandbox's +//! `networkProxy` endpoint. In `egress` mode every connection the sandbox +//! opens to it is tunnelled, byte for byte, through the gateway's +//! `-egress` pipe to the sandbox's host egress proxy, which applies +//! the OpenShell network policy. In `none` mode (no egress policy) every +//! request gets `403 Forbidden`, so the sandbox has no network egress. +//! 2. Connect to the gateway's control pipe and send `READY `. +//! 3. For every `OPEN ` line, connect to the data pipe for +//! ``, dial `127.0.0.1:` (admitted because this process +//! runs as the sandbox's peer identity), report `OK` or `ERR ` and +//! pump bytes in both directions. +//! +//! The process exits when the control pipe closes. + +#[cfg(not(windows))] +fn main() { + eprintln!("openshell-mxc-peer is only supported on Windows"); + std::process::exit(2); +} + +#[cfg(windows)] +mod imp { + use std::io::Write; + use std::sync::Mutex; + use std::time::Duration; + + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::windows::named_pipe::ClientOptions; + use tokio::net::{TcpListener, TcpStream}; + + static LOG: Mutex> = Mutex::new(None); + + fn log(message: &str) { + if let Ok(mut guard) = LOG.lock() { + if let Some(file) = guard.as_mut() { + let _ = writeln!(file, "{message}"); + } + } + } + + fn ctl_pipe_name(token: &str) -> String { + format!(r"\\.\pipe\openshell-mxc-{token}-ctl") + } + + fn data_pipe_name(token: &str, id: u64) -> String { + format!(r"\\.\pipe\openshell-mxc-{token}-d{id}") + } + + fn egress_pipe_name(token: &str) -> String { + format!(r"\\.\pipe\openshell-mxc-{token}-egress") + } + + /// `ERROR_PIPE_BUSY`: every instance is connected; the gateway is creating the + /// next one, so retry shortly. + const ERROR_PIPE_BUSY: i32 = 231; + + async fn open_egress_pipe( + token: &str, + ) -> std::io::Result { + let name = egress_pipe_name(token); + let mut attempts = 0; + loop { + match ClientOptions::new().open(&name) { + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY) && attempts < 250 => { + attempts += 1; + tokio::time::sleep(Duration::from_millis(20)).await; + } + other => return other, + } + } + } + + /// Tunnel one sandbox connection to the gateway's host egress proxy. + async fn tunnel_egress(token: String, mut tcp: TcpStream) { + let mut pipe = match open_egress_pipe(&token).await { + Ok(pipe) => pipe, + Err(error) => { + log(&format!("egress pipe open failed: {error}")); + let _ = tcp + .write_all( + b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + return; + } + }; + // Tell the gateway which sandbox socket this is, so the host proxy can + // resolve the real client process (see `ForwardedClients` in the driver). + let client_port = tcp.peer_addr().map_or(0, |addr| addr.port()); + if pipe + .write_all(format!("CLIENT {client_port}\n").as_bytes()) + .await + .is_err() + { + return; + } + match tokio::io::copy_bidirectional(&mut tcp, &mut pipe).await { + Ok((from_sandbox, to_sandbox)) => log(&format!( + "egress done: {from_sandbox} bytes sandbox->gateway, {to_sandbox} bytes gateway->sandbox" + )), + Err(error) => log(&format!("egress tunnel ended: {error}")), + } + } + + /// Parse `OPEN `. + fn parse_open(line: &str) -> Option<(u64, u16)> { + let mut parts = line.strip_prefix("OPEN ")?.split_whitespace(); + let id = parts.next()?.parse().ok()?; + let port = parts.next()?.parse().ok()?; + parts.next().is_none().then_some((id, port)) + } + + async fn serve_proxy(listener: TcpListener, token: String, egress: bool) { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + if egress { + let _ = stream.set_nodelay(true); + tokio::spawn(tunnel_egress(token.clone(), stream)); + continue; + } + tokio::spawn(async move { + let _ = stream + .write_all( + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + }); + } + } + + async fn handle_open(token: String, id: u64, port: u16) { + let mut pipe = match ClientOptions::new().open(data_pipe_name(&token, id)) { + Ok(pipe) => pipe, + Err(error) => { + log(&format!("[{id}] data pipe open failed: {error}")); + return; + } + }; + let dial = tokio::time::timeout( + Duration::from_secs(5), + TcpStream::connect(("127.0.0.1", port)), + ) + .await; + let mut tcp = match dial { + Ok(Ok(tcp)) => tcp, + Ok(Err(error)) => { + log(&format!("[{id}] dial 127.0.0.1:{port} failed: {error}")); + let _ = pipe.write_all(format!("ERR {error}\n").as_bytes()).await; + return; + } + Err(_) => { + log(&format!("[{id}] dial 127.0.0.1:{port} timed out")); + let _ = pipe.write_all(b"ERR connect timed out\n").await; + return; + } + }; + let _ = tcp.set_nodelay(true); + if pipe.write_all(b"OK\n").await.is_err() { + return; + } + log(&format!("[{id}] relaying to 127.0.0.1:{port}")); + match tokio::io::copy_bidirectional(&mut pipe, &mut tcp).await { + Ok((to_target, from_target)) => log(&format!( + "[{id}] done: {to_target} bytes pipe->tcp, {from_target} bytes tcp->pipe" + )), + Err(error) => log(&format!("[{id}] relay ended: {error}")), + } + } + + pub async fn run() -> i32 { + let mut args = std::env::args().skip(1); + let (Some(token), Some(mode)) = (args.next(), args.next()) else { + eprintln!("usage: openshell-mxc-peer [log-file]"); + return 2; + }; + let egress = match mode.as_str() { + "egress" => true, + "none" => false, + other => { + eprintln!("unknown mode '{other}' (expected egress or none)"); + return 2; + } + }; + if let Some(path) = args.next() { + if let Ok(file) = std::fs::OpenOptions::new().create(true).append(true).open(path) { + if let Ok(mut guard) = LOG.lock() { + *guard = Some(file); + } + } + } + + let proxy = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(error) => { + log(&format!("proxy bind failed: {error}")); + return 1; + } + }; + let proxy_port = proxy.local_addr().map(|addr| addr.port()).unwrap_or(0); + tokio::spawn(serve_proxy(proxy, token.clone(), egress)); + + let mut control = None; + for _ in 0..100 { + match ClientOptions::new().open(ctl_pipe_name(&token)) { + Ok(pipe) => { + control = Some(pipe); + break; + } + Err(_) => tokio::time::sleep(Duration::from_millis(100)).await, + } + } + let Some(mut control) = control else { + log("control pipe unavailable"); + return 1; + }; + if control + .write_all(format!("READY {proxy_port}\n").as_bytes()) + .await + .is_err() + { + return 1; + } + log(&format!("ready: proxy 127.0.0.1:{proxy_port} (egress tunnel: {egress})")); + + let mut lines = BufReader::new(control).lines(); + while let Ok(Some(line)) = lines.next_line().await { + match parse_open(&line) { + Some((id, port)) => { + tokio::spawn(handle_open(token.clone(), id, port)); + } + None => log(&format!("ignoring malformed control line: {line}")), + } + } + log("control pipe closed; exiting"); + 0 + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn open_lines_parse_strictly() { + assert_eq!(parse_open("OPEN 3 18889"), Some((3, 18889))); + assert_eq!(parse_open("OPEN 3"), None); + assert_eq!(parse_open("OPEN 3 70000"), None); + assert_eq!(parse_open("OPEN 3 1 extra"), None); + assert_eq!(parse_open("READY 1"), None); + } + + #[test] + fn pipe_names_match_the_driver() { + assert_eq!(ctl_pipe_name("t"), r"\\.\pipe\openshell-mxc-t-ctl"); + assert_eq!(data_pipe_name("t", 9), r"\\.\pipe\openshell-mxc-t-d9"); + assert_eq!(egress_pipe_name("t"), r"\\.\pipe\openshell-mxc-t-egress"); + } + } +} + +#[cfg(windows)] +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() { + std::process::exit(imp::run().await); +} diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 07e9fee713..ce42aba0ea 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -5,7 +5,10 @@ //! and self-reported readiness. use crate::control_channel::ControlChannel; -use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::mxc::{ + MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, MxcProxyPeer, WxcExecInvoker, +}; +use crate::peer::{EgressTunnel, PeerHandle}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; use crate::relay; use base64::Engine as _; @@ -236,6 +239,21 @@ pub struct MxcComputeConfig { /// Ignored unless `pc_relay_spawner_path` is set. `0` disables spawner /// wrapping (default) — the per-sandbox command runs directly. pub pc_relay_target_port: u16, + /// `processContainer` only: absolute path to `openshell-mxc-peer.exe`. When + /// set, each sandbox runs under MXC `networkProxy` + `allowedProxyPeer` + /// instead of the loopback-allow network section: the driver starts the peer + /// under a per-sandbox `AppContainer` profile, which is the only identity + /// admitted to the sandbox's listeners, and `openshell forward service` + /// reaches the target through that peer over a private named pipe (no + /// broad host-loopback grant). The peer must be readable and executable by + /// "ALL APPLICATION PACKAGES". Requires the relay spawner settings above. + /// With `egress_proxy` enabled, the peer's proxy listener is the sandbox's + /// only network endpoint and tunnels the sandbox's proxy connections over a + /// named pipe to the gateway's per-sandbox host egress proxy, so policy + /// enforcement is unchanged; without it (or for a policy with no network + /// rules) the sandbox has no egress. Incompatible with `pc_network_allow` and + /// `pc_allow_local_network`. Empty (default) disables the mode. + pub pc_proxy_peer_path: String, /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, @@ -273,6 +291,7 @@ impl Default for MxcComputeConfig { pc_network_allow: false, pc_relay_spawner_path: String::new(), pc_relay_target_port: 0, + pc_proxy_peer_path: String::new(), pc_allow_local_network: false, pc_minimal_env: false, default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), @@ -307,7 +326,38 @@ impl MxcComputeConfig { self.wxc_exec_path ))); } - Ok(()) + self.validate_proxy_peer() + } + + /// Cross-field rules for `pc_proxy_peer_path`. + fn validate_proxy_peer(&self) -> openshell_core::Result<()> { + let path = self.pc_proxy_peer_path.trim(); + if path.is_empty() { + return Ok(()); + } + let problem = if !Path::new(path).is_absolute() { + Some(format!("pc_proxy_peer_path must be an absolute path, got '{path}'")) + } else if self.backend != MxcBackend::ProcessContainer { + Some("pc_proxy_peer_path only applies to backend = \"process_container\"".to_string()) + } else if self.pc_relay_spawner_path.is_empty() || self.pc_relay_target_port == 0 { + Some( + "pc_proxy_peer_path requires pc_relay_spawner_path and pc_relay_target_port" + .to_string(), + ) + } else if self.pc_network_allow || self.pc_allow_local_network { + Some( + "pc_proxy_peer_path cannot be combined with pc_network_allow or pc_allow_local_network" + .to_string(), + ) + } else { + None + }; + match problem { + Some(message) => Err(openshell_core::Error::config(format!( + "[openshell.drivers.mxc] {message}" + ))), + None => Ok(()), + } } } @@ -379,6 +429,10 @@ struct SandboxEntry { /// wrapping is active (`pc_relay_spawner_path` configured); dropped on /// delete, which closes the child's stdin. control_channel: Option>, + /// Proxy peer process for this sandbox (`pc_proxy_peer_path` mode). Dropping + /// the last reference terminates the peer and deletes its `AppContainer` + /// profile, so `delete_sandbox` just takes it out of the entry. + peer: Option>, } impl std::fmt::Debug for SandboxEntry { @@ -1252,6 +1306,7 @@ impl MxcComputeBackend { proxy_addr: mapped.proxy_addr, host_proxy: None, control_channel: None, + peer: None, }, ); } @@ -1435,6 +1490,7 @@ impl MxcComputeBackend { signal_file, control_channel, host_proxy, + peer, ) = { let mut registry = self.registry.lock().await; let Some(entry) = registry.get_mut(sandbox_id) else { @@ -1451,6 +1507,7 @@ impl MxcComputeBackend { entry.signal_file.take(), entry.control_channel.take(), entry.host_proxy.take(), + entry.peer.take(), ) }; drop(host_proxy); @@ -1540,6 +1597,13 @@ impl MxcComputeBackend { } } + // The sandbox is gone; stop its proxy peer and delete the peer's + // AppContainer profile. Forward bridges may still hold the handle, so + // terminate explicitly instead of waiting for the last reference. + if let Some(peer) = peer { + let _ = tokio::task::spawn_blocking(move || peer.terminate()).await; + } + let mut registry = self.registry.lock().await; if registry.remove(sandbox_id).is_some() { if let Ok(mut idx) = self.attribution.lock() { @@ -1652,7 +1716,7 @@ impl ForwardSink { target_port: u16, ) -> Result<(SocketAddr, [u8; relay::NONCE_LEN], relay::RelayHandle), OpenDynamicForwardError> { - let (control_channel, sandbox_name) = { + let (control_channel, peer, sandbox_name) = { let reg = self.registry.lock().await; let entry = reg .get(sandbox_id) @@ -1661,7 +1725,7 @@ impl ForwardSink { .control_channel .clone() .ok_or_else(|| OpenDynamicForwardError::NoControlChannel(sandbox_id.to_string()))?; - (channel, entry.sandbox.name.clone()) + (channel, entry.peer.clone(), entry.sandbox.name.clone()) }; // Fresh per forward -- see relay.rs module docs for why this matters @@ -1669,14 +1733,24 @@ impl ForwardSink { let nonce: [u8; relay::NONCE_LEN] = rand::random(); let bind_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let (relay_handle, relay_addr) = relay::start_control_channel_relay( - bind_addr, - sandbox_name, - nonce, - control_channel, - target_port, - ) - .await + // In proxy-peer mode the sandbox only admits the peer identity (and the + // in-sandbox relay cannot dial its own loopback target), so the sandbox + // leg of the forward goes through the peer instead of the control channel. + let (relay_handle, relay_addr) = match peer { + Some(peer) => { + relay::start_peer_relay(bind_addr, sandbox_name, nonce, peer, target_port).await + } + None => { + relay::start_control_channel_relay( + bind_addr, + sandbox_name, + nonce, + control_channel, + target_port, + ) + .await + } + } .map_err(OpenDynamicForwardError::RelayBind)?; Ok((relay_addr, nonce, relay_handle)) @@ -1747,6 +1821,11 @@ async fn run_lifecycle( } else { None }; + // Taken before the handle moves into the registry below; the proxy peer's + // egress tunnel uses it to keep per-process identity (see peer.rs). + let forwarded_clients = host_proxy + .as_ref() + .map(openshell_supervisor_network::host::HostProxyHandle::forwarded_clients); let host_proxy_ca_paths = host_proxy .as_ref() .and_then(openshell_supervisor_network::host::HostProxyHandle::ca_file_paths); @@ -1784,6 +1863,47 @@ async fn run_lifecycle( )); } + // Proxy-peer mode: the peer must be listening before wxc-exec starts, because + // its address is the sandbox's networkProxy and its profile is the sandbox's + // allowedProxyPeer. When a host egress proxy exists (policy has network + // rules) the peer tunnels the sandbox's proxy connections to it. + let proxy_peer = if config.backend == MxcBackend::ProcessContainer + && !config.pc_proxy_peer_path.trim().is_empty() + { + let egress = match (proxy_addr, forwarded_clients) { + (Some(host_proxy_addr), Some(clients)) => Some(EgressTunnel { + host_proxy: host_proxy_addr, + clients, + }), + _ => None, + }; + match PeerHandle::spawn(config.pc_proxy_peer_path.trim(), &sandbox_id, egress).await { + Ok(peer) => { + let peer = Arc::new(peer); + { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.peer = Some(Arc::clone(&peer)); + } + } + Some(peer) + } + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to start proxy peer: {error}"), + ) + .await; + return; + } + } + } else { + None + }; + let readwrite_paths = mapped.readwrite_paths; let readonly_paths = mapped.readonly_paths; let ui = mapped.ui; @@ -1824,7 +1944,15 @@ async fn run_lifecycle( // Layer proxy configuration for every env tier using the staged CA copies // above. The host proxy's private temporary directory is never shared. append_tls_env_vars(&mut env, agent_proxy_ca_paths.as_ref()); - append_proxy_env_vars(&mut env, proxy_addr, proxy_auth.as_ref()); + // In proxy-peer mode the driver sets no HTTP(S)_PROXY / NO_PROXY variables: + // MXC puts the networkProxy endpoint (the peer's listener) into the + // environment of the process it launches, and openshell-supervisor-relay + // passes those variables on to the target. Connections that arrive through + // the peer tunnel are trusted by the host proxy without the per-sandbox + // password, which MXC's variables do not carry. + if proxy_peer.is_none() { + append_proxy_env_vars(&mut env, proxy_addr, proxy_auth.as_ref()); + } env.sort(); // deterministic order for logging / debugging info!(sandbox = %sandbox_name, count = env.len(), "MXC process env vars"); @@ -1930,6 +2058,10 @@ async fn run_lifecycle( let process_container = MxcProcessContainer { least_privilege: config.pc_least_privilege, capabilities: config.pc_capabilities.clone(), + proxy_peer: proxy_peer.as_ref().map(|peer| MxcProxyPeer { + profile: peer.profile().to_string(), + proxy: peer.proxy_addr(), + }), }; // Build the effective network config: // - egress_proxy: use the proxy-based network (already in `network`) @@ -3098,6 +3230,48 @@ mod lifecycle_tests { config.validate_configuration().unwrap(); } + fn proxy_peer_config() -> MxcComputeConfig { + MxcComputeConfig { + wxc_exec_path: r"C:\mxc-kit\bin\wxc-exec.exe".into(), + pc_relay_spawner_path: "C:/openshell-openclaw/openshell-supervisor-relay.exe".into(), + pc_relay_target_port: 18889, + pc_proxy_peer_path: r"C:\openshell-openclaw\openshell-mxc-peer.exe".into(), + ..Default::default() + } + } + + #[test] + fn proxy_peer_mode_is_off_by_default_and_valid_with_the_spawner() { + assert!(MxcComputeConfig::default().pc_proxy_peer_path.is_empty()); + proxy_peer_config().validate_configuration().unwrap(); + } + + #[test] + fn proxy_peer_mode_accepts_egress_proxy() { + let mut config = proxy_peer_config(); + config.egress_proxy = true; + config.egress_proxy_addr = "127.0.0.1:18080".into(); + config.validate_configuration().unwrap(); + } + + #[test] + fn proxy_peer_mode_rejects_incompatible_settings() { + let cases: [(&str, fn(&mut MxcComputeConfig)); 6] = [ + ("absolute", |c| c.pc_proxy_peer_path = "openshell-mxc-peer.exe".into()), + ("process_container", |c| c.backend = MxcBackend::IsolationSession), + ("pc_relay_spawner_path", |c| c.pc_relay_spawner_path.clear()), + ("pc_relay_spawner_path", |c| c.pc_relay_target_port = 0), + ("pc_network_allow", |c| c.pc_network_allow = true), + ("pc_network_allow", |c| c.pc_allow_local_network = true), + ]; + for (needle, mutate) in cases { + let mut config = proxy_peer_config(); + mutate(&mut config); + let error = config.validate_configuration().unwrap_err().to_string(); + assert!(error.contains(needle), "expected '{needle}' in: {error}"); + } + } + #[test] fn governed_egress_defaults_off_and_allocates_unique_loopback_ports() { let config = MxcComputeConfig::default(); @@ -3646,6 +3820,7 @@ mod lifecycle_tests { proxy_addr: None, host_proxy: None, control_channel: None, + peer: None, }, ); let mut events = backend.watch_tx.subscribe(); diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index fe12c4253c..3e4e54c425 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -35,6 +35,8 @@ mod policy_map; #[cfg(target_os = "windows")] mod etw_consumer; #[cfg(target_os = "windows")] +mod peer; +#[cfg(target_os = "windows")] mod relay; #[cfg(target_os = "windows")] diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 2de7d26fa5..c8a7a52a67 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -21,6 +21,10 @@ use tracing::{debug, info}; /// MXC 0.8 directional network schema. pub const MXC_SCHEMA_VERSION: &str = "0.8.0-alpha"; +/// Schema version used in proxy-peer mode, which needs +/// `processContainer.network.allowedProxyPeer` (introduced after 0.8). +pub const MXC_PEER_SCHEMA_VERSION: &str = "0.9.0-alpha"; + /// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; @@ -134,6 +138,17 @@ fn ui_json(ui: &MxcUi) -> serde_json::Value { }) } +/// Proxy-peer mode: the sandbox runs under MXC `runtimeConfig.networkProxy` +/// with `processContainer.network.allowedProxyPeer`, so the only identity that +/// may talk to its listeners is the named `AppContainer` profile (see `peer.rs`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcProxyPeer { + /// `AppContainer` profile name of the peer process (`allowedProxyPeer`). + pub profile: String, + /// Loopback address of the peer's proxy listener (`networkProxy`). + pub proxy: SocketAddr, +} + /// `processContainer`-specific knobs (one-shot `AppContainer` backend). #[derive(Debug, Default, Clone)] pub struct MxcProcessContainer { @@ -141,6 +156,8 @@ pub struct MxcProcessContainer { pub least_privilege: bool, /// `AppContainer` capabilities to grant (e.g. `internetClient`). pub capabilities: Vec, + /// Run in proxy-peer mode. Replaces the loopback-allow network section. + pub proxy_peer: Option, } /// Process config for the exec phase. @@ -253,6 +270,22 @@ fn provision_config_json( config } +/// Rewrite a one-shot config for proxy-peer mode. +/// +/// MXC validates this combination: `networkProxy` needs `egress.default: deny` +/// and `ingress.default: allow`, and a named peer needs `hostLoopback: deny` +/// (no broad host-loopback grant -- the peer identity is the only way in). +fn apply_proxy_peer(config: &mut serde_json::Value, peer: &MxcProxyPeer) { + config["version"] = serde_json::json!(MXC_PEER_SCHEMA_VERSION); + config["network"] = serde_json::json!({ + "egress": { "default": "deny" }, + "ingress": { "default": "allow", "hostLoopback": "deny" }, + }); + config["processContainer"]["network"] = + serde_json::json!({ "allowedProxyPeer": &peer.profile }); + config["runtimeConfig"] = serde_json::json!({ "networkProxy": format!("http://{}", peer.proxy) }); +} + fn oneshot_config_json( container_id: &str, filesystem: &MxcFilesystem, @@ -300,6 +333,9 @@ fn oneshot_config_json( if let Some(network) = network { config["network"] = network_json(network); } + if let Some(peer) = &pc.proxy_peer { + apply_proxy_peer(&mut config, peer); + } // 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 -- @@ -1007,6 +1043,65 @@ mod tests { assert!(config.get("ui").is_none()); } + #[test] + fn oneshot_config_json_proxy_peer_mode_uses_network_proxy_and_named_peer() { + let filesystem = MxcFilesystem::default(); + let pc = MxcProcessContainer { + capabilities: vec!["privateNetworkClientServer".into()], + proxy_peer: Some(MxcProxyPeer { + profile: "openshell-mxc-abc".into(), + proxy: "127.0.0.1:4242".parse().unwrap(), + }), + ..MxcProcessContainer::default() + }; + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: "C:\\work\\demo".into(), + env: Vec::new(), + timeout: 0, + }; + // A loopback-allow network section (the non-peer default) must be replaced. + let loopback_network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + allow_local_network: false, + }; + let config = + oneshot_config_json("sb-1", &filesystem, &pc, &process, Some(&loopback_network), None); + + assert_eq!(config["version"], MXC_PEER_SCHEMA_VERSION); + assert_eq!(config["runtimeConfig"]["networkProxy"], "http://127.0.0.1:4242"); + assert_eq!( + config["processContainer"]["network"]["allowedProxyPeer"], + "openshell-mxc-abc" + ); + // Existing processContainer settings are preserved next to `network`. + assert_eq!( + config["processContainer"]["capabilities"][0], + "privateNetworkClientServer" + ); + // MXC validation: proxy => egress deny + ingress allow; peer => hostLoopback deny. + assert_eq!(config["network"]["egress"]["default"], "deny"); + assert!(config["network"]["egress"].get("allow").is_none()); + assert_eq!(config["network"]["ingress"]["default"], "allow"); + assert_eq!(config["network"]["ingress"]["hostLoopback"], "deny"); + } + + #[test] + fn oneshot_config_json_without_proxy_peer_has_no_runtime_config() { + let pc = MxcProcessContainer::default(); + let process = MxcProcess { + command_line: "cmd /c exit 0".into(), + cwd: String::new(), + env: Vec::new(), + timeout: 0, + }; + let config = oneshot_config_json("sb-1", &MxcFilesystem::default(), &pc, &process, None, None); + assert_eq!(config["version"], MXC_SCHEMA_VERSION); + assert!(config.get("runtimeConfig").is_none()); + assert!(config["processContainer"].get("network").is_none()); + } + #[tokio::test] async fn mock_exec_uses_only_the_mxc_process_environment() { const KEY: &str = "OPENSHELL_MXC_MOCK_ENV_TEST"; diff --git a/crates/openshell-driver-mxc/src/peer.rs b/crates/openshell-driver-mxc/src/peer.rs new file mode 100644 index 0000000000..6d034e3bde --- /dev/null +++ b/crates/openshell-driver-mxc/src/peer.rs @@ -0,0 +1,829 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-sandbox proxy-peer process for MXC `ProcessContainer` sandboxes. +//! +//! MXC's `runtimeConfig.networkProxy` mode lets a sandbox reach *only* its +//! proxy endpoint, and (with `processContainer.network.allowedProxyPeer`) admits +//! only one named `AppContainer` identity to talk to the sandbox's listeners. +//! Direct loopback is blocked everywhere else -- including between processes +//! inside the sandbox -- so the in-sandbox relay can no longer dial the target +//! service itself. This module provides the missing identity: a small helper +//! process (`openshell-mxc-peer.exe`) that the driver starts under a +//! per-sandbox `AppContainer` profile. That profile is the sandbox's +//! `allowedProxyPeer`, so the peer is allowed to dial the sandbox's loopback +//! listeners. +//! +//! ```text +//! host client -> gateway forward listener (relay.rs) +//! | data pipe (DACL: gateway user + peer profile SID only) +//! peer process (AppContainer profile == allowedProxyPeer) +//! | TCP 127.0.0.1: (admitted by identity) +//! target service inside the sandbox +//! ``` +//! +//! Control protocol (newline-delimited text over `\\.\pipe\-ctl`): +//! peer -> gateway: `READY ` once, after binding its proxy listener +//! gateway -> peer: `OPEN ` per forwarded connection +//! +//! Each connection gets its own data pipe `\\.\pipe\-d`. After the +//! peer connects to it and dials the target it writes one status line, `OK` or +//! `ERR `, and then both sides pump raw bytes. +//! +//! Egress (sandbox -> network) runs the other way. The peer's proxy listener is +//! the sandbox's `networkProxy` endpoint; for every connection the sandbox opens +//! to it, the peer connects to the multi-instance pipe `\\.\pipe\-egress` +//! and pumps raw bytes. The gateway side of that pipe dials the sandbox's host +//! egress proxy (the existing per-sandbox policy proxy) and pumps bytes to it, +//! so the peer is a transparent byte tunnel and every policy decision, TLS +//! interception and credential substitution still happens in the gateway. +//! +//! Per-process policy (binary rules) keeps working through the tunnel: the first +//! line the peer writes on an egress pipe is `CLIENT `, the source port the +//! sandbox process used on the peer's proxy listener. The gateway registers an +//! alias from its bridge socket to that original connection +//! (`ForwardedClients`), and the host proxy then resolves the owner of the +//! original socket rather than the owner of the gateway's bridge socket. +//! +//! The pipe names, line formats and the profile-capability list are duplicated +//! in `src/bin/openshell-mxc-peer.rs`, matching how the other relay wire +//! protocols in this crate are duplicated across their two sides. + +#![allow(unsafe_code)] + +use std::ffi::c_void; +use std::io; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; +use tokio::sync::Mutex; +use openshell_supervisor_network::host::ForwardedClients; +use openshell_supervisor_network::procfs::WorkloadProxyTcpConnection; +use tokio::task::JoinHandle; +use tracing::{info, warn}; +use windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, LocalFree}; +use windows::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + ConvertStringSidToSidW, SDDL_REVISION_1, +}; +use windows::Win32::Security::Isolation::{ + CreateAppContainerProfile, DeleteAppContainerProfile, DeriveAppContainerSidFromAppContainerName, +}; +use windows::Win32::Security::{ + GetTokenInformation, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES, + SID_AND_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, +}; +use windows::Win32::System::Threading::{ + CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, InitializeProcThreadAttributeList, + LPPROC_THREAD_ATTRIBUTE_LIST, OpenProcessToken, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + PROCESS_INFORMATION, STARTUPINFOEXW, STARTUPINFOW, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, +}; +use windows::core::{PCWSTR, PWSTR}; + +/// Capabilities the peer profile needs so it can open loopback TCP connections +/// (`internetClient`, `privateNetworkClientServer`). +const PEER_CAPABILITY_SIDS: [&str; 2] = ["S-1-15-3-1", "S-1-15-3-3"]; + +/// Environment variables copied from the gateway to the peer (see +/// `spawn_in_app_container`). +const PEER_BOOTSTRAP_ENV: [&str; 6] = [ + "SYSTEMROOT", + "WINDIR", + "PATH", + "PATHEXT", + "COMSPEC", + "LOCALAPPDATA", +]; + +/// `SE_GROUP_ENABLED`. +const SE_GROUP_ENABLED: u32 = 0x0000_0004; + +/// `HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)`. +const HRESULT_ALREADY_EXISTS: i32 = 0x8007_00B7_u32 as i32; + +const PEER_READY_TIMEOUT: Duration = Duration::from_secs(15); +const DATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_LINE: usize = 512; + +fn win_err(error: windows::core::Error) -> io::Error { + io::Error::other(error.to_string()) +} + +fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// `AppContainer` profile name for a sandbox: `openshell-mxc-` +/// (profile names are limited to 64 characters of `[A-Za-z0-9._-]`). +pub(crate) fn profile_name_for(sandbox_id: &str) -> String { + let id: String = sandbox_id + .chars() + .filter(char::is_ascii_alphanumeric) + .take(32) + .collect(); + format!("openshell-mxc-{id}") +} + +/// Pipe DACL: the gateway's own user plus the peer's `AppContainer` SID, nothing +/// else. An `AppContainer` access check must succeed for both the user SID in +/// the token and the package SID, so both ACEs are required. +pub(crate) fn pipe_sddl(user_sid: &str, profile_sid: &str) -> String { + format!("D:(A;;GA;;;{user_sid})(A;;GA;;;{profile_sid})") +} + +fn ctl_pipe_name(prefix: &str) -> String { + format!(r"\\.\pipe\{prefix}-ctl") +} + +fn data_pipe_name(prefix: &str, id: u64) -> String { + format!(r"\\.\pipe\{prefix}-d{id}") +} + +fn egress_pipe_name(prefix: &str) -> String { + format!(r"\\.\pipe\{prefix}-egress") +} + +/// Where the peer's egress tunnel ends: the sandbox's host egress proxy, plus the +/// registry that lets that proxy resolve the identity of the real sandbox process +/// behind each tunnelled connection. +#[derive(Clone)] +pub struct EgressTunnel { + pub host_proxy: SocketAddr, + pub clients: ForwardedClients, +} + +/// Parse the peer's `CLIENT ` line: the source port the sandbox process +/// used when it connected to the peer's proxy listener. +pub(crate) fn parse_client_port(line: &str) -> Option { + line.strip_prefix("CLIENT ")?.trim().parse().ok() +} + +/// Peer command line: `"" `. +pub(crate) fn peer_command_line(peer_exe: &str, token: &str, egress: bool) -> String { + format!("\"{peer_exe}\" {token} {}", if egress { "egress" } else { "none" }) +} + +/// Parse `READY `. +pub(crate) fn parse_ready(line: &str) -> Option { + line.strip_prefix("READY ")?.trim().parse().ok() +} + +/// Read one `\n`-terminated line one byte at a time so that no payload bytes that +/// follow the line are consumed. +async fn read_line(reader: &mut R) -> io::Result { + let mut bytes = Vec::new(); + let mut one = [0_u8; 1]; + loop { + if reader.read(&mut one).await? == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "pipe closed before end of line", + )); + } + if one[0] == b'\n' { + break; + } + if bytes.len() >= MAX_LINE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "line too long")); + } + bytes.push(one[0]); + } + String::from_utf8(bytes) + .map(|line| line.trim_end_matches('\r').to_string()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "line is not UTF-8")) +} + +fn sid_to_string(sid: PSID) -> io::Result { + let mut text = PWSTR::null(); + // SAFETY: `sid` is a valid SID and `text` is a valid out pointer. + unsafe { ConvertSidToStringSidW(sid, &mut text) }.map_err(win_err)?; + // SAFETY: on success `text` points to a NUL-terminated wide string allocated + // by the OS; it is freed with LocalFree right after copying. + let result = unsafe { text.to_string() }.map_err(|error| io::Error::other(error.to_string())); + // SAFETY: `text` was allocated by ConvertSidToStringSidW (LocalAlloc). + unsafe { + let _ = LocalFree(Some(HLOCAL(text.0.cast()))); + } + result +} + +fn current_user_sid() -> io::Result { + // SAFETY: standard token query with a correctly sized, 8-byte-aligned buffer. + unsafe { + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).map_err(win_err)?; + let mut len = 0_u32; + let _ = GetTokenInformation(token, TokenUser, None, 0, &mut len); + let mut buffer = vec![0_u64; (len as usize).div_ceil(8).max(1)]; + let query = GetTokenInformation( + token, + TokenUser, + Some(buffer.as_mut_ptr().cast()), + len, + &mut len, + ); + let result = match query { + Ok(()) => { + let user = &*buffer.as_ptr().cast::(); + sid_to_string(user.User.Sid) + } + Err(error) => Err(win_err(error)), + }; + let _ = CloseHandle(token); + result + } +} + +/// Self-relative security descriptor built from SDDL, freed on drop. +struct SecurityDescriptor(PSECURITY_DESCRIPTOR); + +// SAFETY: the descriptor is immutable after creation and only read by the OS. +unsafe impl Send for SecurityDescriptor {} +// SAFETY: see above. +unsafe impl Sync for SecurityDescriptor {} + +impl SecurityDescriptor { + fn from_sddl(sddl: &str) -> io::Result { + let sddl = wide(sddl); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `sddl` is NUL-terminated and `descriptor` is a valid out pointer. + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR(sddl.as_ptr()), + SDDL_REVISION_1, + &mut descriptor, + None, + ) + } + .map_err(win_err)?; + Ok(Self(descriptor)) + } + + fn create_pipe(&self, name: &str, first_instance: bool) -> io::Result { + let mut attributes = SECURITY_ATTRIBUTES { + nLength: u32::try_from(size_of::()).unwrap_or(0), + lpSecurityDescriptor: self.0.0, + bInheritHandle: false.into(), + }; + let mut options = ServerOptions::new(); + options.first_pipe_instance(first_instance); + options.reject_remote_clients(true); + // SAFETY: `attributes` is a valid SECURITY_ATTRIBUTES for the call and the + // descriptor it points to outlives the call (owned by `self`). + unsafe { + options.create_with_security_attributes_raw( + name, + std::ptr::addr_of_mut!(attributes).cast::(), + ) + } + } +} + +impl Drop for SecurityDescriptor { + fn drop(&mut self) { + // SAFETY: allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW. + unsafe { + let _ = LocalFree(Some(HLOCAL(self.0.0))); + } + } +} + +/// Frees a SID obtained from the `AppContainer` APIs on drop. +struct OwnedSid(PSID); + +impl OwnedSid { + fn as_psid(&self) -> PSID { + self.0 + } +} + +impl Drop for OwnedSid { + fn drop(&mut self) { + // SAFETY: SIDs returned by CreateAppContainerProfile and + // DeriveAppContainerSidFromAppContainerName are released with FreeSid. + // (The capability SIDs from ConvertStringSidToSidW are separate and are + // intentionally not wrapped here; see `capability_sids`.) + unsafe { + let _ = windows::Win32::Security::FreeSid(self.0); + } + } +} + +fn capability_sids() -> io::Result> { + PEER_CAPABILITY_SIDS + .iter() + .map(|text| { + let text = wide(text); + let mut sid = PSID::default(); + // SAFETY: `text` is NUL-terminated; `sid` is a valid out pointer. The + // SID is deliberately leaked for the (short) lifetime of the gateway: + // it is a few bytes per sandbox and freeing it would require tracking + // it past `CreateProcessW`. + unsafe { ConvertStringSidToSidW(PCWSTR(text.as_ptr()), &mut sid) }.map_err(win_err)?; + Ok(sid) + }) + .collect() +} + +/// Create the profile if needed and return its SID. +fn ensure_profile(name: &str, capabilities: &[SID_AND_ATTRIBUTES]) -> io::Result { + let name_w = wide(name); + let name_p = PCWSTR(name_w.as_ptr()); + // SAFETY: all string arguments are NUL-terminated and outlive the call. + let created = + unsafe { CreateAppContainerProfile(name_p, name_p, name_p, Some(capabilities)) }; + match created { + Ok(sid) => Ok(OwnedSid(sid)), + Err(error) if error.code().0 == HRESULT_ALREADY_EXISTS => { + // SAFETY: `name_p` is NUL-terminated. + unsafe { DeriveAppContainerSidFromAppContainerName(name_p) } + .map(OwnedSid) + .map_err(win_err) + } + Err(error) => Err(win_err(error)), + } +} + +/// Start `exe` inside the `AppContainer` identified by `sid` with `capabilities`. +/// The child gets a minimal environment (never the gateway's, which may hold +/// secrets) and no inherited handles. +fn spawn_in_app_container( + exe: &str, + command_line: &str, + sid: PSID, + capabilities: &mut [SID_AND_ATTRIBUTES], +) -> io::Result { + let mut list_size = 0_usize; + // SAFETY: size query; documented to fail with ERROR_INSUFFICIENT_BUFFER. + unsafe { + let _ = InitializeProcThreadAttributeList(None, 1, None, &mut list_size); + } + let mut list_buffer = vec![0_u64; list_size.div_ceil(8).max(1)]; + let list = LPPROC_THREAD_ATTRIBUTE_LIST(list_buffer.as_mut_ptr().cast()); + // SAFETY: `list` points to a buffer of at least `list_size` bytes. + unsafe { InitializeProcThreadAttributeList(Some(list), 1, None, &mut list_size) } + .map_err(win_err)?; + + let security_capabilities = SECURITY_CAPABILITIES { + AppContainerSid: sid, + Capabilities: capabilities.as_mut_ptr(), + CapabilityCount: u32::try_from(capabilities.len()).unwrap_or(0), + Reserved: 0, + }; + // SAFETY: the attribute value outlives CreateProcessW below. + let update = unsafe { + UpdateProcThreadAttribute( + list, + 0, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize, + Some(std::ptr::addr_of!(security_capabilities).cast::()), + size_of::(), + None, + None, + ) + }; + if let Err(error) = update { + // SAFETY: `list` was initialized above. + unsafe { DeleteProcThreadAttributeList(list) }; + return Err(win_err(error)); + } + + let startup = STARTUPINFOEXW { + StartupInfo: STARTUPINFOW { + cb: u32::try_from(size_of::()).unwrap_or(0), + ..Default::default() + }, + lpAttributeList: list, + }; + let exe_w = wide(exe); + let mut command_w = wide(command_line); + // Same non-secret bootstrap set the driver gives sandboxed workloads: an + // AppContainer `CreateProcessW` fails with ERROR_ENVVAR_NOT_FOUND (203) + // without LOCALAPPDATA. Nothing else from the gateway's environment (which + // can hold secrets) is passed on. + let mut environment: Vec = Vec::new(); + for name in PEER_BOOTSTRAP_ENV { + if let Ok(value) = std::env::var(name) { + environment.extend(format!("{name}={value}").encode_utf16()); + environment.push(0); + } + } + environment.push(0); + + let mut info = PROCESS_INFORMATION::default(); + // SAFETY: all pointers are valid for the duration of the call; the + // environment block is double-NUL-terminated UTF-16 as required by + // CREATE_UNICODE_ENVIRONMENT. + let created = unsafe { + CreateProcessW( + PCWSTR(exe_w.as_ptr()), + Some(PWSTR(command_w.as_mut_ptr())), + None, + None, + false, + EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + Some(environment.as_ptr().cast::()), + PCWSTR::null(), + std::ptr::addr_of!(startup).cast::(), + &mut info, + ) + }; + // SAFETY: `list` was initialized above and is no longer needed. + unsafe { DeleteProcThreadAttributeList(list) }; + created.map_err(win_err)?; + // SAFETY: the thread handle is not used. + unsafe { + let _ = CloseHandle(info.hThread); + } + Ok(info.hProcess) +} + +/// A running proxy-peer process for one sandbox. Dropping it terminates the +/// process and deletes the `AppContainer` profile. +pub struct PeerHandle { + profile: String, + process: isize, + prefix: String, + descriptor: Arc, + control: Mutex, + proxy_addr: SocketAddr, + next_id: AtomicU64, + terminated: AtomicBool, + /// First instance of the egress pipe, created before the peer starts so the + /// name cannot be squatted; handed to the accept loop once the peer is ready. + egress_first: Option, + /// Gateway-side accept loop bridging egress pipe connections to the host + /// egress proxy. Aborted on terminate. + egress_task: StdMutex>>, +} + +impl std::fmt::Debug for PeerHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PeerHandle") + .field("profile", &self.profile) + .field("proxy_addr", &self.proxy_addr) + .finish_non_exhaustive() + } +} + +impl PeerHandle { + /// `AppContainer` profile name; this is the sandbox's `allowedProxyPeer`. + pub fn profile(&self) -> &str { + &self.profile + } + + /// Loopback address of the peer's proxy listener; this is the sandbox's + /// `networkProxy`. + pub fn proxy_addr(&self) -> SocketAddr { + self.proxy_addr + } + + /// Create the profile, start the peer under it and wait until it reports its + /// proxy port over the control pipe. + /// + /// With `egress` set, the peer tunnels every connection its proxy listener + /// accepts to the gateway's host egress proxy over a named pipe; without it + /// the peer's proxy answers `403`. + pub async fn spawn( + peer_exe: &str, + sandbox_id: &str, + egress: Option, + ) -> io::Result { + // All raw-pointer Win32 work happens in this synchronous helper so that no + // non-`Send` value is held across the awaits below. + let mut handle = Self::start_process(peer_exe, sandbox_id, egress.is_some())?; + + let ready = async { + let control = handle.control.get_mut(); + tokio::time::timeout(PEER_READY_TIMEOUT, control.connect()) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy peer did not connect"))??; + let line = tokio::time::timeout(PEER_READY_TIMEOUT, read_line(control)) + .await + .map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "proxy peer did not report READY") + })??; + parse_ready(&line).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected proxy peer greeting: {line}"), + ) + }) + } + .await?; + handle.proxy_addr = SocketAddr::from(([127, 0, 0, 1], ready)); + let egress_enabled = egress.is_some(); + if let (Some(first), Some(tunnel)) = (handle.egress_first.take(), egress) { + let task = tokio::spawn(egress_accept_loop( + Arc::clone(&handle.descriptor), + egress_pipe_name(&handle.prefix), + first, + tunnel, + handle.proxy_addr, + )); + if let Ok(mut slot) = handle.egress_task.lock() { + *slot = Some(task); + } + } + info!( + profile = %handle.profile, + proxy = %handle.proxy_addr, + egress = egress_enabled, + "MXC proxy peer started" + ); + Ok(handle) + } + + /// Create the profile and control pipe and start the peer process. Does not + /// wait for the peer; see [`PeerHandle::spawn`]. + fn start_process(peer_exe: &str, sandbox_id: &str, egress: bool) -> io::Result { + let profile = profile_name_for(sandbox_id); + let token = format!("{:032x}", rand::random::()); + let prefix = format!("openshell-mxc-{token}"); + + let mut capabilities: Vec = capability_sids()? + .into_iter() + .map(|sid| SID_AND_ATTRIBUTES { + Sid: sid, + Attributes: SE_GROUP_ENABLED, + }) + .collect(); + let sid = ensure_profile(&profile, &capabilities)?; + let sddl = pipe_sddl(¤t_user_sid()?, &sid_to_string(sid.as_psid())?); + let descriptor = SecurityDescriptor::from_sddl(&sddl)?; + + // The control pipe exists before the peer starts, so the peer's first + // connect attempt succeeds and no other process can squat the name + // (first_pipe_instance + a DACL that only admits the peer). + let control = descriptor.create_pipe(&ctl_pipe_name(&prefix), true)?; + let egress_first = if egress { + Some(descriptor.create_pipe(&egress_pipe_name(&prefix), true)?) + } else { + None + }; + + let command_line = peer_command_line(peer_exe, &token, egress); + let process = match spawn_in_app_container( + peer_exe, + &command_line, + sid.as_psid(), + &mut capabilities, + ) { + Ok(handle) => handle, + Err(error) => { + // SAFETY: best-effort profile cleanup on the failure path. + unsafe { + let name = wide(&profile); + let _ = DeleteAppContainerProfile(PCWSTR(name.as_ptr())); + } + return Err(io::Error::new( + error.kind(), + format!("failed to start proxy peer '{peer_exe}': {error}"), + )); + } + }; + + // From here on the handle's Drop cleans up the process and profile. + Ok(Self { + profile, + process: process.0 as isize, + prefix, + descriptor: Arc::new(descriptor), + control: Mutex::new(control), + proxy_addr: "127.0.0.1:0".parse().expect("static address"), + next_id: AtomicU64::new(1), + terminated: AtomicBool::new(false), + egress_first, + egress_task: StdMutex::new(None), + }) + } + + /// Ask the peer to dial `127.0.0.1:` inside the sandbox and + /// return the data pipe carrying the connection's bytes. + pub async fn open_data(&self, target_port: u16) -> io::Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let mut data = self + .descriptor + .create_pipe(&data_pipe_name(&self.prefix, id), true)?; + { + let mut control = self.control.lock().await; + control + .write_all(format!("OPEN {id} {target_port}\n").as_bytes()) + .await?; + } + tokio::time::timeout(DATA_CONNECT_TIMEOUT, data.connect()) + .await + .map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "proxy peer did not open data pipe") + })??; + let status = tokio::time::timeout(DATA_CONNECT_TIMEOUT, read_line(&mut data)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy peer sent no status"))??; + if status == "OK" { + Ok(data) + } else { + Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + format!("proxy peer could not reach 127.0.0.1:{target_port}: {status}"), + )) + } + } +} + +/// Gateway side of the egress tunnel: accept peer connections on the egress pipe +/// and bridge each to the host egress proxy at `target`. The next pipe instance +/// is created before the accepted one is handed off, so the peer (which retries +/// on `ERROR_PIPE_BUSY`) always finds a listening instance. +async fn egress_accept_loop( + descriptor: Arc, + name: String, + first: NamedPipeServer, + tunnel: EgressTunnel, + peer_proxy: SocketAddr, +) { + let mut server = first; + loop { + if let Err(error) = server.connect().await { + warn!("MXC proxy peer egress pipe accept failed: {error}"); + return; + } + let connected = server; + server = match descriptor.create_pipe(&name, false) { + Ok(next) => next, + Err(error) => { + warn!("MXC proxy peer egress pipe re-create failed: {error}"); + return; + } + }; + let tunnel = tunnel.clone(); + tokio::spawn(bridge_egress(connected, tunnel, peer_proxy)); + } +} + +/// Bridge one tunnelled sandbox connection to the host egress proxy. +async fn bridge_egress(mut pipe: NamedPipeServer, tunnel: EgressTunnel, peer_proxy: SocketAddr) { + // First line from the peer: the sandbox process's source port on the peer's + // proxy listener, which identifies the original connection. + let line = match tokio::time::timeout(DATA_CONNECT_TIMEOUT, read_line(&mut pipe)).await { + Ok(Ok(line)) => line, + Ok(Err(error)) => { + warn!("MXC proxy peer egress: no client line: {error}"); + return; + } + Err(_) => { + warn!("MXC proxy peer egress: timed out waiting for client line"); + return; + } + }; + let Some(client_port) = parse_client_port(&line) else { + warn!("MXC proxy peer egress: malformed client line"); + return; + }; + let mut tcp = match TcpStream::connect(tunnel.host_proxy).await { + Ok(tcp) => tcp, + Err(error) => { + warn!( + "MXC proxy peer egress: cannot reach host proxy {}: {error}", + tunnel.host_proxy + ); + return; + } + }; + let _ = tcp.set_nodelay(true); + // Register before any request bytes flow, so identity resolution for this + // connection always finds the alias. The guard removes it when the tunnel ends. + let _alias = tcp.local_addr().ok().map(|bridge| { + tunnel.clients.register( + bridge, + WorkloadProxyTcpConnection::new(SocketAddr::from(([127, 0, 0, 1], client_port)), peer_proxy), + ) + }); + let _ = tokio::io::copy_bidirectional(&mut pipe, &mut tcp).await; +} + +impl PeerHandle { + /// Terminate the peer process and delete its `AppContainer` profile. + /// Idempotent. Blocks for up to two seconds waiting for the process to exit, + /// so call it from `spawn_blocking` in async contexts. Other holders of the + /// handle (for example forward bridges) can keep their `Arc`, so teardown does + /// not wait for `Drop`. + pub fn terminate(&self) { + if self.terminated.swap(true, Ordering::AcqRel) { + return; + } + if let Ok(mut slot) = self.egress_task.lock() { + if let Some(task) = slot.take() { + task.abort(); + } + } + let process = HANDLE(self.process as *mut c_void); + // SAFETY: `process` is the handle returned by CreateProcessW and owned by + // this struct; the `terminated` flag guarantees it is closed exactly once. + unsafe { + let _ = TerminateProcess(process, 1); + let _ = WaitForSingleObject(process, 2000); + let _ = CloseHandle(process); + let name = wide(&self.profile); + if let Err(error) = DeleteAppContainerProfile(PCWSTR(name.as_ptr())) { + warn!(profile = %self.profile, "failed to delete proxy peer profile: {error}"); + } + } + info!(profile = %self.profile, "MXC proxy peer terminated"); + } +} + +impl Drop for PeerHandle { + fn drop(&mut self) { + self.terminate(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_name_is_stable_and_within_limits() { + let name = profile_name_for("6e3db1ed-8861-457f-95bd-35ac9714f4fb"); + assert_eq!(name, "openshell-mxc-6e3db1ed8861457f95bd35ac9714f4fb"); + assert!(name.len() <= 64); + assert_eq!(profile_name_for("a/b\\c:d"), "openshell-mxc-abcd"); + } + + #[test] + fn pipe_sddl_admits_only_user_and_profile() { + assert_eq!( + pipe_sddl("S-1-5-21-1", "S-1-15-2-9"), + "D:(A;;GA;;;S-1-5-21-1)(A;;GA;;;S-1-15-2-9)" + ); + } + + #[test] + fn pipe_names_are_namespaced_per_prefix() { + assert_eq!(ctl_pipe_name("p"), r"\\.\pipe\p-ctl"); + assert_eq!(data_pipe_name("p", 7), r"\\.\pipe\p-d7"); + assert_eq!(egress_pipe_name("p"), r"\\.\pipe\p-egress"); + } + + #[test] + fn client_port_line_parsing() { + assert_eq!(parse_client_port("CLIENT 61000"), Some(61000)); + assert_eq!(parse_client_port("CLIENT 7 "), Some(7)); + assert_eq!(parse_client_port("CLIENT x"), None); + assert_eq!(parse_client_port("CLIENT 70000"), None); + assert_eq!(parse_client_port("READY 1"), None); + } + + #[test] + fn peer_command_line_carries_the_egress_mode() { + assert_eq!( + peer_command_line(r"C:\x\peer.exe", "tok", true), + r#""C:\x\peer.exe" tok egress"# + ); + assert_eq!( + peer_command_line(r"C:\x\peer.exe", "tok", false), + r#""C:\x\peer.exe" tok none"# + ); + } + + #[test] + fn ready_line_parsing() { + assert_eq!(parse_ready("READY 4242"), Some(4242)); + assert_eq!(parse_ready("READY 17 "), Some(17)); + assert_eq!(parse_ready("READY x"), None); + assert_eq!(parse_ready("OPEN 1 2"), None); + } + + #[tokio::test] + async fn read_line_stops_at_newline_and_keeps_payload() { + let (mut a, mut b) = tokio::io::duplex(64); + a.write_all(b"OK\nPAYLOAD").await.unwrap(); + assert_eq!(read_line(&mut b).await.unwrap(), "OK"); + let mut rest = [0_u8; 7]; + b.read_exact(&mut rest).await.unwrap(); + assert_eq!(&rest, b"PAYLOAD"); + } + + #[tokio::test] + async fn read_line_rejects_oversized_lines_and_eof() { + let (mut a, mut b) = tokio::io::duplex(2048); + a.write_all(&vec![b'x'; MAX_LINE + 10]).await.unwrap(); + assert_eq!( + read_line(&mut b).await.unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + let (a, mut b) = tokio::io::duplex(8); + drop(a); + assert_eq!( + read_line(&mut b).await.unwrap_err().kind(), + io::ErrorKind::UnexpectedEof + ); + } +} diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs index 45b0776290..48e9d4d354 100644 --- a/crates/openshell-driver-mxc/src/relay.rs +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -61,6 +61,7 @@ //! service from that point on. use crate::control_channel::ControlChannel; +use crate::peer::PeerHandle; use base64::Engine; use futures::{SinkExt, StreamExt}; use openshell_core::net::set_tcp_nodelay_best_effort; @@ -215,6 +216,91 @@ pub async fn start_control_channel_relay( Ok((RelayHandle { shutdown_tx }, actual)) } +/// Start a host-loopback listener whose sandbox leg runs through the sandbox's +/// proxy peer (see `peer.rs`): each authenticated host connection makes the peer +/// dial `127.0.0.1:` inside the sandbox over a private named pipe. +/// Used when the sandbox runs under MXC `networkProxy` + `allowedProxyPeer`, +/// where the in-sandbox relay cannot dial its own loopback target. +pub async fn start_peer_relay( + bind_addr: SocketAddr, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + peer: Arc, + target_port: u16, +) -> std::io::Result<(RelayHandle, SocketAddr)> { + let listener = TcpListener::bind(bind_addr).await?; + let actual = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(peer_relay_task( + listener, + sandbox_name.clone(), + nonce, + peer, + target_port, + shutdown_rx, + )); + info!(sandbox = %sandbox_name, relay = %actual, "MXC peer relay started"); + Ok((RelayHandle { shutdown_tx }, actual)) +} + +async fn peer_relay_task( + listener: TcpListener, + sandbox_name: String, + nonce: [u8; NONCE_LEN], + peer: Arc, + target_port: u16, + mut shutdown_rx: oneshot::Receiver<()>, +) { + // Connections are bridged concurrently; dropping the set on shutdown aborts + // any bridge that is still running. + let mut bridges = tokio::task::JoinSet::new(); + loop { + let mut host_stream = tokio::select! { + result = listener.accept() => match result { + Ok((stream, addr)) => { + info!(sandbox = %sandbox_name, %addr, "MXC peer relay: host client connected"); + set_tcp_nodelay_best_effort(&stream); + stream + } + Err(error) => { + warn!(sandbox = %sandbox_name, "MXC peer relay listener error: {error}"); + return; + } + }, + Some(_) = bridges.join_next(), if !bridges.is_empty() => continue, + _ = &mut shutdown_rx => return, + }; + + let sandbox = sandbox_name.clone(); + let peer = Arc::clone(&peer); + bridges.spawn(async move { + let mut auth_buf = [0_u8; NONCE_LEN]; + match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { + Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} + Ok(Ok(_) | Err(_)) | Err(_) => return, + } + let mut data = match peer.open_data(target_port).await { + Ok(data) => data, + Err(error) => { + warn!(sandbox = %sandbox, "MXC peer relay: peer could not open session: {error}"); + return; + } + }; + match tokio::io::copy_bidirectional(&mut host_stream, &mut data).await { + Ok((host_to_sandbox_bytes, sandbox_to_host_bytes)) => info!( + sandbox = %sandbox, + host_to_sandbox_bytes, + sandbox_to_host_bytes, + "MXC peer relay: host client disconnected" + ), + Err(error) => { + info!(sandbox = %sandbox, "MXC peer relay: bridge ended: {error}"); + } + } + }); + } +} + async fn control_channel_relay_task( listener: TcpListener, sandbox_name: String, diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs index e2a020ffb5..bfee733fc2 100644 --- a/crates/openshell-supervisor-network/src/host.rs +++ b/crates/openshell-supervisor-network/src/host.rs @@ -33,6 +33,7 @@ use crate::l7::tls::{ use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; use crate::proxy::{ProxyHandle, ProxyIdentityMode}; +pub use crate::windows_process::{ForwardedClientGuard, ForwardedClients}; /// Ephemeral credential required from a sandbox before the host proxy will /// evaluate or forward its request. @@ -85,6 +86,7 @@ pub struct HostProxyHandle { #[allow(dead_code)] tls_dir: Option, pub policy_local_ctx: Arc, + forwarded_clients: ForwardedClients, } impl HostProxyHandle { @@ -93,6 +95,14 @@ impl HostProxyHandle { self.proxy.http_addr() } + /// Registry for connections that reach this proxy through a tunnel, so that + /// per-process identity still resolves to the sandbox process that opened the + /// original connection. See [`ForwardedClients`]. + #[must_use] + pub fn forwarded_clients(&self) -> ForwardedClients { + self.forwarded_clients.clone() + } + #[must_use] pub fn ca_file_paths(&self) -> Option<(PathBuf, PathBuf)> { self.ca_file_paths.clone() @@ -246,9 +256,11 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result Result>, + /// Connections that reach this proxy through a tunnel (the MXC proxy + /// peer) and the original sandbox connection each one stands for. + forwarded_clients: crate::windows_process::ForwardedClients, }, /// Static fallback for platforms without socket-owner resolution and for /// tests that need to inject a deterministic identity. @@ -216,9 +219,27 @@ impl ProxyIdentityMode { identity_cache: Arc::new(BinaryIdentityCache::new()), required_proxy_authorization, event_context: None, + forwarded_clients: crate::windows_process::ForwardedClients::default(), } } + /// Share a tunnelled-connection registry with this identity mode (see + /// [`crate::windows_process::ForwardedClients`]). + #[cfg(target_os = "windows")] + pub(super) fn with_forwarded_clients( + mut self, + clients: crate::windows_process::ForwardedClients, + ) -> Self { + match &mut self { + Self::Windows { + forwarded_clients, .. + } => *forwarded_clients = clients, + #[cfg(test)] + Self::Static { .. } => {} + } + self + } + #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] pub(crate) fn static_binary(path: impl Into) -> Result { Self::static_binary_with_client_auth(path, None) @@ -269,6 +290,21 @@ impl ProxyIdentityMode { } } + /// Whether `peer` is a connection that reached this proxy through a + /// registered tunnel (see [`crate::windows_process::ForwardedClients`]). + fn is_tunnelled_client(&self, peer: Option) -> bool { + match (self, peer) { + #[cfg(target_os = "windows")] + ( + Self::Windows { + forwarded_clients, .. + }, + Some(peer), + ) => forwarded_clients.is_tunnelled(peer), + _ => false, + } + } + fn required_proxy_authorization(&self) -> Option<&str> { match self { #[cfg(target_os = "linux")] @@ -1934,6 +1970,7 @@ async fn handle_tcp_connection( return Ok(()); } if let Some(expected) = identity_mode.required_proxy_authorization() + && !identity_mode.is_tunnelled_client(client.peer_addr().ok()) && !has_valid_proxy_authorization(request, expected) { warn!("Rejected host proxy request with missing or invalid per-sandbox credentials"); @@ -3170,9 +3207,16 @@ fn authorize_egress_intent( intent, ), #[cfg(target_os = "windows")] - ProxyIdentityMode::Windows { identity_cache, .. } => { - authorize_egress_intent_windows(connection, engine, identity_cache, intent) - } + ProxyIdentityMode::Windows { + identity_cache, + forwarded_clients, + .. + } => authorize_egress_intent_windows( + forwarded_clients.resolve(connection), + engine, + identity_cache, + intent, + ), #[cfg(any(not(any(target_os = "linux", target_os = "windows")), test))] ProxyIdentityMode::Static { binary_path, diff --git a/crates/openshell-supervisor-network/src/windows_process.rs b/crates/openshell-supervisor-network/src/windows_process.rs index 15a1d828e5..1fbbc7b99e 100644 --- a/crates/openshell-supervisor-network/src/windows_process.rs +++ b/crates/openshell-supervisor-network/src/windows_process.rs @@ -3,9 +3,11 @@ //! Windows TCP socket-owner and process-image resolution. +use std::collections::HashMap; use std::mem::{offset_of, size_of, size_of_val}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use miette::Result; use windows::Win32::Foundation::{CloseHandle, ERROR_INSUFFICIENT_BUFFER, HANDLE}; @@ -32,6 +34,77 @@ impl Drop for ProcessHandle { } } +/// Aliases for connections that reach the host proxy through a tunnel. +/// +/// Host-side proxy identity is the owner of the client socket the proxy accepted. +/// When the sandbox can only reach a relay process (the MXC proxy peer) that +/// forwards its bytes to the proxy, the accepted socket belongs to the forwarder, +/// not to the sandbox process that opened the original connection. The forwarder's +/// owner (the gateway) registers, per tunnelled connection, which original +/// `workload -> proxy` connection the accepted socket stands for, and identity +/// resolution then looks up that original connection instead. +/// +/// Only code holding this handle, which lives inside the gateway process, can +/// register an alias. +#[derive(Clone, Default)] +pub struct ForwardedClients { + aliases: Arc>>, +} + +/// Removes its alias when dropped. Keep it alive for as long as the tunnelled +/// connection is open. +pub struct ForwardedClientGuard { + clients: ForwardedClients, + bridge: SocketAddr, +} + +impl ForwardedClients { + /// Register that connections accepted from `bridge` (the forwarder's socket as + /// the proxy sees it) belong to `original`. + #[must_use] + pub fn register( + &self, + bridge: SocketAddr, + original: WorkloadProxyTcpConnection, + ) -> ForwardedClientGuard { + if let Ok(mut aliases) = self.aliases.lock() { + aliases.insert(bridge, original); + } + ForwardedClientGuard { + clients: self.clone(), + bridge, + } + } + + /// Whether the connection accepted from `bridge` is a registered tunnel. Such a + /// connection arrived through the sandbox's access-controlled peer pipe, which + /// only the gateway can register, so it does not need the per-sandbox proxy + /// password (the MXC-provided proxy variables carry no credentials). + pub(crate) fn is_tunnelled(&self, bridge: SocketAddr) -> bool { + self.aliases + .lock() + .is_ok_and(|aliases| aliases.contains_key(&bridge)) + } + + /// Map an accepted connection to the original connection it tunnels, or + /// return it unchanged when it is not tunnelled. + pub(crate) fn resolve(&self, connection: WorkloadProxyTcpConnection) -> WorkloadProxyTcpConnection { + self.aliases + .lock() + .ok() + .and_then(|aliases| aliases.get(&connection.workload).copied()) + .unwrap_or(connection) + } +} + +impl Drop for ForwardedClientGuard { + fn drop(&mut self) { + if let Ok(mut aliases) = self.clients.aliases.lock() { + aliases.remove(&self.bridge); + } + } +} + /// Resolve the process that owns the workload side of an accepted proxy TCP /// connection and return its PID and executable image path. pub fn resolve_tcp_peer_identity(connection: WorkloadProxyTcpConnection) -> Result<(PathBuf, u32)> { @@ -278,6 +351,34 @@ mod tests { const CHILD_PORT_ENV: &str = "OPENSHELL_TEST_WINDOWS_SOCKET_OWNER_PORT"; + #[test] + fn forwarded_clients_map_tunnelled_connections_and_clean_up() { + let clients = ForwardedClients::default(); + let bridge: SocketAddr = "127.0.0.1:50000".parse().unwrap(); + let proxy: SocketAddr = "127.0.0.1:18080".parse().unwrap(); + let original = WorkloadProxyTcpConnection::new( + "127.0.0.1:61000".parse().unwrap(), + "127.0.0.1:62000".parse().unwrap(), + ); + let tunnelled = WorkloadProxyTcpConnection::new(bridge, proxy); + + // Not registered: returned unchanged. + assert_eq!(clients.resolve(tunnelled), tunnelled); + + assert!(!clients.is_tunnelled(bridge)); + let guard = clients.register(bridge, original); + assert!(clients.is_tunnelled(bridge)); + assert!(!clients.is_tunnelled("127.0.0.1:50002".parse().unwrap())); + assert_eq!(clients.resolve(tunnelled), original); + // A different bridge socket is unaffected. + let other = WorkloadProxyTcpConnection::new("127.0.0.1:50001".parse().unwrap(), proxy); + assert_eq!(clients.resolve(other), other); + + drop(guard); + assert!(!clients.is_tunnelled(bridge)); + assert_eq!(clients.resolve(tunnelled), tunnelled); + } + #[test] fn socket_owner_child() { let Ok(port) = std::env::var(CHILD_PORT_ENV) else { diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs index 568696b96e..c2545d7d8c 100644 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -293,6 +293,36 @@ fn byte_preview(data: &[u8]) -> String { /// because our stdout is reserved exclusively for the control-channel /// protocol with the driver. The child's stdin is closed; it isn't part of /// this channel. +/// Proxy variables the target inherits from this process's own environment when +/// the launch request does not set them. MXC puts its `networkProxy` endpoint +/// into the environment of the process it launches (this relay); the target is +/// started with a cleared environment, so without this it would never see them. +/// `NO_PROXY` is deliberately not inherited: it could only widen what bypasses +/// the proxy. +const INHERITED_PROXY_ENV: [&str; 4] = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]; + +/// Proxy variables from `parent` that `child_env` does not already define +/// (names compare case-insensitively, as on Windows). Values the launch request +/// sets always win. +fn inherited_proxy_env( + parent: impl Fn(&str) -> Option, + child_env: &[(String, String)], +) -> Vec<(String, String)> { + INHERITED_PROXY_ENV + .iter() + .filter(|key| { + !child_env + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(key)) + }) + .filter_map(|key| { + parent(key) + .filter(|value| !value.is_empty()) + .map(|value| ((*key).to_string(), value)) + }) + .collect() +} + fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { if command.is_empty() { anyhow::bail!("launch command must not be empty"); @@ -305,7 +335,7 @@ fn spawn_target(command: Vec, env: Vec) -> anyhow::Result = env + let mut child_env: Vec<(String, String)> = env .iter() .filter(|l| l.contains('=')) .filter_map(|l| { @@ -317,6 +347,14 @@ fn spawn_target(command: Vec, env: Vec) -> anyhow::Result impl Fn(&str) -> Option { + let map: HashMap = vars + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |key| map.get(key).cloned() + } + + #[test] + fn copies_missing_proxy_variables_from_the_parent() { + let mut got = inherited_proxy_env( + parent(&[ + ("HTTP_PROXY", "http://127.0.0.1:1"), + ("HTTPS_PROXY", "http://127.0.0.1:1"), + ]), + &[("PATH".into(), "C:\\Windows".into())], + ); + got.sort(); + assert_eq!( + got, + vec![ + ("HTTPS_PROXY".to_string(), "http://127.0.0.1:1".to_string()), + ("HTTP_PROXY".to_string(), "http://127.0.0.1:1".to_string()), + ] + ); + } + + #[test] + fn launch_request_values_win_case_insensitively() { + let got = inherited_proxy_env( + parent(&[("HTTP_PROXY", "http://parent"), ("HTTPS_PROXY", "http://parent")]), + &[("http_proxy".into(), "http://driver".into())], + ); + assert_eq!( + got, + vec![("HTTPS_PROXY".to_string(), "http://parent".to_string())] + ); + } + + #[test] + fn never_inherits_no_proxy_or_empty_values() { + let got = inherited_proxy_env( + parent(&[ + ("NO_PROXY", "*"), + ("no_proxy", "*"), + ("HTTP_PROXY", ""), + ("ALL_PROXY", "http://x"), + ]), + &[], + ); + assert!(got.is_empty()); + } +} + #[cfg(test)] mod forward_connect_tests { use super::connect_forward_target; From c44279ab9899c1e407a18bb3cadac738c339831a Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Thu, 8 Oct 2026 17:19:09 -0700 Subject: [PATCH 36/39] fix(mxc): make proxy-peer mode egress-only Signed-off-by: Shailendra Singh --- .../build-openshell-mxc-windows/SKILL.md | 5 +- CONTRIBUTING.md | 7 +- architecture/windows.md | 57 ++-- crates/openshell-driver-mxc/README.md | 43 +-- .../examples/README-openclaw-forward.txt | 40 +-- .../examples/e2e-policies/ws-agent.yaml | 8 +- .../examples/mxc-openclaw-gateway.toml | 14 +- .../examples/mxc-ws-gateway.toml | 15 +- .../examples/run-openclaw-forward-mock.ps1 | 1 + .../examples/run-openclaw-forward-test.ps1 | 16 +- .../examples/run-ws-agent-test.ps1 | 19 +- .../qualification/README.md | 11 +- .../qualification/gb300-woa.json | 9 +- .../qualification/run-gb300-woa.ps1 | 2 +- .../src/bin/openshell-mxc-peer.rs | 118 ++------- crates/openshell-driver-mxc/src/driver.rs | 145 ++++++---- crates/openshell-driver-mxc/src/mxc.rs | 26 +- crates/openshell-driver-mxc/src/peer.rs | 171 +++++------- crates/openshell-driver-mxc/src/relay.rs | 248 +++--------------- .../tests/demo_examples.rs | 48 +++- crates/openshell-gateway/src/lib.rs | 12 +- crates/openshell-server/src/grpc/sandbox.rs | 11 +- crates/openshell-server/src/lib.rs | 16 +- .../src/windows_process.rs | 5 +- crates/openshell-supervisor-relay/src/imp.rs | 5 +- docs/reference/gateway-config.mdx | 28 +- tasks/scripts/windows-msvc.ps1 | 4 +- 27 files changed, 501 insertions(+), 583 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 6b6820e1ea..6eb3d13659 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -18,6 +18,7 @@ supported deliverables: - `openshell-gateway.exe` - `openshell.exe` - `openshell-supervisor-relay.exe` (Windows-only MXC workload relay) +- `openshell-mxc-peer.exe` (Windows-only identity-scoped MXC proxy peer) It intentionally does not make Windows a Docker, Kubernetes, Podman, or VM runtime host. @@ -299,8 +300,8 @@ 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`, `openshell.exe`, and `openshell-supervisor-relay.exe` for x64. | -| `windows:build:arm64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, and `openshell-supervisor-relay.exe` for ARM64. | +| `windows:build:x64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, `openshell-supervisor-relay.exe`, and `openshell-mxc-peer.exe` for x64. | +| `windows:build:arm64` | Release-builds `openshell-gateway.exe`, `openshell.exe`, `openshell-supervisor-relay.exe`, and `openshell-mxc-peer.exe` 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. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74beb5c6fe..4fd5cb4a6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -365,8 +365,9 @@ cargo build -p openshell-prover --target x86_64-pc-windows-msvc --features bundl Use the platform-native `windows:*` mise tasks for Windows MSVC development. The lane supports x64 and ARM64 and builds `openshell-gateway.exe`, -`openshell.exe`, and `openshell-supervisor-relay.exe`. It does not enable -Docker, Kubernetes, Podman, or VM-backed execution on Windows. +`openshell.exe`, `openshell-supervisor-relay.exe`, and +`openshell-mxc-peer.exe`. It does not enable Docker, Kubernetes, Podman, or +VM-backed execution on Windows. Install Visual Studio C++ Build Tools, a compatible Windows SDK, Rust through rustup, mise, and the Visual Studio LLVM tools used by `bindgen`. Test-bearing @@ -383,7 +384,7 @@ install the Windows compiler toolchain. | Task | Purpose | |---|---| | `windows:lint:` | Run Clippy for the Windows-supported workspace on the selected target architecture. | -| `windows:build:` | Build the three Windows release executables. | +| `windows:build:` | Build the four Windows release executables. | | `windows:test:` | Run the workspace suite natively; the target must match the host architecture. | | `windows:test:unsupported:` | Run the focused unsupported-driver contracts. | | `windows:test:mxc-real:` | Run the probe-gated real-`wxc-exec` integration suite on the matching host. | diff --git a/architecture/windows.md b/architecture/windows.md index 17646b66ac..a87106cbf7 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -39,6 +39,7 @@ flowchart LR subgraph MXC["MXC isolation boundary"] PC["ProcessContainer"] + PEER["Per-sandbox proxy peer
(separate AppContainer identity)"] ISO["IsolationSession"] RELAY["openshell-supervisor-relay"] AGENT["Agent process"] @@ -51,7 +52,10 @@ flowchart LR WXC --> PC WXC --> ISO - PC -. "ProcessContainer governed egress only" .-> PROXY + PC -. "default governed egress:
host loopback" .-> PROXY + PC -->|"proxy-peer mode:
MXC networkProxy / allowedProxyPeer"| PEER + PEER -->|"identity-restricted
egress pipe"| DRV + DRV -. "policy-governed egress" .-> PROXY DRV <-->|"inherited stdin/stdout control channel"| RELAY WXC -. "OS Sandboxing events" .-> ETW ``` @@ -82,7 +86,7 @@ onto the agent user's default environment. | Filesystem | Read-only/read-write grants with default-deny behavior | OpenShell filesystem-policy grants are unsupported; MXC rejects non-empty read-only/read-write grants | | Portable UI policy | Supported completely | Every explicit `ui` section is rejected before provisioning | | Governed network policy | Supported through the host proxy when enabled | Rejected because the backend cannot enforce the loopback-only proxy path; without an explicit network policy, the backend retains MXC's default-allow egress | -| Supervisor relay and dynamic forwarding | Optional | Optional | +| Supervisor relay and dynamic forwarding | Optional in the default network mode; dynamic forwarding is rejected in proxy-peer mode | Optional | Neither backend implements interactive `sandbox connect` or interactive exec through the standard supervisor protocol. The MXC `StartSandbox` operation is @@ -119,7 +123,7 @@ The production mapping in | Policy area | Windows enforcement | |---|---| | Filesystem | On ProcessContainer, `read_only` and `read_write` become MXC path grants, and `include_workdir` adds the resolved working directory as read-write. The mapper normalizes separators but does not translate Linux-rooted locations into Windows paths. ProcessContainer supplies the default-deny boundary. IsolationSession cannot accept non-empty filesystem grants; without them, its backend defaults determine filesystem visibility. | -| Network | An explicit network policy requires governed egress on ProcessContainer. The mapper gives MXC loopback-only egress and returns the complete network policy to a per-sandbox host CONNECT proxy. This path requires MXC's `baseContainerSupportsIngressHostLoopbackAllow` host capability; fallback tiers without it can run ordinary ProcessContainer workloads but cannot reach the host proxy. MXC denies direct Internet access; the proxy evaluates destinations, ports, TLS/L7 rules, credential bindings, and binary rules against the configured agent command as its static process identity. Network middleware configuration is rejected because the host proxy does not receive the gateway middleware registry. IsolationSession rejects explicit network policy; without one, it retains MXC's default-allow egress. | +| Network | An explicit network policy requires governed egress on ProcessContainer. The default mode gives MXC loopback-only egress and returns the complete network policy to a per-sandbox host CONNECT proxy; it requires MXC's `baseContainerSupportsIngressHostLoopbackAllow` host capability and also exposes unrelated host-loopback listeners. Proxy-peer mode instead denies host loopback and direct egress, then uses MXC `runtimeConfig.networkProxy` and a per-sandbox `allowedProxyPeer` AppContainer identity to reach only the OpenShell peer. MXC requires `ingress.default = allow` for this arrangement, so private-network clients can still reach servers in the target sandbox. The host proxy evaluates destinations, ports, TLS/L7 rules, credential bindings, and binary rules. Network middleware configuration is rejected because the host proxy does not receive the gateway middleware registry. IsolationSession rejects explicit network policy; without one, it retains MXC's default-allow egress. | | UI | ProcessContainer maps graphical UI, directional clipboard access, and input injection into MXC's top-level `ui` object. An absent section maps to the restrictive UI posture. Within an explicit section, omitted fields deny. IsolationSession rejects even an empty explicit section. | | Process | MXC supplies the Windows process-isolation boundary, but the mapper has no portable equivalent for `run_as_user` or `run_as_group`; callers must not treat those fields as enforced Windows identity controls. The canonical command, environment, and working directory are launch inputs rather than process-policy grants. | | Landlock | MXC has no equivalent for the Linux Landlock compatibility mode, including `hard_requirement`. The mapper reports a non-blocking warning; Windows filesystem assurance comes from the selected MXC backend's native semantics, not Landlock. | @@ -138,18 +142,32 @@ changes require sandbox recreation. ## Governed Egress and Credentials Governed egress uses a split enforcement model. MXC blocks direct Internet -traffic and permits host loopback; a unique authenticated proxy listener holds -the full OpenShell network policy. The driver injects standard proxy variables -for proxy-aware clients and stages only public CA certificates under an -authorized sandbox path. The ephemeral CA private key remains in host-proxy -memory. +traffic, while a unique host CONNECT proxy holds the full OpenShell network +policy. The driver stages only public CA certificates under an authorized +sandbox path. The ephemeral CA private key remains in host-proxy memory. -The loopback grant is broader than the proxy listener: a governed sandbox can +The default mode permits host loopback and injects standard proxy variables. +That loopback grant is broader than the proxy listener: a governed sandbox can reach other host services bound to loopback. Per-sandbox proxy credentials prevent another sandbox from using the OpenShell proxy, but they do not isolate unrelated host-loopback services or authenticate individual processes within one sandbox. +Proxy-peer mode narrows that exception. The driver starts +`openshell-mxc-peer.exe` under a per-sandbox AppContainer identity, names that +identity as MXC's `allowedProxyPeer`, sets MXC's `networkProxy`, and keeps +`hostLoopback` and direct egress denied. Governed egress crosses an +identity-restricted named pipe between the gateway and the peer. The peer is +only an egress byte tunnel; policy evaluation, TLS interception, credentials, +and process attribution remain in the gateway. This is not complete inbound +network isolation: MXC requires `ingress.default = allow` for the target +ProcessContainer, which enables private-network server traffic, but MXC does +not provide the gateway or the peer with an identity-scoped reverse path to the +workload. The driver therefore rejects dynamic forwarding while proxy-peer mode +is enabled. The peer replaces broad sandbox-to-host-loopback access with an +identified egress connection, but operators must still accept the remaining +private-network ingress surface. + Two gateway-wide ProcessContainer compatibility settings can deliberately broaden this boundary. `pc_network_allow` permits unrestricted outbound TCP, and `pc_allow_local_network` enables MXC local-network access. These are operator @@ -184,16 +202,20 @@ the command and environment after the relay announces readiness, and waits for the configured target port before publishing runtime readiness. Dynamic `openshell forward service` requests use a dedicated MXC path because -there is no `ConnectSupervisor` session. The gateway calls the driver's -`ForwardSink`; the driver creates an authenticated host-loopback listener and -multiplexes `forward_open`, `forward_read`, `forward_write`, and +there is no `ConnectSupervisor` session. In the default mode, the gateway calls +the driver's `ForwardSink`; the driver creates an authenticated host-loopback +listener and multiplexes `forward_open`, `forward_read`, `forward_write`, and `forward_close` operations over the inherited control channel. A fresh nonce -authenticates each host-side forward. This capability does not add interactive -shell or general exec support. +authenticates each gateway-side forward listener. In proxy-peer mode, MXC denies +the sandbox-local target connection and does not expose an identity-scoped +reverse connection from the gateway or peer to the workload, so the driver +rejects the forward request before opening a listener. Neither mode adds +interactive shell or general exec support. The implementation boundary spans `crates/openshell-driver-mxc/src/control_channel.rs`, -`crates/openshell-driver-mxc/src/relay.rs`, and +`crates/openshell-driver-mxc/src/relay.rs`, +`crates/openshell-driver-mxc/src/peer.rs`, and `crates/openshell-supervisor-relay/`. ## Readiness, Lifecycle, and Persistence @@ -252,8 +274,9 @@ requests and `agent_socket_path` because it has neither GPU integration nor the standard in-sandbox supervisor. The Windows release-profile build lane produces `openshell-gateway.exe`, -`openshell.exe`, and `openshell-supervisor-relay.exe` for x64 and ARM64 as -validation artifacts; the hosted workflow does not publish them. An x64 host +`openshell.exe`, `openshell-supervisor-relay.exe`, and +`openshell-mxc-peer.exe` for x64 and ARM64 as validation artifacts; the hosted +workflow does not publish them. An x64 host can cross-check and cross-build ARM64, but native runtime tests and MXC qualification must execute on the matching architecture. See [Windows build and validation](../CONTRIBUTING.md#windows-build-and-validation) diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 6979f9bd46..0636b6dc62 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -13,9 +13,10 @@ effective `SandboxPolicy` and carries it on the driver-only copy of 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 and self-reports -readiness. Optional `openshell-supervisor-relay` wrapping provides launch, -shutdown, and dynamic forwarding over an inherited stdin/stdout control channel; -it does not implement the Linux `ConnectSupervisor` protocol. +readiness. Optional `openshell-supervisor-relay` wrapping provides launch and +shutdown control. It also carries dynamic forwarding in the default network +mode. Dynamic forwarding is unavailable in proxy-peer mode. The relay does not +implement the Linux `ConnectSupervisor` protocol. ## Capability Matrix @@ -23,11 +24,11 @@ it does not implement the Linux `ConnectSupervisor` protocol. |---|---| | Filesystem policy | Read-only/read-write grants come only from `SandboxPolicy`. `process_container` enforces them with default-deny behavior. `isolation_session` does not support OpenShell filesystem-policy grants; MXC rejects non-empty grants during provisioning. | | UI policy | `process_container` advertises complete support and maps portable graphical UI, clipboard-direction, and input-injection controls to MXC; omitted fields inside an explicit section deny. `isolation_session` advertises no support, so the gateway rejects any explicit section before provisioning. | -| Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates MXC 1.0 loopback-only egress plus the full policy enforced by a per-sandbox OpenShell host CONNECT proxy. The driver injects proxy environment variables for proxy-aware clients; direct Internet access remains denied by MXC. A policy without network rules does not activate the proxy. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | +| Network policy | With `egress_proxy = true` on `process_container`, an explicit `network_policies` rule activates the full policy in a per-sandbox OpenShell host CONNECT proxy. The default mode uses MXC loopback-only egress. Optional proxy-peer mode keeps host loopback denied and admits only a per-sandbox AppContainer peer, at the cost of MXC-required private-network server ingress. Direct Internet access remains denied by MXC. A policy without network rules does not activate governed egress. Otherwise rejected synchronously. `isolation_session` remains fail-closed. | | Provider credentials | The child receives revision-scoped placeholders and non-secret provider environment only. The per-sandbox host proxy retains the resolver and substitutes credentials only for their bound endpoints. | | Process policy | Unsupported; MXC supplies OS isolation only. | | Resource limits | Unsupported; MXC exposes no CPU rate control or memory limiting to non-WSLC backends. `CreateSandbox` rejects any request carrying `cpu_limit`, `cpu_request`, `memory_limit`, or `memory_request` synchronously rather than silently discarding them. | -| Dynamic forwarding | Supported through `openshell-supervisor-relay`; interactive exec/connect remain unsupported. | +| Dynamic forwarding | The default network mode uses `openshell-supervisor-relay`; proxy-peer mode rejects forwarding because MXC provides no identity-scoped reverse path to the workload. Interactive exec/connect remain unsupported. | | Network middleware | Rejected before launch until the host proxy receives the gateway middleware registry. | | ETW/OCSF audit | Optional Windows Sandboxing ETW consumer attributes host events to OpenShell sandboxes and emits OCSF records. | | Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | @@ -107,19 +108,18 @@ By default a `process_container` sandbox gets `hostLoopback: allow` plus an `egress.allow` rule for `127.0.0.1/32`, so the sandbox can reach the host relay and the host egress proxy, but also any other service bound to host loopback. -Setting `pc_proxy_peer_path` switches the sandbox to MXC's strict model instead: +Setting `pc_proxy_peer_path` switches the sandbox to the identity-scoped model: `runtimeConfig.networkProxy` + `processContainer.network.allowedProxyPeer` with -`hostLoopback: deny` (MXC schema `1.0.0`). The sandbox can then reach only -its proxy endpoint, and only one `AppContainer` identity may talk to its -listeners. The driver provides both from one helper process, `openshell-mxc-peer.exe`, -which it starts for every sandbox under a per-sandbox `AppContainer` profile -(`openshell-mxc-`); the profile is the `allowedProxyPeer`: +`hostLoopback: deny` (MXC schema `1.0.0`). Direct sandbox egress can then reach +only its proxy endpoint. The driver provides that endpoint from +`openshell-mxc-peer.exe`, which it starts for every sandbox under a per-sandbox +`AppContainer` profile (`openshell-mxc-`); the profile is the +`allowedProxyPeer`: ```text -forward: host client -> gateway forward listener -> named pipe -> peer - -> 127.0.0.1: inside the sandbox -egress: sandbox (HTTP(S)_PROXY) -> peer proxy listener -> named pipe +egress: sandbox (HTTP(S)_PROXY) -> MXC networkProxy -> peer -> named pipe -> gateway -> per-sandbox host egress proxy (OpenShell policy) +forward: unsupported in proxy-peer mode ``` - In this mode the driver sets no `HTTP_PROXY`/`HTTPS_PROXY`. MXC puts its @@ -134,7 +134,7 @@ egress: sandbox (HTTP(S)_PROXY) -> peer proxy listener -> named pipe Only the gateway can mark a connection as tunnelled, and only the peer can open the pipe. Direct connections to the host proxy still need the password. -- The pipes are created with a DACL that admits only the gateway's user and the +- The peer pipes are created with a DACL that admits only the gateway's user and the peer's profile SID. The peer is a byte tunnel only; policy decisions, TLS interception and credential substitution stay in the gateway's host proxy. - Per-process binary rules still apply: the peer reports the sandbox client's @@ -142,10 +142,17 @@ egress: sandbox (HTTP(S)_PROXY) -> peer proxy listener -> named pipe of the original sandbox socket rather than the gateway's bridge socket. - Without `egress_proxy`, or for a policy with no network rules, the peer's proxy answers `403` and the sandbox has no egress. +- MXC requires `ingress.default = allow` for the target ProcessContainer in + this mode. That enables the `privateNetworkClientServer` capability and means + private-network clients can reach servers bound by the sandbox. It does not + provide the gateway or peer with an identity-scoped reverse path to the + workload. `openshell forward service` therefore fails before opening a + listener. The peer removes broad sandbox access to arbitrary host-loopback + listeners; it does not provide private-network ingress isolation. - `openshell-mxc-peer.exe` must be readable and executable by "ALL APPLICATION PACKAGES" (for example `icacls /grant *S-1-15-2-1:(OI)(CI)(RX)`). -- Requires `pc_relay_spawner_path` / `pc_relay_target_port` and the `process_container` - backend, and cannot be combined with `pc_network_allow` or `pc_allow_local_network`. +- Requires the `process_container` backend and cannot be combined with + `pc_network_allow` or `pc_allow_local_network`. - The peer and its profile are removed when the sandbox is deleted. If the gateway is killed, the peer exits when its control pipe closes, but its profile stays registered until it is removed by hand (`DeleteAppContainerProfile`). @@ -162,7 +169,7 @@ The `command` array is required and preserves Windows argument boundaries. `cwd` UI capability (Win32k syscalls, clipboard, input injection) is a `SandboxPolicy` concern, not gateway TOML -- see the Capability Matrix above and `docs/reference/policy-schema.mdx`'s `ui` section. Defaults to disabled (Win32k syscall lockdown) when a policy has no explicit `ui:` section; set `allow_graphical_ui: true` for agents that touch user32/gdi32 at startup even without opening a real window (e.g. Node.js-based targets like OpenClaw's gateway -- see `examples/e2e-policies/openclaw-gateway.yaml`). -`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: for a sandbox policy with explicit network rules, the driver reserves a unique ephemeral loopback port. MXC 1.0 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. A policy without network rules keeps MXC's default network posture and receives neither a host listener nor proxy environment variables. The current governed-egress policy permits all loopback ports, so governed sandboxes can also reach unrelated host services bound to loopback. Control-channel forwarding does not require the legacy reverse-WebSocket connections to fresh host ports; restricting the generated policy is separate hardening work. Do not treat this path as loopback-service isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. +`egress_proxy_addr` must be a `127.0.0.1:PORT` address. The port acts only as a configuration seed: for a sandbox policy with explicit network rules, the driver reserves a unique ephemeral loopback port. MXC 1.0 denies direct Internet egress and permits `127.0.0.1/32`; the driver points proxy-aware clients at the per-sandbox listener using environment variables. A policy without network rules keeps MXC's default network posture and receives neither a host listener nor proxy environment variables. The current default-mode governed-egress policy permits all loopback ports, so governed sandboxes can also reach unrelated host services bound to loopback. Proxy-peer mode removes that outbound loopback grant but disables dynamic forwarding. Do not treat either path as complete network isolation. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. When `etw_audit` is enabled, each gateway process owns a distinct real-time ETW session named from the stable `OpenShell-MXC-ETW` prefix, its process ID, and a diff --git a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt index 84d41bbf73..cbfcb3e14e 100644 --- a/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt +++ b/crates/openshell-driver-mxc/examples/README-openclaw-forward.txt @@ -3,13 +3,13 @@ OpenShell MXC - OpenClaw + dynamic forward test (both backends) WHAT THIS PROVES The full path for reaching a service inside an MXC sandbox that has NO - in-sandbox supervisor process (ProcessContainer additionally has NO - inbound network capability at all): + in-sandbox supervisor process. This forwarding test uses the default network + mode; MXC 1.0 proxy-peer mode is egress-only and rejects dynamic forwarding: gateway -> MXC driver -> ProcessContainer OR isolation_session sandbox -> openshell-supervisor-relay launches OpenClaw's gateway inside it, with no relay awareness in OpenClaw itself -> `openshell forward service --target-port 18889` opens a fresh, - on-demand WebSocket relay for THIS call only (nothing pre-declared + on-demand authenticated relay for THIS call only (nothing pre-declared in the config beyond the startup liveness port; the relay is torn down when the forward ends) -> a real OpenClaw client on the HOST, talking only through that @@ -26,20 +26,12 @@ WHAT THIS PROVES what else differs (ProcessContainer-only fields it ignores entirely). PREREQUISITES (on this test box) - - An ELEVATED (Administrator) PowerShell session, for -Backend - process_container specifically. On this box's wxc-exec build, - process_container falls back to an "AppContainer + DACL" isolation - tier that needs two privileged operations: (1) WRITE_DAC on share_dir - to stamp the AppContainer's ACL -- fixable non-elevated if you own - share_dir yourself (first run wins ownership; icacls /setowner fixes a - folder an earlier elevated run left owned by Administrators), but - (2) with egress_proxy = true (mxc-openclaw-gateway.toml's default), - wxc-exec also calls NetworkIsolationSetAppContainerConfig to grant the - AppContainer a loopback exemption so it can reach the host's egress - proxy -- that Windows API requires Administrator regardless of file - ownership. Non-elevated fails both with ERROR_ACCESS_DENIED (0x5), the - second as "Network proxy error: Failed to set loopback exemption: - 0x00000005". -Backend isolation_session does not hit either path. + - An ELEVATED (Administrator) PowerShell session may be required for + -Backend process_container on hosts whose probe selects the + "AppContainer + DACL" isolation tier. The runner must update ACLs on the + staged files. A directory owned by an + earlier elevated run can also require ownership repair before a + non-elevated rerun. -Backend isolation_session does not use the proxy peer. - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe). Use a standalone copy in a regular directory, not a copy inside an installed MSIX or WindowsApps package directory. If CreateSandbox reports @@ -125,6 +117,10 @@ FILES IN THIS PACKAGE launches inside the sandbox in place of OpenClaw directly (OpenClaw itself has no relay awareness) + openshell-mxc-peer.exe optional separate AppContainer helper used as + MXC's per-sandbox allowedProxyPeer for + governed egress; not used by this forwarding + test because peer mode rejects forwarding openclaw-capture.mjs thin Node.js wrapper that appends the sandboxed process's stdout/stderr to a log file in share_dir and records only the @@ -149,8 +145,8 @@ NOTES the in-process wxc shim. It needs no Node.js or OpenClaw install and does not test the relay, WebSocket forwarding, OpenClaw, proxy behavior, or MXC. - The control plane between CLI and gateway runs with --disable-tls on - loopback (that's a separate test point, T2). This test's relay traffic - (host <-> sandbox) is a separate, unrelated WebSocket tunnel. + loopback (that's a separate test point, T2). The forwarded application + traffic uses the driver's separate authenticated relay. - A "supervisor session not connected" / ssh 255 message during sandbox create is EXPECTED on MXC and harmless - the agent already ran in-driver. - `pc_minimal_env = true` in mxc-openclaw-gateway.toml (process_container @@ -161,3 +157,9 @@ NOTES - The relay is entirely on-demand: nothing is listening on any fixed host port before you run `openshell forward service`, and nothing is left listening after the forward process exits. + - MXC 1.0 dynamic forwarding requires the default mode's broad private-network + ingress and host-loopback posture. PASS does not claim sandbox-local-only + networking or private-network ingress isolation. + - Proxy-peer mode denies the target workload direct Internet and arbitrary + host-loopback access, but MXC 1.0 supplies no identity-scoped reverse path + for dynamic forwarding. This example leaves `pc_proxy_peer_path` empty. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml index 683a8c0c66..6491365db5 100644 --- a/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml +++ b/crates/openshell-driver-mxc/examples/e2e-policies/ws-agent.yaml @@ -10,8 +10,8 @@ # system paths and share_dir). # - TCP socket binding on port 22000 (governed by pc_capabilities in the # gateway TOML, not by filesystem policy here). -# - Private/loopback client access for openshell-supervisor-relay to dial -# the driver's on-demand relay — granted by pc_capabilities in the TOML. +# - Private/loopback client access for openshell-supervisor-relay to dial the +# sandbox-local target — granted by pc_allow_local_network in the TOML. # - No writes to the host filesystem. # # workload directory (passed by run-ws-agent-test.ps1, default C:\work\openshell-mxc-ws) @@ -25,7 +25,9 @@ # # This example intentionally omits network_policies because it does not need # governed Internet egress. The driver therefore does not start a host CONNECT -# proxy or inject proxy environment variables for this sandbox. +# proxy or inject proxy environment variables for this sandbox. The gateway +# TOML explicitly accepts MXC's broader private-network ingress and host-loopback +# posture because MXC 1.0 cannot provide sandbox-local-only forwarding. version: 1 filesystem_policy: diff --git a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml index f5885accda..18068de520 100644 --- a/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-openclaw-gateway.toml @@ -6,8 +6,9 @@ # Runs OpenClaw's gateway inside an MXC ProcessContainer, with its port # reachable from the host via dynamic `openshell forward service` bridging # (openshell-supervisor-relay.exe + the driver's on-demand relay -- see -# crates/openshell-driver-mxc/src/relay.rs). There is no static/always-on -# bridge: every `forward service` call opens its own short-lived relay. +# crates/openshell-driver-mxc/src/relay.rs). +# There is no static/always-on bridge: every `forward service` call opens its +# own short-lived relay. # # Driven by run-openclaw-forward-test.ps1, which patches wxc_exec_path and # stages node.exe / openclaw-capture.mjs / openshell-supervisor-relay.exe / @@ -49,9 +50,14 @@ pc_minimal_env = true # --port above: used as an early liveness check (does the target ever bind # it?) and as the target port openshell-supervisor-relay bridges by default. # There is no static bridge -- relay bridging is entirely on-demand via -# ForwardSink::open_dynamic_forward / the control channel's "forward" op, -# driven by `openshell forward service --target-port 18889`. +# ForwardSink::open_dynamic_forward, driven by +# `openshell forward service --target-port 18889`. pc_relay_spawner_path = "C:/openshell-openclaw/openshell-supervisor-relay.exe" pc_relay_target_port = 18889 +# Proxy-peer mode is egress-only under MXC 1.0 and therefore cannot be combined +# with this dynamic-forwarding demonstration. Set this only for workloads that +# do not need `openshell forward service`. +pc_proxy_peer_path = "" + debug = true diff --git a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml index 5f534bc48c..52e1b698cf 100644 --- a/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml +++ b/crates/openshell-driver-mxc/examples/mxc-ws-gateway.toml @@ -18,8 +18,8 @@ # Wrap the sandbox command in openshell-supervisor-relay.exe # instead of launching it directly (see driver.rs's # launch handshake). This is what gives the driver a -# control channel into the sandbox, which dynamic -# forwarding depends on. +# control channel for launch, readiness, shutdown, and +# dynamic forwarding. # # Connectivity: entirely on-demand via `openshell forward service # --target-port 22000` (ForwardSink::open_dynamic_forward / the control @@ -51,11 +51,17 @@ pc_capabilities = ["privateNetworkClientServer"] # process_container only: keep standard privilege level (not LPA). pc_least_privilege = false -# No governed Internet egress is needed. The relay reaches the driver's -# on-demand private-interface listener through privateNetworkClientServer above. +# No governed Internet egress is needed. This forwarding test deliberately +# leaves proxy-peer mode disabled because MXC 1.0 proxy peers are egress-only. egress_proxy = false egress_proxy_addr = "" +# MXC 1.0 requires the broad local-network posture for the in-sandbox relay to +# dial the workload's loopback listener. This also permits private-network +# server ingress and host-loopback access; the test documents that residual +# exposure instead of claiming sandbox-local-only networking. +pc_allow_local_network = true + # The workload command and cwd are supplied per sandbox by # run-ws-agent-test.ps1 through `sandbox create --driver-config-json`. @@ -63,6 +69,7 @@ egress_proxy_addr = "" # Patched at runtime by run-ws-agent-test.ps1. pc_relay_spawner_path = "C:/work/openshell-mxc-ws/openshell-supervisor-relay.exe" pc_relay_target_port = 22000 +pc_proxy_peer_path = "" # Enable for verbose wxc-exec output during debugging. debug = true diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-mock.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-mock.ps1 index 4ccc91032a..9462f6f118 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-mock.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-mock.ps1 @@ -98,6 +98,7 @@ try { $toml = [regex]::Replace($toml, '(?m)^wxc_exec_path\s*=.*$', "wxc_exec_path = `"$mockWxc`"") $toml = $toml.Replace('C:/openshell-openclaw', $shareFwd) $toml = [regex]::Replace($toml, '(?m)^pc_relay_spawner_path\s*=.*$', 'pc_relay_spawner_path = ""') + $toml = [regex]::Replace($toml, '(?m)^pc_proxy_peer_path\s*=.*$', 'pc_proxy_peer_path = ""') $tomlUsed = Join-Path $resultDir "mxc-openclaw-gateway.used.toml" [System.IO.File]::WriteAllText($tomlUsed, $toml, [System.Text.UTF8Encoding]::new($false)) diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index 73edf045af..e94cef0be7 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -5,8 +5,7 @@ # # Proves the full path this package exists to demonstrate: # gateway -> MXC driver -> ProcessContainer OR isolation_session sandbox -# (neither has an in-sandbox supervisor process; ProcessContainer also -# has no inbound network capability at all) +# (neither has an in-sandbox supervisor process) # -> openshell-supervisor-relay launches OpenClaw's gateway inside it # -> `openshell forward service --target-port 18889` opens a per-request, # on-demand WebSocket relay (bound fresh for this call, torn down when @@ -30,7 +29,8 @@ # only read paths under share_dir, so this script STAGES (copies) your # node.exe, the openclaw package, and this package's own # openclaw-capture.mjs / openshell-supervisor-relay.exe into share_dir before -# creating the sandbox -- see the "Stage artifacts" step below. The OpenClaw +# creating the sandbox -- see the +# "Stage artifacts" step below. The OpenClaw # package can be large (native-addon plugins etc.); the copy uses robocopy # and only re-copies changed files on a rerun. # @@ -392,7 +392,6 @@ try { Info "staged openclaw-capture.mjs" Copy-ItemRetry $relayExe (Join-Path $shareDirNorm "openshell-supervisor-relay.exe") Info "staged openshell-supervisor-relay.exe" - New-Item -ItemType Directory -Force $openClawStageDir | Out-Null # /MIR deletes files in the destination not present in the source, which # is what we want on a rerun after an OpenClaw upgrade/rollback -- without @@ -765,8 +764,7 @@ openclaw_install : $OpenClawInstallDir target_self_probe : $selfProbeOutcome ($selfProbeResponseBytes response bytes; diagnostic only) What PASS means: the gateway created a sandbox on the $Backend backend (no -in-sandbox supervisor process; ProcessContainer also has no inbound network -capability at all); openshell-supervisor-relay launched OpenClaw's gateway +in-sandbox supervisor process); openshell-supervisor-relay launched OpenClaw's gateway inside it via the driver's control channel; `openshell forward service` opened a fresh, on-demand WebSocket relay for this one call (nothing pre-declared beyond the startup liveness port); and a @@ -774,8 +772,10 @@ REAL OpenClaw client running on this host, talking only through that forwarded port, authenticated with a token and got back a real 'ok: true' health response. The egress proof also required an allowed HTTPS request to pass through the OpenShell proxy while a denied host and direct Internet -bypass were blocked. Host loopback remains broadly reachable because dynamic -forwarding uses ephemeral loopback ports. +bypass were blocked. Host loopback remains broadly reachable in the default +ProcessContainer mode because dynamic forwarding needs MXC's bidirectional +loopback posture. Proxy-peer mode removes that exposure but does not support +dynamic forwarding under MXC 1.0, so this forwarding test leaves it disabled. Files in this bundle: transcript.txt full console transcript diff --git a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 index 3d2254c496..9cbb0982f1 100644 --- a/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ws-agent-test.ps1 @@ -222,7 +222,7 @@ function Start-Gw { ForEach-Object { Info $_ } throw "gateway exited early (code $($p.ExitCode)). See $gwLog." } - if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + if ([System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners().Port -contains $Port) { return $p } Start-Sleep -Milliseconds 400 @@ -268,7 +268,8 @@ function Register-Cli { function Wait-PortOpen([int]$port, [int]$seconds) { $deadline = (Get-Date).AddSeconds($seconds) while ((Get-Date) -lt $deadline) { - if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { + $listeners = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners() + if ($listeners.Port -contains $port) { return $true } Start-Sleep -Milliseconds 500 @@ -279,7 +280,8 @@ function Wait-PortOpen([int]$port, [int]$seconds) { function Wait-PortClosed([int]$port, [int]$seconds) { $deadline = (Get-Date).AddSeconds($seconds) while ((Get-Date) -lt $deadline) { - if (-not (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue)) { + $listeners = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners() + if ($listeners.Port -notcontains $port) { return $true } Start-Sleep -Milliseconds 500 @@ -352,7 +354,6 @@ function Render-Toml { } $relayExeFwd = Fwd $relayExe - $t = [regex]::Replace($t, '(?m)^\s*#?\s*pc_relay_spawner_path\s*=.*$', "pc_relay_spawner_path = `"$relayExeFwd`"") @@ -425,11 +426,9 @@ try { Ok "wxc-exec: $WxcExecPath" # A real run exercises process_container with egress_proxy disabled - # (mxc-ws-gateway.toml). The sandbox connects directly to the driver's - # route-selected private-interface relay listener through the - # privateNetworkClientServer capability; the governed host CONNECT - # proxy is not part of this qualification path. Elevation is not - # required here; keep logging the elevation state for diagnostics only. + # (mxc-ws-gateway.toml). The sandbox reaches its loopback target over + # the spawner control channel; proxy-peer mode is intentionally disabled + # because MXC 1.0 proxy peers do not support dynamic forwarding. $wid = [Security.Principal.WindowsIdentity]::GetCurrent() $wp = New-Object Security.Principal.WindowsPrincipal($wid) $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) @@ -638,7 +637,7 @@ try { # log line can land a couple of seconds after the raw TCP connect # already succeeded (observed up to ~2.3s). Poll for it rather # than checking once immediately, or this races and fails spuriously. - $readyPattern = "port $WsPort ready after" + $readyPattern = "host confirmed target listener on port $WsPort" $readyDeadline = (Get-Date).AddSeconds(15) $readyOk = $false while ((Get-Date) -lt $readyDeadline -and -not $readyOk) { diff --git a/crates/openshell-driver-mxc/qualification/README.md b/crates/openshell-driver-mxc/qualification/README.md index 7397fc2dc3..8c46fbd149 100644 --- a/crates/openshell-driver-mxc/qualification/README.md +++ b/crates/openshell-driver-mxc/qualification/README.md @@ -79,8 +79,9 @@ stages only tracked examples plus freshly built ARM64 binaries. It never treats an existing source tree or an earlier result bundle as current evidence. The evidence directory contains environment/source provenance, the MXC host -probe, command logs with timings, ARM64 binary hashes, the complete MXC policy -and OpenClaw bundles, `evidence.json`, and the final validation log. Do not put -provider credentials in command lines or retained files. Optional credential -coverage must use a scoped non-production credential and prove that retained -artifacts contain no secret value. +probe, command logs with timings, ARM64 hashes for the gateway, CLI, supervisor +relay, proxy peer, and Z3 runtime, the complete MXC policy and OpenClaw bundles, +`evidence.json`, and the final validation log. Do not put provider credentials +in command lines or retained files. Optional credential coverage must use a +scoped non-production credential and prove that retained artifacts contain no +secret value. diff --git a/crates/openshell-driver-mxc/qualification/gb300-woa.json b/crates/openshell-driver-mxc/qualification/gb300-woa.json index 63ba49e8f4..d5444f18a8 100644 --- a/crates/openshell-driver-mxc/qualification/gb300-woa.json +++ b/crates/openshell-driver-mxc/qualification/gb300-woa.json @@ -179,7 +179,7 @@ ], "pass_criteria": [ "Release build exits 0", - "openshell-gateway.exe, openshell.exe, openshell-supervisor-relay.exe, and libz3.dll exist for aarch64-pc-windows-msvc", + "openshell-gateway.exe, openshell.exe, openshell-supervisor-relay.exe, openshell-mxc-peer.exe, and libz3.dll exist for aarch64-pc-windows-msvc", "Size and SHA256 are recorded for every artifact" ], "artifact_roles": [ @@ -309,7 +309,8 @@ "node.exe PE machine is ARM64", "OpenClaw package version is captured", "Sandbox reaches ready and the authenticated host-side OpenClaw client returns ok=true through openshell forward service", - "Allowed HTTPS succeeds while denied host and direct Internet bypass remain blocked" + "Allowed HTTPS succeeds while denied host and direct Internet bypass remain blocked", + "ProcessContainer uses the default forwarding mode; broad private-network ingress and host-loopback reachability are recorded as MXC limitations, and proxy-peer mode is not claimed to support forwarding" ], "artifact_roles": [ "openclaw_forward_log", @@ -523,10 +524,10 @@ ], "commands": [], "pass_criteria": [ - "Dynamic service forwarding is tested without claiming interactive exec/connect support" + "Dynamic service forwarding is tested in the default network mode without claiming proxy-peer forwarding or interactive exec/connect support" ], "artifact_roles": [], - "reason": "The supervisor relay supports launch, shutdown, and dynamic forwarding but not the Linux ConnectSupervisor protocol." + "reason": "The MXC driver supports launch, shutdown, and dynamic service forwarding in the default network mode but not in proxy-peer mode or through the Linux ConnectSupervisor protocol." }, { "id": "nemoclaw-native-x64", diff --git a/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 b/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 index 8823490f1c..a1584622ed 100644 --- a/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 +++ b/crates/openshell-driver-mxc/qualification/run-gb300-woa.ps1 @@ -336,7 +336,7 @@ if ([string]::IsNullOrWhiteSpace($targetRoot)) { } $releaseDir = Join-Path $targetRoot "$RustTarget\release" $binaryRows = @() -foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe", "libz3.dll")) { +foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe", "openshell-mxc-peer.exe", "libz3.dll")) { $path = Join-Path $releaseDir $binary if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required ARM64 release artifact is missing: $path" diff --git a/crates/openshell-driver-mxc/src/bin/openshell-mxc-peer.rs b/crates/openshell-driver-mxc/src/bin/openshell-mxc-peer.rs index fc2d8f32e1..f51fbaea3b 100644 --- a/crates/openshell-driver-mxc/src/bin/openshell-mxc-peer.rs +++ b/crates/openshell-driver-mxc/src/bin/openshell-mxc-peer.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Proxy-peer helper started by the OpenShell MXC driver under a per-sandbox +//! Proxy-peer helper started by the `OpenShell` MXC driver under a per-sandbox //! `AppContainer` profile (the sandbox's `processContainer.network. //! allowedProxyPeer`). See `src/peer.rs` in this crate for the architecture and //! the control protocol; this process is the other side of it. @@ -12,13 +12,12 @@ //! `networkProxy` endpoint. In `egress` mode every connection the sandbox //! opens to it is tunnelled, byte for byte, through the gateway's //! `-egress` pipe to the sandbox's host egress proxy, which applies -//! the OpenShell network policy. In `none` mode (no egress policy) every +//! the `OpenShell` network policy. In `none` mode (no egress policy) every //! request gets `403 Forbidden`, so the sandbox has no network egress. //! 2. Connect to the gateway's control pipe and send `READY `. -//! 3. For every `OPEN ` line, connect to the data pipe for -//! ``, dial `127.0.0.1:` (admitted because this process -//! runs as the sandbox's peer identity), report `OK` or `ERR ` and -//! pump bytes in both directions. +//! 3. Stay alive until the gateway closes the control pipe. Dynamic forwarding +//! is intentionally unsupported in proxy-peer mode because MXC does not admit +//! reverse peer-to-workload connections. //! //! The process exits when the control pipe closes. @@ -41,10 +40,10 @@ mod imp { static LOG: Mutex> = Mutex::new(None); fn log(message: &str) { - if let Ok(mut guard) = LOG.lock() { - if let Some(file) = guard.as_mut() { - let _ = writeln!(file, "{message}"); - } + if let Ok(mut guard) = LOG.lock() + && let Some(file) = guard.as_mut() + { + let _ = writeln!(file, "{message}"); } } @@ -52,10 +51,6 @@ mod imp { format!(r"\\.\pipe\openshell-mxc-{token}-ctl") } - fn data_pipe_name(token: &str, id: u64) -> String { - format!(r"\\.\pipe\openshell-mxc-{token}-d{id}") - } - fn egress_pipe_name(token: &str) -> String { format!(r"\\.\pipe\openshell-mxc-{token}-egress") } @@ -112,14 +107,6 @@ mod imp { } } - /// Parse `OPEN `. - fn parse_open(line: &str) -> Option<(u64, u16)> { - let mut parts = line.strip_prefix("OPEN ")?.split_whitespace(); - let id = parts.next()?.parse().ok()?; - let port = parts.next()?.parse().ok()?; - parts.next().is_none().then_some((id, port)) - } - async fn serve_proxy(listener: TcpListener, token: String, egress: bool) { loop { let Ok((mut stream, _)) = listener.accept().await else { @@ -140,50 +127,14 @@ mod imp { } } - async fn handle_open(token: String, id: u64, port: u16) { - let mut pipe = match ClientOptions::new().open(data_pipe_name(&token, id)) { - Ok(pipe) => pipe, - Err(error) => { - log(&format!("[{id}] data pipe open failed: {error}")); - return; - } - }; - let dial = tokio::time::timeout( - Duration::from_secs(5), - TcpStream::connect(("127.0.0.1", port)), - ) - .await; - let mut tcp = match dial { - Ok(Ok(tcp)) => tcp, - Ok(Err(error)) => { - log(&format!("[{id}] dial 127.0.0.1:{port} failed: {error}")); - let _ = pipe.write_all(format!("ERR {error}\n").as_bytes()).await; - return; - } - Err(_) => { - log(&format!("[{id}] dial 127.0.0.1:{port} timed out")); - let _ = pipe.write_all(b"ERR connect timed out\n").await; - return; - } - }; - let _ = tcp.set_nodelay(true); - if pipe.write_all(b"OK\n").await.is_err() { - return; - } - log(&format!("[{id}] relaying to 127.0.0.1:{port}")); - match tokio::io::copy_bidirectional(&mut pipe, &mut tcp).await { - Ok((to_target, from_target)) => log(&format!( - "[{id}] done: {to_target} bytes pipe->tcp, {from_target} bytes tcp->pipe" - )), - Err(error) => log(&format!("[{id}] relay ended: {error}")), - } - } - pub async fn run() -> i32 { - let mut args = std::env::args().skip(1); - let (Some(token), Some(mode)) = (args.next(), args.next()) else { - eprintln!("usage: openshell-mxc-peer [log-file]"); - return 2; + let (token, mode, log_path) = { + let mut args = std::env::args().skip(1); + let (Some(token), Some(mode)) = (args.next(), args.next()) else { + eprintln!("usage: openshell-mxc-peer [log-file]"); + return 2; + }; + (token, mode, args.next()) }; let egress = match mode.as_str() { "egress" => true, @@ -193,12 +144,14 @@ mod imp { return 2; } }; - if let Some(path) = args.next() { - if let Ok(file) = std::fs::OpenOptions::new().create(true).append(true).open(path) { - if let Ok(mut guard) = LOG.lock() { - *guard = Some(file); - } - } + if let Some(path) = log_path + && let Ok(file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + && let Ok(mut guard) = LOG.lock() + { + *guard = Some(file); } let proxy = match TcpListener::bind("127.0.0.1:0").await { @@ -208,7 +161,7 @@ mod imp { return 1; } }; - let proxy_port = proxy.local_addr().map(|addr| addr.port()).unwrap_or(0); + let proxy_port = proxy.local_addr().map_or(0, |addr| addr.port()); tokio::spawn(serve_proxy(proxy, token.clone(), egress)); let mut control = None; @@ -232,16 +185,13 @@ mod imp { { return 1; } - log(&format!("ready: proxy 127.0.0.1:{proxy_port} (egress tunnel: {egress})")); + log(&format!( + "ready: proxy 127.0.0.1:{proxy_port} (egress tunnel: {egress})" + )); let mut lines = BufReader::new(control).lines(); while let Ok(Some(line)) = lines.next_line().await { - match parse_open(&line) { - Some((id, port)) => { - tokio::spawn(handle_open(token.clone(), id, port)); - } - None => log(&format!("ignoring malformed control line: {line}")), - } + log(&format!("ignoring unexpected control line: {line}")); } log("control pipe closed; exiting"); 0 @@ -251,19 +201,9 @@ mod imp { mod tests { use super::*; - #[test] - fn open_lines_parse_strictly() { - assert_eq!(parse_open("OPEN 3 18889"), Some((3, 18889))); - assert_eq!(parse_open("OPEN 3"), None); - assert_eq!(parse_open("OPEN 3 70000"), None); - assert_eq!(parse_open("OPEN 3 1 extra"), None); - assert_eq!(parse_open("READY 1"), None); - } - #[test] fn pipe_names_match_the_driver() { assert_eq!(ctl_pipe_name("t"), r"\\.\pipe\openshell-mxc-t-ctl"); - assert_eq!(data_pipe_name("t", 9), r"\\.\pipe\openshell-mxc-t-d9"); assert_eq!(egress_pipe_name("t"), r"\\.\pipe\openshell-mxc-t-egress"); } } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index a6dcd83919..19f1fc4287 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -242,11 +242,11 @@ pub struct MxcComputeConfig { /// `processContainer` only: absolute path to `openshell-mxc-peer.exe`. When /// set, each sandbox runs under MXC `networkProxy` + `allowedProxyPeer` /// instead of the loopback-allow network section: the driver starts the peer - /// under a per-sandbox `AppContainer` profile, which is the only identity - /// admitted to the sandbox's listeners, and `openshell forward service` - /// reaches the target through that peer over a private named pipe (no - /// broad host-loopback grant). The peer must be readable and executable by - /// "ALL APPLICATION PACKAGES". Requires the relay spawner settings above. + /// under a per-sandbox `AppContainer` profile and directs MXC egress to its + /// proxy listener without a broad host-loopback grant. The peer must be + /// readable and executable by "ALL APPLICATION PACKAGES". MXC 1.0 does not + /// admit reverse peer-to-workload or sandbox-local loopback connections, so + /// dynamic forwarding is unavailable while this mode is enabled. /// With `egress_proxy` enabled, the peer's proxy listener is the sandbox's /// only network endpoint and tunnels the sandbox's proxy connections over a /// named pipe to the gateway's per-sandbox host egress proxy, so policy @@ -305,14 +305,11 @@ impl MxcComputeConfig { return Ok(()); } let problem = if !Path::new(path).is_absolute() { - Some(format!("pc_proxy_peer_path must be an absolute path, got '{path}'")) + Some(format!( + "pc_proxy_peer_path must be an absolute path, got '{path}'" + )) } else if self.backend != MxcBackend::ProcessContainer { Some("pc_proxy_peer_path only applies to backend = \"process_container\"".to_string()) - } else if self.pc_relay_spawner_path.is_empty() || self.pc_relay_target_port == 0 { - Some( - "pc_proxy_peer_path requires pc_relay_spawner_path and pc_relay_target_port" - .to_string(), - ) } else if self.pc_network_allow || self.pc_allow_local_network { Some( "pc_proxy_peer_path cannot be combined with pc_network_allow or pc_allow_local_network" @@ -321,12 +318,11 @@ impl MxcComputeConfig { } else { None }; - match problem { - Some(message) => Err(openshell_core::Error::config(format!( + problem.map_or(Ok(()), |message| { + Err(openshell_core::Error::config(format!( "[openshell.drivers.mxc] {message}" - ))), - None => Ok(()), - } + ))) + }) } } @@ -1069,6 +1065,7 @@ impl MxcComputeBackend { pub fn forward_sink(&self) -> ForwardSink { ForwardSink { registry: self.registry.clone(), + proxy_peer_mode: !self.config.pc_proxy_peer_path.trim().is_empty(), } } @@ -1645,6 +1642,10 @@ pub enum OpenDynamicForwardError { "sandbox {0} has no control channel (not launched via a relay spawner, or not yet Ready)" )] NoControlChannel(String), + #[error( + "sandbox {0} uses MXC proxy-peer networking, which does not support dynamic forwarding without opening bidirectional host loopback" + )] + ProxyPeerForwardUnsupported(String), #[error("failed to bind ephemeral relay listener: {0}")] RelayBind(#[source] std::io::Error), #[error("control channel request failed: {0}")] @@ -1658,6 +1659,7 @@ pub enum OpenDynamicForwardError { #[derive(Clone)] pub struct ForwardSink { registry: Arc>>, + proxy_peer_mode: bool, } impl ForwardSink { @@ -1667,57 +1669,52 @@ impl ForwardSink { /// directly by the gateway process itself, no `AppContainer` boundary on /// that leg — a per-forward auth nonce the caller MUST send as the first /// bytes on its own connection to that address (see `relay.rs` module - /// docs: the relay is host-interface-bound, so another reachable process - /// could otherwise race to connect first and hijack the forward), and a + /// docs: another local process could otherwise race to connect first and + /// hijack the forward), and a /// [`relay::RelayHandle`] the caller must hold for as long as the /// forward should stay open, then `.stop()` (or just drop) to tear it /// down. /// - /// Target host is always `127.0.0.1` inside the `AppContainer` (matching - /// `TcpRelayTarget`'s existing loopback-only restriction at the gRPC - /// layer), so there's no separate `target_host` parameter to thread - /// through — the sandbox-side `forward` op only ever dials loopback. + /// The requested target is always sandbox loopback (matching + /// `TcpRelayTarget`'s existing restriction at the gRPC layer). The default + /// mode dials it inside the sandbox through the control-channel relay. + /// Proxy-peer mode returns [`OpenDynamicForwardError::ProxyPeerForwardUnsupported`]: + /// MXC 1.0 admits workload-to-peer proxy traffic but not reverse + /// peer-to-workload or sandbox-local loopback dials. pub async fn open_dynamic_forward( &self, sandbox_id: &str, target_port: u16, ) -> Result<(SocketAddr, [u8; relay::NONCE_LEN], relay::RelayHandle), OpenDynamicForwardError> { - let (control_channel, peer, sandbox_name) = { + let (control_channel, sandbox_name) = { let reg = self.registry.lock().await; let entry = reg .get(sandbox_id) .ok_or_else(|| OpenDynamicForwardError::SandboxNotFound(sandbox_id.to_string()))?; - let channel = entry - .control_channel - .clone() - .ok_or_else(|| OpenDynamicForwardError::NoControlChannel(sandbox_id.to_string()))?; - (channel, entry.peer.clone(), entry.sandbox.name.clone()) + if self.proxy_peer_mode { + return Err(OpenDynamicForwardError::ProxyPeerForwardUnsupported( + sandbox_id.to_string(), + )); + } + (entry.control_channel.clone(), entry.sandbox.name.clone()) }; // Fresh per forward -- see relay.rs module docs for why this matters - // on a host-interface listener. + // on a host-loopback listener. let nonce: [u8; relay::NONCE_LEN] = rand::random(); let bind_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - // In proxy-peer mode the sandbox only admits the peer identity (and the - // in-sandbox relay cannot dial its own loopback target), so the sandbox - // leg of the forward goes through the peer instead of the control channel. - let (relay_handle, relay_addr) = match peer { - Some(peer) => { - relay::start_peer_relay(bind_addr, sandbox_name, nonce, peer, target_port).await - } - None => { - relay::start_control_channel_relay( - bind_addr, - sandbox_name, - nonce, - control_channel, - target_port, - ) - .await - } - } + let control_channel = control_channel + .ok_or_else(|| OpenDynamicForwardError::NoControlChannel(sandbox_id.to_string()))?; + let (relay_handle, relay_addr) = relay::start_control_channel_relay( + bind_addr, + sandbox_name, + nonce, + control_channel, + target_port, + ) + .await .map_err(OpenDynamicForwardError::RelayBind)?; Ok((relay_addr, nonce, relay_handle)) @@ -3207,15 +3204,13 @@ mod lifecycle_tests { fn proxy_peer_config() -> MxcComputeConfig { MxcComputeConfig { wxc_exec_path: r"C:\mxc-kit\bin\wxc-exec.exe".into(), - pc_relay_spawner_path: "C:/openshell-openclaw/openshell-supervisor-relay.exe".into(), - pc_relay_target_port: 18889, pc_proxy_peer_path: r"C:\openshell-openclaw\openshell-mxc-peer.exe".into(), ..Default::default() } } #[test] - fn proxy_peer_mode_is_off_by_default_and_valid_with_the_spawner() { + fn proxy_peer_mode_is_off_by_default_and_does_not_require_the_relay() { assert!(MxcComputeConfig::default().pc_proxy_peer_path.is_empty()); proxy_peer_config().validate_configuration().unwrap(); } @@ -3230,11 +3225,14 @@ mod lifecycle_tests { #[test] fn proxy_peer_mode_rejects_incompatible_settings() { - let cases: [(&str, fn(&mut MxcComputeConfig)); 6] = [ - ("absolute", |c| c.pc_proxy_peer_path = "openshell-mxc-peer.exe".into()), - ("process_container", |c| c.backend = MxcBackend::IsolationSession), - ("pc_relay_spawner_path", |c| c.pc_relay_spawner_path.clear()), - ("pc_relay_spawner_path", |c| c.pc_relay_target_port = 0), + type ConfigMutation = fn(&mut MxcComputeConfig); + let cases: [(&str, ConfigMutation); 4] = [ + ("absolute", |c| { + c.pc_proxy_peer_path = "openshell-mxc-peer.exe".into(); + }), + ("process_container", |c| { + c.backend = MxcBackend::IsolationSession; + }), ("pc_network_allow", |c| c.pc_network_allow = true), ("pc_network_allow", |c| c.pc_allow_local_network = true), ]; @@ -3246,6 +3244,43 @@ mod lifecycle_tests { } } + #[tokio::test] + async fn proxy_peer_mode_rejects_dynamic_forward_without_side_effects() { + let backend = MxcComputeBackend::new_mocked(proxy_peer_config()); + let sandbox = driver_sandbox("proxy-peer-forward"); + backend.registry.lock().await.insert( + sandbox.id.clone(), + SandboxEntry { + sandbox: sandbox.clone(), + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Running, + lifecycle_gate: Arc::new(Mutex::new(())), + exec_child: None, + shutdown_tx: None, + terminated_rx: None, + signal_file: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, + control_channel: None, + peer: None, + }, + ); + + let Err(error) = backend + .forward_sink() + .open_dynamic_forward(&sandbox.id, 22000) + .await + else { + panic!("proxy-peer dynamic forward unexpectedly succeeded"); + }; + assert!(matches!( + error, + OpenDynamicForwardError::ProxyPeerForwardUnsupported(id) if id == sandbox.id + )); + } + #[test] fn governed_egress_defaults_off_and_allocates_unique_loopback_ports() { let config = MxcComputeConfig::default(); diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 44b2177dcb..2a8c77fbe9 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -133,8 +133,9 @@ fn ui_json(ui: &MxcUi) -> serde_json::Value { } /// Proxy-peer mode: the sandbox runs under MXC `runtimeConfig.networkProxy` -/// with `processContainer.network.allowedProxyPeer`, so the only identity that -/// may talk to its listeners is the named `AppContainer` profile (see `peer.rs`). +/// with `processContainer.network.allowedProxyPeer`, so MXC directs its proxy +/// traffic to the listener hosted by the named `AppContainer` profile while +/// general host-loopback access remains denied (see `peer.rs`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct MxcProxyPeer { /// `AppContainer` profile name of the peer process (`allowedProxyPeer`). @@ -276,7 +277,8 @@ fn apply_proxy_peer(config: &mut serde_json::Value, peer: &MxcProxyPeer) { }); config["processContainer"]["network"] = serde_json::json!({ "allowedProxyPeer": &peer.profile }); - config["runtimeConfig"] = serde_json::json!({ "networkProxy": format!("http://{}", peer.proxy) }); + config["runtimeConfig"] = + serde_json::json!({ "networkProxy": format!("http://{}", peer.proxy) }); } fn oneshot_config_json( @@ -1013,11 +1015,20 @@ mod tests { proxy: Some("127.0.0.1:18080".parse().unwrap()), allow_local_network: false, }; - let config = - oneshot_config_json("sb-1", &filesystem, &pc, &process, Some(&loopback_network), None); + let config = oneshot_config_json( + "sb-1", + &filesystem, + &pc, + &process, + Some(&loopback_network), + None, + ); assert_eq!(config["version"], MXC_SCHEMA_VERSION); - assert_eq!(config["runtimeConfig"]["networkProxy"], "http://127.0.0.1:4242"); + assert_eq!( + config["runtimeConfig"]["networkProxy"], + "http://127.0.0.1:4242" + ); assert_eq!( config["processContainer"]["network"]["allowedProxyPeer"], "openshell-mxc-abc" @@ -1043,7 +1054,8 @@ mod tests { env: Vec::new(), timeout: 0, }; - let config = oneshot_config_json("sb-1", &MxcFilesystem::default(), &pc, &process, None, None); + let config = + oneshot_config_json("sb-1", &MxcFilesystem::default(), &pc, &process, None, None); assert_eq!(config["version"], MXC_SCHEMA_VERSION); assert!(config.get("runtimeConfig").is_none()); assert!(config["processContainer"].get("network").is_none()); diff --git a/crates/openshell-driver-mxc/src/peer.rs b/crates/openshell-driver-mxc/src/peer.rs index 6d034e3bde..549d1f0dbc 100644 --- a/crates/openshell-driver-mxc/src/peer.rs +++ b/crates/openshell-driver-mxc/src/peer.rs @@ -4,39 +4,31 @@ //! Per-sandbox proxy-peer process for MXC `ProcessContainer` sandboxes. //! //! MXC's `runtimeConfig.networkProxy` mode lets a sandbox reach *only* its -//! proxy endpoint, and (with `processContainer.network.allowedProxyPeer`) admits -//! only one named `AppContainer` identity to talk to the sandbox's listeners. -//! Direct loopback is blocked everywhere else -- including between processes -//! inside the sandbox -- so the in-sandbox relay can no longer dial the target -//! service itself. This module provides the missing identity: a small helper +//! proxy endpoint. This module supplies that endpoint with a small helper //! process (`openshell-mxc-peer.exe`) that the driver starts under a -//! per-sandbox `AppContainer` profile. That profile is the sandbox's -//! `allowedProxyPeer`, so the peer is allowed to dial the sandbox's loopback -//! listeners. +//! per-sandbox `AppContainer` profile. That profile is named as the sandbox's +//! `processContainer.network.allowedProxyPeer`, so MXC admits the workload's +//! connection to the proxy while keeping general host-loopback access denied. //! //! ```text -//! host client -> gateway forward listener (relay.rs) -//! | data pipe (DACL: gateway user + peer profile SID only) -//! peer process (AppContainer profile == allowedProxyPeer) -//! | TCP 127.0.0.1: (admitted by identity) -//! target service inside the sandbox +//! sandbox process -> MXC networkProxy -> peer proxy listener +//! | egress pipe +//! gateway -> policy proxy -> network //! ``` //! //! Control protocol (newline-delimited text over `\\.\pipe\-ctl`): //! peer -> gateway: `READY ` once, after binding its proxy listener -//! gateway -> peer: `OPEN ` per forwarded connection //! -//! Each connection gets its own data pipe `\\.\pipe\-d`. After the -//! peer connects to it and dials the target it writes one status line, `OK` or -//! `ERR `, and then both sides pump raw bytes. -//! -//! Egress (sandbox -> network) runs the other way. The peer's proxy listener is -//! the sandbox's `networkProxy` endpoint; for every connection the sandbox opens -//! to it, the peer connects to the multi-instance pipe `\\.\pipe\-egress` +//! The peer's proxy listener is the sandbox's `networkProxy` endpoint; for every +//! connection the sandbox opens to it, the peer connects to the multi-instance +//! pipe `\\.\pipe\-egress` //! and pumps raw bytes. The gateway side of that pipe dials the sandbox's host //! egress proxy (the existing per-sandbox policy proxy) and pumps bytes to it, //! so the peer is a transparent byte tunnel and every policy decision, TLS //! interception and credential substitution still happens in the gateway. +//! Dynamic forwarding is unavailable in proxy-peer mode: MXC admits the +//! workload's connection to this peer, not a reverse peer-to-workload dial, and +//! keeps sandbox-local loopback denied. //! //! Per-process policy (binary rules) keeps working through the tunnel: the first //! line the peer writes on an egress pipe is `CLIENT `, the source port the @@ -54,16 +46,18 @@ use std::ffi::c_void; use std::io; use std::net::SocketAddr; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use openshell_supervisor_network::host::ForwardedClients; +use openshell_supervisor_network::procfs::WorkloadProxyTcpConnection; +use tokio::io::AsyncReadExt; +#[cfg(test)] +use tokio::io::AsyncWriteExt; use tokio::net::TcpStream; use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; use tokio::sync::Mutex; -use openshell_supervisor_network::host::ForwardedClients; -use openshell_supervisor_network::procfs::WorkloadProxyTcpConnection; use tokio::task::JoinHandle; use tracing::{info, warn}; use windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, LocalFree}; @@ -106,7 +100,7 @@ const PEER_BOOTSTRAP_ENV: [&str; 6] = [ const SE_GROUP_ENABLED: u32 = 0x0000_0004; /// `HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)`. -const HRESULT_ALREADY_EXISTS: i32 = 0x8007_00B7_u32 as i32; +const HRESULT_ALREADY_EXISTS: i32 = 0x8007_00B7_u32.cast_signed(); const PEER_READY_TIMEOUT: Duration = Duration::from_secs(15); const DATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); @@ -122,7 +116,7 @@ fn wide(value: &str) -> Vec { /// `AppContainer` profile name for a sandbox: `openshell-mxc-` /// (profile names are limited to 64 characters of `[A-Za-z0-9._-]`). -pub(crate) fn profile_name_for(sandbox_id: &str) -> String { +fn profile_name_for(sandbox_id: &str) -> String { let id: String = sandbox_id .chars() .filter(char::is_ascii_alphanumeric) @@ -134,7 +128,7 @@ pub(crate) fn profile_name_for(sandbox_id: &str) -> String { /// Pipe DACL: the gateway's own user plus the peer's `AppContainer` SID, nothing /// else. An `AppContainer` access check must succeed for both the user SID in /// the token and the package SID, so both ACEs are required. -pub(crate) fn pipe_sddl(user_sid: &str, profile_sid: &str) -> String { +fn pipe_sddl(user_sid: &str, profile_sid: &str) -> String { format!("D:(A;;GA;;;{user_sid})(A;;GA;;;{profile_sid})") } @@ -142,10 +136,6 @@ fn ctl_pipe_name(prefix: &str) -> String { format!(r"\\.\pipe\{prefix}-ctl") } -fn data_pipe_name(prefix: &str, id: u64) -> String { - format!(r"\\.\pipe\{prefix}-d{id}") -} - fn egress_pipe_name(prefix: &str) -> String { format!(r"\\.\pipe\{prefix}-egress") } @@ -161,17 +151,20 @@ pub struct EgressTunnel { /// Parse the peer's `CLIENT ` line: the source port the sandbox process /// used when it connected to the peer's proxy listener. -pub(crate) fn parse_client_port(line: &str) -> Option { +fn parse_client_port(line: &str) -> Option { line.strip_prefix("CLIENT ")?.trim().parse().ok() } /// Peer command line: `"" `. -pub(crate) fn peer_command_line(peer_exe: &str, token: &str, egress: bool) -> String { - format!("\"{peer_exe}\" {token} {}", if egress { "egress" } else { "none" }) +fn peer_command_line(peer_exe: &str, token: &str, egress: bool) -> String { + format!( + "\"{peer_exe}\" {token} {}", + if egress { "egress" } else { "none" } + ) } /// Parse `READY `. -pub(crate) fn parse_ready(line: &str) -> Option { +fn parse_ready(line: &str) -> Option { line.strip_prefix("READY ")?.trim().parse().ok() } @@ -203,7 +196,7 @@ async fn read_line(reader: &mut R) -> io::Result io::Result { let mut text = PWSTR::null(); // SAFETY: `sid` is a valid SID and `text` is a valid out pointer. - unsafe { ConvertSidToStringSidW(sid, &mut text) }.map_err(win_err)?; + unsafe { ConvertSidToStringSidW(sid, &raw mut text) }.map_err(win_err)?; // SAFETY: on success `text` points to a NUL-terminated wide string allocated // by the OS; it is freed with LocalFree right after copying. let result = unsafe { text.to_string() }.map_err(|error| io::Error::other(error.to_string())); @@ -218,16 +211,16 @@ fn current_user_sid() -> io::Result { // SAFETY: standard token query with a correctly sized, 8-byte-aligned buffer. unsafe { let mut token = HANDLE::default(); - OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).map_err(win_err)?; + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).map_err(win_err)?; let mut len = 0_u32; - let _ = GetTokenInformation(token, TokenUser, None, 0, &mut len); + let _ = GetTokenInformation(token, TokenUser, None, 0, &raw mut len); let mut buffer = vec![0_u64; (len as usize).div_ceil(8).max(1)]; let query = GetTokenInformation( token, TokenUser, Some(buffer.as_mut_ptr().cast()), len, - &mut len, + &raw mut len, ); let result = match query { Ok(()) => { @@ -258,7 +251,7 @@ impl SecurityDescriptor { ConvertStringSecurityDescriptorToSecurityDescriptorW( PCWSTR(sddl.as_ptr()), SDDL_REVISION_1, - &mut descriptor, + &raw mut descriptor, None, ) } @@ -326,7 +319,8 @@ fn capability_sids() -> io::Result> { // SID is deliberately leaked for the (short) lifetime of the gateway: // it is a few bytes per sandbox and freeing it would require tracking // it past `CreateProcessW`. - unsafe { ConvertStringSidToSidW(PCWSTR(text.as_ptr()), &mut sid) }.map_err(win_err)?; + unsafe { ConvertStringSidToSidW(PCWSTR(text.as_ptr()), &raw mut sid) } + .map_err(win_err)?; Ok(sid) }) .collect() @@ -337,8 +331,7 @@ fn ensure_profile(name: &str, capabilities: &[SID_AND_ATTRIBUTES]) -> io::Result let name_w = wide(name); let name_p = PCWSTR(name_w.as_ptr()); // SAFETY: all string arguments are NUL-terminated and outlive the call. - let created = - unsafe { CreateAppContainerProfile(name_p, name_p, name_p, Some(capabilities)) }; + let created = unsafe { CreateAppContainerProfile(name_p, name_p, name_p, Some(capabilities)) }; match created { Ok(sid) => Ok(OwnedSid(sid)), Err(error) if error.code().0 == HRESULT_ALREADY_EXISTS => { @@ -363,12 +356,12 @@ fn spawn_in_app_container( let mut list_size = 0_usize; // SAFETY: size query; documented to fail with ERROR_INSUFFICIENT_BUFFER. unsafe { - let _ = InitializeProcThreadAttributeList(None, 1, None, &mut list_size); + let _ = InitializeProcThreadAttributeList(None, 1, None, &raw mut list_size); } let mut list_buffer = vec![0_u64; list_size.div_ceil(8).max(1)]; let list = LPPROC_THREAD_ATTRIBUTE_LIST(list_buffer.as_mut_ptr().cast()); // SAFETY: `list` points to a buffer of at least `list_size` bytes. - unsafe { InitializeProcThreadAttributeList(Some(list), 1, None, &mut list_size) } + unsafe { InitializeProcThreadAttributeList(Some(list), 1, None, &raw mut list_size) } .map_err(win_err)?; let security_capabilities = SECURITY_CAPABILITIES { @@ -432,7 +425,7 @@ fn spawn_in_app_container( Some(environment.as_ptr().cast::()), PCWSTR::null(), std::ptr::addr_of!(startup).cast::(), - &mut info, + &raw mut info, ) }; // SAFETY: `list` was initialized above and is no longer needed. @@ -454,7 +447,6 @@ pub struct PeerHandle { descriptor: Arc, control: Mutex, proxy_addr: SocketAddr, - next_id: AtomicU64, terminated: AtomicBool, /// First instance of the egress pipe, created before the peer starts so the /// name cannot be squatted; handed to the accept loop once the peer is ready. @@ -504,7 +496,9 @@ impl PeerHandle { let control = handle.control.get_mut(); tokio::time::timeout(PEER_READY_TIMEOUT, control.connect()) .await - .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy peer did not connect"))??; + .map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "proxy peer did not connect") + })??; let line = tokio::time::timeout(PEER_READY_TIMEOUT, read_line(control)) .await .map_err(|_| { @@ -570,25 +564,22 @@ impl PeerHandle { }; let command_line = peer_command_line(peer_exe, &token, egress); - let process = match spawn_in_app_container( - peer_exe, - &command_line, - sid.as_psid(), - &mut capabilities, - ) { - Ok(handle) => handle, - Err(error) => { - // SAFETY: best-effort profile cleanup on the failure path. - unsafe { - let name = wide(&profile); - let _ = DeleteAppContainerProfile(PCWSTR(name.as_ptr())); + let process = + match spawn_in_app_container(peer_exe, &command_line, sid.as_psid(), &mut capabilities) + { + Ok(handle) => handle, + Err(error) => { + // SAFETY: best-effort profile cleanup on the failure path. + unsafe { + let name = wide(&profile); + let _ = DeleteAppContainerProfile(PCWSTR(name.as_ptr())); + } + return Err(io::Error::new( + error.kind(), + format!("failed to start proxy peer '{peer_exe}': {error}"), + )); } - return Err(io::Error::new( - error.kind(), - format!("failed to start proxy peer '{peer_exe}': {error}"), - )); - } - }; + }; // From here on the handle's Drop cleans up the process and profile. Ok(Self { @@ -598,43 +589,11 @@ impl PeerHandle { descriptor: Arc::new(descriptor), control: Mutex::new(control), proxy_addr: "127.0.0.1:0".parse().expect("static address"), - next_id: AtomicU64::new(1), terminated: AtomicBool::new(false), egress_first, egress_task: StdMutex::new(None), }) } - - /// Ask the peer to dial `127.0.0.1:` inside the sandbox and - /// return the data pipe carrying the connection's bytes. - pub async fn open_data(&self, target_port: u16) -> io::Result { - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let mut data = self - .descriptor - .create_pipe(&data_pipe_name(&self.prefix, id), true)?; - { - let mut control = self.control.lock().await; - control - .write_all(format!("OPEN {id} {target_port}\n").as_bytes()) - .await?; - } - tokio::time::timeout(DATA_CONNECT_TIMEOUT, data.connect()) - .await - .map_err(|_| { - io::Error::new(io::ErrorKind::TimedOut, "proxy peer did not open data pipe") - })??; - let status = tokio::time::timeout(DATA_CONNECT_TIMEOUT, read_line(&mut data)) - .await - .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "proxy peer sent no status"))??; - if status == "OK" { - Ok(data) - } else { - Err(io::Error::new( - io::ErrorKind::ConnectionRefused, - format!("proxy peer could not reach 127.0.0.1:{target_port}: {status}"), - )) - } - } } /// Gateway side of the egress tunnel: accept peer connections on the egress pipe @@ -702,7 +661,10 @@ async fn bridge_egress(mut pipe: NamedPipeServer, tunnel: EgressTunnel, peer_pro let _alias = tcp.local_addr().ok().map(|bridge| { tunnel.clients.register( bridge, - WorkloadProxyTcpConnection::new(SocketAddr::from(([127, 0, 0, 1], client_port)), peer_proxy), + WorkloadProxyTcpConnection::new( + SocketAddr::from(([127, 0, 0, 1], client_port)), + peer_proxy, + ), ) }); let _ = tokio::io::copy_bidirectional(&mut pipe, &mut tcp).await; @@ -718,10 +680,10 @@ impl PeerHandle { if self.terminated.swap(true, Ordering::AcqRel) { return; } - if let Ok(mut slot) = self.egress_task.lock() { - if let Some(task) = slot.take() { - task.abort(); - } + if let Ok(mut slot) = self.egress_task.lock() + && let Some(task) = slot.take() + { + task.abort(); } let process = HANDLE(self.process as *mut c_void); // SAFETY: `process` is the handle returned by CreateProcessW and owned by @@ -768,7 +730,6 @@ mod tests { #[test] fn pipe_names_are_namespaced_per_prefix() { assert_eq!(ctl_pipe_name("p"), r"\\.\pipe\p-ctl"); - assert_eq!(data_pipe_name("p", 7), r"\\.\pipe\p-d7"); assert_eq!(egress_pipe_name("p"), r"\\.\pipe\p-egress"); } diff --git a/crates/openshell-driver-mxc/src/relay.rs b/crates/openshell-driver-mxc/src/relay.rs index bd33e38e30..19302abe90 100644 --- a/crates/openshell-driver-mxc/src/relay.rs +++ b/crates/openshell-driver-mxc/src/relay.rs @@ -1,70 +1,32 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! WebSocket relay embedded in the gateway for MXC `ProcessContainer` sandboxes. +//! Dynamic forwarding relay embedded in the gateway for MXC sandboxes. //! -//! The `AppContainer` needs private-network client access (for example, the -//! `privateNetworkClientServer` capability used by the qualification profile). -//! The driver binds a relay listener on demand (`start_relay`, e.g. from -//! `ForwardSink::open_dynamic_forward`) and tells the in-sandbox spawner its -//! address over the stdin/stdout control channel; the spawner connects directly -//! to it as a WebSocket CLIENT (Phase A). Governed egress and its host CONNECT -//! proxy are not part of this relay path. Host clients connect as raw TCP -//! (Phase B); the relay tunnels their bytes through Phase A so the in-sandbox -//! agent can pipe them directly to the target service. Each relay is -//! per-request and short-lived — bound fresh for each `openshell forward -//! service` call, torn down when that forward ends. +//! The driver binds a loopback listener on demand from +//! `ForwardSink::open_dynamic_forward`. Host clients authenticate with a fresh +//! per-forward nonce. In the default network mode, the gateway multiplexes +//! target I/O over the inherited stdin/stdout control channel to +//! `openshell-supervisor-relay`. Proxy-peer mode deliberately does not provide +//! dynamic forwarding because MXC denies both sandbox-local loopback and reverse +//! peer-to-workload connections in that posture. Governed egress and its host +//! CONNECT proxy are not part of this path. Each relay is per-request and +//! short-lived. //! //! ```text -//! host TCP client -> relay (gateway, raw TCP accept) -//! | tunnel via Phase A WS -//! sandbox agent -> local service (openclaw:18889) +//! host TCP client -> authenticated gateway loopback listener +//! -> control channel -> sandbox-local target (default mode) //! ``` //! -//! The listener is bound to the host's route-selected IPv4 interface rather -//! than loopback. `AppContainer` fallback does not map its `127.0.0.1` to the -//! host, so a loopback listener is unreachable unless traffic is sent through -//! the CONNECT proxy; that proxy can also capture the bridge's separate -//! sandbox-local target connection. Binding one concrete host interface keeps -//! the target hop on sandbox loopback and lets the MXC 1.0 directional ingress -//! policy authorize only the host callback. In principle another host or local process could -//! race to connect before the real Phase A/B peer does and -//! hijack or inject traffic into the forward. Both phases are authenticated -//! against a fresh, unguessable per-forward nonce (`ForwardSink:: -//! open_dynamic_forward` generates it) instead of trusting connection order: -//! -//! Phase A (sandbox spawner, WS client) must send `TEXT "AUTH:"` as its first message, before anything else is -//! accepted from that connection -- see openshell-supervisor- -//! relay's `run_relay_bridge`, which sends this immediately -//! after connecting. -//! Phase B (host client, raw TCP -- normally the gateway process itself, -//! connecting right after `open_dynamic_forward` returns) must -//! write the raw nonce bytes as the first bytes on the -//! connection, before any tunneled application data -- see -//! openshell-server's `ForwardTcp` handler. -//! -//! A connection that fails or times out on this check is closed and the -//! relay keeps waiting for the real peer, rather than treating the first -//! comer as authoritative or tearing the whole relay down (a wrong guess -//! shouldn't be a viable way to deny service to the real caller either). -//! -//! Protocol over Phase A (WS connection from sandbox to relay), after auth: -//! TEXT "`SESSION_START`" — relay opened a Phase B TCP connection -//! BINARY — bytes from Phase B TCP stream -//! TEXT "`SESSION_END`" — Phase B TCP connection closed -//! WS Close — relay shutting down (`delete_sandbox` or error) -//! -//! Phase B (host client), after the nonce prefix, is a plain byte stream — -//! no WS handshake — so the client's full byte stream (including any WS -//! upgrade request and frames) is tunneled transparently to the in-sandbox -//! service from that point on. +//! The host client must write the raw nonce bytes before application data. +//! A connection that fails or times out on this check is closed, and the relay +//! keeps accepting so a wrong guess cannot deny service to the real caller. use crate::control_channel::ControlChannel; -use crate::peer::PeerHandle; use base64::Engine; use futures::{SinkExt, StreamExt}; use openshell_core::net::set_tcp_nodelay_best_effort; +use std::io; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -74,12 +36,9 @@ use tokio::sync::oneshot; use tokio_tungstenite::{accept_async, tungstenite::Message}; use tracing::{info, warn}; -/// Length in bytes of the per-forward auth nonce (see module docs). Must -/// match `NONCE_LEN` in `driver.rs` (which generates it) and -/// `openshell-supervisor-relay`'s copy (which echoes it back on Phase A) -- -/// duplicated rather than shared via a common crate, matching how the rest -/// of this wire protocol (e.g. the "`SESSION_START`"/"`SESSION_END`" literals) -/// is already duplicated across the two sides. +/// Length in bytes of the per-forward auth nonce (see module docs). The gateway +/// creates it in `driver.rs` and writes it before application bytes on the +/// host-side connection. pub const NONCE_LEN: usize = 32; /// How long to wait for a freshly-accepted connection to present its auth @@ -89,12 +48,6 @@ pub const NONCE_LEN: usize = 32; /// for long either. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); -/// Select the concrete host IPv4 address used to reach the machine's default -/// route. UDP `connect` performs route selection without sending a packet, so -/// this does not depend on the probe endpoint being reachable. Binding the -/// relay to that exact interface avoids exposing it on every interface while -/// still making it reachable from an `AppContainer` whose loopback is isolated -/// from the host's loopback. /// Fixed-time byte comparison so a wrong guess doesn't leak how many /// leading bytes it got right via response timing. The nonce is one-shot /// (a fresh relay per forward) so this is defense in depth rather than @@ -106,12 +59,9 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } -/// Hex-encode `bytes` (lowercase, unpadded). `pub` (crate-visible in -/// practice, since `relay` isn't a `pub mod`) so `ForwardSink:: -/// open_dynamic_forward` in `driver.rs` can use the exact same encoding to -/// build the "forward" control-channel request's `nonce` field that this -/// module expects back from the sandbox on Phase A. -pub fn encode_hex(bytes: &[u8]) -> String { +/// Hex-encode `bytes` (lowercase, unpadded) for control-channel session IDs and +/// the retained legacy relay protocol. +fn encode_hex(bytes: &[u8]) -> String { use std::fmt::Write; bytes .iter() @@ -121,40 +71,10 @@ pub fn encode_hex(bytes: &[u8]) -> String { }) } -/// Render the first `n` bytes of `data` as a printable-ASCII preview -/// (non-printable bytes shown as `.`), for hop-by-hop diagnostic logging. -/// Not a general-purpose formatter -- just enough to eyeball whether e.g. an -/// HTTP/WS handshake looks intact versus corrupted or empty. -/// -/// Not called anywhere: forwarded traffic can carry auth headers, cookies, or -/// other sensitive payload, and this relay's own logs are gateway logs, so no -/// byte preview is ever logged, at any level. Kept only so a future opt-in -/// diagnostic mode has a ready-made (still-redaction-worthy) formatter to -/// start from. -#[allow(dead_code)] -fn byte_preview(data: &[u8]) -> String { - const MAX: usize = 120; - let n = data.len().min(MAX); - let mut s: String = data[..n] - .iter() - .map(|&b| { - if b.is_ascii_graphic() || b == b' ' { - b as char - } else { - '.' - } - }) - .collect(); - if data.len() > MAX { - s.push_str("..."); - } - s -} - // ── Public handle ───────────────────────────────────────────────────────────── -/// Owned handle returned by [`start_relay`]. Drop or call [`stop`] to -/// shut down the relay task and release the listener port. +/// Owned handle returned by either relay implementation. Drop or call +/// [`stop`](RelayHandle::stop) to shut down the relay task and release the port. pub struct RelayHandle { shutdown_tx: oneshot::Sender<()>, } @@ -165,42 +85,16 @@ impl RelayHandle { } } -// ── Entry point ─────────────────────────────────────────────────────────────── - -/// Bind a TCP listener on `bind_addr` and spawn the relay task. The caller -/// communicates the returned address (and `nonce`) to the sandbox directly -/// (over the control channel) — this doesn't touch the filesystem at all. -/// `nonce` must be freshly generated per call (see module docs) — it's what -/// lets this host-interface relay tell the real Phase A/B peers apart from -/// any other local process that might race to connect first. -pub async fn start_relay( - bind_addr: SocketAddr, - sandbox_name: String, - nonce: [u8; NONCE_LEN], -) -> std::io::Result<(RelayHandle, SocketAddr)> { - let listener = TcpListener::bind(bind_addr).await?; - let actual = listener.local_addr()?; - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - tokio::spawn(relay_task( - listener, - sandbox_name.clone(), - nonce, - shutdown_rx, - )); - info!(sandbox = %sandbox_name, relay = %actual, "MXC relay started"); - Ok((RelayHandle { shutdown_tx }, actual)) -} - /// Start a host-loopback listener whose sandbox leg is multiplexed over the -/// inherited stdin/stdout control channel. Unlike [`start_relay`], this path -/// never asks the `AppContainer` to connect back to a host network address. +/// inherited stdin/stdout control channel. This path never asks the +/// `AppContainer` to connect back to a host network address. pub async fn start_control_channel_relay( bind_addr: SocketAddr, sandbox_name: String, nonce: [u8; NONCE_LEN], control_channel: Arc, target_port: u16, -) -> std::io::Result<(RelayHandle, SocketAddr)> { +) -> io::Result<(RelayHandle, SocketAddr)> { let listener = TcpListener::bind(bind_addr).await?; let actual = listener.local_addr()?; let (shutdown_tx, shutdown_rx) = oneshot::channel(); @@ -216,91 +110,6 @@ pub async fn start_control_channel_relay( Ok((RelayHandle { shutdown_tx }, actual)) } -/// Start a host-loopback listener whose sandbox leg runs through the sandbox's -/// proxy peer (see `peer.rs`): each authenticated host connection makes the peer -/// dial `127.0.0.1:` inside the sandbox over a private named pipe. -/// Used when the sandbox runs under MXC `networkProxy` + `allowedProxyPeer`, -/// where the in-sandbox relay cannot dial its own loopback target. -pub async fn start_peer_relay( - bind_addr: SocketAddr, - sandbox_name: String, - nonce: [u8; NONCE_LEN], - peer: Arc, - target_port: u16, -) -> std::io::Result<(RelayHandle, SocketAddr)> { - let listener = TcpListener::bind(bind_addr).await?; - let actual = listener.local_addr()?; - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - tokio::spawn(peer_relay_task( - listener, - sandbox_name.clone(), - nonce, - peer, - target_port, - shutdown_rx, - )); - info!(sandbox = %sandbox_name, relay = %actual, "MXC peer relay started"); - Ok((RelayHandle { shutdown_tx }, actual)) -} - -async fn peer_relay_task( - listener: TcpListener, - sandbox_name: String, - nonce: [u8; NONCE_LEN], - peer: Arc, - target_port: u16, - mut shutdown_rx: oneshot::Receiver<()>, -) { - // Connections are bridged concurrently; dropping the set on shutdown aborts - // any bridge that is still running. - let mut bridges = tokio::task::JoinSet::new(); - loop { - let mut host_stream = tokio::select! { - result = listener.accept() => match result { - Ok((stream, addr)) => { - info!(sandbox = %sandbox_name, %addr, "MXC peer relay: host client connected"); - set_tcp_nodelay_best_effort(&stream); - stream - } - Err(error) => { - warn!(sandbox = %sandbox_name, "MXC peer relay listener error: {error}"); - return; - } - }, - Some(_) = bridges.join_next(), if !bridges.is_empty() => continue, - _ = &mut shutdown_rx => return, - }; - - let sandbox = sandbox_name.clone(); - let peer = Arc::clone(&peer); - bridges.spawn(async move { - let mut auth_buf = [0_u8; NONCE_LEN]; - match tokio::time::timeout(AUTH_TIMEOUT, host_stream.read_exact(&mut auth_buf)).await { - Ok(Ok(_)) if constant_time_eq(&auth_buf, &nonce) => {} - Ok(Ok(_) | Err(_)) | Err(_) => return, - } - let mut data = match peer.open_data(target_port).await { - Ok(data) => data, - Err(error) => { - warn!(sandbox = %sandbox, "MXC peer relay: peer could not open session: {error}"); - return; - } - }; - match tokio::io::copy_bidirectional(&mut host_stream, &mut data).await { - Ok((host_to_sandbox_bytes, sandbox_to_host_bytes)) => info!( - sandbox = %sandbox, - host_to_sandbox_bytes, - sandbox_to_host_bytes, - "MXC peer relay: host client disconnected" - ), - Err(error) => { - info!(sandbox = %sandbox, "MXC peer relay: bridge ended: {error}"); - } - } - }); - } -} - async fn control_channel_relay_task( listener: TcpListener, sandbox_name: String, @@ -426,6 +235,9 @@ fn control_response_ok( ) } +// Retained temporarily for legacy callback-protocol compatibility; both +// production paths above avoid a sandbox-to-host callback socket. +#[allow(dead_code)] async fn relay_task( listener: TcpListener, sandbox_name: String, diff --git a/crates/openshell-driver-mxc/tests/demo_examples.rs b/crates/openshell-driver-mxc/tests/demo_examples.rs index a6293f7220..5be9c20ba0 100644 --- a/crates/openshell-driver-mxc/tests/demo_examples.rs +++ b/crates/openshell-driver-mxc/tests/demo_examples.rs @@ -157,8 +157,12 @@ fn shipped_runners_isolate_cli_state_and_gateway_overrides() { } #[test] -fn shipped_audit_and_websocket_configs_use_current_schema() { - for name in ["mxc-ocsf-audit.toml", "mxc-ws-gateway.toml"] { +fn shipped_audit_and_forwarding_configs_use_current_schema() { + for name in [ + "mxc-ocsf-audit.toml", + "mxc-ws-gateway.toml", + "mxc-openclaw-gateway.toml", + ] { let source = read_example(name); let parsed: Value = toml::from_str(&source) .unwrap_or_else(|error| panic!("failed to parse {name}: {error}")); @@ -174,6 +178,43 @@ fn shipped_audit_and_websocket_configs_use_current_schema() { } } +#[test] +fn shipped_processcontainer_forwarding_examples_disable_proxy_peer() { + for name in ["mxc-ws-gateway.toml", "mxc-openclaw-gateway.toml"] { + let parsed: Value = toml::from_str(&read_example(name)) + .unwrap_or_else(|error| panic!("failed to parse {name}: {error}")); + let mxc = parsed + .get("openshell") + .and_then(Value::as_table) + .and_then(|openshell| openshell.get("drivers")) + .and_then(Value::as_table) + .and_then(|drivers| drivers.get("mxc")) + .and_then(Value::as_table) + .unwrap_or_else(|| panic!("{name} is missing [openshell.drivers.mxc]")); + assert_eq!( + mxc.get("pc_proxy_peer_path").and_then(Value::as_str), + Some(""), + "{name} must leave egress-only proxy-peer mode disabled" + ); + assert_eq!( + mxc.get("backend").and_then(Value::as_str), + Some("process_container") + ); + assert!( + mxc.get("pc_relay_spawner_path") + .and_then(Value::as_str) + .is_some_and(|path| !path.is_empty()), + "{name} must configure the relay required by its forwarding test" + ); + assert!( + mxc.get("pc_relay_target_port") + .and_then(Value::as_integer) + .is_some_and(|port| port > 0), + "{name} must configure the target port required by its forwarding test" + ); + } +} + #[test] fn shipped_inference_configs_declare_required_mxc_settings() { for name in ["mxc-ollama.toml", "mxc-inference.toml"] { @@ -330,6 +371,9 @@ fn shipped_runners_parse_in_windows_powershell() { "mxc-provider-credential-probe.ps1", "run-ocsf-audit.ps1", "run-mxc-e2e.ps1", + "run-ws-agent-test.ps1", + "run-openclaw-forward-test.ps1", + "run-openclaw-forward-mock.ps1", ] { let path = examples_root().join(name); let script = r" diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index f5f2074da9..f9aa9ec685 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -185,12 +185,20 @@ impl openshell_server::ComputeDriverForwardSink for MxcForwardSink { &self, sandbox_id: &str, target_port: u16, - ) -> Result<(std::net::SocketAddr, Vec, Box), String> { + ) -> Result< + (std::net::SocketAddr, Vec, Box), + openshell_server::ComputeDriverForwardError, + > { let (addr, nonce, handle) = self .0 .open_dynamic_forward(sandbox_id, target_port) .await - .map_err(|error| error.to_string())?; + .map_err(|error| match error { + openshell_driver_mxc::OpenDynamicForwardError::ProxyPeerForwardUnsupported(_) => { + openshell_server::ComputeDriverForwardError::Unsupported(error.to_string()) + } + _ => openshell_server::ComputeDriverForwardError::Other(error.to_string()), + })?; Ok((addr, nonce.to_vec(), Box::new(handle))) } } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 73bdd75299..da102f92d5 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -1982,7 +1982,16 @@ pub(super) async fn handle_forward_tcp( let (relay_addr, nonce, relay_handle) = forward_sink .open_dynamic_forward(&sandbox_id, target_port) .await - .map_err(|e| Status::unavailable(format!("driver dynamic forward failed: {e}")))?; + .map_err(|error| match error { + crate::ComputeDriverForwardError::Unsupported(message) => { + Status::failed_precondition(format!( + "driver dynamic forward is unsupported: {message}" + )) + } + crate::ComputeDriverForwardError::Other(message) => { + Status::unavailable(format!("driver dynamic forward failed: {message}")) + } + })?; // This is a latency-sensitive request/response tunnel, including on // loopback -- small agent-protocol/WS frames can otherwise stall diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6023532753..c0f44b06e8 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1091,7 +1091,21 @@ pub trait ComputeDriverForwardSink: Send + Sync { &self, sandbox_id: &str, target_port: u16, - ) -> std::result::Result<(SocketAddr, Vec, Box), String>; + ) -> std::result::Result< + (SocketAddr, Vec, Box), + ComputeDriverForwardError, + >; +} + +/// Failure returned by a driver's optional dynamic-forward capability. +#[derive(Debug, thiserror::Error)] +pub enum ComputeDriverForwardError { + /// The selected driver mode cannot provide dynamic forwarding safely. + #[error("{0}")] + Unsupported(String), + /// The driver supports dynamic forwarding, but this attempt failed. + #[error("{0}")] + Other(String), } /// Factory for a compute driver linked into a gateway binary. diff --git a/crates/openshell-supervisor-network/src/windows_process.rs b/crates/openshell-supervisor-network/src/windows_process.rs index 1fbbc7b99e..92268fe9ab 100644 --- a/crates/openshell-supervisor-network/src/windows_process.rs +++ b/crates/openshell-supervisor-network/src/windows_process.rs @@ -88,7 +88,10 @@ impl ForwardedClients { /// Map an accepted connection to the original connection it tunnels, or /// return it unchanged when it is not tunnelled. - pub(crate) fn resolve(&self, connection: WorkloadProxyTcpConnection) -> WorkloadProxyTcpConnection { + pub(crate) fn resolve( + &self, + connection: WorkloadProxyTcpConnection, + ) -> WorkloadProxyTcpConnection { self.aliases .lock() .ok() diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs index 830334d83a..bd1cf07c95 100644 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -1176,7 +1176,10 @@ mod inherited_proxy_env_tests { #[test] fn launch_request_values_win_case_insensitively() { let got = inherited_proxy_env( - parent(&[("HTTP_PROXY", "http://parent"), ("HTTPS_PROXY", "http://parent")]), + parent(&[ + ("HTTP_PROXY", "http://parent"), + ("HTTPS_PROXY", "http://parent"), + ]), &[("http_proxy".into(), "http://driver".into())], ); assert_eq!( diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index dfe0b03e78..5e2b89814b 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -682,6 +682,9 @@ pc_capabilities = [] # allocates a unique ephemeral port for each sandbox. egress_proxy = false egress_proxy_addr = "" +# Optional identity-scoped proxy peer. Requires process_container; see the MXC +# section below. +pc_proxy_peer_path = "" debug = false etw_audit = true ``` @@ -997,6 +1000,8 @@ pc_capabilities = [] # CONNECT proxy enforces the trimmed network policy. Requires process_container. egress_proxy = false egress_proxy_addr = "" +# Absolute path to openshell-mxc-peer.exe. Empty uses the host-loopback model. +pc_proxy_peer_path = "" debug = false etw_audit = false ``` @@ -1019,7 +1024,28 @@ access check to grant the requested operation. The MXC real-binary integration suite verifies `TokenIsAppContainer`, `TokenAppContainerSid`, and denial of a non-mutating administrator-gated Service Control Manager open. -Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. +Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. In the default mode, MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. + +Set `pc_proxy_peer_path` to the absolute path of +`openshell-mxc-peer.exe` to replace that broad host-loopback exception with +MXC's identity-scoped proxy-peer model. This mode requires +`backend = "process_container"` and cannot be combined with +`pc_network_allow` or `pc_allow_local_network`. The helper must be readable and +executable by ALL APPLICATION PACKAGES. The driver launches one helper under a +per-sandbox AppContainer profile, uses that profile as MXC's +`allowedProxyPeer`, configures MXC's `networkProxy`, and keeps direct egress and +`hostLoopback` denied. Governed egress crosses an identity-restricted named pipe +to the helper instead of granting the workload access to arbitrary host-loopback +listeners. MXC does not give the gateway or peer an identity-scoped reverse path +to the workload, so `openshell forward service` fails before opening a listener +when this mode is enabled. + +This mode narrows outbound and loopback reachability, but it is not complete +inbound isolation. MXC 1.0 requires `ingress.default = "allow"` for the target +ProcessContainer, which enables private-network server traffic. Accept that +residual exposure only on hosts whose private-network boundary is appropriate +for the workload. This required ingress setting does not make dynamic forwarding +available to the gateway. The driver points common TLS trust variables, including `CURL_CA_BUNDLE`, at the staged public CA bundle. Windows inbox `curl.exe` uses Schannel and does not honor `CURL_CA_BUNDLE` automatically. Invoke it with `--cacert %CURL_CA_BUNDLE%` so HTTPS requests through the governed-egress proxy trust the per-sandbox CA. diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index 3fe5655285..75db0bea4a 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -516,7 +516,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 --bin openshell-supervisor-relay $Z3GatewayFeatures" ` + -CargoArgs "cargo build --release --target $RustTarget --bin openshell-gateway --bin openshell --bin openshell-supervisor-relay --bin openshell-mxc-peer $Z3GatewayFeatures" ` -LogName "build-$RustTarget-release.log" $z3Runtime = if ([string]::IsNullOrWhiteSpace($env:Z3_LIBRARY_PATH_OVERRIDE)) { @@ -649,7 +649,7 @@ function Get-Sha256([string] $Path) { function Show-Artifacts([string[]] $RustTargets) { $rows = @() foreach ($rustTarget in $RustTargets) { - foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe", "libz3.dll")) { + foreach ($binary in @("openshell-gateway.exe", "openshell.exe", "openshell-supervisor-relay.exe", "openshell-mxc-peer.exe", "libz3.dll")) { $path = Join-Path $TargetDir "$rustTarget\release\$binary" if (-not (Test-Path $path)) { continue From 701efe9308dac03fd347e130cee517839bf86b34 Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Thu, 8 Oct 2026 21:39:07 -0700 Subject: [PATCH 37/39] fix(mxc): authorize and release proxy peers Signed-off-by: Shailendra Singh --- .../build-openshell-mxc-windows/SKILL.md | 17 + architecture/windows.md | 15 + crates/openshell-driver-mxc/Cargo.toml | 2 +- crates/openshell-driver-mxc/README.md | 17 +- .../src/control_channel.rs | 14 +- crates/openshell-driver-mxc/src/driver.rs | 322 +++++++++++++++++- crates/openshell-driver-mxc/src/lib.rs | 2 + crates/openshell-driver-mxc/src/peer.rs | 251 ++++++++++++-- .../openshell-driver-mxc/src/peer_firewall.rs | 213 ++++++++++++ .../tests/wxc_exec_real.rs | 21 +- crates/openshell-supervisor-relay/src/imp.rs | 42 ++- .../tests/control_channel_contract.rs | 93 ++++- docs/reference/gateway-config.mdx | 14 + skills/debug-openshell-cluster/SKILL.md | 19 ++ 14 files changed, 973 insertions(+), 69 deletions(-) create mode 100644 crates/openshell-driver-mxc/src/peer_firewall.rs diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index 6eb3d13659..c7c630b011 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -361,6 +361,23 @@ The default-feature tests are also included in the full workspace test run. The focused task is available for local diagnosis and selective-build validation; GitHub Actions does not re-run it after the full suite. +### Proxy-peer firewall validation + +Proxy-peer startup requires permission to manage Windows Firewall rules. The +peer rule is restricted to its executable, AppContainer SID, loopback endpoints +and ephemeral TCP port; the gateway removes it after confirmed workload exit, +stop or launch failure. On an unelevated host, validate the actionable startup +failure and report successful rule installation and real peer egress as unavailable. +Do not count the permission-denied path as successful peer connectivity. +For the real peer HTTPS/L7 test, stage an AC-readable `openshell-mxc-peer.exe`, +set `OPENSHELL_MXC_PEER_EXE` to its absolute path, and run the architecture's +`windows:test:mxc-real:*` task with firewall-management permission. The peer test +must execute rather than print SKIP before claiming connectivity coverage. + +Relay protocol version 5 requires matching gateway and relay binaries. The +compiled relay contract tests cover curated non-peer environments and explicit +peer proxy inheritance, including override precedence and NO_PROXY exclusion. + ## Test Accounting Guidance When reporting `windows:ci`, distinguish these categories: diff --git a/architecture/windows.md b/architecture/windows.md index a87106cbf7..9d206de449 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -168,6 +168,21 @@ is enabled. The peer replaces broad sandbox-to-host-loopback access with an identified egress connection, but operators must still accept the remaining private-network ingress surface. +Peer readiness includes a Windows Firewall inbound allow rule scoped to the +peer executable, AppContainer SID, loopback addresses and ephemeral TCP port. +Proxy-peer mode requires permission to manage local firewall rules; startup +fails with an actionable diagnostic if permission is missing or active policy +ignores inbound rules. The gateway removes the rule, process, pipes and profile +after confirmed workload termination, including natural exit and launch failure. +A timed-out stop or delete retains ownership until termination is confirmed. +Retaining the sandbox record does not retain a live peer. Abrupt gateway death +can leave a rule and profile for operator cleanup. + +The relay launch protocol explicitly enables inheritance of MXC-injected proxy +variables for peer launches. Other curated launch environments exclude ambient +proxy settings. Protocol version 5 rejects a stale relay that cannot enforce +this distinction; restage the gateway and relay together. + Two gateway-wide ProcessContainer compatibility settings can deliberately broaden this boundary. `pc_network_allow` permits unrestricted outbound TCP, and `pc_allow_local_network` enables MXC local-network access. These are operator diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 31fb9e4233..b4f06f13ae 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -33,7 +33,7 @@ uuid = { workspace = true } # ETW/TDH real-time consumer and host CONNECT proxy integration. [target.'cfg(target_os = "windows")'.dependencies] openshell-supervisor-network = { path = "../openshell-supervisor-network" } -windows = { workspace = true } +windows = { workspace = true, features = ["Win32_NetworkManagement_WindowsFirewall", "Win32_System_Com"] } # WebSocket relay embedded in the gateway for ProcessContainer host<->sandbox # connectivity (see src/relay.rs). tokio-tungstenite = { workspace = true } diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 0636b6dc62..442562f75f 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -153,9 +153,20 @@ forward: unsupported in proxy-peer mode PACKAGES" (for example `icacls /grant *S-1-15-2-1:(OI)(CI)(RX)`). - Requires the `process_container` backend and cannot be combined with `pc_network_allow` or `pc_allow_local_network`. -- The peer and its profile are removed when the sandbox is deleted. If the gateway - is killed, the peer exits when its control pipe closes, but its profile stays - registered until it is removed by hand (`DeleteAppContainerProfile`). +- Proxy-peer mode requires permission to manage Windows Firewall rules. Before + publishing readiness, the gateway installs an inbound TCP allow rule scoped to + the helper executable, profile SID, `127.0.0.1` endpoints and listener port. + Startup fails if permission is missing or active firewall policy ignores + inbound rules. The gateway does not change profile defaults or loopback exemptions. +- The peer, pipes, firewall rule and profile are released after confirmed workload + exit, stop or launch failure, even when the sandbox record is retained. A stop + or delete timeout retains ownership for retry. If the gateway is killed, the + peer exits when its control pipe closes, but its profile and firewall rule can + remain. Remove orphan `OpenShell MXC peer openshell-mxc-*` rules and profiles + only after confirming the owning gateway and peer are gone. +- Restage the gateway and `openshell-supervisor-relay.exe` together. Protocol + version 5 gates inheritance of MXC proxy variables on the peer launch flag; + other curated launch environments exclude ambient proxies. 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: diff --git a/crates/openshell-driver-mxc/src/control_channel.rs b/crates/openshell-driver-mxc/src/control_channel.rs index 76df43456a..036d886fdc 100644 --- a/crates/openshell-driver-mxc/src/control_channel.rs +++ b/crates/openshell-driver-mxc/src/control_channel.rs @@ -50,7 +50,7 @@ pub type ReadySlot = Mutex>>>; /// "forward", or the `"target_ready"` event itself) -- an independently /// staged, stale relay binary then fails fast with a clear error instead of /// hanging or misbehaving against fields/events it doesn't understand. -const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 4; +const REQUIRED_SUPERVISOR_RELAY_PROTOCOL_VERSION: u64 = 5; /// One control channel per sandboxed process. `request()` is safe to call /// concurrently — each call gets its own correlation id and awaits only its @@ -284,7 +284,7 @@ mod tests { let (slot, rx) = armed_ready_slot(); let consumed = - ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":4}"#) + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":5}"#) .await; assert!(consumed); @@ -296,20 +296,20 @@ mod tests { let (slot, rx) = armed_ready_slot(); let consumed = - ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":1}"#) + ControlChannel::try_route_ready(&slot, r#"{"event":"ready","protocol_version":4}"#) .await; assert!( consumed, "a recognized ready event is consumed even when rejected" ); - let err = rx.await.unwrap().expect_err("version 1 must be rejected"); + let err = rx.await.unwrap().expect_err("version 4 must be rejected"); assert!( - err.contains('1'), + err.contains('4'), "error should name the offending version: {err}" ); assert!( - err.contains('4'), + err.contains('5'), "error should name the required version: {err}" ); } @@ -363,7 +363,7 @@ mod tests { // other's slot. let consumed = ControlChannel::try_route_target_status( &slot, - r#"{"event":"ready","protocol_version":4}"#, + r#"{"event":"ready","protocol_version":5}"#, ) .await; diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 19f1fc4287..742360abb4 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -1404,6 +1404,7 @@ impl MxcComputeBackend { } } + release_proxy_peer(&self.registry, &sandbox_id).await; let mut registry = self.registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { entry.isolation_stopped = isolation_stopped; @@ -1454,7 +1455,6 @@ impl MxcComputeBackend { signal_file, control_channel, host_proxy, - peer, ) = { let mut registry = self.registry.lock().await; let Some(entry) = registry.get_mut(sandbox_id) else { @@ -1471,7 +1471,6 @@ impl MxcComputeBackend { entry.signal_file.take(), entry.control_channel.take(), entry.host_proxy.take(), - entry.peer.take(), ) }; drop(host_proxy); @@ -1564,9 +1563,7 @@ impl MxcComputeBackend { // The sandbox is gone; stop its proxy peer and delete the peer's // AppContainer profile. Forward bridges may still hold the handle, so // terminate explicitly instead of waiting for the last reference. - if let Some(peer) = peer { - let _ = tokio::task::spawn_blocking(move || peer.terminate()).await; - } + release_proxy_peer(&self.registry, sandbox_id).await; let mut registry = self.registry.lock().await; if registry.remove(sandbox_id).is_some() { @@ -2118,9 +2115,9 @@ async fn run_lifecycle( // "launch" request that can never arrive -- a silent hang, // not a failure. Fail the sandbox now instead. let err = "wxc-exec stdin is not piped; control-channel launch cannot proceed"; - set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, err).await; let _ = child.kill().await; let _ = child.wait().await; + set_failed(®istry, &watch_tx, &sandbox, &sandbox_id, err).await; return; } } else { @@ -2382,6 +2379,7 @@ async fn run_lifecycle( let launch_data = serde_json::json!({ "command": sandbox_config.command, "env": env, + "inherit_proxy_env": proxy_peer.is_some(), }); match channel .request("launch", launch_data, std::time::Duration::from_mins(2)) @@ -2515,8 +2513,9 @@ async fn run_lifecycle( // wait out the full timeout for a signal that never comes, // while `child` -- which does not kill-on-drop -- leaks. // Kill and reap it directly instead. - let _ = child.kill().await; - let _ = child.wait().await; + if child.kill().await.is_ok() && child.wait().await.is_ok() { + release_proxy_peer(®istry, &sandbox_id).await; + } } else if let Some(mut rx) = terminated_rx { // leftover_child was None, so monitor_exec already claimed // exec_child and is the one racing shutdown_tx against @@ -2641,6 +2640,7 @@ async fn monitor_exec( { idx.retire_launch(&sandbox_id, pid); } + release_proxy_peer(®istry, &sandbox_id).await; info!(sandbox = %sandbox.name, "MXC ProcessContainer terminated for sandbox stop/delete"); let _ = done_tx.send(true); return; @@ -2650,6 +2650,7 @@ async fn monitor_exec( (child.wait().await, None) }; if wait_result.is_ok() { + release_proxy_peer(®istry, &sandbox_id).await; if let Some(pid) = wxc_pid && let Ok(mut idx) = attribution.lock() { @@ -2717,6 +2718,21 @@ async fn monitor_exec( } } +/// Release the peer after confirmed workload termination or before a workload +/// was spawned. A retained terminal sandbox has no peer resources. +async fn release_proxy_peer( + registry: &Arc>>, + sandbox_id: &str, +) { + let peer = { + let mut reg = registry.lock().await; + reg.get_mut(sandbox_id).and_then(|entry| entry.peer.take()) + }; + if let Some(peer) = peer { + let _ = tokio::task::spawn_blocking(move || peer.terminate()).await; + } +} + /// Control-channel diagnostics can contain the relay's captured workload /// stderr. Scrub the decoded text at the diagnostic boundary, leaving protocol /// envelopes and successful control-channel payloads untouched. @@ -2771,7 +2787,11 @@ async fn set_failed( } entry.sandbox = failed.clone(); entry.phase_state = PhaseState::Failed(message.to_string()); + let before_workload = entry.terminated_rx.is_none() && entry.exec_child.is_none(); drop(reg); + if before_workload { + release_proxy_peer(registry, sandbox_id).await; + } let _ = watch_tx.send(sandbox_event(failed)); } @@ -4440,6 +4460,280 @@ mod lifecycle_tests { assert!(failed.is_some(), "sandbox should report ExecFailed"); } + async fn attach_test_peer( + backend: &MxcComputeBackend, + sandbox_id: &str, + ) -> (Arc, std::process::Child) { + let (peer, child) = PeerHandle::test_fixture(); + let peer = Arc::new(peer); + backend + .registry + .lock() + .await + .get_mut(sandbox_id) + .unwrap() + .peer = Some(peer.clone()); + (peer, child) + } + + #[tokio::test] + async fn terminal_workload_releases_peer_before_sandbox_deletion() { + for (id, code, reason) in [ + ("peer-success", 0, "AgentCompleted"), + ("peer-failure", 17, "ExecFailed"), + ] { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + // Insert the resource owner before running the actual monitor on a + // real short-lived workload; do not race a synthetic sleep. + let sandbox = driver_sandbox(id); + let mut child = tokio::process::Command::new(inbox_cmd()) + .args(["/d", "/c", &format!("exit {code}")]) + .spawn() + .unwrap(); + drop(child.stdin.take()); + let (done_tx, done_rx) = watch::channel(false); + let (_tx, rx) = oneshot::channel(); + backend.registry.lock().await.insert( + id.into(), + SandboxEntry { + sandbox: sandbox.clone(), + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Starting, + lifecycle_gate: Arc::new(Mutex::new(())), + exec_child: Some(child), + shutdown_tx: None, + terminated_rx: Some(done_rx.clone()), + signal_file: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, + control_channel: None, + peer: None, + }, + ); + let (peer, mut peer_child) = attach_test_peer(&backend, id).await; + monitor_exec( + backend.registry.clone(), + backend.watch_tx.clone(), + backend.attribution.clone(), + sandbox, + id.into(), + Some((rx, done_tx)), + ) + .await; + assert!(*done_rx.borrow()); + let retained = backend.get_sandbox(id).await.unwrap(); + assert_eq!(ready_condition(&retained).unwrap().reason, reason); + assert!( + backend + .registry + .lock() + .await + .get(id) + .unwrap() + .peer + .is_none() + ); + peer.assert_released(&mut peer_child); + } + } + + #[tokio::test] + async fn launch_failure_retains_peer_until_monitor_confirms_termination() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let sandbox = driver_sandbox("peer-launch-failure"); + let child = tokio::process::Command::new(inbox_powershell()) + .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 60"]) + .spawn() + .unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let (done_tx, done_rx) = watch::channel(false); + backend.registry.lock().await.insert( + sandbox.id.clone(), + SandboxEntry { + sandbox: sandbox.clone(), + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Starting, + lifecycle_gate: Arc::new(Mutex::new(())), + exec_child: Some(child), + shutdown_tx: Some(shutdown_tx), + terminated_rx: Some(done_rx.clone()), + signal_file: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, + control_channel: None, + peer: None, + }, + ); + let (peer, mut peer_child) = attach_test_peer(&backend, &sandbox.id).await; + set_launch_failed( + &backend.registry, + &backend.watch_tx, + &sandbox, + &sandbox.id, + "target readiness failed", + &[], + ) + .await; + assert!( + backend + .registry + .lock() + .await + .get(&sandbox.id) + .unwrap() + .peer + .is_some() + ); + assert!(peer_child.try_wait().unwrap().is_none()); + backend + .registry + .lock() + .await + .get_mut(&sandbox.id) + .unwrap() + .shutdown_tx + .take() + .unwrap() + .send(()) + .unwrap(); + monitor_exec( + backend.registry.clone(), + backend.watch_tx.clone(), + backend.attribution.clone(), + sandbox.clone(), + sandbox.id.clone(), + Some((shutdown_rx, done_tx)), + ) + .await; + assert!(*done_rx.borrow()); + assert!( + backend + .registry + .lock() + .await + .get(&sandbox.id) + .unwrap() + .peer + .is_none() + ); + peer.assert_released(&mut peer_child); + assert_eq!( + ready_condition(&backend.get_sandbox(&sandbox.id).await.unwrap()) + .unwrap() + .reason, + "ProvisionFailed" + ); + } + + #[tokio::test] + async fn timed_out_delete_preserves_peer_until_confirmed_retry() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let sandbox = driver_sandbox("peer-delete-retry"); + let (done_tx, done_rx) = watch::channel(false); + backend.registry.lock().await.insert( + sandbox.id.clone(), + SandboxEntry { + sandbox: sandbox.clone(), + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Running, + lifecycle_gate: Arc::new(Mutex::new(())), + exec_child: None, + shutdown_tx: None, + terminated_rx: Some(done_rx), + signal_file: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, + control_channel: None, + peer: None, + }, + ); + let (peer, mut child) = attach_test_peer(&backend, &sandbox.id).await; + let error = backend + .delete_sandbox(&sandbox.id, &sandbox.name) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::DeadlineExceeded); + assert!( + backend + .registry + .lock() + .await + .get(&sandbox.id) + .unwrap() + .peer + .is_some() + ); + assert!( + child.try_wait().unwrap().is_none(), + "unconfirmed workload must retain its peer" + ); + done_tx.send(true).unwrap(); + assert!( + backend + .delete_sandbox(&sandbox.id, &sandbox.name) + .await + .unwrap() + ); + peer.assert_released(&mut child); + } + + #[tokio::test] + async fn failure_before_workload_launch_releases_peer() { + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + let sandbox = driver_sandbox("peer-startup-failure"); + backend.registry.lock().await.insert( + sandbox.id.clone(), + SandboxEntry { + sandbox: sandbox.clone(), + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Starting, + lifecycle_gate: Arc::new(Mutex::new(())), + exec_child: None, + shutdown_tx: None, + terminated_rx: None, + signal_file: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, + control_channel: None, + peer: None, + }, + ); + let (peer, mut child) = attach_test_peer(&backend, &sandbox.id).await; + set_failed( + &backend.registry, + &backend.watch_tx, + &sandbox, + &sandbox.id, + "test spawn failure", + ) + .await; + assert!( + backend + .registry + .lock() + .await + .get(&sandbox.id) + .unwrap() + .peer + .is_none() + ); + peer.assert_released(&mut child); + assert_eq!( + ready_condition(&backend.get_sandbox(&sandbox.id).await.unwrap()) + .unwrap() + .reason, + "ProvisionFailed" + ); + } + #[tokio::test] async fn stop_terminates_and_reaps_a_running_process_container() { let tmp = tempfile::tempdir().unwrap(); @@ -4463,6 +4757,7 @@ mod lifecycle_tests { .await .expect("long-running child should start"); tokio::time::sleep(Duration::from_millis(250)).await; + let (peer, mut peer_child) = attach_test_peer(&backend, "sb-stop").await; let running = backend.get_sandbox("sb-stop").await.unwrap(); assert_eq!(ready_condition(&running).unwrap().reason, "AgentRunning"); @@ -4472,6 +4767,17 @@ mod lifecycle_tests { .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"); + assert!( + backend + .registry + .lock() + .await + .get("sb-stop") + .unwrap() + .peer + .is_none() + ); + peer.assert_released(&mut peer_child); } /// `delete_sandbox` must await *confirmed* `ProcessContainer` termination diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 2b1e495871..0a6b5c6e38 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -37,6 +37,8 @@ mod etw_consumer; #[cfg(target_os = "windows")] mod peer; #[cfg(target_os = "windows")] +mod peer_firewall; +#[cfg(target_os = "windows")] mod relay; #[cfg(target_os = "windows")] diff --git a/crates/openshell-driver-mxc/src/peer.rs b/crates/openshell-driver-mxc/src/peer.rs index 549d1f0dbc..cd4fe11b17 100644 --- a/crates/openshell-driver-mxc/src/peer.rs +++ b/crates/openshell-driver-mxc/src/peer.rs @@ -43,6 +43,7 @@ #![allow(unsafe_code)] +use crate::peer_firewall::PeerFirewallRule; use std::ffi::c_void; use std::io; use std::net::SocketAddr; @@ -331,7 +332,14 @@ fn ensure_profile(name: &str, capabilities: &[SID_AND_ATTRIBUTES]) -> io::Result let name_w = wide(name); let name_p = PCWSTR(name_w.as_ptr()); // SAFETY: all string arguments are NUL-terminated and outlive the call. - let created = unsafe { CreateAppContainerProfile(name_p, name_p, name_p, Some(capabilities)) }; + let created = unsafe { + CreateAppContainerProfile( + name_p, + name_p, + name_p, + (!capabilities.is_empty()).then_some(capabilities), + ) + }; match created { Ok(sid) => Ok(OwnedSid(sid)), Err(error) if error.code().0 == HRESULT_ALREADY_EXISTS => { @@ -445,7 +453,9 @@ pub struct PeerHandle { process: isize, prefix: String, descriptor: Arc, - control: Mutex, + control: Mutex>, + firewall: StdMutex>, + profile_sid: String, proxy_addr: SocketAddr, terminated: AtomicBool, /// First instance of the egress pipe, created before the peer starts so the @@ -493,7 +503,11 @@ impl PeerHandle { let mut handle = Self::start_process(peer_exe, sandbox_id, egress.is_some())?; let ready = async { - let control = handle.control.get_mut(); + let control = handle + .control + .get_mut() + .as_mut() + .expect("control pipe exists during startup"); tokio::time::timeout(PEER_READY_TIMEOUT, control.connect()) .await .map_err(|_| { @@ -512,6 +526,16 @@ impl PeerHandle { }) } .await?; + // READY proves only that bind succeeded. Firewall authorization is + // required before the driver may publish this endpoint to a workload. + let name = format!("OpenShell MXC peer {}", handle.prefix); + let exe = peer_exe.to_string(); + let sid = handle.profile_sid.clone(); + let rule = + tokio::task::spawn_blocking(move || PeerFirewallRule::install(name, &exe, &sid, ready)) + .await + .map_err(io::Error::other)??; + *handle.firewall.get_mut().expect("new firewall mutex") = Some(rule); handle.proxy_addr = SocketAddr::from(([127, 0, 0, 1], ready)); let egress_enabled = egress.is_some(); if let (Some(first), Some(tunnel)) = (handle.egress_first.take(), egress) { @@ -535,6 +559,96 @@ impl PeerHandle { Ok(handle) } + /// Native resource fixture: a real child, `AppContainer` profile and secured + /// pipe. It avoids requiring an installed, AC-readable peer executable. + #[cfg(test)] + pub(super) fn test_fixture() -> (Self, std::process::Child) { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle}; + let profile = profile_name_for(&uuid::Uuid::new_v4().to_string()); + let sid = ensure_profile(&profile, &[]).unwrap(); + let profile_sid = sid_to_string(sid.as_psid()).unwrap(); + let descriptor = + SecurityDescriptor::from_sddl(&pipe_sddl(¤t_user_sid().unwrap(), &profile_sid)) + .unwrap(); + let prefix = format!("openshell-mxc-test-{}", uuid::Uuid::new_v4()); + let control = descriptor + .create_pipe(&ctl_pipe_name(&prefix), true) + .unwrap(); + let root = std::env::var("SYSTEMROOT").unwrap(); + let child = std::process::Command::new(format!( + r"{root}\System32\WindowsPowerShell\v1.0\powershell.exe" + )) + .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 60"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + let mut process = HANDLE::default(); + // SAFETY: duplicate the live child's process handle for PeerHandle to own. + unsafe { + DuplicateHandle( + GetCurrentProcess(), + HANDLE(child.as_raw_handle()), + GetCurrentProcess(), + &raw mut process, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .unwrap(); + } + ( + Self { + profile, + process: process.0 as isize, + prefix, + descriptor: Arc::new(descriptor), + control: Mutex::new(Some(control)), + firewall: StdMutex::new(None), + profile_sid, + proxy_addr: "127.0.0.1:0".parse().unwrap(), + terminated: AtomicBool::new(false), + egress_first: None, + egress_task: StdMutex::new(None), + }, + child, + ) + } + + #[cfg(test)] + pub(super) fn assert_released(&self, child: &mut std::process::Child) { + assert!(self.terminated.load(Ordering::Acquire)); + assert!( + child.try_wait().unwrap().is_some(), + "peer child must be reaped before reporting termination" + ); + assert!( + self.control.try_lock().unwrap().is_none(), + "retained Arc must not retain the control pipe" + ); + // first_pipe_instance proves the old pipe was closed, even though this + // fixture deliberately retains an Arc to the terminated PeerHandle. + let _rebound = self + .descriptor + .create_pipe(&ctl_pipe_name(&self.prefix), true) + .unwrap(); + let name = wide(&self.profile); + // SAFETY: terminated strings, no capabilities; the returned SID is freed. + unsafe { + let recreated = CreateAppContainerProfile( + PCWSTR(name.as_ptr()), + PCWSTR(name.as_ptr()), + PCWSTR(name.as_ptr()), + None, + ) + .expect("retained peer Arc must not retain its AppContainer profile"); + let _sid = OwnedSid(recreated); + DeleteAppContainerProfile(PCWSTR(name.as_ptr())).unwrap(); + } + } + /// Create the profile and control pipe and start the peer process. Does not /// wait for the peer; see [`PeerHandle::spawn`]. fn start_process(peer_exe: &str, sandbox_id: &str, egress: bool) -> io::Result { @@ -550,30 +664,30 @@ impl PeerHandle { }) .collect(); let sid = ensure_profile(&profile, &capabilities)?; - let sddl = pipe_sddl(¤t_user_sid()?, &sid_to_string(sid.as_psid())?); - let descriptor = SecurityDescriptor::from_sddl(&sddl)?; - - // The control pipe exists before the peer starts, so the peer's first - // connect attempt succeeds and no other process can squat the name - // (first_pipe_instance + a DACL that only admits the peer). - let control = descriptor.create_pipe(&ctl_pipe_name(&prefix), true)?; - let egress_first = if egress { - Some(descriptor.create_pipe(&egress_pipe_name(&prefix), true)?) - } else { - None - }; + let result = (|| { + let profile_sid = sid_to_string(sid.as_psid())?; + let sddl = pipe_sddl(¤t_user_sid()?, &profile_sid); + let descriptor = SecurityDescriptor::from_sddl(&sddl)?; + + // The control pipe exists before the peer starts, so the peer's first + // connect attempt succeeds and no other process can squat the name + // (first_pipe_instance + a DACL that only admits the peer). + let control = descriptor.create_pipe(&ctl_pipe_name(&prefix), true)?; + let egress_first = if egress { + Some(descriptor.create_pipe(&egress_pipe_name(&prefix), true)?) + } else { + None + }; - let command_line = peer_command_line(peer_exe, &token, egress); - let process = - match spawn_in_app_container(peer_exe, &command_line, sid.as_psid(), &mut capabilities) - { + let command_line = peer_command_line(peer_exe, &token, egress); + let process = match spawn_in_app_container( + peer_exe, + &command_line, + sid.as_psid(), + &mut capabilities, + ) { Ok(handle) => handle, Err(error) => { - // SAFETY: best-effort profile cleanup on the failure path. - unsafe { - let name = wide(&profile); - let _ = DeleteAppContainerProfile(PCWSTR(name.as_ptr())); - } return Err(io::Error::new( error.kind(), format!("failed to start proxy peer '{peer_exe}': {error}"), @@ -581,18 +695,30 @@ impl PeerHandle { } }; - // From here on the handle's Drop cleans up the process and profile. - Ok(Self { - profile, - process: process.0 as isize, - prefix, - descriptor: Arc::new(descriptor), - control: Mutex::new(control), - proxy_addr: "127.0.0.1:0".parse().expect("static address"), - terminated: AtomicBool::new(false), - egress_first, - egress_task: StdMutex::new(None), - }) + // From here on the handle's Drop cleans up the process and profile. + Ok(Self { + profile: profile.clone(), + process: process.0 as isize, + prefix, + descriptor: Arc::new(descriptor), + control: Mutex::new(Some(control)), + firewall: StdMutex::new(None), + profile_sid, + proxy_addr: "127.0.0.1:0".parse().expect("static address"), + terminated: AtomicBool::new(false), + egress_first, + egress_task: StdMutex::new(None), + }) + })(); + if result.is_err() { + // SAFETY: profile creation succeeded; all preparation failures must + // release it, including SID conversion, DACL and pipe creation. + unsafe { + let name = wide(&profile); + let _ = DeleteAppContainerProfile(PCWSTR(name.as_ptr())); + } + } + result } } @@ -685,6 +811,10 @@ impl PeerHandle { { task.abort(); } + // Close the control pipe even while other Arc holders survive. + if let Ok(mut control) = self.control.try_lock() { + control.take(); + } let process = HANDLE(self.process as *mut c_void); // SAFETY: `process` is the handle returned by CreateProcessW and owned by // this struct; the `terminated` flag guarantees it is closed exactly once. @@ -697,6 +827,9 @@ impl PeerHandle { warn!(profile = %self.profile, "failed to delete proxy peer profile: {error}"); } } + if let Ok(mut firewall) = self.firewall.lock() { + firewall.take(); + } info!(profile = %self.profile, "MXC proxy peer terminated"); } } @@ -711,6 +844,52 @@ impl Drop for PeerHandle { mod tests { use super::*; + #[tokio::test] + #[ignore = "requires a staged AC-readable helper in OPENSHELL_MXC_PEER_EXE"] + async fn peer_startup_confirms_firewall_or_fails_before_ready() { + let exe = std::env::var("OPENSHELL_MXC_PEER_EXE").expect("stage the real helper first"); + let id = uuid::Uuid::new_v4().to_string(); + match PeerHandle::spawn(&exe, &id, None).await { + Ok(peer) => { + assert_ne!(peer.proxy_addr().port(), 0); + tokio::task::spawn_blocking(move || peer.terminate()) + .await + .unwrap(); + eprintln!( + "EVIDENCE: actual AppContainer peer reached firewall-authorized readiness" + ); + } + Err(error) => { + let message = error.to_string(); + assert!( + message.contains("firewall authorization failed"), + "{message}" + ); + assert!( + message.contains("permission to manage Windows Firewall rules"), + "{message}" + ); + eprintln!( + "EVIDENCE: actual AppContainer peer failed before readiness with an actionable firewall diagnostic" + ); + } + } + // Profile creation must succeed anew, proving cleanup on either path. + let name = wide(&profile_name_for(&id)); + // SAFETY: terminated strings and no capabilities; SID is freed on drop. + unsafe { + let sid = CreateAppContainerProfile( + PCWSTR(name.as_ptr()), + PCWSTR(name.as_ptr()), + PCWSTR(name.as_ptr()), + None, + ) + .expect("startup failure or termination must release the peer profile"); + let _sid = OwnedSid(sid); + DeleteAppContainerProfile(PCWSTR(name.as_ptr())).unwrap(); + } + } + #[test] fn profile_name_is_stable_and_within_limits() { let name = profile_name_for("6e3db1ed-8861-457f-95bd-35ac9714f4fb"); diff --git a/crates/openshell-driver-mxc/src/peer_firewall.rs b/crates/openshell-driver-mxc/src/peer_firewall.rs new file mode 100644 index 0000000000..6fb2f4c9c8 --- /dev/null +++ b/crates/openshell-driver-mxc/src/peer_firewall.rs @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lifecycle-owned inbound authorization for the unpackaged `AppContainer` peer. +//! COM objects stay on the initializing thread; the owner stores only a name. + +#![allow(unsafe_code)] + +use std::io; +use windows::Win32::NetworkManagement::WindowsFirewall::{ + INetFwPolicy2, INetFwRule3, NET_FW_ACTION_ALLOW, NET_FW_IP_PROTOCOL_TCP, + NET_FW_MODIFY_STATE_OK, NET_FW_PROFILE2_ALL, NET_FW_PROFILE2_DOMAIN, NET_FW_PROFILE2_PRIVATE, + NET_FW_PROFILE2_PUBLIC, NET_FW_RULE_DIR_IN, NetFwPolicy2, NetFwRule, +}; +use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, +}; +use windows::core::BSTR; + +struct ComApartment; +impl ComApartment { + fn new() -> windows::core::Result { + // SAFETY: initialization/uninitialization are paired on this thread. + unsafe { + CoInitializeEx(None, COINIT_MULTITHREADED).ok()?; + } + Ok(Self) + } +} +impl Drop for ComApartment { + fn drop(&mut self) { + // SAFETY: new() successfully initialized COM on the same thread. + unsafe { CoUninitialize() }; + } +} + +fn error(error: impl std::fmt::Display) -> io::Error { + io::Error::other(format!( + "MXC proxy peer firewall authorization failed: {error}. Proxy-peer mode requires \ + permission to manage Windows Firewall rules; run the gateway with that permission \ + and allow local inbound rules in the active firewall profiles. \ + The gateway will create only a loopback TCP rule for this peer executable, \ + AppContainer SID and listener port." + )) +} + +fn configured_rule( + name: &str, + exe: &str, + sid: &str, + port: u16, +) -> windows::core::Result { + // SAFETY: COM is initialized by the caller and all BSTR inputs are owned. + unsafe { + let rule: INetFwRule3 = CoCreateInstance(&NetFwRule, None, CLSCTX_INPROC_SERVER)?; + rule.SetName(&BSTR::from(name))?; + rule.SetApplicationName(&BSTR::from(exe))?; + rule.SetLocalAppPackageId(&BSTR::from(sid))?; + // Protocol must be set before the port properties. + rule.SetProtocol(NET_FW_IP_PROTOCOL_TCP.0)?; + rule.SetLocalPorts(&BSTR::from(port.to_string()))?; + rule.SetLocalAddresses(&BSTR::from("127.0.0.1"))?; + rule.SetRemoteAddresses(&BSTR::from("127.0.0.1"))?; + rule.SetDirection(NET_FW_RULE_DIR_IN)?; + rule.SetProfiles(NET_FW_PROFILE2_ALL.0)?; + rule.SetAction(NET_FW_ACTION_ALLOW)?; + rule.SetEdgeTraversal(false.into())?; + rule.SetEnabled(true.into())?; + Ok(rule) + } +} + +/// A unique rule owned by a single peer. No COM interface crosses threads. +pub struct PeerFirewallRule { + name: String, +} +impl PeerFirewallRule { + pub fn install(name: String, exe: &str, sid: &str, port: u16) -> io::Result { + let _apartment = ComApartment::new().map_err(error)?; + // SAFETY: COM is initialized on this thread. This changes only one + // new rule; it never changes profile defaults or loopback exemptions. + unsafe { + let policy: INetFwPolicy2 = + CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER).map_err(error)?; + if policy.LocalPolicyModifyState().map_err(error)? != NET_FW_MODIFY_STATE_OK { + return Err(error( + "local firewall rules are overridden or inbound traffic is blocked", + )); + } + let profiles = policy.CurrentProfileTypes().map_err(error)?; + for profile in [ + NET_FW_PROFILE2_DOMAIN, + NET_FW_PROFILE2_PRIVATE, + NET_FW_PROFILE2_PUBLIC, + ] { + if profiles & profile.0 != 0 + && policy + .get_BlockAllInboundTraffic(profile) + .map_err(error)? + .as_bool() + { + return Err(error("an active firewall profile blocks all inbound rules")); + } + } + let rule = configured_rule(&name, exe, sid, port).map_err(error)?; + policy.Rules().map_err(error)?.Add(&rule).map_err(error)?; + } + Ok(Self { name }) + } +} +impl Drop for PeerFirewallRule { + fn drop(&mut self) { + let result = (|| -> windows::core::Result<()> { + let _apartment = ComApartment::new()?; + // SAFETY: COM is initialized; remove only this owner's random name. + unsafe { + let policy: INetFwPolicy2 = + CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER)?; + policy.Rules()?.Remove(&BSTR::from(&self.name)) + } + })(); + if let Err(error) = result { + tracing::warn!(rule = %self.name, %error, "failed to remove MXC proxy peer firewall rule"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firewall_install_has_explicit_permission_failure_or_owned_cleanup() { + let name = format!("OpenShell MXC peer test-{}", uuid::Uuid::new_v4()); + let result = PeerFirewallRule::install( + name.clone(), + r"C:\peer.exe", + "S-1-15-2-123456789-234567890-345678901-456789012-567890123-678901234-789012345", + 54321, + ); + match result { + Ok(rule) => { + let _apartment = ComApartment::new().unwrap(); + // SAFETY: the apartment and collection are live on this thread. + unsafe { + let policy: INetFwPolicy2 = + CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER).unwrap(); + assert!(policy.Rules().unwrap().Item(&BSTR::from(&name)).is_ok()); + drop(rule); + assert!(policy.Rules().unwrap().Item(&BSTR::from(&name)).is_err()); + } + eprintln!("EVIDENCE: native firewall install and removal succeeded"); + } + Err(error) => { + let message = error.to_string(); + assert!( + message.contains("permission to manage Windows Firewall rules"), + "{message}" + ); + assert!( + message.contains("0x80070005"), + "expected a permission denial on a standard token: {message}" + ); + let _apartment = ComApartment::new().unwrap(); + // SAFETY: read-only lookup after failed installation. + unsafe { + let policy: INetFwPolicy2 = + CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER).unwrap(); + assert!(policy.Rules().unwrap().Item(&BSTR::from(&name)).is_err()); + } + eprintln!( + "EVIDENCE: native firewall permission denial was actionable and left no rule" + ); + } + } + } + + #[test] + fn peer_rule_is_scoped_to_identity_executable_and_loopback_port() { + let _apartment = ComApartment::new().unwrap(); + let rule = configured_rule( + "test-no-install", + r"C:\peer.exe", + "S-1-15-2-123456789-234567890-345678901-456789012-567890123-678901234-789012345", + 54321, + ) + .unwrap(); + // Inspect the actual COM rule object without installing a host rule. + // SAFETY: object and apartment are live on this thread. + unsafe { + assert_eq!(rule.ApplicationName().unwrap().to_string(), r"C:\peer.exe"); + assert_eq!( + rule.LocalAppPackageId().unwrap().to_string(), + "S-1-15-2-123456789-234567890-345678901-456789012-567890123-678901234-789012345" + ); + assert_eq!(rule.Protocol().unwrap(), NET_FW_IP_PROTOCOL_TCP.0); + assert_eq!(rule.LocalPorts().unwrap().to_string(), "54321"); + assert_eq!( + rule.LocalAddresses().unwrap().to_string(), + "127.0.0.1/255.255.255.255" + ); + assert_eq!( + rule.RemoteAddresses().unwrap().to_string(), + "127.0.0.1/255.255.255.255" + ); + assert_eq!(rule.Direction().unwrap(), NET_FW_RULE_DIR_IN); + assert_eq!(rule.Action().unwrap(), NET_FW_ACTION_ALLOW); + assert_eq!(rule.Profiles().unwrap(), NET_FW_PROFILE2_ALL.0); + assert!(rule.Enabled().unwrap().as_bool()); + assert!(!rule.EdgeTraversal().unwrap().as_bool()); + } + } +} diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 0d028b39c8..c1c9b36ff9 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -999,6 +999,22 @@ fn pc_oneshot_token_is_appcontainer_without_admin_access() { #[tokio::test] #[ignore = "requires real wxc-exec and outbound HTTPS"] async fn pc_https_egress_reads_injected_ca_bundle() { + https_egress_reads_injected_ca_bundle(None).await; +} + +/// The same real HTTPS/L7 policy proof through the `AppContainer` peer. This +/// requires a staged AC-readable helper and firewall-management permission. +#[tokio::test] +#[ignore = "requires real wxc-exec, outbound HTTPS and peer firewall permission"] +async fn pc_peer_https_egress_reads_injected_ca_bundle() { + let Ok(peer) = std::env::var("OPENSHELL_MXC_PEER_EXE") else { + eprintln!("SKIP: set OPENSHELL_MXC_PEER_EXE to a staged AC-readable helper"); + return; + }; + https_egress_reads_injected_ca_bundle(Some(peer)).await; +} + +async fn https_egress_reads_injected_ca_bundle(peer: Option) { let Some(wxc) = wxc_path() else { eprintln!("SKIP: wxc-exec not found"); return; @@ -1008,7 +1024,9 @@ async fn pc_https_egress_reads_injected_ca_bundle() { eprintln!("SKIP: processcontainer not live: {reason}"); return; } - if let Err(reason) = probe_processcontainer_host_loopback(&wxc) { + if peer.is_none() + && let Err(reason) = probe_processcontainer_host_loopback(&wxc) + { eprintln!("SKIP: processcontainer governed egress unavailable: {reason}"); return; } @@ -1114,6 +1132,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { let config = MxcComputeConfig { wxc_exec_path: wxc.to_string_lossy().into_owned(), egress_proxy: true, + pc_proxy_peer_path: peer.unwrap_or_default(), egress_proxy_addr: "127.0.0.1:18080".to_string(), ..Default::default() }; diff --git a/crates/openshell-supervisor-relay/src/imp.rs b/crates/openshell-supervisor-relay/src/imp.rs index bd1cf07c95..838a2a4675 100644 --- a/crates/openshell-supervisor-relay/src/imp.rs +++ b/crates/openshell-supervisor-relay/src/imp.rs @@ -73,9 +73,12 @@ //! hanging or misbehaving later), then blocks waiting for a `"launch"` //! request carrying `data: {"command": [...], "env": [...]}` (one arg per //! `command` element, first is the executable; `env` is `"KEY=VALUE"` -//! strings, replacing the inherited environment entirely when non-empty -- -//! lets runtimes that choke on an unrecognized host env, e.g. node.js -//! `STATUS_DLL_INIT_FAILED`, get a curated one instead). The driver sends +//! strings, replacing the inherited environment entirely when non-empty. +//! `inherit_proxy_env: true` is sent only for MXC proxy-peer launches to retain +//! the HTTP(S) proxy endpoint MXC injects; it defaults to false. This flag never +//! copies `NO_PROXY`. Curated environment entries take precedence. +//! This lets runtimes that choke on an unrecognized host env, e.g. node.js +//! `STATUS_DLL_INIT_FAILED`, get a curated one instead. The driver sends //! this once its stdout-reader observes the ready event (see driver.rs). //! Command/env travel over this channel rather than as `agent-cmd.txt`/ //! `agent-env.txt` files in `share_dir` -- keeps them (which can carry @@ -110,7 +113,7 @@ use tokio_tungstenite::tungstenite::Message; /// out-of-sync peer can't safely ignore, so an independently staged, stale /// binary on either side fails fast with a clear version-mismatch error /// instead of hanging or misbehaving against a field/event it predates. -const PROTOCOL_VERSION: u64 = 4; +const PROTOCOL_VERSION: u64 = 5; const TARGET_STDERR_TAIL_LINES: usize = 20; const TARGET_STDERR_LINE_CHARS: usize = 1024; @@ -146,7 +149,7 @@ pub async fn run() -> anyhow::Result<()> { // control channel, not as files read from share_dir. Shutdown notice // arrives the same way (a later "shutdown" request) -- no share_dir // files are used by this process at all. - let (launch_tx, launch_rx) = oneshot::channel::<(Vec, Vec)>(); + let (launch_tx, launch_rx) = oneshot::channel::<(Vec, Vec, bool)>(); let launch_slot = Arc::new(tokio::sync::Mutex::new(Some(launch_tx))); let (target_ready_tx, target_ready_rx) = oneshot::channel::<()>(); let target_ready_slot = Arc::new(tokio::sync::Mutex::new(Some(target_ready_tx))); @@ -169,11 +172,11 @@ pub async fn run() -> anyhow::Result<()> { )); eprintln!("[openshell-supervisor-relay] waiting for launch request from driver..."); - let (command, env) = launch_rx + let (command, env, inherit_proxy_env) = launch_rx .await .map_err(|_| anyhow::anyhow!("control channel closed before a launch request arrived"))?; - let mut target = match spawn_target(command, env) { + let mut target = match spawn_target(command, env, inherit_proxy_env) { Ok(target) => target, Err(error) => { let message = format!("target process failed to start: {error:#}"); @@ -323,7 +326,11 @@ fn inherited_proxy_env( .collect() } -fn spawn_target(command: Vec, env: Vec) -> anyhow::Result { +fn spawn_target( + command: Vec, + env: Vec, + inherit_proxy_env: bool, +) -> anyhow::Result { if command.is_empty() { anyhow::bail!("launch command must not be empty"); } @@ -347,7 +354,14 @@ fn spawn_target(command: Vec, env: Vec) -> anyhow::Result, Vec)>>>; +type LaunchSlot = tokio::sync::Mutex, Vec, bool)>>>; /// Holds the one-shot sender the driver's host-side listener observation /// fires. Startup does not become ready until this confirmation arrives. type TargetReadySlot = tokio::sync::Mutex>>; @@ -701,7 +715,11 @@ async fn handle_control_request( slot.map_or_else( || serde_json::json!({"id": id, "ok": false, "error": "launch already requested"}), |tx| { - let _ = tx.send((command, env)); + let inherit_proxy_env = data + .get("inherit_proxy_env") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let _ = tx.send((command, env, inherit_proxy_env)); serde_json::json!({"id": id, "ok": true}) }, ) diff --git a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs index c74fbc63ca..86537e8cc7 100644 --- a/crates/openshell-supervisor-relay/tests/control_channel_contract.rs +++ b/crates/openshell-supervisor-relay/tests/control_channel_contract.rs @@ -96,8 +96,13 @@ impl RelayProcess { /// observe. Confirmed by hand: without continuous draining, this /// deadlocks the relay process itself, not just this test. async fn spawn_capturing_stderr(target_port: u16) -> Self { + Self::spawn_capturing_stderr_with_env(target_port, &[]).await + } + + async fn spawn_capturing_stderr_with_env(target_port: u16, env: &[(&str, &str)]) -> Self { let mut child = Command::new(env!("CARGO_BIN_EXE_openshell-supervisor-relay")) .arg(target_port.to_string()) + .envs(env.iter().copied()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -187,7 +192,7 @@ impl RelayProcess { async fn expect_ready(&mut self) { let v = self.next_json().await; assert_eq!(v["event"], "ready"); - assert_eq!(v["protocol_version"], 4); + assert_eq!(v["protocol_version"], 5); } async fn launch(&mut self, id: u64, command: &[&str]) -> Value { @@ -720,3 +725,89 @@ async fn concurrent_forwards_do_not_cross_talk() { other => panic!("unexpected message on B: {other:?}"), } } + +// Drive the real binary with ambient proxy variables, just as IsolationSession +// inherits its user's environment. The launch request supplies a curated env. +async fn assert_proxy_environment(inherit: Option, explicit_proxy: bool) { + let ambient = "http://127.0.0.1:43210/ambient-test"; + let mut relay = RelayProcess::spawn_capturing_stderr_with_env( + 0, + &[ + ("HTTP_PROXY", ambient), + ("HTTPS_PROXY", ambient), + ("http_proxy", ambient), + ("https_proxy", ambient), + ("NO_PROXY", "ambient-bypass"), + ], + ) + .await; + relay.expect_ready().await; + let root = std::env::var("SYSTEMROOT").unwrap(); + let powershell = format!(r"{root}\System32\WindowsPowerShell\v1.0\powershell.exe"); + let script = "Write-Output ('PROXY=' + $env:HTTP_PROXY); Write-Output ('SECURE=' + $env:HTTPS_PROXY); Write-Output ('BYPASS=' + $env:NO_PROXY); Start-Sleep -Seconds 60"; + let mut env = vec![format!("SYSTEMROOT={root}"), "CURATED=1".to_string()]; + if explicit_proxy { + env.push("HTTP_PROXY=http://127.0.0.1:12345/explicit-test".to_string()); + } + let mut data = json!({"command": [powershell, "-NoProfile", "-Command", script], "env": env}); + if let Some(inherit) = inherit { + data["inherit_proxy_env"] = json!(inherit); + } + relay.send(json!({"id":1,"op":"launch","data":data})).await; + assert_eq!(relay.next_json().await["ok"], true); + let expected = if explicit_proxy { + "http://127.0.0.1:12345/explicit-test" + } else if inherit == Some(true) { + ambient + } else { + "" + }; + let secure = if inherit == Some(true) { ambient } else { "" }; + let rx = relay.stderr_lines.as_mut().unwrap(); + let mut output = Vec::new(); + tokio::time::timeout(Duration::from_mins(1), async { + while let Some(line) = rx.recv().await { + output.push(line.clone()); + if line.starts_with("[target stdout] BYPASS=") { + break; + } + } + }) + .await + .expect("target environment probe must finish"); + assert!( + output.contains(&format!("[target stdout] PROXY={expected}")), + "{output:?}" + ); + assert!( + output.contains(&format!("[target stdout] SECURE={secure}")), + "{output:?}" + ); + assert!( + output.contains(&"[target stdout] BYPASS=".to_string()), + "{output:?}" + ); + relay.send(json!({"id":2,"op":"shutdown"})).await; + assert_eq!(relay.next_json().await["ok"], true); + tokio::time::timeout(TIMEOUT, relay.child.wait()) + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn curated_launch_without_peer_metadata_drops_ambient_proxy() { + assert_proxy_environment(None, false).await; +} +#[tokio::test] +async fn non_peer_launch_explicitly_drops_ambient_proxy() { + assert_proxy_environment(Some(false), false).await; +} +#[tokio::test] +async fn peer_launch_preserves_mxc_proxy_without_no_proxy_bypass() { + assert_proxy_environment(Some(true), false).await; +} +#[tokio::test] +async fn peer_launch_keeps_explicit_proxy_override() { + assert_proxy_environment(Some(true), true).await; +} diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 5e2b89814b..cd9ac709c0 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -1040,6 +1040,20 @@ listeners. MXC does not give the gateway or peer an identity-scoped reverse path to the workload, so `openshell forward service` fails before opening a listener when this mode is enabled. +Proxy-peer mode also requires the gateway account to have permission to manage +Windows Firewall rules. Before startup succeeds, the gateway adds an inbound +TCP allow rule scoped to the helper executable, AppContainer SID, loopback +addresses and ephemeral listener port. Startup fails with a diagnostic when +permission is missing or an active firewall profile ignores inbound rules. The +gateway removes the peer, pipes, profile and rule after confirmed workload exit, +stop or launch failure. A timed-out stop or delete retains them until termination +is confirmed. An abrupt gateway termination can leave the profile and rule for +operator cleanup. + +Restage the gateway and `openshell-supervisor-relay.exe` together: relay protocol +version 5 enables inheritance of MXC proxy variables only for peer launches. +Other curated launch environments exclude ambient proxy settings. + This mode narrows outbound and loopback reachability, but it is not complete inbound isolation. MXC 1.0 requires `ingress.default = "allow"` for the target ProcessContainer, which enables private-network server traffic. Accept that diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 2d8247f75e..ad0acb1c6c 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -850,3 +850,22 @@ probes. Arguments after `--` validate the effective daemon invocation, including its command-line overrides. Preflight preserves every failed file. Do not advise users to delete or rewrite it automatically; back it up and follow the manual schema-v2 migration in the Gateway Configuration reference. + +### MXC proxy-peer startup failures + +When `pc_proxy_peer_path` is configured, the gateway must be able to manage local +Windows Firewall rules. Peer startup adds an inbound TCP allow rule scoped to +the peer executable, AppContainer SID, loopback addresses and ephemeral listener +port. An authorization error or active policy that ignores inbound rules fails +startup before readiness; verify the gateway account's permission and effective +firewall policy. Do not disable the firewall or add a broad port exception. + +Peers, pipes, profiles and firewall rules are released after confirmed workload +termination, including natural completion and launch failure. A timed-out stop +or delete retains ownership for retry. After abrupt gateway death, inspect +orphan `OpenShell MXC peer openshell-mxc-*` rules and profiles and confirm no +owning gateway or peer is alive before removing them. + +Protocol version 5 requires the gateway and `openshell-supervisor-relay.exe` to +be restaged together. Proxy inheritance is explicit for peer launches; a curated +non-peer launch must not copy the gateway user's ambient proxy settings. From 572de53b24e6925b5a2e6b342b8250b6a1584c1d Mon Sep 17 00:00:00 2001 From: Shailendra Singh Date: Fri, 9 Oct 2026 12:11:13 -0700 Subject: [PATCH 38/39] test(mxc): qualify BaseContainer proxy without elevation Signed-off-by: Shailendra Singh --- .../build-openshell-mxc-windows/SKILL.md | 17 ++ architecture/windows.md | 13 +- crates/openshell-driver-mxc/README.md | 45 ++++- crates/openshell-driver-mxc/src/driver.rs | 3 +- .../openshell-driver-mxc/src/peer_firewall.rs | 19 +- .../tests/wxc_exec_real.rs | 183 +++++++++++++++++- docs/reference/gateway-config.mdx | 14 +- skills/debug-openshell-cluster/SKILL.md | 8 +- 8 files changed, 281 insertions(+), 21 deletions(-) diff --git a/.agents/skills/build-openshell-mxc-windows/SKILL.md b/.agents/skills/build-openshell-mxc-windows/SKILL.md index c7c630b011..79356f78b6 100644 --- a/.agents/skills/build-openshell-mxc-windows/SKILL.md +++ b/.agents/skills/build-openshell-mxc-windows/SKILL.md @@ -361,6 +361,19 @@ The default-feature tests are also included in the full workspace test run. The focused task is available for local diagnosis and selective-build validation; GitHub Actions does not re-run it after the full suite. +### BaseContainer no-UAC qualification + +Run the `pc_basecontainer_` tests in `tests/wxc_exec_real.rs` from an unelevated +terminal with `OPENSHELL_MXC_QUALIFY_NO_UAC=1` and the real +`OPENSHELL_WXC_EXEC_PATH`. Use the native target and serial execution. One test +proves the actual OpenShell host-proxy HTTPS/CA/L7 path with +`pc_proxy_peer_path` empty; the other independently proves MXC's native +`runtimeConfig.networkProxy` with an ordinary host listener. Both assert +`TokenElevation=0` and the BaseContainer probe tier. Once enabled, qualification +fails on missing prerequisites or a skipped workload. Without the opt-in flag, +the generic real-MXC task skips them; do not count those skips as proof. +Neither test creates a Windows Firewall rule or launches an AppContainer peer. + ### Proxy-peer firewall validation Proxy-peer startup requires permission to manage Windows Firewall rules. The @@ -373,6 +386,10 @@ For the real peer HTTPS/L7 test, stage an AC-readable `openshell-mxc-peer.exe`, set `OPENSHELL_MXC_PEER_EXE` to its absolute path, and run the architecture's `windows:test:mxc-real:*` task with firewall-management permission. The peer test must execute rather than print SKIP before claiming connectivity coverage. +The current writer's `INetFwRules::Add` plus `LocalAppPackageId` combination is +[unsupported by Windows](https://learn.microsoft.com/en-us/windows/win32/api/netfw/nf-netfw-inetfwrules-add). +Elevation alone is not a verified remedy. A passing permission-denial test +proves failure handling, not successful firewall installation or peer HTTPS. Relay protocol version 5 requires matching gateway and relay binaries. The compiled relay contract tests cover curated non-peer environments and explicit diff --git a/architecture/windows.md b/architecture/windows.md index 9d206de449..0bc91042fb 100644 --- a/architecture/windows.md +++ b/architecture/windows.md @@ -81,7 +81,7 @@ onto the agent user's default environment. | Property | `process_container` | `isolation_session` | |---|---|---| -| Runtime model | One-shot AppContainer process; default backend | Persistent MXC session used to run one configured process | +| Runtime model | One-shot MXC process; prefers BaseContainer, with a compatible AppContainer fallback; default backend | Persistent MXC session used to run one configured process | | Driver lifecycle | Launch and monitor `wxc-exec` | `provision` -> `start` -> `exec`; stop/delete issue `stop` and `deprovision` | | Filesystem | Read-only/read-write grants with default-deny behavior | OpenShell filesystem-policy grants are unsupported; MXC rejects non-empty read-only/read-write grants | | Portable UI policy | Supported completely | Every explicit `ui` section is rejected before provisioning | @@ -153,6 +153,12 @@ prevent another sandbox from using the OpenShell proxy, but they do not isolate unrelated host-loopback services or authenticate individual processes within one sandbox. +On a host where MXC selects BaseContainer and supports host loopback, the +default host-proxy mode runs from an unelevated harness without a UAC prompt. +Leave `pc_proxy_peer_path` empty; this path creates no Windows Firewall rule. +The OpenShell backend name remains `process_container`; BaseContainer is MXC's +selected enforcement tier. + Proxy-peer mode narrows that exception. The driver starts `openshell-mxc-peer.exe` under a per-sandbox AppContainer identity, names that identity as MXC's `allowedProxyPeer`, sets MXC's `networkProxy`, and keeps @@ -172,7 +178,10 @@ Peer readiness includes a Windows Firewall inbound allow rule scoped to the peer executable, AppContainer SID, loopback addresses and ephemeral TCP port. Proxy-peer mode requires permission to manage local firewall rules; startup fails with an actionable diagnostic if permission is missing or active policy -ignores inbound rules. The gateway removes the rule, process, pipes and profile +ignores inbound rules. The current rule writer uses `INetFwRules::Add` with +`LocalAppPackageId`, a combination Windows does not support. This optional mode +is not qualified for unattended use; elevation alone does not resolve that +implementation limitation. The gateway removes the rule, process, pipes and profile after confirmed workload termination, including natural exit and launch failure. A timed-out stop or delete retains ownership until termination is confirmed. Retaining the sandbox record does not retain a live peer. Abrupt gateway death diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 442562f75f..e7d2b2aec0 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -9,8 +9,9 @@ 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 gateway composes the create-time effective `SandboxPolicy` and carries it on the driver-only copy of -`DriverSandboxSpec.policy`. `process_container` launches a one-shot AppContainer -and is the default. The opt-in `isolation_session` backend uses the +`DriverSandboxSpec.policy`. `process_container` launches a one-shot MXC process +and is the default. MXC selects BaseContainer when supported, otherwise a +compatible AppContainer fallback. The opt-in `isolation_session` backend uses the state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. The driver launches and monitors the configured workload and self-reports readiness. Optional `openshell-supervisor-relay` wrapping provides launch and @@ -102,6 +103,41 @@ fallback. If it is combined with `egress_proxy = true`, a sandbox policy without explicit network rules is rejected synchronously rather than falling through from governed egress to `network.egress.default = "allow"`. +### BaseContainer proxy without UAC + +For an unelevated agent harness on a BaseContainer host with +`baseContainerSupportsIngressHostLoopbackAllow`, use the default host proxy: + +```toml +[openshell.drivers.mxc] +backend = "process_container" +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" +pc_proxy_peer_path = "" +``` + +Provide a per-sandbox `cwd` and explicit network policy. This path injects proxy +and CA environment variables and does not create Windows Firewall rules. +BaseContainer is the tier selected by MXC, not a separate OpenShell backend +name. The existing host-loopback access tradeoff still applies. + +Run both strict no-UAC qualifications from a non-elevated terminal: + +```powershell +$env:OPENSHELL_WXC_EXEC_PATH = "C:\mxc\wxc-exec.exe" +$env:OPENSHELL_MXC_QUALIFY_NO_UAC = "1" +cargo test -p openshell-driver-mxc --test wxc_exec_real pc_basecontainer_ -- --ignored --test-threads=1 --nocapture +``` + +The first test drives the OpenShell host proxy and checks HTTPS CA validation +and L7 denial. The second independently sets MXC's native +`runtimeConfig.networkProxy` against an ordinary local listener without an +AppContainer peer. OpenShell's default mode uses proxy environment variables; +it does not select that native runtime-proxy mapping, which has different +forwarding constraints. Enabled qualification fails on elevation, an unsuitable +MXC tier, missing prerequisites, or a skipped HTTPS workload. Without the +opt-in flag, the generic real-MXC suite skips these two qualifications. + ### Proxy-peer mode By default a `process_container` sandbox gets `hostLoopback: allow` plus an @@ -157,7 +193,10 @@ forward: unsupported in proxy-peer mode publishing readiness, the gateway installs an inbound TCP allow rule scoped to the helper executable, profile SID, `127.0.0.1` endpoints and listener port. Startup fails if permission is missing or active firewall policy ignores - inbound rules. The gateway does not change profile defaults or loopback exemptions. + inbound rules. The current `INetFwRules::Add` writer specifies + `LocalAppPackageId`, which [Windows does not support](https://learn.microsoft.com/en-us/windows/win32/api/netfw/nf-netfw-inetfwrules-add). + Peer connectivity is not qualified; elevating the gateway alone is not a + verified fix. The gateway does not change profile defaults or loopback exemptions. - The peer, pipes, firewall rule and profile are released after confirmed workload exit, stop or launch failure, even when the sandbox record is retained. A stop or delete timeout retains ownership for retry. If the gateway is killed, the diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 742360abb4..8d7429a6b4 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -155,7 +155,8 @@ async fn wait_for_target_listener(port: u16) -> std::io::Result<()> { /// - `IsolationSession`: persistent, attachable session /// (provision → start → exec → stop → deprovision). Does not support /// `OpenShell` filesystem-policy grants; backend defaults determine visibility. -/// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a +/// - `ProcessContainer` (default): one-shot MXC process. MXC selects `BaseContainer` +/// when supported and an `AppContainer` fallback otherwise. 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")] diff --git a/crates/openshell-driver-mxc/src/peer_firewall.rs b/crates/openshell-driver-mxc/src/peer_firewall.rs index 6fb2f4c9c8..6bea44cfe6 100644 --- a/crates/openshell-driver-mxc/src/peer_firewall.rs +++ b/crates/openshell-driver-mxc/src/peer_firewall.rs @@ -37,10 +37,10 @@ impl Drop for ComApartment { fn error(error: impl std::fmt::Display) -> io::Error { io::Error::other(format!( "MXC proxy peer firewall authorization failed: {error}. Proxy-peer mode requires \ - permission to manage Windows Firewall rules; run the gateway with that permission \ - and allow local inbound rules in the active firewall profiles. \ - The gateway will create only a loopback TCP rule for this peer executable, \ - AppContainer SID and listener port." + permission to manage Windows Firewall rules. The current INetFwRules::Add with \ + LocalAppPackageId implementation is unsupported by Windows; elevation alone is \ + not a verified remedy. Default BaseContainer host-proxy mode with \ + pc_proxy_peer_path unset does not call this firewall API." )) } @@ -103,7 +103,11 @@ impl PeerFirewallRule { } } let rule = configured_rule(&name, exe, sid, port).map_err(error)?; - policy.Rules().map_err(error)?.Add(&rule).map_err(error)?; + policy + .Rules() + .map_err(error)? + .Add(&rule) + .map_err(|cause| error(format!("INetFwRules::Add: {cause}")))?; } Ok(Self { name }) } @@ -157,6 +161,11 @@ mod tests { message.contains("permission to manage Windows Firewall rules"), "{message}" ); + assert!(message.contains("INetFwRules::Add:"), "{message}"); + assert!( + message.contains("elevation alone is not a verified remedy"), + "{message}" + ); assert!( message.contains("0x80070005"), "expected a permission denial on a standard token: {message}" diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index c1c9b36ff9..4bbc1a360b 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -119,6 +119,7 @@ mod token_probe { let (can_create_service, create_service_error) = service_manager_create_access(); let snapshot = serde_json::json!({ + "is_elevated": token_u32(token, 20), // TokenElevation "is_appcontainer": token_u32(token, 29), // TokenIsAppContainer "has_appcontainer_sid": has_appcontainer_sid(token), "can_create_service": can_create_service, @@ -993,9 +994,178 @@ fn pc_oneshot_token_is_appcontainer_without_admin_access() { drop(tempdir); } +/// Opt-in qualifications preserve the generic suite's skip-safe contract. +/// Once enabled, unsuitable hosts or a skipped workload fail qualification. +fn require_unelevated_basecontainer() -> Option { + if std::env::var("OPENSHELL_MXC_QUALIFY_NO_UAC").as_deref() != Ok("1") { + eprintln!("SKIP: set OPENSHELL_MXC_QUALIFY_NO_UAC=1 for strict no-UAC qualification"); + return None; + } + let snapshot = token_probe::snapshot(); + assert_eq!( + snapshot["is_elevated"], 0, + "no-UAC qualification must run from an unelevated harness: {snapshot}" + ); + assert!( + !std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|v| v == "1"), + "no-UAC qualification must use real MXC" + ); + let wxc = wxc_path().expect("no-UAC qualification requires real wxc-exec"); + let output = Command::new(&wxc) + .arg("--probe") + .output() + .expect("MXC probe"); + assert!(output.status.success(), "MXC probe failed: {output:?}"); + let probe: serde_json::Value = serde_json::from_slice(&output.stdout).expect("MXC probe JSON"); + assert_eq!(probe["tier"], "base-container", "MXC probe: {probe}"); + assert_eq!(probe["needsDaclAugmentation"], false, "MXC probe: {probe}"); + assert_eq!( + probe["probes"]["baseContainerSupportsIngressHostLoopbackAllow"], true, + "MXC probe: {probe}" + ); + eprintln!("EVIDENCE: unelevated host token={snapshot}; MXC probe={probe}"); + Some(wxc) +} + +/// Exercise the actual `OpenShell` driver, host proxy, CA injection and L7 policy +/// from an unelevated harness with the optional `AppContainer` peer disabled. +#[tokio::test] +#[ignore = "requires unelevated BaseContainer host, real wxc-exec and outbound HTTPS"] +async fn pc_basecontainer_https_proxy_without_elevation() { + let Some(_wxc) = require_unelevated_basecontainer() else { + return; + }; + assert!( + https_egress_reads_injected_ca_bundle(None).await, + "no-UAC HTTPS qualification must execute, not skip" + ); + assert_eq!(token_probe::snapshot()["is_elevated"], 0); + eprintln!( + "EVIDENCE: BaseContainer host proxy, peer disabled: HTTPS GET passed, \ + injected CA verified, HTTPS POST denied with 403; harness remained unelevated" + ); +} + +/// Independently exercise MXC's native `runtimeConfig.networkProxy` contract +/// with an ordinary host listener. No peer profile or firewall rule is created. +#[tokio::test] +#[ignore = "requires unelevated BaseContainer host and real wxc-exec"] +async fn pc_basecontainer_runtime_proxy_without_elevation() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let Some(wxc) = require_unelevated_basecontainer() else { + return; + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("local proxy listener"); + let proxy_url = format!("http://{}", listener.local_addr().unwrap()); + let (_tempdir, temp_path) = temp_fixture(); + let response_path = PathBuf::from(&temp_path).join("proxy-response.txt"); + let curl = PathBuf::from(std::env::var("SYSTEMROOT").expect("SYSTEMROOT")) + .join("System32") + .join("curl.exe"); + assert!(curl.exists(), "Windows curl.exe required"); + let config = serde_json::json!({ + "version": "1.0.0", + "containerId": format!("no-uac-proxy-{}", uuid::Uuid::new_v4()), + "containment": "processcontainer", + "process": { + "commandLine": format!( + "cmd /d /c echo PROXY=%HTTP_PROXY% && \"{}\" --fail --silent \ + --show-error --max-time 15 --noproxy \"\" --proxy \"%HTTP_PROXY%\" \ + http://example.invalid/no-uac --output \"{}\"", + curl.display(), response_path.display(), + ), + "cwd": temp_path, + "timeout": 30_000, + }, + "filesystem": { "readwritePaths": [temp_path] }, + "ui": { "disable": false, "clipboard": "none", "injection": false }, + "network": { + "egress": { "default": "deny" }, + "ingress": { "default": "allow", "hostLoopback": "allow" }, + }, + "runtimeConfig": { "networkProxy": proxy_url }, + }); + let b64 = + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&config).unwrap()); + let request_probe = Command::new(&wxc) + .args(["--probe", "--config-base64", &b64]) + .output() + .expect("request-aware MXC probe"); + assert!( + request_probe.status.success(), + "request probe failed: {request_probe:?}" + ); + let probe: serde_json::Value = + serde_json::from_slice(&request_probe.stdout).expect("request probe JSON"); + assert_eq!(probe["tier"], "base-container", "request probe: {probe}"); + + let proxy = async { + tokio::time::timeout(Duration::from_secs(20), async { + let (mut stream, _) = listener.accept().await.expect("proxy connection"); + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let len = stream.read(&mut chunk).await.expect("proxy request"); + assert_ne!(len, 0, "proxy request ended before headers"); + request.extend_from_slice(&chunk[..len]); + assert!(request.len() <= 8192, "proxy request headers too large"); + } + let request = String::from_utf8(request).expect("HTTP request text"); + assert!( + request.starts_with("GET http://example.invalid/no-uac HTTP/1.1\r\n"), + "expected absolute-form request through configured proxy: {request}" + ); + let body = "openshell-no-uac-proxy"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("proxy response"); + stream.shutdown().await.expect("close proxy response"); + request + }) + .await + .expect("configured proxy must receive request") + }; + let workload = tokio::task::spawn_blocking(move || { + Command::new(&wxc) + .args(["--config-base64", &b64]) + .output() + .expect("real MXC workload") + }); + let (request, output) = tokio::join!(proxy, workload); + let output = output.expect("MXC workload task"); + assert!( + output.status.success(), + "runtime proxy workload failed: {output:?}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!("PROXY={proxy_url}")), + "MXC must inject the configured HTTP_PROXY: {stdout}" + ); + assert_eq!( + std::fs::read_to_string(response_path).expect("proxied response"), + "openshell-no-uac-proxy" + ); + assert_eq!(token_probe::snapshot()["is_elevated"], 0); + eprintln!( + "EVIDENCE: native runtimeConfig.networkProxy={proxy_url}; peer identity omitted; \ + real BaseContainer delivered {}; harness remained unelevated", + request.lines().next().unwrap() + ); +} + /// 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`. +/// that the driver's internal TLS share is reachable from the MXC workload. #[tokio::test] #[ignore = "requires real wxc-exec and outbound HTTPS"] async fn pc_https_egress_reads_injected_ca_bundle() { @@ -1014,28 +1184,28 @@ async fn pc_peer_https_egress_reads_injected_ca_bundle() { https_egress_reads_injected_ca_bundle(Some(peer)).await; } -async fn https_egress_reads_injected_ca_bundle(peer: Option) { +async fn https_egress_reads_injected_ca_bundle(peer: Option) -> bool { let Some(wxc) = wxc_path() else { eprintln!("SKIP: wxc-exec not found"); - return; + return false; }; if let Err(reason) = probe_processcontainer(&wxc) { eprintln!("SKIP: processcontainer not live: {reason}"); - return; + return false; } if peer.is_none() && let Err(reason) = probe_processcontainer_host_loopback(&wxc) { eprintln!("SKIP: processcontainer governed egress unavailable: {reason}"); - return; + return false; } 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()); - return; + return false; } let output_dir = tempfile::tempdir().expect("HTTPS output directory"); @@ -1194,6 +1364,7 @@ async fn https_egress_reads_injected_ca_bundle(peer: Option) { || post_response.contains("no matching L7 allow rule"), "POST denial must come from the OpenShell L7 policy: {post_response}" ); + true } /// Prove that host-proxy binary policy follows the process that owns each TCP diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index cd9ac709c0..6547bfaa20 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -1026,6 +1026,13 @@ non-mutating administrator-gated Service Control Manager open. Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `/.openshell-proxy//`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. In the default mode, MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback. +For an agent harness running without elevation, use `backend = "process_container"`, +enable `egress_proxy`, and leave `pc_proxy_peer_path = ""`. On a host where MXC +selects BaseContainer and reports `baseContainerSupportsIngressHostLoopbackAllow`, +this host-proxy path creates no Windows Firewall rules and requires no UAC +prompt. BaseContainer is an MXC enforcement tier; it is not a gateway backend +name. The host-loopback access described above still applies. + Set `pc_proxy_peer_path` to the absolute path of `openshell-mxc-peer.exe` to replace that broad host-loopback exception with MXC's identity-scoped proxy-peer model. This mode requires @@ -1044,8 +1051,11 @@ Proxy-peer mode also requires the gateway account to have permission to manage Windows Firewall rules. Before startup succeeds, the gateway adds an inbound TCP allow rule scoped to the helper executable, AppContainer SID, loopback addresses and ephemeral listener port. Startup fails with a diagnostic when -permission is missing or an active firewall profile ignores inbound rules. The -gateway removes the peer, pipes, profile and rule after confirmed workload exit, +permission is missing or an active firewall profile ignores inbound rules. +The current rule writer combines `INetFwRules::Add` with `LocalAppPackageId`, +which [Windows does not support](https://learn.microsoft.com/en-us/windows/win32/api/netfw/nf-netfw-inetfwrules-add). +This optional mode is not qualified for unattended use; granting administrator +permission alone is not a verified remedy. The gateway removes the peer, pipes, profile and rule after confirmed workload exit, stop or launch failure. A timed-out stop or delete retains them until termination is confirmed. An abrupt gateway termination can leave the profile and rule for operator cleanup. diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index ad0acb1c6c..ec1e8d7ddd 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -857,8 +857,12 @@ When `pc_proxy_peer_path` is configured, the gateway must be able to manage loca Windows Firewall rules. Peer startup adds an inbound TCP allow rule scoped to the peer executable, AppContainer SID, loopback addresses and ephemeral listener port. An authorization error or active policy that ignores inbound rules fails -startup before readiness; verify the gateway account's permission and effective -firewall policy. Do not disable the firewall or add a broad port exception. +startup before readiness. The current rule writer uses `INetFwRules::Add` with +`LocalAppPackageId`, which Windows does not support, so do not prescribe UAC +approval as a verified fix. Default BaseContainer host-proxy mode with +`pc_proxy_peer_path` empty creates no firewall rule and supports an unelevated +harness on a host with MXC host-loopback support. Keep the loopback-access +tradeoff explicit. Do not disable the firewall or add a broad port exception. Peers, pipes, profiles and firewall rules are released after confirmed workload termination, including natural completion and launch failure. A timed-out stop From aed110e94b0e31a01dcd39e9d2ec13b3a7d3853a Mon Sep 17 00:00:00 2001 From: Prashant S Khodade Date: Mon, 12 Oct 2026 00:52:55 -0700 Subject: [PATCH 39/39] fix(mxc): tolerate post-create exec failure in openclaw forward test The test ends 'sandbox create' with '-- exit'. In a supervisor-less MXC sandbox the CLI's post-create exec fails with exit 255 and 'CreateProcessW failed error:2 posix_spawnp: No such file or directory', which the harness's benign-message list did not cover, so the script aborted before the forward and health checks even though the sandbox was created and ready. Treat that specific outcome as benign. Signed-off-by: Prashant S Khodade --- .../examples/run-openclaw-forward-test.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 index e94cef0be7..4d26b8ec84 100644 --- a/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 +++ b/crates/openshell-driver-mxc/examples/run-openclaw-forward-test.ps1 @@ -535,6 +535,10 @@ try { $createCode = 1 } $createBenign = Show-SandboxCreate $createOut $SandboxName + # The trailing `-- exit` post-create exec has nothing to run in a supervisor-less + # sandbox; current CLIs report that as exit 255 + "posix_spawnp: No such file". + # The sandbox itself is created and ready, so do not abort on it. + if ($createCode -eq 255 -and (($createOut | Out-String) -match "posix_spawnp")) { $createBenign = $true } if ($createCode -ne 0 -and -not $createBenign) { throw "sandbox create '$SandboxName' failed (exit $createCode): $($createOut | Out-String)" }