diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index 8c8ba6f546..8f9f3ef462 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -359,7 +359,7 @@ stopped. Delete remains 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. +**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. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. An endpoint with omitted `protocol` retains explicit-proxy behavior. Explicit `protocol: tcp` requests policy DNS and transparent TCP and currently requires @@ -429,7 +429,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. 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. MXC rejects live policy replacement and merge updates; delete and recreate an MXC sandbox instead. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect 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/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 61beab95f1..b8753330fe 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -381,7 +381,7 @@ The sandbox name defaults to the last-used sandbox. ### `openshell policy update [name]` -Incrementally merge live network policy changes into the current sandbox policy. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision. +Incrementally merge live network policy changes into the current sandbox policy when the selected compute driver supports live updates. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision. MXC rejects live policy merges; delete and recreate an MXC sandbox instead. | Flag | Default | Description | |------|---------|-------------| @@ -411,7 +411,7 @@ Notes: ### `openshell policy set [name] --policy ` -Replace the full policy on a live sandbox. Only the dynamic `network_policies` field can be changed at runtime. +Replace the full policy on a live sandbox when the selected compute driver supports live updates. Only the dynamic `network_policies` field can be changed at runtime. MXC rejects live policy replacement; delete and recreate an MXC sandbox instead. | Flag | Default | Description | |------|---------|-------------| diff --git a/.gitattributes b/.gitattributes index af03ae26af..ab8f61c0d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,6 @@ crates/openshell-core/src/proto/openshell.*.rs linguist-generated # Vendored OCSF schemas fetched from schema.ocsf.io crates/openshell-ocsf/schemas/** linguist-generated + +# TypeScript tooling and Biome require stable LF input on every host +sdk/typescript/** text eol=lf diff --git a/Cargo.lock b/Cargo.lock index fe85d58a7a..d1636ff12f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3875,6 +3875,28 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "openshell-driver-mxc" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "futures", + "openshell-core", + "openshell-ocsf", + "openshell-policy", + "serde", + "serde_json", + "serde_yml", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "uuid", + "windows", +] + [[package]] name = "openshell-driver-podman" version = "0.0.0" @@ -4192,6 +4214,7 @@ dependencies = [ "openshell-driver-docker", "openshell-driver-kubernetes", "openshell-driver-kubernetes-secrets", + "openshell-driver-mxc", "openshell-driver-podman", "openshell-driver-vault", "openshell-extension-core", @@ -4236,6 +4259,7 @@ dependencies = [ "tower 0.5.3", "tower-http 0.6.8", "tracing", + "tracing-appender", "tracing-opentelemetry", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index c484ec95b1..32d0026451 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ terminal-colorsaurus = "1.0" # Error handling 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 = ["Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/architecture/README.md b/architecture/README.md index d6cde146b2..0d814a7a22 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -141,6 +141,10 @@ bridge networks, port mappings, NAT traversal, or bespoke tunnels. The common runtime requirement is narrower: the supervisor must be able to reach the gateway. +The Windows MXC driver is an explicit exception. It launches and monitors a +one-shot workload in the driver, self-reports readiness, and does not expose a +supervisor session, interactive connect, live policy delivery, or governed egress. + The gateway delivers desired state; the sandbox applies it locally. Policy, settings, credentials, and inference routes flow from the gateway to the supervisor. The supervisor validates and applies what can change at runtime, diff --git a/architecture/windows-msvc-build.md b/architecture/windows-msvc-build.md index df875bc8ef..e940b83273 100644 --- a/architecture/windows-msvc-build.md +++ b/architecture/windows-msvc-build.md @@ -1,14 +1,15 @@ # Windows MSVC Build Design This page records the design decisions for the native Windows MSVC build lane. -It is intentionally build-only. It does not make Windows a Docker, Kubernetes, -Podman, or VM runtime host. +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. - 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 @@ -18,7 +19,7 @@ Podman, or VM runtime host. - 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, DPAPI integration, or MXC policy translation in this build lane. +- Do not implement named-pipe driver IPC, Windows services, MSI packaging, Credential Manager integration, or DPAPI integration in this lane. ## Unsupported Driver Strategy @@ -42,6 +43,7 @@ on Windows. | Kubernetes | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. | | Podman | Driver crate excluded; server config contract retained. | Gateway construction returns unsupported. | | VM | Driver crate excluded from workspace validation. | 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. @@ -62,7 +64,7 @@ Windows validation is exposed through `tasks/windows.toml`: | `windows:check:arm64` | Check the ARM64 MSVC gateway/CLI build graph. | | `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, excluding unsupported Windows packages as top-level test targets. | +| `windows:test:x64` | Run native x64 workspace tests, including MXC mapper and lifecycle tests, while excluding unsupported Windows packages as top-level test targets. | | `windows:test:arm64` | Run native ARM64 workspace tests with the same package exclusions. | | `windows:test:unsupported:x64` | Run focused server/runtime tests for unsupported driver contracts. | | `windows:test:unsupported:arm64` | Run the same focused contracts natively on ARM64. | @@ -159,5 +161,5 @@ A successful Windows build report should include: - Focused unsupported-driver contract test status. - Artifact size and SHA256 for each Windows binary. -Warnings from Linux-only dead code are acceptable in this build-only phase when +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/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 62411e20b5..e2cbfb5bfe 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -125,6 +125,8 @@ pub enum ComputeDriverKind { Vm, Docker, Podman, + /// Microsoft MXC isolation session (Windows only). + Mxc, } impl ComputeDriverKind { @@ -135,6 +137,7 @@ impl ComputeDriverKind { Self::Vm => "vm", Self::Docker => "docker", Self::Podman => "podman", + Self::Mxc => "mxc", } } } @@ -175,8 +178,9 @@ impl FromStr for ComputeDriverKind { "vm" => Ok(Self::Vm), "docker" => Ok(Self::Docker), "podman" => Ok(Self::Podman), + "mxc" => Ok(Self::Mxc), other => Err(format!( - "unsupported compute driver '{other}'. expected one of: kubernetes, vm, docker, podman" + "unsupported compute driver '{other}'. expected one of: kubernetes, vm, docker, podman, mxc" )), } } diff --git a/crates/openshell-core/src/telemetry.rs b/crates/openshell-core/src/telemetry.rs index b2c9b79152..ad15989944 100644 --- a/crates/openshell-core/src/telemetry.rs +++ b/crates/openshell-core/src/telemetry.rs @@ -165,6 +165,7 @@ pub enum TelemetryComputeDriver { Kubernetes, Podman, Vm, + Mxc, Unknown, } @@ -176,6 +177,7 @@ impl TelemetryComputeDriver { Self::Kubernetes => "kubernetes", Self::Podman => "podman", Self::Vm => "vm", + Self::Mxc => "mxc", Self::Unknown => "unknown", } } @@ -187,6 +189,7 @@ impl TelemetryComputeDriver { "k8s" | "kubernetes" => Self::Kubernetes, "podman" => Self::Podman, "vm" => Self::Vm, + "mxc" => Self::Mxc, _ => Self::Unknown, } } @@ -198,6 +201,7 @@ impl TelemetryComputeDriver { Some(crate::ComputeDriverKind::Kubernetes) => Self::Kubernetes, Some(crate::ComputeDriverKind::Podman) => Self::Podman, Some(crate::ComputeDriverKind::Vm) => Self::Vm, + Some(crate::ComputeDriverKind::Mxc) => Self::Mxc, None => Self::Unknown, } } diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml new file mode 100644 index 0000000000..b05c7e82e1 --- /dev/null +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-driver-mxc" +description = "MXC (Windows isolation session) compute driver for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "openshell_driver_mxc" + +[dependencies] +openshell-core = { path = "../openshell-core" } +# OCSF builders + emit target used by the Windows ETW audit consumer. OS-agnostic +# crate (no windows deps), so safe to depend on from all targets; only the +# windows-gated `etw_consumer` module actually uses it. +openshell-ocsf = { path = "../openshell-ocsf" } +tokio = { workspace = true } +tonic = { workspace = true } +futures = { workspace = true } +tokio-stream = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +# ETW/TDH real-time consumer (Plane A audit). Windows-only so the Linux/WSL +# build stays an empty stub. +[target.'cfg(target_os = "windows")'.dependencies] +windows = { workspace = true } + +[dev-dependencies] +tokio = { 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 +# proto. Inert on non-Windows. +openshell-policy = { path = "../openshell-policy" } +# Needed by the real-wxc integration test (wxc_exec_real.rs) which builds +# --config-base64 payloads without going through the async WxcExecInvoker. +# base64 and serde_json are already [dependencies] but dev-dependency resolution +# is independent; explicit entries make them visible to integration tests. +base64 = { workspace = true } +serde_json = { 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 } + +[lints] +workspace = true diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md new file mode 100644 index 0000000000..6496dfe286 --- /dev/null +++ b/crates/openshell-driver-mxc/README.md @@ -0,0 +1,118 @@ +# openshell-driver-mxc + +OpenShell compute driver backed by **Microsoft MXC** (`wxc-exec`) on Windows. + +## Design + +This driver implements the gateway's `ComputeDriver` contract as an in-process +library linked into `openshell-gateway`. `process_container` launches a one-shot +AppContainer and is the default. The opt-in `isolation_session` backend uses the +state-aware `provision` → `start` → `exec` → `stop` → `deprovision` lifecycle. +The driver launches and monitors the configured workload itself and self-reports +readiness; there is no in-sandbox supervisor or `ConnectSupervisor` relay. + +## Capability Matrix + +| Capability | MXC driver | +|---|---| +| 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. | +| Network policy | Rejected synchronously during sandbox creation until an enforcing egress path is bound. | +| Process policy | Unsupported; MXC supplies OS isolation only. | +| Interactive exec/connect/forward | Unsupported; the configured workload runs in-driver. | +| Restart durability | Unsupported; the in-memory registry cannot recover live sessions. | + +The filesystem enforcement proof has two paths: + +- A write to a path granted by the sandbox policy succeeds. +- A `process_container` write outside the sandbox policy fails with Windows access denied, and the driver reports the failed workload. + +## Configuration (`[openshell.drivers.mxc]`) + +Gateway configuration contains only host runtime settings: + +```toml +[openshell.drivers.mxc] +wxc_exec_path = "C:\\path\\to\\wxc-exec.exe" +# Default: process_container. isolation_session is grant-only and opt-in. +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +debug = false +``` + +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 +$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}' +openshell sandbox create --name mxc-demo --policy demo.yaml ` + --driver-config-json $config --env MODE=demo --no-tty +``` + +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. + +Network policy and live policy replacement or merge updates are rejected while the gateway uses MXC. Delete and recreate the sandbox to apply a different filesystem policy. + +## Prerequisites (live runs) + +- Windows 11 Insider build ≥ 26300.8553 +- `IsoSessionApp.dll` present and registered +- `wxc-exec.exe` built with `--features isolation_session` + +For off-box smoke tests against the in-process mock shim (no `wxc-exec`, +no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. + +## Policy mapping + +The production driver maps the typed `SandboxPolicy` to MXC configuration before it inserts a registry entry or invokes `wxc-exec`. Mapping failure therefore returns from `CreateSandbox` without leaving a partial sandbox. + +`EmbeddedPolicyMapper` calls the embedded [`policy_map`](src/policy_map/) module directly and normalizes filesystem paths to Windows form. It does not add gateway-configured host paths. The policy supplied for the sandbox is the only source of filesystem grants. + +The mapper retains an internal policy-splitting seam for future development, but the runtime exposes no governed-egress switch. Any network rule fails closed until an enforcing proxy is implemented and bound to the sandbox lifecycle. + +Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. + +## Packaging the demo for the demo box + +Use [`examples/package-demo.ps1`](examples/package-demo.ps1) to assemble +the gateway EXE, CLI EXE, runtime DLLs (`libz3.dll`), `demo.yaml`, the +gateway config, and the runbook into one folder, then copy that folder to +the demo Windows host and follow `mxc-demo-runbook.md` inside it. The +script prints a SHA256 manifest so the operator can sanity-check what +landed before moving it. + +## Real-MXC test lane + +Three tasks drive real `wxc-exec.exe` hardware; all are **skip-safe** — any test +or scenario that requires an absent binary or backend prints a SKIP reason and +exits 0 rather than failing. + +| Task | What it runs | When to use | +|---|---|---| +| `windows:test:mxc-real:x64` | `tests/wxc_exec_real.rs` — Tier-2 invoker tests with `--ignored --test-threads=1` | Pre-merge on any Windows host that has `wxc-exec`; dry-run tests always pass; enforcement tests probe-gate themselves | +| `windows:e2e:mxc` | `examples/run-mxc-e2e.ps1` — Tier-3 scenario runner, real binary, probe-gated | Demo box / nightly; needs the gateway + CLI binaries in the script directory | +| `windows:e2e:mxc:mock` | Same runner with `-Mock` — wiring-only, no real `wxc-exec` needed | Any Windows host (CI, dev machine); validates wiring and the network-reject scenario | + +**Probe script:** `examples/probe-mxc-host.ps1` is an operator/CI preflight that emits a JSON capability report +(OS build, wxc-exec path/version, dry-run exit code, per-backend trial result, +and a `verdicts` object). Run it before the real-MXC lane to understand what +will PASS vs SKIP on a given host: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +``` + +**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). + +## Deferred work + +- **Interactive exec/connect/forward** → `adapt-openshell-gateway-windows` +- **Governed egress** remains fail-closed until an enforcing proxy is implemented and bound to sandbox lifecycle. +- **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-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt new file mode 100644 index 0000000000..ba960e42cf --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -0,0 +1,77 @@ +OpenShell MXC - ETW -> OCSF audit-trail example +=============================================== + +WHAT THIS PROVES / PRODUCES + The full Windows OCSF audit path on this box: + gateway -> MXC driver -> process_container sandbox + -> the OS "Sandboxing" ETW provider fires as the sandbox is created + -> the gateway's in-process consumer decodes each event, attributes it to + an OpenShell sandbox_id, and maps it to OCSF + -> events are written to a durable JSONL audit log AND printed as + human-readable shorthand. + + The deliverable is the OCSF log: openshell-ocsf..log, one OCSF event + object per line - the same schema and medium the Linux OpenShell pipeline + produces (Windows is at functional parity). + + OCSF classes you will see: + [6002] Application Lifecycle - sandbox created + [5019] Device Config State Change - OS policy / hardening / proxy / console + [1007] Process Activity - in-sandbox process launch (+ cmd line) + [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) + - 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; + without it the run fails fast with a clear message. + +HOW TO RUN + 1. Open an ELEVATED PowerShell in THIS folder. + 2. Run: + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 + If wxc-exec is somewhere else: + ... -File .\run-ocsf-audit.ps1 -WxcExecPath "D:\path\to\wxc-exec.exe" + +WHAT YOU GET BACK + The script prints PASS/FAIL + an event-type coverage count and class breakdown, + points you at the OCSF audit log, and creates: + results-.zip + It contains the OCSF audit log (openshell-ocsf..log), the full transcript, + the gateway logs (with the human-readable OCSF shorthand), a summary, and the + exact config + policy used. To auto-copy the bundle to a shared location, pass + -ShareOut '\\server\share' (off by default; results stay local otherwise). + +FILES IN THIS PACKAGE + openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) + openshell.exe the CLI + mxc-ocsf-audit.toml gateway/driver config (process_container, etw_audit=true, egress proxy) + ocsf-audit.yaml sandbox policy (read-write grant to the share dir) + run-ocsf-audit.ps1 the orchestrator you run + README-ocsf-audit.txt this file + (wxc-exec.exe is used IN PLACE on the box; not shipped) + +USEFUL OPTIONS + -SandboxCount Create n sandboxes (default 2). More sandboxes = more events. + -NoProxy Skip the per-sandbox egress proxy. This omits ONLY the + SandboxProxyConfigured config event; everything else is + still produced. (Default is proxy ON for the full set.) + -WxcExecPath Path to wxc-exec.exe on this box. + -ShareOut Copy the results bundle to a shared location + (e.g. \\server\share). Off by default (results stay local). + -KeepRunning Leave the gateway running afterward for inspection. + +NOTES + - The control plane between CLI and gateway runs with --disable-tls on loopback; + that is unrelated to the OCSF audit path this example exercises. + - A "supervisor session not connected" / ssh 255 message during sandbox create + is EXPECTED on MXC and harmless - the agent already ran in-driver. + - The proxy path requires the host-side CONNECT proxy and an absolute agent + binary (the packaged config uses C:\Windows\System32\cmd.exe); the run script + handles this for you. + - The Sandboxing provider reports the sandbox entry-point process, not the full + in-sandbox process tree. Deep process-tree auditing would need a second ETW + source (Microsoft-Windows-Kernel-Process) and is out of scope for this trail. + - cmd_line is captured verbatim into OCSF process.cmd_line with no redaction on + this path; treat the audit log as sensitive at rest and in transit. diff --git a/crates/openshell-driver-mxc/examples/demo.yaml b/crates/openshell-driver-mxc/examples/demo.yaml new file mode 100644 index 0000000000..1dcc23144f --- /dev/null +++ b/crates/openshell-driver-mxc/examples/demo.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# demo.yaml — June 15 MXC filesystem-policy proof. +# +# Minimal filesystem policy granting the workload folder read-write. The granted +# path must cover the per-sandbox command executable, working directory, and any +# files that command reads or writes. ProcessContainer denies all ungranted paths. +# +# NOTE: the canonical OpenShell policy YAML key is `filesystem_policy` +# (parsed by the `openshell-policy` crate into SandboxPolicy.filesystem), NOT +# `filesystem`. The MXC driver's policy bridge then re-emits this under the +# `filesystem_policy` key the embedded mapper expects. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-demo" # = OPENSHELL_MXC_SHARE_DIR (host-visible share) + +# No landlock, process, or network policy is present. MXC rejects every network +# policy at create time until an enforcing egress path is available; it never +# silently drops network rules. diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml new file mode 100644 index 0000000000..8be1dec9cf --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-empty.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-empty.yaml — Empty filesystem policy (default-deny) scenario. +# +# No paths are granted. With processcontainer (AppContainer), every write is +# denied by the OS without any host ACL configuration — genuine default-deny. +# This scenario is processcontainer-only (isolation_session has no deny primitive). +# Used by the fs-default-deny-empty scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: [] diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml new file mode 100644 index 0000000000..7fd2d0864b --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-readonly.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-readonly.yaml — Read-only grant + read-write share scenario. +# +# The read_only path is a prepared directory whose content the agent can read +# but not write; the read_write path is DemoDir (host-visible share). +# Used by the fs-readonly scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - "C:/work/openshell-mxc-e2e-ro-src" + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml new file mode 100644 index 0000000000..38dafa5ed5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/fs-rw.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# fs-rw.yaml — Filesystem read-write grant scenario. +# +# Grants read-write access to the DemoDir (substituted at runtime). +# Used by the fs-rw-positive-negative scenario in run-mxc-e2e.ps1. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" diff --git a/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml new file mode 100644 index 0000000000..e5529eaeb6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/e2e-policies/network-reject.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# network-reject.yaml — Network policy rejection scenario. +# +# A filesystem grant plus a network_policies rule. The driver rejects sandbox +# create at map time with invalid_argument naming the network rule on every +# backend until MXC has a bound, enforcing egress path. +# +# This scenario requires NO live backend — it passes even on this box and in +# mock mode because the rejection happens in the policy mapper before wxc-exec +# is invoked. It is the only scenario that never SKIPs. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-e2e" + +network_policies: + test_rule: + name: test-network-reject + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only diff --git a/crates/openshell-driver-mxc/examples/mxc-gateway.toml b/crates/openshell-driver-mxc/examples/mxc-gateway.toml new file mode 100644 index 0000000000..4fbd2c41f5 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-gateway.toml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# MXC gateway runtime configuration. +# +# Commands, working directories, and environment variables are sandbox-scoped. +# Supply command and cwd through --driver-config-json when creating a sandbox, +# and environment variables through the standard sandbox --env option. + +[openshell.drivers.mxc] +# Path to wxc-exec.exe. Required for live runs. Leave the default for mock-mode +# smoke tests (set OPENSHELL_MXC_MOCK_WXC=1 instead). +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. +backend = "process_container" + +# process_container only: request a Less-Privileged AppContainer. +# pc_least_privilege = false +# process_container only: AppContainer capabilities to grant. +# pc_capabilities = [] + +# isolation_session only. Never use "small" (known OS bug). +default_configuration_id = "composable" + +# Enable --debug on wxc-exec invocations. +debug = false diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml new file mode 100644 index 0000000000..d41e08efe3 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# MXC gateway config for the ETW -> OCSF audit-trail example. +# +# Goal: exercise the in-process ETW consumer (Plane A) end-to-end so that +# creating a sandbox produces a full OCSF audit trail — Application Lifecycle +# [6002], Device Config State Change [5019], Process Activity [1007] and +# Detection Finding [2004] — written to a durable JSONL log, just like the Linux +# OCSF pipeline. +# +# run-ocsf-audit.ps1 patches wxc_exec_path, backend, etw_audit, the egress-proxy +# switch and agent_command into a disposable copy of this file, so the values +# here are sane defaults; edit them if you run the gateway directly. + +[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). +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" + +# One-shot AppContainer. This is the backend whose Sandboxing ETW the consumer +# captures. (isolation_session is "dark" — it emits no provider events.) +backend = "process_container" + +default_configuration_id = "composable" + +# Host folder mapped read-write into the sandbox. +share_dir = "C:/work/openshell-mxc-demo" +agent_cwd = "C:/work/openshell-mxc-demo" + +# A simple in-policy write — enough to make wxc-exec provision an AppContainer and +# drive the Sandboxing provider. Absolute cmd.exe path is REQUIRED when the egress +# proxy is on (the host proxy hashes agent_command[0] as its static identity +# binary, so it must be an absolute, existing exe). +agent_command = [ + "C:\\Windows\\System32\\cmd.exe", + "/c", + "echo hello from openshell ocsf audit 1>C:\\work\\openshell-mxc-demo\\hello.txt", +] + +debug = false + +# Turn ON the Plane-A ETW -> OCSF audit consumer. This is the core of the example. +etw_audit = true + +# Per-sandbox governed egress. Enabling this makes the driver start a host CONNECT +# proxy and hand MXC a `network.proxy` redirect, which is what makes MXC emit the +# SandboxProxyConfigured event — the config event mapped to OCSF CONFIG [5019] +# that completes full event coverage. Requires backend = process_container and a +# loopback (127.0.0.1) seed address; the driver allocates a unique ephemeral port +# per sandbox from this seed. Run-ocsf-audit.ps1 disables this when passed +# -NoProxy. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml new file mode 100644 index 0000000000..ade2f69eec --- /dev/null +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# ocsf-audit.yaml — sandbox policy for the MXC ETW -> OCSF audit-trail example. +# +# Minimal filesystem policy granting the shared host folder read-write; everything +# else is default-deny. The granted path MUST match `share_dir` / +# OPENSHELL_MXC_SHARE_DIR in mxc-ocsf-audit.toml. +# +# 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. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-demo" diff --git a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 new file mode 100644 index 0000000000..4a20e7b86c --- /dev/null +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# probe-mxc-host.ps1 - Operator/CI preflight for a prospective MXC host. +# This diagnostic is not invoked by the driver and does not mutate host state. +# It reports OS, wxc-exec, and backend availability so real tests can skip +# unsupported scenarios with an explicit reason. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\probe-mxc-host.ps1 +# powershell -NoProfile -ExecutionPolicy Bypass -File .\probe-mxc-host.ps1 -OutFile caps.json +# +# The script is read-only except for a per-run temp directory. It never enables +# OS features or modifies system state. +# +# Exit codes: +# 0 - report emitted (even if backends are unavailable) +# 1 - unexpected error (should not happen on a healthy box) + +[CmdletBinding()] +param( + [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [string] $OutFile, + # Emit the complete JSON report to stdout. Default output is a short + # human-readable summary (the full report still goes to -OutFile if set). + [switch] $Full +) + +$ErrorActionPreference = "Stop" +# Prevent PS 7+ from turning native non-zero exits into terminating errors. +$PSNativeCommandUseErrorActionPreference = $false + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +function Invoke-Native([string[]] $ArgList) { + # Run a native exe and capture all output regardless of exit code. + # In PowerShell 5.1, a non-zero exit from a native exe can emit + # ErrorRecord objects into the output stream when $ErrorActionPreference + # is Stop (via NativeCommandError). We temporarily relax the preference + # and collect both String and ErrorRecord outputs into a single string. + $saved = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $raw = & $ArgList[0] $ArgList[1..($ArgList.Length - 1)] 2>&1 + $code = $LASTEXITCODE + $text = ($raw | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $_.ToString() + } else { + $_ + } + }) -join "`n" + return @{ ExitCode = $code; Output = $text } + } finally { + $ErrorActionPreference = $saved + } +} + +function Invoke-WxcDryRun([string] $wxc, [hashtable] $config) { + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + return Invoke-Native @($wxc, "--config-base64", $b64, "--dry-run") +} + +function Invoke-WxcPhase([string] $wxc, [hashtable] $config, [switch] $Experimental) { + $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) + } +} + +function Invoke-WxcProbe([string] $wxc) { + return Invoke-Native @($wxc, "--probe") +} + +# ── OS info ─────────────────────────────────────────────────────────────────── + +$osVersion = [System.Environment]::OSVersion.Version +$osBuild = $osVersion.Build +$osRevision = 0 +try { + $ubr = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -ErrorAction SilentlyContinue).UBR + if ($null -ne $ubr) { $osRevision = $ubr } +} catch {} +$osBuildFull = "$osBuild.$osRevision" +$isoSessionMinBuild = 26300 +$isoSessionMinRevision = 8553 + +# ── wxc-exec info ───────────────────────────────────────────────────────────── + +$wxcInfo = @{ + path = $WxcExecPath + exists = $false + size = $null + mtime = $null +} + +if (Test-Path $WxcExecPath) { + $item = Get-Item $WxcExecPath + $wxcInfo.exists = $true + $wxcInfo.size = $item.Length + $wxcInfo.mtime = $item.LastWriteTime.ToString("o") +} + +# ── Probe section ───────────────────────────────────────────────────────────── + +$probeOutput = $null +$dryRunExitCode = $null +$dryRunOutput = $null +$pcTrialResult = "absent" +$pcTrialMessage = "wxc-exec not found" +$isoTrialResult = "absent" +$isoTrialMessage = "wxc-exec not found" + +if ($wxcInfo.exists) { + # --probe + $probeResult = Invoke-WxcProbe -wxc $WxcExecPath + $probeOutput = $probeResult.Output + + # dry-run trial (minimal processcontainer config) + $dryConfig = @{ + version = "0.6.0-alpha" + containerId = "probe-dryrun" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 0 + } + filesystem = @{ + readwritePaths = @("%TEMP%") + } + } + $dryResult = Invoke-WxcDryRun -wxc $WxcExecPath -config $dryConfig + $dryRunExitCode = $dryResult.ExitCode + $dryRunOutput = $dryResult.Output + + # processcontainer one-shot trial + $pcConfig = @{ + version = "0.6.0-alpha" + containerId = "probe-pc-oneshot" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 10 + } + filesystem = @{ + readwritePaths = @("%TEMP%") + } + processContainer = @{ + leastPrivilege = $false + } + } + $pcResult = Invoke-WxcPhase -wxc $WxcExecPath -config $pcConfig + $pcOutput = $pcResult.Output + $pcOutputLower = $pcOutput.ToLower() + + if ($pcResult.ExitCode -eq 0) { + $pcTrialResult = "works" + $pcTrialMessage = "processcontainer one-shot exited 0" + } elseif ($pcOutputLower -match "backend_error" -or $pcOutputLower -match "e_notimpl" -or $pcOutputLower -match "velocity") { + $pcTrialResult = "backend_error" + # Try to extract the message from the JSON envelope. + $pcTrialMessage = "backend_error: velocity keys not enabled (E_NOTIMPL)" + try { + $envelope = $pcOutput | ConvertFrom-Json + if ($null -ne $envelope.error) { + $pcTrialMessage = "backend_error: $($envelope.error.message)" + } + } catch {} + } else { + $pcTrialResult = "error" + if ([string]::IsNullOrWhiteSpace($pcOutput)) { + $pcTrialMessage = "exit $($pcResult.ExitCode) with no output captured" + } else { + $pcTrialMessage = "exit $($pcResult.ExitCode): $pcOutput" + } + } + + # isolation_session provision trial + $isoConfig = @{ + version = "0.6.0-alpha" + phase = "provision" + containment = "isolation_session" + filesystem = @{ + readwritePaths = @() + readonlyPaths = @() + } + experimental = @{ + isolation_session = @{ + configurationId = "composable" + provision = @{} + } + } + } + $isoResult = Invoke-WxcPhase -wxc $WxcExecPath -config $isoConfig -Experimental + $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" + } elseif ($isoResult.ExitCode -eq 0) { + # Provision succeeded — deprovision immediately to avoid orphaning. + $isoTrialResult = "live" + $isoTrialMessage = "isolation_session provision succeeded" + $sandboxId = $null + try { + $envelope = $isoOutput | ConvertFrom-Json + if ($null -ne $envelope.result) { + $sandboxId = $envelope.result.sandboxId + } + } 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 + if ($deprovResult.ExitCode -eq 0) { + $isoTrialMessage = "isolation_session live (provisioned $sandboxId, deprovisioned cleanly)" + } else { + $snippet = $deprovResult.Output + if ($snippet.Length -gt 200) { $snippet = $snippet.Substring(0, 200) } + $isoTrialMessage = "isolation_session live (provisioned $sandboxId; deprovision FAILED exit $($deprovResult.ExitCode): $snippet -- clean up manually before running lifecycle tests)" + } + } + } else { + $isoTrialResult = "error" + $isoTrialMessage = "exit $($isoResult.ExitCode): $isoOutput" + } +} + +# ── Verdicts ────────────────────────────────────────────────────────────────── + +$pcVerdict = $null +if ($pcTrialResult -eq "works") { + $pcVerdict = "live" +} else { + $pcVerdict = "unavailable: $pcTrialMessage" +} + +$isoVerdict = $null +if ($isoTrialResult -eq "live") { + $isoVerdict = "live" +} else { + $isoVerdict = "unavailable: $isoTrialMessage" +} + +$dryRunVerdict = $null +if ($null -eq $dryRunExitCode) { + $dryRunVerdict = "unavailable: wxc-exec not found" +} elseif ($dryRunExitCode -eq 0) { + $dryRunVerdict = "ok" +} else { + $dryRunVerdict = "failed: exit $dryRunExitCode" +} + +# ── Assemble report ─────────────────────────────────────────────────────────── + +$report = [ordered]@{ + generatedAt = (Get-Date).ToString("o") + host = [ordered]@{ + osBuild = $osBuildFull + osBuildNumber = $osBuild + osRevision = $osRevision + isoSessionBuildRequirement = "${isoSessionMinBuild}.${isoSessionMinRevision}" + meetsIsoBuildReq = ($osBuild -gt $isoSessionMinBuild) -or + ($osBuild -eq $isoSessionMinBuild -and $osRevision -ge $isoSessionMinRevision) + } + wxcExec = $wxcInfo + probeOutput = $probeOutput + dryRun = [ordered]@{ + exitCode = $dryRunExitCode + output = $dryRunOutput + } + processcontainerTrial = [ordered]@{ + result = $pcTrialResult + message = $pcTrialMessage + } + isolationSessionTrial = [ordered]@{ + result = $isoTrialResult + message = $isoTrialMessage + } + verdicts = [ordered]@{ + processcontainer = $pcVerdict + isolation_session = $isoVerdict + dryRun = $dryRunVerdict + } +} + +$json = $report | ConvertTo-Json -Depth 10 + +if ($Full) { + Write-Output $json +} else { + $met = "not met" + if ($report.host.meetsIsoBuildReq) { $met = "met" } + Write-Host "MXC host probe - OS build $osBuildFull (isolation_session requires $($report.host.isoSessionBuildRequirement): $met)" + Write-Host "wxc-exec: $WxcExecPath (exists=$($wxcInfo.exists))" + Write-Host "verdicts:" + Write-Host " processcontainer : $pcVerdict" + 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)" +} + +if ($OutFile) { + $json | Out-File -FilePath $OutFile -Encoding utf8 + Write-Host "Report written to $OutFile" -ForegroundColor Cyan +} diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 new file mode 100644 index 0000000000..9f8baa3446 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -0,0 +1,484 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-mxc-e2e.ps1 - MXC e2e scenario runner. +# +# Starts the gateway ONCE, runs a table of policy scenarios, emits per-scenario +# PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero only on +# FAIL. Reuses the gateway-start / CLI-register / teardown pattern from +# run-demo.ps1. +# +# PowerShell 5.1-compatible (no && / || / ternary operators). +# +# Usage examples: +# +# # Real mode (probe-gated — backends that are absent are SKIPped): +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 +# +# # Mock mode (wiring-only; no real wxc-exec or enforcement): +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -Mock +# +# # Choose backend / filter scenarios: +# .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw +# +# Scenarios & expected verdicts: +# fs-rw - rw grant on DemoDir; in-policy write succeeds. +# Both backends; skipped when backend not live (non-mock). +# fs-readonly - ro grant on a source dir + rw on DemoDir; +# write to ro dir should be denied. +# Both backends; skipped when backend not live. +# fs-default-deny - empty filesystem policy; every write denied. +# processcontainer only (isolation_session has no deny +# primitive); skipped on isolation_session. +# network-reject - rw grant + network_policies rule; +# sandbox create must FAIL (invalid_argument). +# Runs on ANY backend including mock — never skips. + +[CmdletBinding()] +param( + [string] $DemoDir = "C:\work\openshell-mxc-e2e", + [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [ValidateSet("isolation_session", "process_container")] + [string] $Backend = "process_container", + [string] $Scenario, + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-e2e", + [switch] $Mock, + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $false + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +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 Skip([string]$m) { Write-Host "[SKIP] $m" -ForegroundColor Yellow } +function Warn([string]$m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } + +# ── Pre-flight ──────────────────────────────────────────────────────────────── + +# In real mode, assert OPENSHELL_MXC_MOCK_WXC is NOT set. +# A stale mock env var would silently re-mock a run that should be real. +if (-not $Mock) { + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + + "A stale mock env var would silently re-mock a real run. " + + "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." + } +} + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$toml = Join-Path $here "mxc-gateway.toml" +$policyDir = Join-Path $here "e2e-policies" + +foreach ($f in @($gateway, $cli, $toml)) { + if (-not (Test-Path $f)) { + throw "Missing artifact: $f`nBuild first or run from a demo-package folder." + } +} +if (-not (Test-Path $policyDir)) { + throw "e2e-policies/ directory not found at $policyDir" +} + +# ── Backend probe ───────────────────────────────────────────────────────────── + +# Returns a verdict hash for a given backend: {Live: bool, Reason: string} +function Probe-Backend([string] $backendName, [string] $wxc) { + if ($Mock) { + # In mock mode all backends are "live" — enforcement is simulated. + return @{ Live = $true; Reason = "mock mode" } + } + if (-not (Test-Path $wxc)) { + return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } + } + + if ($backendName -eq "process_container") { + $config = @{ + version = "0.6.0-alpha" + containerId = "e2e-probe-pc" + containment = "processcontainer" + process = @{ + commandLine = "cmd /c exit 0" + cwd = "%TEMP%" + timeout = 10 + } + filesystem = @{ readwritePaths = @("%TEMP%") } + processContainer = @{ leastPrivilege = $false } + } + $json = $config | ConvertTo-Json -Depth 20 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $b64 = [Convert]::ToBase64String($bytes) + $outObj = & $wxc --config-base64 $b64 2>&1 + $exitCode = $LASTEXITCODE + $output = ($outObj -join "`n").ToLower() + if ($exitCode -eq 0) { + return @{ Live = $true; Reason = "process_container probe exit 0" } + } + $reason = "process_container unavailable: exit $exitCode" + if ($output -match "backend_error" -or $output -match "e_notimpl" -or $output -match "velocity") { + $reason = "process_container backend_error (velocity keys not enabled)" + } + return @{ Live = $false; Reason = $reason } + } + + if ($backendName -eq "isolation_session") { + $config = @{ + version = "0.6.0-alpha" + phase = "provision" + containment = "isolation_session" + filesystem = @{ readwritePaths = @(); readonlyPaths = @() } + experimental = @{ + isolation_session = @{ + configurationId = "composable" + provision = @{} + } + } + } + $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 + $exitCode = $LASTEXITCODE + $output = ($outObj -join "`n").ToLower() + if ($output -match "backend_unavailable" -or $output -match "0x80040154") { + return @{ Live = $false; Reason = "isolation_session backend_unavailable (IsoSessionApp.dll absent)" } + } + if ($exitCode -ne 0) { + return @{ Live = $false; Reason = "isolation_session probe failed: exit $exitCode" } + } + # Provision succeeded — deprovision immediately. + $sandboxId = $null + try { + $rawOut = ($outObj -join "`n") + $parsed = $rawOut | ConvertFrom-Json + $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 } + } + } + $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 + } + return @{ Live = $true; Reason = "isolation_session probe: provisioned and deprovisioned" } + } + + return @{ Live = $false; Reason = "unknown backend: $backendName" } +} + +# ── Mode setup ──────────────────────────────────────────────────────────────── + +$mode = if ($Mock) { "MOCK" } else { "REAL" } +Step "Pre-flight (mode=$mode, backend=$Backend)" + +if ($Mock) { + $env:OPENSHELL_MXC_MOCK_WXC = "1" + Info "OPENSHELL_MXC_MOCK_WXC=1 — mock mode: enforcement simulated" +} else { + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + if (-not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath or use -Mock." + } + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + Info "wxc-exec: $WxcExecPath" +} + +# Patch the TOML copy for backend + wxc_exec_path (mirrors run-demo.ps1). +$tomlText = Get-Content $toml -Raw +$backendLine = "backend = `"$Backend`"" +if ($tomlText -match '(?m)^\s*#?\s*backend\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*backend\s*=.*$', $backendLine) +} else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$backendLine") +} +if (-not $Mock) { + $escaped = $WxcExecPath.Replace('\', '\\') + $wxcLine = "wxc_exec_path = `"$escaped`"" + if ($tomlText -match '(?m)^\s*#?\s*wxc_exec_path\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`n$wxcLine") + } +} +Set-Content $toml -Value $tomlText -Encoding UTF8 +Info "patched $(Split-Path $toml -Leaf): backend=$Backend" + +# Probe backend liveness now (used by scenario gate below). +$backendProbe = Probe-Backend -backendName $Backend -wxc $WxcExecPath +if ($backendProbe.Live) { + Ok "Backend '$Backend' is live: $($backendProbe.Reason)" +} else { + Warn "Backend '$Backend' is not live: $($backendProbe.Reason)" + Warn "Enforcement scenarios will SKIP; network-reject scenario will still run." +} + +# ── Port check ──────────────────────────────────────────────────────────────── + +Step "Check gateway port $Port" +$busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue +if ($busy) { + throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." +} +Ok "port $Port free" + +# ── Prepare DemoDir ─────────────────────────────────────────────────────────── + +Step "Prepare DemoDir $DemoDir" +New-Item -ItemType Directory -Force $DemoDir | Out-Null +Ok "DemoDir ready" + +$env:OPENSHELL_DRIVERS = "mxc" +$env:OPENSHELL_MXC_SHARE_DIR = $DemoDir + +# ── Start gateway ───────────────────────────────────────────────────────────── + +Step "Start gateway" +$gwLog = Join-Path $here "gateway.e2e.log" +$gwErrLog = "$gwLog.err" +Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + +$gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + +Info "gateway pid $($gw.Id); logs: $gwLog" + +$results = @() + +try { + # Wait for listening + $deadline = (Get-Date).AddSeconds(30) + $ready = $false + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { + Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($gw.ExitCode)). See log." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + $ready = $true + break + } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "gateway did not start within 30 s." } + Ok "gateway listening on $Port" + + # Register CLI + Step "Register CLI" + $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)" } + try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway select: $($_.Exception.Message) (continuing)" } + Ok "CLI registered" + + # ── Scenario definitions ────────────────────────────────────────────────── + # + # Each scenario is a hashtable: + # Name - unique identifier + # PolicyFile - path to the policy YAML fixture + # Backends - list: "both" / "process_container" / "isolation_session" + # ExpectFail - $true means `sandbox create` itself must fail (invalid_argument) + # ExpectArtifact - whether the workload should create its target file + # Description - human-readable label + + $allScenarios = @( + @{ + Name = "fs-rw" + PolicyFile = Join-Path $policyDir "fs-rw.yaml" + Backends = "both" + ExpectFail = $false + ExpectArtifact = $true + Description = "rw grant on DemoDir; in-policy write should succeed" + }, + @{ + Name = "fs-readonly" + PolicyFile = Join-Path $policyDir "fs-readonly.yaml" + Backends = "both" + ExpectFail = $false + ExpectArtifact = $true + Description = "ro grant + rw share; write to ro dir should be denied" + }, + @{ + Name = "fs-default-deny" + PolicyFile = Join-Path $policyDir "fs-empty.yaml" + Backends = "process_container" + ExpectFail = $false + ExpectArtifact = $false + Description = "empty filesystem policy; all writes denied (process_container only)" + }, + @{ + Name = "network-reject" + PolicyFile = Join-Path $policyDir "network-reject.yaml" + Backends = "both" + ExpectFail = $true + Description = "network_policies rule causes sandbox create to fail (no live backend needed)" + } + ) + + # Apply optional scenario filter. + if ($Scenario) { + $filtered = $allScenarios | Where-Object { $_.Name -eq $Scenario } + if ($filtered.Count -eq 0) { + throw "Scenario '$Scenario' not found. Available: $(($allScenarios | ForEach-Object { $_.Name }) -join ', ')" + } + $allScenarios = $filtered + } + + # ── Run scenarios ───────────────────────────────────────────────────────── + + foreach ($sc in $allScenarios) { + Step "Scenario: $($sc.Name)" + Info $sc.Description + + # Backend gate: skip enforcement scenarios when backend not live (and not mock and not ExpectFail). + $skipReason = $null + if (-not $sc.ExpectFail) { + $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) + if (-not $backendMatches) { + $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" + } elseif (-not $backendProbe.Live -and -not $Mock) { + $skipReason = "backend not live: $($backendProbe.Reason)" + } + } + + if ($null -ne $skipReason) { + Skip "$($sc.Name): $skipReason" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "SKIP"; Reason = $skipReason } + continue + } + + # Policy file must exist. + if (-not (Test-Path $sc.PolicyFile)) { + Bad "$($sc.Name): policy fixture not found at $($sc.PolicyFile)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "policy fixture missing" } + continue + } + + # Build per-sandbox MXC workload config. Commands and working directories + # are create-time inputs, not gateway-wide settings. + $target = Join-Path $DemoDir "$($sc.Name)-result.txt" + Remove-Item $target -Force -ErrorAction SilentlyContinue + $targetFwd = $target.Replace('\', '/') + $demoDirFwd = $DemoDir.Replace('\', '/') + $driverConfig = @{ + mxc = @{ + command = @("cmd", "/c", "echo.ok>$targetFwd") + cwd = $demoDirFwd + } + } | ConvertTo-Json -Compress -Depth 4 + # Windows PowerShell 5.1 removes embedded quotes when it builds the + # native command line. Escape them so the CLI receives valid JSON. + $driverConfigArg = if ($PSVersionTable.PSVersion.Major -lt 7) { + $driverConfig.Replace('"', '\"') + } else { + $driverConfig + } + # Run sandbox create. + $createOut = $null + $createExitCode = 0 + try { + $createOut = & $cli sandbox create --name $sc.Name --policy $sc.PolicyFile --driver-config-json $driverConfigArg --no-tty 2>&1 + $createExitCode = $LASTEXITCODE + } catch { + $createOut = $_.Exception.Message + $createExitCode = 1 + } + $createOutStr = ($createOut -join "`n") + Info "create exit: $createExitCode" + + # Delete sandbox (best-effort; no-op if create failed). + try { & $cli sandbox delete $sc.Name 2>&1 | Out-Null } catch {} + + # Evaluate. + if ($sc.ExpectFail) { + # network-reject: create must fail. + if ($createExitCode -ne 0) { + Ok "$($sc.Name): create correctly failed (exit $createExitCode)" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "create failed as expected" } + } else { + Bad "$($sc.Name): create succeeded but should have failed" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + } + } else { + # Wiring check in mock mode: the artifact must match the policy's + # expected outcome. In particular, default-deny passes only when + # the workload cannot create its target file. + if ($Mock) { + if ($sc.ExpectArtifact) { + $deadline = (Get-Date).AddSeconds(10) + while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { + Start-Sleep -Milliseconds 300 + } + } + $artifactExists = Test-Path $target + if ($artifactExists -eq $sc.ExpectArtifact) { + $outcome = if ($artifactExists) { "present" } else { "absent" } + Ok "$($sc.Name): artifact $outcome as expected (mock wiring OK)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "mock wiring: artifact $outcome as expected" } + } else { + Bad "$($sc.Name): artifact outcome did not match policy (present=$artifactExists, expected=$($sc.ExpectArtifact))" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "mock wiring: artifact present=$artifactExists, expected=$($sc.ExpectArtifact)" } + } + } else { + # Real mode: artifact presence == enforcement worked. + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline -and -not (Test-Path $target)) { + Start-Sleep -Milliseconds 500 + } + if ($createExitCode -eq 0 -and (Test-Path $target)) { + Ok "$($sc.Name): in-policy write succeeded" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "in-policy write produced artifact" } + } else { + Bad "$($sc.Name): FAIL (create=$createExitCode, artifact=$(Test-Path $target))" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create=$createExitCode, artifact=$(Test-Path $target)" } + } + } + } + } + +} finally { + if ($KeepRunning) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning)" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup" + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + Info "stopped gateway pid $($gw.Id)" + } +} + +# ── Summary table ───────────────────────────────────────────────────────────── + +Step "Summary" +$results | Format-Table -AutoSize + +$failCount = ($results | Where-Object { $_.Result -eq "FAIL" }).Count +$passCount = ($results | Where-Object { $_.Result -eq "PASS" }).Count +$skipCount = ($results | Where-Object { $_.Result -eq "SKIP" }).Count + +Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount" + +if ($failCount -gt 0) { + Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red + exit 1 +} else { + Write-Host "`nALL SCENARIOS PASSED (or SKIPPED)" -ForegroundColor Green + exit 0 +} diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 new file mode 100644 index 0000000000..e36f995f4e --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -0,0 +1,370 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-ocsf-audit.ps1 - gateway-driven ETW -> OCSF audit-trail example for OpenShell/MXC. +# +# Proves the FULL product path on the test box AND produces a durable OCSF log: +# start gateway (etw_audit on, OCSF JSONL on) -> register CLI -> +# create N sandboxes (each drives the OS "Sandboxing" ETW provider) -> +# the in-process consumer decodes, attributes, and maps every event to OCSF -> +# tear the sandboxes + gateway down -> collect the OCSF log + every artifact +# into a results\ folder -> zip it. +# +# The deliverable is the OCSF audit log itself: openshell-ocsf..log, a +# durable JSONL file with one OCSF event object per line - the same schema and +# medium the Linux OpenShell pipeline produces. +# +# MUST RUN ELEVATED. Opening the real-time ETW session requires an elevated shell +# (Run as administrator) or an account in the 'Performance Log Users' group. +# +# Run from inside the package folder (gateway + cli + mxc-ocsf-audit.toml + +# ocsf-audit.yaml + this script all sit together): +# +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe +# +# By default the per-sandbox egress proxy is ON so the full event set (including +# SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. +# +# The deliverable is the OCSF audit log (openshell-ocsf..log) inside the +# results-*.zip the script produces. Pass -ShareOut '\\server\share' to also copy +# the bundle to a shared location (off by default). + +[CmdletBinding()] +param( + # Real wxc-exec on the test box. + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + # Host folder mapped read-write into the sandbox (must match ocsf-audit.yaml). + [string] $ShareDir = "C:\work\openshell-mxc-demo", + # How many sandboxes to create (each drives a full event burst). + [int] $SandboxCount = 2, + # Disable the per-sandbox egress proxy (omits the SandboxProxyConfigured event). + [switch] $NoProxy, + # Gateway bind port (matches the gateway default) + CLI registration name. + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-ocsf", + # Internal driver ETW session name (used to clean up a leaked session). + [string] $SessionName = "OpenShell-MXC-ETW", + # 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 = "", + # Leave the gateway running afterward (for inspection). + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +# Don't let expected non-zero CLI exits (e.g. the post-create attach) throw on PS 7.4+. +$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 (everything we hand back) ------------------------------------ +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + +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 } + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$policy = Join-Path $here "ocsf-audit.yaml" +$tomlSrc = Join-Path $here "mxc-ocsf-audit.toml" +$toml = Join-Path $resultDir "mxc-ocsf-audit.used.toml" # disposable patched copy (bundled) + +$gw = $null +$passed = $true +$proxyOn = -not $NoProxy + +try { + # 1. Validate artifacts + privilege. + Step "Validate package artifacts" + foreach ($f in @($gateway, $cli, $policy, $tomlSrc)) { + if (-not (Test-Path $f)) { throw "missing artifact: $f (run this script from inside the package folder)" } + Info "found $(Split-Path $f -Leaf)" + } + Info "machine : $env:COMPUTERNAME user: $env:USERNAME PS: $($PSVersionTable.PSVersion)" + + # Opening the real-time ETW session requires elevation or 'Performance Log Users'. + $wid = [Security.Principal.WindowsIdentity]::GetCurrent() + $wp = New-Object Security.Principal.WindowsPrincipal($wid) + $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + $plu = $wp.IsInRole((New-Object Security.Principal.SecurityIdentifier("S-1-5-32-559"))) + Info "elevated=$admin perfLogUsers=$plu" + if (-not $admin -and -not $plu) { + 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)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath pointing at the real binary." + } + Info "wxc-exec: $WxcExecPath" + + # 2. Patch the disposable TOML copy: wxc path + backend + etw_audit + egress. + Step "Patch gateway config (disposable copy)" + $tomlText = Get-Content $tomlSrc -Raw + $escaped = $WxcExecPath.Replace('\', '\\') + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', "wxc_exec_path = `"$escaped`"") + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*backend\s*=.*$', 'backend = "process_container"') + if ($tomlText -match '(?m)^\s*#?\s*etw_audit\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*etw_audit\s*=.*$', 'etw_audit = true') + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true") + } + $proxyVal = if ($proxyOn) { 'true' } else { 'false' } + if ($tomlText -match '(?m)^\s*#?\s*egress_proxy\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*egress_proxy\s*=.*$', "egress_proxy = $proxyVal") + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`negress_proxy = $proxyVal") + } + Set-Content $toml -Value $tomlText -Encoding UTF8 + Copy-Item $policy (Join-Path $resultDir "ocsf-audit.used.yaml") -Force + Info "backend=process_container etw_audit=true egress_proxy=$proxyVal" + + # 3. Port must be free. Auto-clear a stale OUR-gateway; refuse anything else. + 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 "stale gateway on port $Port (pid $($owner.Id)) - stopping it" + Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } else { + throw "port $Port in use by '$($owner.Name)' (pid $($busy.OwningProcess)) - not our gateway; stop it and retry." + } + } + Ok "port $Port free" + + # 4. ETW session pre-flight. A force-killed gateway never runs Drop, so its + # real-time ETW session LEAKS and can starve the next run's capture. Stop + # any leftover before we start. + Step "ETW session pre-flight" + $leaked = @(logman query -ets 2>$null | Select-String -SimpleMatch $SessionName) + Info "leaked '$SessionName' sessions before run: $($leaked.Count)" + if ($leaked.Count -gt 0) { logman stop $SessionName -ets 2>&1 | Out-Null; Info "stopped leaked session(s)" } + + # 5. Prepare share folder. + New-Item -ItemType Directory -Force $ShareDir | Out-Null + Remove-Item (Join-Path $ShareDir "hello.txt") -Force -ErrorAction SilentlyContinue + + # 6. Gateway environment. Enable the durable OCSF JSONL audit sink and point it + # at THIS run's dir so the log lands directly in the bundle. + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_MXC_SHARE_DIR = $ShareDir + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + $env:OPENSHELL_OCSF_JSON = "1" + $env:OPENSHELL_OCSF_LOG_DIR = $resultDir + 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", "--config", $toml, "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + Info "gateway pid $($gw.Id); logs -> $(Split-Path $gwLog -Leaf) (+ .err)" + + # 8. Wait until the gateway is listening. + $deadline = (Get-Date).AddSeconds(30); $ready = $false + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { + Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($gw.ExitCode)). See logs above." + } + 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" + + # 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)" } + Ok "selected gateway '$GatewayName'" + + # 10. Create N sandboxes. Each drives the Sandboxing provider -> a full OCSF + # event burst. The post-create interactive attach failure is EXPECTED on + # MXC (no in-sandbox supervisor) and harmless - the agent already ran. + Step "Create $SandboxCount sandbox(es) (drives the Sandboxing provider)" + for ($i = 1; $i -le $SandboxCount; $i++) { + $name = "ocsf$i" + Info "-- creating $name --" + try { & $cli sandbox create --name $name --policy $policy --no-tty -- exit 2>&1 | ForEach-Object { Info $_ } } + catch { Info "sandbox create attach: $($_.Exception.Message) (expected on MXC - agent ran in-driver; continuing)" } + Start-Sleep -Seconds 3 + try { & $cli sandbox delete $name 2>&1 | Out-Null } catch {} + } +} +catch { + Bad $_.Exception.Message + $passed = $false +} +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" + } 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)" + } + # Belt-and-suspenders: force-kill skips Drop, so stop the leaked session here. + if (-not $KeepRunning) { logman stop $SessionName -ets 2>&1 | Out-Null } + + # ---- summarise the OCSF audit trail -------------------------------------- + $logText = @() + if (Test-Path (Join-Path $resultDir "gateway.log")) { $logText += Get-Content (Join-Path $resultDir "gateway.log") } + if (Test-Path (Join-Path $resultDir "gateway.err.log")) { $logText += Get-Content (Join-Path $resultDir "gateway.err.log") } + # The gateway writes ANSI colour codes even when redirected; strip them so + # matches are reliable. + $esc = [char]27 + $logText = $logText | ForEach-Object { $_ -replace "$esc\[[0-9;]*m", "" } + + $consumerStarted = [bool]($logText | Select-String -SimpleMatch "consumer started" -Quiet) + $consumerFailed = [bool]($logText | Select-String -SimpleMatch "ETW audit consumer failed to start" -Quiet) + + # Locate the durable OCSF JSONL audit log and tally by OCSF class. + $jsonlFiles = @(Get-ChildItem -Path $resultDir -Filter "openshell-ocsf*.log" -ErrorAction SilentlyContinue) + $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 = @() + if ($jsonlPath) { + $raw = @(Get-Content $jsonlPath -ErrorAction SilentlyContinue | Where-Object { $_.Trim() -ne "" }) + $jsonlCount = $raw.Count + foreach ($line in $raw) { + 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.device -and $o.device.hostname) { $hosts += [string]$o.device.hostname } + } catch { $jsonlBad++ } + } + $sids = @($sids | Select-Object -Unique) + $hosts = @($hosts | Select-Object -Unique) + } + + # Event-type coverage (detected from the human-readable shorthand lines). + function Seen([string]$pat) { [bool]($logText | Select-String -Pattern $pat -Quiet) } + + # Expected happy-path ETW->OCSF event types for THIS run. The egress-proxy + # event only fires when the proxy is enabled, so it only counts toward the + # expected total when -NoProxy was NOT passed. + $coreEvents = [ordered]@{ + "sandbox lifecycle (start)" = Seen "(?i)ocsf:.*LIFECYCLE:" + "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" + "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" + "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" + "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" + "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" + "process launch (command line)" = Seen "(?i)ocsf:.*PROC:LAUNCH" + } + if ($proxyOn) { $coreEvents["egress proxy configured"] = Seen "(?i)ocsf:.*proxy configured" } + + # Findings are anomaly / fallback signals - reported separately, NOT part of + # the expected-coverage denominator (a clean run may emit none). + $findingEvents = [ordered]@{ + "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" + "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" + } + + $coreExpected = $coreEvents.Count + $coreObserved = @($coreEvents.Values | Where-Object { $_ }).Count + $findingsObserved = @($findingEvents.Values | Where-Object { $_ }).Count + $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count + if ($passed) { $passed = $consumerStarted -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] } + $coreLines = foreach ($k in $coreEvents.Keys) { " {0} {1}" -f $(if ($coreEvents[$k]) { "[x]" } else { "[ ]" }), $k } + $findingLines = foreach ($k in $findingEvents.Keys) { " {0} {1}" -f $(if ($findingEvents[$k]) { "[x]" } else { "[ ]" }), $k } + + Step "RESULT" + $summary = @" +OpenShell MXC ETW -> OCSF audit trail +===================================== +timestamp : $stamp +machine : $env:COMPUTERNAME +user : $env:USERNAME (admin=$admin perfLogUsers=$plu) +verdict : $verdict +event coverage : $coreObserved of $coreExpected expected event types fired (+ $findingsObserved anomaly finding(s)) +proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits egress proxy event)' }) +wxc_exec : $WxcExecPath +backend : process_container +gateway_port : $Port +sandboxes : $SandboxCount (distinct sandbox_ids in log: $($sids.Count)) + +Event-type coverage - $coreObserved of $coreExpected expected event types fired: +$($coreLines -join "`r`n") + +Anomaly findings emitted (not counted toward coverage; a clean run may emit none): $findingsObserved +$($findingLines -join "`r`n") + +OCSF events written : $jsonlCount total ($jsonlBad invalid-json) across $classesSeen OCSF class(es) +$($classLines -join "`r`n") + +>> YOUR OCSF AUDIT LOG (the deliverable - durable JSONL, one OCSF event per line): + $(if ($jsonlPath) { $jsonlPath } else { '(none written - see gateway.log)' }) + +Files in this bundle ($resultDir): + openshell-ocsf..log THE DELIVERABLE: durable OCSF audit trail (JSONL) + summary.txt this summary + transcript.txt full console transcript + gateway.log / .err.log gateway stdout/stderr (OCSF shorthand lines live here) + 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 +started, decoded the Sandboxing provider, attributed each event to a sandbox_id, +mapped them 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. +"@ + 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 {} + + # Zip the bundle for easy return (defensive; never throw out of finally). + try { + $zip = Join-Path $here "results-$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 } + + # Auto-push the bundle to the shared drive for pickup/analysis (skip if we + # already ran from the share, or if -ShareOut "" disables it). + if (-not [string]::IsNullOrWhiteSpace($ShareOut)) { + try { + $alreadyThere = $false + try { if ((Resolve-Path $here).Path -eq (Resolve-Path $ShareOut -ErrorAction SilentlyContinue).Path) { $alreadyThere = $true } } catch {} + if ($alreadyThere) { + Write-Host "PUSHED: results-$stamp (ran from share; already there)" -ForegroundColor Green + } elseif (Test-Path $ShareOut) { + if ($zip -and (Test-Path $zip)) { Copy-Item $zip (Join-Path $ShareOut "results-$stamp.zip") -Force } + Write-Host "PUSHED: results-$stamp.zip -> $ShareOut" -ForegroundColor Green + } else { + Write-Host "share not reachable: $ShareOut (results local only at $resultDir)" -ForegroundColor Yellow + } + } catch { Write-Host "push failed: $($_.Exception.Message)" -ForegroundColor Yellow } + } + + Write-Host "`nYour OCSF audit log:" -ForegroundColor Cyan + Write-Host " $(if ($jsonlPath) { $jsonlPath } else { '(none written - see gateway.log)' })" -ForegroundColor Green +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs new file mode 100644 index 0000000000..ec8e3565a6 --- /dev/null +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -0,0 +1,1331 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, +//! and self-reported readiness. + +use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; +use futures::Stream; +use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; +use openshell_core::proto::SandboxPolicy; +use openshell_core::proto::compute::v1::{ + DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, + GetCapabilitiesResponse, WatchSandboxesDeletedEvent, WatchSandboxesEvent, + WatchSandboxesPlatformEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, +}; +use openshell_core::proto_struct::struct_to_json_value; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::{Mutex, broadcast, mpsc, watch}; +use tokio::task::JoinHandle; +use tokio_stream::wrappers::ReceiverStream; +use tracing::{info, warn}; + +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"; + +// ── Config ──────────────────────────────────────────────────────────────────── + +/// Which MXC backend the driver targets. +/// +/// - `IsolationSession`: persistent, attachable session +/// (provision → start → exec → stop → deprovision). Grant-only filesystem +/// policy — it has no deny primitive and is NOT default-deny. +/// - `ProcessContainer` (default): one-shot `AppContainer`. Genuinely default-deny: a +/// write to any ungranted path is denied by the OS. No persistent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MxcBackend { + IsolationSession, + #[default] + 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)] +pub struct MxcComputeConfig { + /// Path to `wxc-exec.exe`. Required for live runs. + pub wxc_exec_path: String, + /// Backend to target. Default: `process_container`. + pub backend: MxcBackend, + /// `processContainer` only: request a Less-Privileged `AppContainer`. + pub pc_least_privilege: bool, + /// `processContainer` only: `AppContainer` capabilities to grant. + pub pc_capabilities: Vec, + /// MXC `configurationId` for isolation session. Default: `"composable"`. + /// Never use `"small"` (known OS bug). + pub default_configuration_id: String, + + /// Enable `--debug` flag on `wxc-exec` invocations. + pub debug: bool, + /// Enable the in-process ETW → OCSF audit consumer (Plane A). Consumes the OS + /// Sandboxing provider MXC drives and emits OCSF into the gateway trail. + /// Requires the gateway account to be in "Performance Log Users" (or admin). + pub etw_audit: bool, +} + +impl Default for MxcComputeConfig { + fn default() -> Self { + Self { + wxc_exec_path: "wxc-exec.exe".into(), + backend: MxcBackend::default(), + pc_least_privilege: false, + pc_capabilities: Vec::new(), + default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), + + debug: false, + etw_audit: false, + } + } +} + +/// Per-sandbox MXC workload settings supplied through +/// `template.driver_config.mxc` / `--driver-config-json`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct MxcSandboxConfig { + command: Vec, + #[serde(default)] + cwd: String, +} + +// ── Registry entry ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PhaseState { + Starting, + Running, + Stopped, + Failed(String), +} + +struct SandboxEntry { + sandbox: DriverSandbox, + iso_sandbox_id: Option, + isolation_stopped: bool, + phase_state: PhaseState, + /// Serializes stop/delete with provisioning and process launch. + lifecycle_gate: Arc>, + monitor_cancel: Option>, + monitor_task: Option>, +} + +impl std::fmt::Debug for SandboxEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SandboxEntry") + .field("sandbox_id", &self.sandbox.id) + .field("iso_sandbox_id", &self.iso_sandbox_id) + .field("isolation_stopped", &self.isolation_stopped) + .field("phase_state", &self.phase_state) + .finish_non_exhaustive() + } +} + +// ── Watch stream helpers ────────────────────────────────────────────────────── + +pub type WatchStream = Pin< + Box> + Send>, +>; + +fn sandbox_event(sandbox: DriverSandbox) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Sandbox( + WatchSandboxesSandboxEvent { + sandbox: Some(sandbox), + }, + )), + } +} + +fn deleted_event(sandbox_id: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::Deleted( + WatchSandboxesDeletedEvent { sandbox_id }, + )), + } +} + +fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSandboxesEvent { + WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::PlatformEvent( + WatchSandboxesPlatformEvent { + sandbox_id, + event: Some(DriverPlatformEvent { + timestamp_ms: 0, + source: "mxc-driver".into(), + r#type: "Warning".into(), + reason: reason.to_string(), + message, + metadata: HashMap::new(), + }), + }, + )), + } +} + +// ── Driver ──────────────────────────────────────────────────────────────────── + +/// In-process MXC compute driver. +pub struct MxcComputeBackend { + config: MxcComputeConfig, + invoker: WxcExecInvoker, + registry: Arc>>, + watch_tx: Arc>, + policy_mapper: Arc, + /// Out-of-band side channel for the `SandboxPolicy` (A1). The proto driver + /// contract has no `policy` field and there is no driver-side + /// `GetSandboxConfig`, so `ComputeRuntime::create_sandbox` stages the policy + /// here keyed by sandbox id (mirroring the `sandbox_token` injection), + /// immediately before dispatching to this backend's `create_sandbox`, which + /// removes/consumes it. + pending_policies: Arc>>, + /// In-process ETW → OCSF audit consumer (Plane A). `Some` only when + /// `config.etw_audit` is set and the session started; kept alive here so it + /// stops when the backend is dropped (held purely for its `Drop`, hence + /// never read directly). + #[allow(dead_code)] + etw_session: Option, + /// Shared MXC-ETW → `sandbox_id` attribution index. Seeded by the driver + /// (`pid → sandbox_id`) as it launches sandboxes and read by the ETW + /// consumer thread to map/emit OCSF. `Arc` even when audit is off so the + /// launch path is branch-free. + attribution: Arc>, +} + +impl std::fmt::Debug for MxcComputeBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MxcComputeBackend") + .field("wxc_exec_path", &self.config.wxc_exec_path) + .finish_non_exhaustive() + } +} + +fn sandbox_config(sandbox: &DriverSandbox) -> Result { + let config = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.driver_config.as_ref()) + .ok_or_else(|| { + tonic::Status::invalid_argument( + "mxc requires template.driver_config.mxc with a non-empty command array", + ) + })?; + let config: MxcSandboxConfig = + serde_json::from_value(struct_to_json_value(config)).map_err(|error| { + tonic::Status::invalid_argument(format!("invalid mxc driver_config: {error}")) + })?; + if config.command.is_empty() || config.command[0].is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc driver_config.command must contain a non-empty executable", + )); + } + Ok(config) +} + +fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { + let Some(spec) = sandbox.spec.as_ref() else { + return Vec::new(); + }; + let mut environment = spec + .template + .as_ref() + .map_or_else(HashMap::new, |template| template.environment.clone()); + environment.extend(spec.environment.clone()); + let mut environment = environment + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>(); + environment.sort_unstable(); + environment +} + +fn 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 +} +impl MxcComputeBackend { + pub fn new(config: MxcComputeConfig) -> Self { + let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); + let (watch_tx, _) = broadcast::channel(256); + + // Start the Plane-A ETW → OCSF consumer if enabled. The consumer thread + // attributes each event to a `sandbox_id` via `attribution` (seeded by + // the launch path) and emits OCSF for the mapped classes. + // Failure is non-fatal — the driver still runs, just without ETW audit. + let attribution = Arc::new(std::sync::Mutex::new( + crate::etw_consumer::AttributionIndex::new(), + )); + let etw_session = if config.etw_audit { + match crate::etw_consumer::start_session(attribution.clone()) { + Ok(session) => Some(session), + Err(e) => { + warn!(error = %e, "MXC ETW audit consumer failed to start; continuing without it"); + None + } + } + } else { + None + }; + + Self { + invoker, + config, + registry: Arc::new(Mutex::new(HashMap::new())), + watch_tx: Arc::new(watch_tx), + // Production policy translation is always handled by the embedded + // mapper before any MXC lifecycle side effects begin. + policy_mapper: Arc::new(EmbeddedPolicyMapper), + pending_policies: Arc::new(Mutex::new(HashMap::new())), + etw_session, + attribution, + } + } + + /// Returns a clone of the `pending_policies` side channel so the gateway's + /// `ComputeRuntime` can stage the typed `SandboxPolicy` by sandbox id right + /// before dispatching `create_sandbox` (A1 wiring). + pub fn policy_sink(&self) -> Arc>> { + self.pending_policies.clone() + } + + /// Test-only constructor wiring the in-process mock `wxc-exec` shim. + #[cfg(test)] + pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { + let mut backend = Self::new(config); + backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); + backend + } + + pub fn capabilities(&self) -> GetCapabilitiesResponse { + GetCapabilitiesResponse { + driver_name: DRIVER_NAME.to_string(), + driver_version: DRIVER_VERSION.to_string(), + default_image: DEFAULT_IMAGE_SENTINEL.to_string(), + gateway_manages_lifecycle: false, + } + } + + pub fn validate_sandbox_create(&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(), + )) + .map_err(tonic::Status::invalid_argument)? + .is_some() + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support GPU sandboxes", + )); + } + if let Some(tmpl) = &spec.template + && !tmpl.agent_socket_path.is_empty() + { + return Err(tonic::Status::invalid_argument( + "mxc driver does not support agent_socket_path (no in-sandbox supervisor)", + )); + } + } + sandbox_config(sandbox)?; + Ok(()) + } + pub async fn get_sandbox(&self, sandbox_name: &str) -> Option { + let registry = self.registry.lock().await; + registry + .values() + .find(|e| e.sandbox.name == sandbox_name) + .map(|e| e.sandbox.clone()) + } + + pub async fn list_sandboxes(&self) -> Vec { + let registry = self.registry.lock().await; + registry.values().map(|e| e.sandbox.clone()).collect() + } + + pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> { + let sandbox_id = sandbox.id.clone(); + + // Consume the out-of-band policy staged by `ComputeRuntime::create_sandbox` + // (A1). Always remove so rejected creates cannot leak policy state. + let policy = self.pending_policies.lock().await.remove(&sandbox_id); + self.validate_sandbox_create(sandbox)?; + let sandbox_config = sandbox_config(sandbox)?; + + // 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 mapped = self + .policy_mapper + .map( + policy.as_ref(), + &MapCtx { + sandbox_id: sandbox_id.clone(), + egress: None, + }, + ) + .map_err(|error| tonic::Status::invalid_argument(error.to_string()))?; + + if sandbox + .spec + .as_ref() + .is_none_or(|spec| spec.sandbox_token.is_empty()) + { + tracing::debug!( + sandbox = %sandbox.name, + "no sandbox_token minted (no supervisor consumer on MXC)" + ); + } + + let sandbox_name = sandbox.name.clone(); + let lifecycle_gate = Arc::new(Mutex::new(())); + // Take the gate before publishing the entry. stop/delete can discover the + // sandbox immediately, but cannot pass this guard until startup has either + // installed a cancellable child monitor or failed. + let startup_guard = lifecycle_gate.clone().lock_owned().await; + { + let mut registry = self.registry.lock().await; + if registry.contains_key(&sandbox_id) { + return Err(tonic::Status::already_exists(format!( + "sandbox {sandbox_name} already exists" + ))); + } + let initial = make_sandbox_with_condition( + sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "Starting".into(), + message: "MXC lifecycle starting".into(), + last_transition_time: String::new(), + }, + false, + ); + let _ = self.watch_tx.send(sandbox_event(initial.clone())); + registry.insert( + sandbox_id.clone(), + SandboxEntry { + sandbox: initial, + iso_sandbox_id: None, + isolation_stopped: false, + phase_state: PhaseState::Starting, + lifecycle_gate, + monitor_cancel: None, + monitor_task: None, + }, + ); + } + + let invoker = self.invoker.clone(); + let config = self.config.clone(); + let registry = self.registry.clone(); + let watch_tx = self.watch_tx.clone(); + let attribution = self.attribution.clone(); + let sandbox = sandbox.clone(); + tokio::spawn(async move { + run_lifecycle( + invoker, + config, + registry, + watch_tx, + attribution, + sandbox, + sandbox_config, + mapped, + startup_guard, + ) + .await; + }); + + Ok(()) + } + pub async fn stop_sandbox(&self, sandbox_name: &str) -> Result<(), tonic::Status> { + let (sandbox_id, lifecycle_gate) = { + let registry = self.registry.lock().await; + let entry = registry + .values() + .find(|entry| entry.sandbox.name == sandbox_name) + .ok_or_else(|| { + tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) + })?; + (entry.sandbox.id.clone(), entry.lifecycle_gate.clone()) + }; + + let _lifecycle_guard = lifecycle_gate.lock().await; + let (iso_id, mut isolation_stopped, cancel, monitor_task) = { + let mut registry = self.registry.lock().await; + let entry = registry.get_mut(&sandbox_id).ok_or_else(|| { + tonic::Status::not_found(format!("sandbox {sandbox_name} not found")) + })?; + ( + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + entry.monitor_cancel.take(), + entry.monitor_task.take(), + ) + }; + 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; + } + + let mut registry = self.registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.isolation_stopped = isolation_stopped; + entry.phase_state = PhaseState::Stopped; + entry.sandbox = make_sandbox_with_condition( + &entry.sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "Stopped".into(), + message: "MXC sandbox stopped".into(), + last_transition_time: String::new(), + }, + false, + ); + let snapshot = entry.sandbox.clone(); + drop(registry); + let _ = self.watch_tx.send(sandbox_event(snapshot)); + } + Ok(()) + } + pub async fn delete_sandbox( + &self, + sandbox_id: &str, + sandbox_name: &str, + ) -> Result { + let lifecycle_gate = { + let registry = self.registry.lock().await; + let Some(entry) = registry.get(sandbox_id) else { + return Ok(false); + }; + if entry.sandbox.name != sandbox_name { + return Err(tonic::Status::failed_precondition( + "sandbox_id did not match sandbox_name", + )); + } + entry.lifecycle_gate.clone() + }; + + let _lifecycle_guard = lifecycle_gate.lock().await; + let (iso_id, isolation_stopped, cancel, monitor_task) = { + let mut registry = self.registry.lock().await; + let Some(entry) = registry.get_mut(sandbox_id) else { + return Ok(false); + }; + ( + entry.iso_sandbox_id.clone(), + entry.isolation_stopped, + entry.monitor_cancel.take(), + entry.monitor_task.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 { + if !isolation_stopped { + self.invoker.stop(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec stop failed: {error}")) + })?; + // Persist phase progress before deprovision. If deprovision + // fails, a retry resumes here instead of stopping twice. + let mut registry = self.registry.lock().await; + if let Some(entry) = registry.get_mut(sandbox_id) { + entry.isolation_stopped = true; + } + } + self.invoker.deprovision(iso_id).await.map_err(|error| { + tonic::Status::internal(format!("wxc-exec deprovision failed: {error}")) + })?; + } + + let mut registry = self.registry.lock().await; + if registry.remove(sandbox_id).is_some() { + if let Ok(mut idx) = self.attribution.lock() { + idx.forget(sandbox_id); + } + let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); + return Ok(true); + } + Ok(false) + } + /// Returns a stream of watch events. + /// + /// First emits a snapshot of all current sandboxes, then forwards live + /// events from the broadcast channel. + pub async fn watch_sandboxes(&self) -> WatchStream { + let (tx, rx) = + mpsc::channel::>(256); + + // Subscribe while holding the registry lock. Every transition is then + // represented by either this snapshot or the live receiver. + let (snapshots, mut broadcast_rx): (Vec, _) = { + let registry = self.registry.lock().await; + let broadcast_rx = self.watch_tx.subscribe(); + let snapshots = registry + .values() + .map(|entry| entry.sandbox.clone()) + .collect(); + (snapshots, broadcast_rx) + }; + + let tx_clone = tx.clone(); + tokio::spawn(async move { + // Deliver initial snapshots. + for sb in snapshots { + if tx_clone.send(Ok(sandbox_event(sb))).await.is_err() { + return; + } + } + // Forward live events. + loop { + match broadcast_rx.recv().await { + Ok(event) => { + if tx_clone.send(Ok(event)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + // Drop lagged events — the gateway re-syncs via Get/List. + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + + Box::pin(ReceiverStream::new(rx)) + } +} + +// ── Lifecycle task ──────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +async fn run_lifecycle( + invoker: WxcExecInvoker, + config: MxcComputeConfig, + registry: Arc>>, + watch_tx: Arc>, + attribution: Arc>, + sandbox: DriverSandbox, + sandbox_config: MxcSandboxConfig, + mapped: MappedConfig, + _startup_guard: tokio::sync::OwnedMutexGuard<()>, +) { + let sandbox_id = sandbox.id.clone(); + let sandbox_name = sandbox.name.clone(); + let filesystem = MxcFilesystem { + readwrite_paths: mapped.readwrite_paths, + readonly_paths: mapped.readonly_paths, + // OpenShell's policy model has no explicit deny field; default-deny is + // implicit and enforced by processContainer at the OS boundary. + denied_paths: Vec::new(), + }; + let command_line = encode_windows_command_line(&sandbox_config.command); + let process = MxcProcess { + command_line: command_line.clone(), + cwd: sandbox_config.cwd, + env: sandbox_environment(&sandbox), + timeout: 0, + }; + + let child = match config.backend { + MxcBackend::IsolationSession => { + let iso_sandbox_id = match invoker + .provision(&config.default_configuration_id, filesystem, None) + .await + { + Ok(id) => id, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + }; + info!(sandbox = %sandbox_name, iso_id = %iso_sandbox_id, "MXC provisioned"); + { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + // Publish cleanup identity before any later lifecycle await. + entry.iso_sandbox_id = Some(iso_sandbox_id.clone()); + entry.isolation_stopped = false; + } + } + if let Err(error) = invoker.start(&iso_sandbox_id).await { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + info!(sandbox = %sandbox_name, "MXC started"); + match invoker.spawn_exec(&iso_sandbox_id, process).await { + Ok(child) => child, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + } + } + MxcBackend::ProcessContainer => { + let process_container = MxcProcessContainer { + least_privilege: config.pc_least_privilege, + capabilities: config.pc_capabilities.clone(), + }; + match invoker + .run_oneshot(&sandbox_id, filesystem, process_container, process, None) + .await + { + Ok(child) => child, + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &error.to_string(), + ) + .await; + return; + } + } + } + }; + 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. + return; + }; + + // Seed ETW attribution while holding the registry lock so a concurrent + // `delete` cannot remove the sandbox after we register (which would leave + // a stale key). The `wxc-exec` pid we just spawned is the collision-proof + // anchor that ties the `Sandboxing` provider's events back to this + // `sandbox_id` (command line is a fallback matcher). No-op unless the ETW + // consumer is running. + if let Some(pid) = child.id() { + if let Ok(mut idx) = attribution.lock() { + idx.register_launch(&sandbox_id, &sandbox_name, pid, &command_line); + } + } + + 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(), + sandbox.clone(), + sandbox_id.clone(), + cancel_rx, + child, + ))); + } + let _ = watch_tx.send(sandbox_event(ready_sandbox)); +} + +async fn monitor_exec( + registry: Arc>>, + watch_tx: Arc>, + sandbox: DriverSandbox, + sandbox_id: String, + mut cancel_rx: watch::Receiver, + mut child: tokio::process::Child, +) { + let status = tokio::select! { + status = child.wait() => status, + changed = cancel_rx.changed() => { + let should_kill = changed.is_ok() && *cancel_rx.borrow_and_update(); + if should_kill { + if let Err(error) = child.kill().await { + warn!(sandbox = %sandbox.name, error = %error, "failed to terminate MXC agent process"); + } + // `kill` waits on current Tokio releases, but an explicit wait is + // harmless and guarantees the OS process handle is reaped. + let _ = child.wait().await; + } + return; + } + }; + + match status { + Ok(status) if status.success() => { + info!(sandbox = %sandbox.name, "MXC agent exec completed successfully"); + let done = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "True".into(), + reason: "AgentCompleted".into(), + message: "Agent exec finished successfully (exit code 0)".into(), + last_transition_time: String::new(), + }, + false, + ); + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.sandbox = done.clone(); + entry.phase_state = PhaseState::Running; + } + drop(registry); + let _ = watch_tx.send(sandbox_event(done)); + } + Ok(status) => { + let code = status.code().unwrap_or(-1); + warn!(sandbox = %sandbox.name, exit_code = code, "MXC agent exec exited non-zero"); + let _ = watch_tx.send(platform_event( + sandbox_id.clone(), + "AgentExecFailed", + format!("agent exited with code {code}; possible out-of-policy write"), + )); + let failed = make_sandbox_with_condition( + &sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ExecFailed".into(), + message: format!("Agent exec exited {code}"), + last_transition_time: String::new(), + }, + false, + ); + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(format!("exit code {code}")); + } + drop(registry); + let _ = watch_tx.send(sandbox_event(failed)); + } + Err(error) => { + warn!(sandbox = %sandbox.name, error = %error, "MXC agent exec wait error"); + } + } +} +async fn set_failed( + registry: &Arc>>, + watch_tx: &Arc>, + sandbox: &DriverSandbox, + sandbox_id: &str, + message: &str, +) { + warn!(sandbox = %sandbox.name, error = %message, "MXC lifecycle failed"); + let failed = make_sandbox_with_condition( + sandbox, + &DriverCondition { + r#type: "Ready".into(), + status: "False".into(), + reason: "ProvisionFailed".into(), + message: message.to_string(), + last_transition_time: String::new(), + }, + false, + ); + let mut reg = registry.lock().await; + if let Some(entry) = reg.get_mut(sandbox_id) { + entry.sandbox = failed.clone(); + entry.phase_state = PhaseState::Failed(message.to_string()); + } + drop(reg); + let _ = watch_tx.send(sandbox_event(failed)); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn make_sandbox_with_condition( + base: &DriverSandbox, + condition: &DriverCondition, + deleting: bool, +) -> DriverSandbox { + DriverSandbox { + id: base.id.clone(), + name: base.name.clone(), + namespace: base.namespace.clone(), + workspace: base.workspace.clone(), + spec: base.spec.clone(), + status: Some(DriverSandboxStatus { + sandbox_name: base.name.clone(), + instance_id: String::new(), + agent_fd: String::new(), + sandbox_fd: String::new(), + conditions: vec![condition.clone()], + deleting, + }), + } +} + +// ── Lifecycle + policy-proof tests (mock wxc-exec) ───────────────────────────── +// +// These drive the full create → provision → start → exec → self-report Ready +// flow against the in-process mock shim, proving the positive (in-policy write +// succeeds, Ready reached) and negative (out-of-policy write denied + denial +// event) paths WITHOUT the demo box. Windows-only (the crate is Windows-gated), +// run by the `windows:test:x64` mise lane. +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use futures::StreamExt; + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; + use std::time::Duration; + + fn driver_sandbox(id: &str) -> DriverSandbox { + driver_sandbox_with_command(id, "", vec!["cmd".into(), "/c".into(), "exit 0".into()]) + } + + fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec) -> DriverSandbox { + let serde_json::Value::Object(driver_config) = serde_json::json!({ + "command": command, + "cwd": cwd, + }) else { + unreachable!(); + }; + DriverSandbox { + id: id.to_string(), + name: id.to_string(), + namespace: String::new(), + workspace: String::new(), + spec: Some(DriverSandboxSpec { + sandbox_token: "test-token".into(), + template: Some(DriverSandboxTemplate { + driver_config: Some( + openshell_core::proto_struct::json_object_to_struct(driver_config).unwrap(), + ), + ..Default::default() + }), + ..Default::default() + }), + status: None, + } + } + fn fs_policy(read_write: &[&str]) -> SandboxPolicy { + SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: read_write.iter().map(ToString::to_string).collect(), + }), + ..Default::default() + } + } + + fn ready_condition(sb: &DriverSandbox) -> Option { + sb.status + .as_ref()? + .conditions + .iter() + .find(|c| c.r#type == "Ready") + .cloned() + } + + /// Poll the backend registry until the predicate matches or the deadline hits. + async fn wait_for( + backend: &MxcComputeBackend, + name: &str, + mut pred: F, + ) -> Option + where + F: FnMut(&DriverSandbox) -> bool, + { + for _ in 0..100 { + if let Some(sandbox) = backend.get_sandbox(name).await + && pred(&sandbox) + { + return Some(sandbox); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + None + } + + #[test] + fn mxc_config_defaults_to_default_deny_process_container() { + assert_eq!( + MxcComputeConfig::default().backend, + MxcBackend::ProcessContainer + ); + } + + #[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 + .as_mut() + .unwrap() + .environment + .insert("SHARED".into(), "template".into()); + spec.environment.insert("SHARED".into(), "spec".into()); + spec.environment.insert("TOKEN".into(), "value".into()); + assert_eq!( + sandbox_environment(&sandbox), + vec!["SHARED=spec".to_string(), "TOKEN=value".to_string()] + ); + } + + #[test] + fn windows_command_line_preserves_argument_boundaries() { + assert_eq!( + encode_windows_command_line(&[ + r"C:\Program Files\Agent\agent.exe".into(), + "hello world".into(), + String::new(), + ]), + r#""C:\Program Files\Agent\agent.exe" "hello world" """# + ); + assert_eq!( + quote_windows_argument(r#"say "hello""#), + r#""say \"hello\"""# + ); + assert_eq!( + quote_windows_argument("trailing slash\\ "), + r#""trailing slash\ ""# + ); + } + #[tokio::test] + async fn positive_in_policy_write_reaches_ready_and_materializes_file() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + // Stage the policy via the A1 side channel (as ComputeRuntime would). + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-pos".into(), fs_policy(&[&share])); + + let sb = driver_sandbox_with_command("sb-pos", &share, cmd); + backend.create_sandbox(&sb).await.expect("create accepted"); + + // Self-reported Ready=True (no supervisor) once the agent exec launches. + let ready = wait_for(&backend, "sb-pos", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!(ready.is_some(), "sandbox should self-report Ready=True"); + + // Positive proof: the in-policy write materializes the host artifact. + let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let mut found = false; + for _ in 0..100 { + if host_path.exists() { + found = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(found, "hello.txt should appear in the granted share folder"); + + // A successful one-shot agent (exit 0) must STAY Ready, not demote to + // Error. Assert the terminal condition is Ready=True/AgentCompleted so the + // positive demo shows a green Ready phase, not a red Error. + let completed = wait_for(&backend, "sb-pos", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentCompleted") + }) + .await; + assert!( + completed.is_some(), + "sandbox should remain Ready=True (AgentCompleted) after a successful exec, never demote to Error" + ); + } + + #[tokio::test] + async fn processcontainer_one_shot_in_policy_write_reaches_ready() { + // The processContainer backend skips provision/start and runs a single + // one-shot. The mock routes through `run_oneshot`, deriving grants from + // the filesystem (not a provision step), so the in-policy write should + // materialize and the sandbox should reach Ready=True. + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-pc".into(), fs_policy(&[&share])); + + let sb = driver_sandbox_with_command("sb-pc", &share, cmd); + backend.create_sandbox(&sb).await.expect("create accepted"); + + let ready = wait_for(&backend, "sb-pc", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!( + ready.is_some(), + "processContainer sandbox should self-report Ready=True" + ); + let recorded = crate::mxc::mock_recorded_config("sb-pc").expect("mock recorded config"); + assert!( + recorded.get("network").is_none(), + "coarse path must not emit an MXC network block" + ); + + let host_path = std::path::Path::new(tmp.path()).join("hello.txt"); + let mut found = false; + for _ in 0..100 { + if host_path.exists() { + found = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + found, + "in-policy write should materialize under processContainer" + ); + } + + #[tokio::test] + async fn negative_out_of_policy_write_is_denied_with_event() { + let share_tmp = tempfile::tempdir().unwrap(); + let out_tmp = tempfile::tempdir().unwrap(); + let share = share_tmp.path().to_string_lossy().replace('\\', "/"); + let out_path = format!( + "{}/hello.txt", + out_tmp.path().to_string_lossy().replace('\\', "/") + ); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {out_path} -Value hi"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + // Subscribe to the watch stream BEFORE create so we catch the denial event. + let mut stream = backend.watch_sandboxes().await; + + let sink = backend.policy_sink(); + sink.lock() + .await + .insert("sb-neg".into(), fs_policy(&[&share])); + backend + .create_sandbox(&driver_sandbox_with_command("sb-neg", &share, cmd)) + .await + .expect("create accepted"); + + // Collect events until we observe the AgentExecFailed platform event. + let mut saw_denial = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { + Ok(Some(Ok(ev))) => { + if let Some(watch_sandboxes_event::Payload::PlatformEvent(event)) = ev.payload + && event + .event + .as_ref() + .is_some_and(|event| event.reason == "AgentExecFailed") + { + saw_denial = true; + break; + } + } + Ok(_) => break, + Err(_) => {} + } + } + assert!( + saw_denial, + "expected an AgentExecFailed denial platform event" + ); + + // The out-of-policy artifact must NOT have been written by the mock. + let out_fs = std::path::Path::new(out_tmp.path()).join("hello.txt"); + assert!(!out_fs.exists(), "out-of-policy write must be denied"); + + // And the sandbox surfaces a terminal ExecFailed Ready=False condition. + let failed = wait_for(&backend, "sb-neg", |s| { + ready_condition(s).is_some_and(|c| c.status == "False" && c.reason == "ExecFailed") + }) + .await; + assert!(failed.is_some(), "sandbox should report ExecFailed"); + } + + #[tokio::test] + async fn stop_terminates_and_reaps_a_running_process_container() { + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let marker = format!("{share}/started.txt"); + let command = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath '{marker}' -Value started; Start-Sleep -Seconds 60"), + ]; + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + backend + .policy_sink() + .lock() + .await + .insert("sb-stop".into(), fs_policy(&[&share])); + backend + .create_sandbox(&driver_sandbox_with_command("sb-stop", "", command)) + .await + .expect("create accepted"); + wait_for(&backend, "sb-stop", |sandbox| { + ready_condition(sandbox).is_some_and(|condition| condition.reason == "AgentRunning") + }) + .await + .expect("long-running child should start"); + let marker_path = std::path::Path::new(tmp.path()).join("started.txt"); + for _ in 0..100 { + if marker_path.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!( + marker_path.exists(), + "the long-running child must execute before stop tests cancellation" + ); + + tokio::time::timeout(Duration::from_secs(5), backend.stop_sandbox("sb-stop")) + .await + .expect("stop should not wait for the child sleep") + .expect("stop should terminate and reap the child"); + let stopped = backend.get_sandbox("sb-stop").await.unwrap(); + assert_eq!(ready_condition(&stopped).unwrap().reason, "Stopped"); + } + + #[tokio::test] + async fn unmappable_network_policy_fails_create_lifecycle() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + + let backend = MxcComputeBackend::new_mocked(MxcComputeConfig::default()); + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + backend + .policy_sink() + .lock() + .await + .insert("sb-net".into(), policy); + let error = backend + .create_sandbox(&driver_sandbox("sb-net")) + .await + .expect_err("unmappable policy must fail CreateSandbox synchronously"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(backend.get_sandbox("sb-net").await.is_none()); + } +} diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs new file mode 100644 index 0000000000..0503a4216b --- /dev/null +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -0,0 +1,1864 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real-time ETW → OCSF audit consumer for MXC (Plane A). +//! +//! MXC does not emit its own ETW; the events we consume are produced by the OS +//! **Sandboxing** TraceLogging provider (`{f6ec123e-…}`) as a side effect of the +//! AppContainer / `processcontainer` operations MXC drives. This module runs one +//! process-wide real-time trace session, decodes events via TDH, and (in later +//! checkpoints) attributes each to an OpenShell `sandbox_id` and emits OCSF +//! through the gateway's tracing sink (`TracingLogBus`). +//! +//! Two responsibilities are kept behind a clean internal seam so a future +//! crate-extraction is a move-file, not a rewrite: +//! 1. **capture + decode** (this module's `unsafe` TDH/ETW code) → produces a +//! neutral [`DecodedEtwEvent`]. Knows nothing about OCSF or the registry. +//! 2. **attribute + map + emit** (the `handler` closure passed to +//! [`start_session`]) → `DecodedEtwEvent` → registry lookup → OCSF. +//! +//! Ported from MXC's reference consumer +//! (`msft-mxc/src/tools/mxc_diagnostic_console/src/etw.rs`), trimmed to Plane A +//! (Sandboxing provider only — the Kernel-General provider needs privilege our +//! service account does not have and is not required for Plane A). +//! +//! Checkpoint 2: capture + decode only. `start_session`'s handler currently just +//! logs decoded events at `debug`. Attribution + OCSF mapping land in later +//! checkpoints, without touching the capture/decode seam below. + +// This module is a thin, self-contained wrapper over the Windows ETW/TDH C API, +// which is unavoidably `unsafe`. The workspace lint `unsafe_code = "warn"` is +// allowed here (and only here) rather than annotating dozens of FFI blocks; the +// unsafe surface is confined to this file behind the safe `start_session` API. +#![allow(unsafe_code)] +// Scaffold: OCSF emit/context helpers are unused until checkpoint 3. +#![allow(dead_code)] +// The following pedantic/nursery lints are inherent to decoding raw ETW records +// against Windows structs and are allowed for this FFI module only: +// - pointer casts over the `EVENT_TRACE_PROPERTIES` / TDH buffers (the documented +// Win32 pattern of a `Vec` backing a header struct), +// - width/sign casts on fixed, small size/level values, +// - GUID/brace text in doc comments. +#![allow( + clippy::cast_ptr_alignment, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::borrow_as_ptr, + clippy::ptr_as_ptr, + clippy::match_same_arms, + clippy::redundant_pub_crate, + clippy::doc_markdown +)] + +use std::collections::{HashMap, VecDeque}; +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use windows::Win32::Foundation::WIN32_ERROR; +use windows::Win32::System::Diagnostics::Etw::{ + CONTROLTRACE_HANDLE, CloseTrace, ControlTraceW, EVENT_HEADER, EVENT_HEADER_EXTENDED_DATA_ITEM, + EVENT_PROPERTY_INFO, EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, + EVENT_TRACE_PROPERTIES, EVENT_TRACE_REAL_TIME_MODE, EnableTraceEx2, OpenTraceW, + PROCESS_TRACE_MODE_EVENT_RECORD, PROCESS_TRACE_MODE_REAL_TIME, PROCESSTRACE_HANDLE, + ProcessTrace, StartTraceW, TRACE_EVENT_INFO, TRACE_LEVEL_VERBOSE, TdhGetEventInformation, + WNODE_FLAG_TRACED_GUID, +}; +use windows::core::{GUID, PCWSTR, PWSTR}; + +use openshell_ocsf::{ + ActionId, ActivityId, AppLifecycleBuilder, ConfigStateChangeBuilder, DetectionFindingBuilder, + DispositionId, FindingInfo, LaunchTypeId, OcsfEvent, Process, ProcessActivityBuilder, + SandboxContext, SecurityLevelId, SeverityId, StateId, StatusId, +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// OS ProcessModel/Sandboxing TraceLogging provider — the Plane-A source. +/// `{f6ec123e-314e-400b-9e0a-151365e23083}`. +pub(crate) const SANDBOXING_PROVIDER_GUID: GUID = + GUID::from_u128(0xf6ec123e_314e_400b_9e0a_151365e23083); + +/// Our real-time session name (distinct from MXC's diagnostic console session). +const SESSION_NAME: &str = "OpenShell-MXC-ETW"; + +/// `EVENT_CONTROL_CODE_ENABLE_PROVIDER`. +const EVENT_CONTROL_CODE_ENABLE_PROVIDER: u32 = 1; + +/// `TdhGetEventInformation` sizing probe returns this when asking for the buffer size. +const ERROR_INSUFFICIENT_BUFFER: u32 = 122; + +// TDH InType constants for property decoding. +const TDH_INTYPE_UNICODESTRING: u16 = 1; +const TDH_INTYPE_ANSISTRING: u16 = 2; +const TDH_INTYPE_INT8: u16 = 3; +const TDH_INTYPE_UINT8: u16 = 4; +const TDH_INTYPE_INT16: u16 = 5; +const TDH_INTYPE_UINT16: u16 = 6; +const TDH_INTYPE_INT32: u16 = 7; +const TDH_INTYPE_UINT32: u16 = 8; +const TDH_INTYPE_INT64: u16 = 9; +const TDH_INTYPE_UINT64: u16 = 10; +const TDH_INTYPE_FLOAT: u16 = 11; +const TDH_INTYPE_DOUBLE: u16 = 12; +const TDH_INTYPE_BOOLEAN: u16 = 13; +const TDH_INTYPE_GUID: u16 = 15; +const TDH_INTYPE_POINTER: u16 = 16; +const TDH_INTYPE_FILETIME: u16 = 17; +const TDH_INTYPE_HEXINT32: u16 = 20; +const TDH_INTYPE_HEXINT64: u16 = 21; + +// --------------------------------------------------------------------------- +// Neutral decoded event (the capture/decode → attribute/map seam) +// --------------------------------------------------------------------------- + +/// TraceLogging activity opcodes we care about. +const OPCODE_START: u8 = 1; +const OPCODE_STOP: u8 = 2; + +/// An owned, `Send` copy of a raw ETW event record, captured in the callback so +/// the (slow) TDH decode happens off the real-time `ProcessTrace` pump thread. +/// +/// Decoding inline in the callback made the pump fall behind during the +/// sandbox-create burst, and ETW silently dropped mid-burst events into +/// `RealTimeBuffersLost`. The callback now does only cheap byte copies and hands +/// off; the consumer thread reconstructs an [`EVENT_RECORD`] over these owned +/// buffers and decodes at leisure. TraceLogging events carry their schema in the +/// extended-data items, so those are deep-copied too (not just `UserData`). +struct RawEtwEvent { + header: EVENT_HEADER, + user_data: Vec, + /// Extended-data item headers (their `DataPtr` is re-pointed at `ext_bufs` + /// before decode). + ext_items: Vec, + /// Owned backing buffers for each extended-data item, index-aligned with + /// `ext_items`. + ext_bufs: Vec>, +} + +// SAFETY: every field is either a `Vec` or a POD Windows struct whose only +// address-like field (`EVENT_HEADER_EXTENDED_DATA_ITEM::DataPtr`, a `u64`) is +// re-pointed at our owned buffers on the consumer thread before use. No borrowed +// kernel pointers survive the callback, so this is sound to move across threads. +unsafe impl Send for RawEtwEvent {} + +/// A decoded ETW event, independent of OCSF and the driver registry. +#[derive(Debug, Clone)] +pub(crate) struct DecodedEtwEvent { + /// Provider that emitted the event. + pub provider: GUID, + /// TraceLogging event id. + pub event_id: u16, + /// Event level (1=crit … 5=verbose). + pub level: u8, + /// Activity opcode: 1=Start, 2=Stop, 0=Info (plain event). + pub opcode: u8, + /// Emitting process id. + pub process_id: u32, + /// ETW activity id (event header) — the cross-process/cross-event correlator + /// for payload-keyless events like `SandboxConfig`. + pub activity_id: GUID, + /// Event/task name from TDH, if present. + pub event_name: Option, + /// Top-level properties as `(name, value)`; string values keep TDH's quotes. + pub props: Vec<(String, String)>, +} + +impl DecodedEtwEvent { + /// Raw property value (may be quoted for string types), first match wins. + pub fn get(&self, key: &str) -> Option<&str> { + self.props + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + /// Property value with surrounding double-quotes trimmed (for string types). + pub fn get_unquoted(&self, key: &str) -> Option { + self.get(key) + .map(|v| v.trim_matches('"').to_string()) + .filter(|s| !s.is_empty()) + } + + /// The MXC sandbox identity, if this event carries a non-empty one. + pub fn identity(&self) -> Option { + self.get_unquoted("identity") + } + + /// The Correlation-Vector base (`.` → ``) from `__TlgCV__` or + /// `correlationVector`, if present. A cross-event correlator MXC stamps on + /// most (not all) events. + pub fn cv_base(&self) -> Option { + self.get_unquoted("__TlgCV__") + .or_else(|| self.get_unquoted("correlationVector")) + .map(|cv| cv.split('.').next().unwrap_or(&cv).to_string()) + .filter(|s| !s.is_empty()) + } + + /// Compact `name { k=v, k=v }` rendering for debug logging. + pub fn summary(&self) -> String { + let name = self.event_name.as_deref().unwrap_or(""); + if self.props.is_empty() { + format!("{name} (id={})", self.event_id) + } else { + let joined: Vec = self.props.iter().map(|(k, v)| format!("{k}={v}")).collect(); + format!("{name} (id={}) {{ {} }}", self.event_id, joined.join(", ")) + } + } +} + +// --------------------------------------------------------------------------- +// Session handle (RAII) +// --------------------------------------------------------------------------- + +/// Health of the blocking `ProcessTrace` pump, shared between the pump thread and +/// the owning [`EtwSession`] (review #4). Previously `ProcessTrace`'s result was +/// discarded, so if capture died mid-run (e.g. the session was stopped out from +/// under us) the backend had no way to know. The pump records its outcome here so +/// an *unexpected* termination is logged at ERROR and can be queried via +/// [`EtwSession::is_capture_alive`]. +#[derive(Default)] +struct CaptureHealth { + /// Set once the pump's `ProcessTrace` has returned (capture is no longer running). + stopped: AtomicBool, + /// Set by [`EtwSession::stop`] *before* stopping the session, so a deliberate + /// shutdown isn't misreported as a capture failure. + stopping: AtomicBool, + /// The `WIN32_ERROR` code `ProcessTrace` returned (0 == `ERROR_SUCCESS`). + /// Only meaningful once `stopped` is set. + exit_code: AtomicU32, +} + +/// A running real-time ETW session plus its worker threads. Dropping (or calling +/// [`EtwSession::stop`]) stops the session and joins the threads. +pub(crate) struct EtwSession { + handle: u64, + pump_thread: Option>, + consumer_thread: Option>, + health: Arc, +} + +impl EtwSession { + /// Stop the session and join worker threads. Idempotent. + pub fn stop(&mut self) { + // Mark the stop as expected *before* triggering it so the pump thread's + // `ProcessTrace` return isn't logged as an unexpected capture death. + self.health.stopping.store(true, Ordering::SeqCst); + if self.handle != 0 { + stop_session(self.handle); + self.handle = 0; + } + // ControlTraceW(STOP) makes ProcessTrace return → the pump thread ends and + // drops the boxed Sender → the consumer thread's recv loop sees + // `Disconnected`, does a final pending drain, and exits. + if let Some(t) = self.pump_thread.take() { + let _ = t.join(); + } + if let Some(t) = self.consumer_thread.take() { + let _ = t.join(); + } + } + + /// Whether the `ProcessTrace` pump is still running. Returns `false` once the + /// pump has returned — whether from a deliberate [`stop`](Self::stop) or an + /// unexpected termination. Exposed so the backend can surface capture health + /// in status/diagnostics (review #4). + pub fn is_capture_alive(&self) -> bool { + !self.health.stopped.load(Ordering::SeqCst) + } +} + +impl Drop for EtwSession { + fn drop(&mut self) { + self.stop(); + } +} + +/// A successfully-opened real-time trace, handed to the pump thread to run the +/// blocking `ProcessTrace`. Produced by [`open_trace`] on the *caller* thread so +/// an `OpenTraceW` failure is surfaced synchronously (review #4) rather than +/// dying silently on the worker after `start_session` already returned `Ok`. +/// +/// SAFETY (`Send`): the contained raw `Sender` pointer and trace handle are only +/// ever touched by the single pump thread that takes ownership of this struct; +/// the boxed `Sender` lives until that thread reclaims it after `ProcessTrace` +/// returns, and `name` (the `LoggerName` buffer `OpenTraceW` referenced) is kept +/// alive for the whole `ProcessTrace` duration. +struct OpenedTrace { + handle: PROCESSTRACE_HANDLE, + name: Vec, + tx_ptr: *mut mpsc::Sender, +} +unsafe impl Send for OpenedTrace {} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Start the real-time ETW session on the Sandboxing provider. Every decoded +/// event is attributed (via `index`) and mapped to OCSF on a dedicated consumer +/// thread. The driver seeds `index` (pid → sandbox_id) as it launches sandboxes. +/// +/// Returns an [`EtwSession`] that must be kept alive; dropping it stops capture. +pub(crate) fn start_session(index: Arc>) -> Result { + cleanup_stale_session(); + + let handle = start_trace_session()?; + enable_provider(handle)?; + + let (tx, rx) = mpsc::channel::(); + + let consumer_thread = std::thread::Builder::new() + .name("etw-ocsf-consumer".into()) + .spawn(move || { + // Decode off the pump thread: the callback only copies bytes, so the + // real-time buffers drain fast and the create burst isn't dropped. + // + // A *timed* recv lets us also re-drive the pending buffer during a + // lull: an event that beat the driver's `register_launch` is replayed + // within one tick once attribution lands, without having to wait for + // the next ETW event (which may never arrive for a lone/last sandbox). + loop { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(mut raw) => { + match decode_raw(&mut raw) { + Some(ev) => process_event(&index, ev), + None => tracing::debug!( + target: "mxc_etw", + id = raw.header.EventDescriptor.Id, + opcode = raw.header.EventDescriptor.Opcode, + pid = raw.header.ProcessId, + "TDH decode failed for event" + ), + } + drain_and_emit(&index); + } + Err(mpsc::RecvTimeoutError::Timeout) => drain_and_emit(&index), + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + // Final drain on shutdown so anything still resolvable is emitted. + drain_and_emit(&index); + }) + .map_err(|e| { + stop_session(handle); + format!("failed to spawn ETW consumer thread: {e}") + })?; + + // Open the trace on THIS thread (review #4): `OpenTraceW` is a quick, + // synchronous call, so we can return its failure to the caller instead of + // reporting the session "started" and then having the worker die silently. + // Only the *blocking* `ProcessTrace` runs on the pump thread. On failure we + // reclaim the boxed Sender (which disconnects the consumer's channel so it + // exits), stop the session, and join the consumer before returning `Err`. + let tx_ptr = Box::into_raw(Box::new(tx)); + let opened = match open_trace(tx_ptr) { + Ok(o) => o, + Err(e) => { + unsafe { drop(Box::from_raw(tx_ptr)) }; + stop_session(handle); + let _ = consumer_thread.join(); + return Err(e); + } + }; + + let health = Arc::new(CaptureHealth::default()); + let pump_health = health.clone(); + let pump_thread = match std::thread::Builder::new() + .name("etw-ocsf-pump".into()) + .spawn(move || run_trace(opened, pump_health)) + { + Ok(t) => t, + Err(e) => { + // The trace is open but we couldn't spawn the pump. Stop the session, + // reclaim the boxed Sender so the consumer disconnects, and join it. + unsafe { drop(Box::from_raw(tx_ptr)) }; + stop_session(handle); + let _ = consumer_thread.join(); + return Err(format!("failed to spawn ETW pump thread: {e}")); + } + }; + + tracing::info!( + session = SESSION_NAME, + "MXC ETW→OCSF consumer started (Sandboxing provider)" + ); + + Ok(EtwSession { + handle, + pump_thread: Some(pump_thread), + consumer_thread: Some(consumer_thread), + health, + }) +} + +// --------------------------------------------------------------------------- +// Session management +// --------------------------------------------------------------------------- + +fn session_name_wide() -> Vec { + SESSION_NAME + .encode_utf16() + .chain(std::iter::once(0)) + .collect() +} + +fn alloc_properties_buf() -> Vec { + let props_size = size_of::(); + let name_wide_len = SESSION_NAME.encode_utf16().count() + 1; + let name_bytes = name_wide_len * 2; + let total = props_size + name_bytes + 2; + + let mut buf = vec![0u8; total]; + let props = buf.as_mut_ptr().cast::(); + unsafe { + (*props).Wnode.BufferSize = total as u32; + (*props).LoggerNameOffset = props_size as u32; + (*props).LogFileNameOffset = (props_size + name_bytes) as u32; + } + buf +} + +fn start_trace_session() -> Result { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + + unsafe { + (*props).Wnode.Flags = WNODE_FLAG_TRACED_GUID; + (*props).Wnode.ClientContext = 1; // QPC timestamps + (*props).LogFileMode = EVENT_TRACE_REAL_TIME_MODE; + // ETW uses per-processor buffers. A short sandbox-create burst can leave + // a low-volume buffer on one CPU unflushed until the session stops, + // intermittently dropping mid-stream events (e.g. SandboxConfig). A 1s + // flush timer forces every per-CPU buffer to deliver promptly; the + // buffer sizing gives headroom for the create burst. + (*props).BufferSize = 64; // KB per buffer + (*props).MinimumBuffers = 8; + (*props).MaximumBuffers = 64; + (*props).FlushTimer = 1; // seconds + } + + let mut handle = CONTROLTRACE_HANDLE::default(); + let status = unsafe { StartTraceW(&mut handle, PCWSTR(name.as_ptr()), props) }; + + if status != WIN32_ERROR(0) { + return Err(format!( + "StartTraceW failed: error {} (needs 'Performance Log Users' or admin)", + status.0 + )); + } + + Ok(handle.Value) +} + +fn enable_provider(session_handle: u64) -> Result<(), String> { + let h = CONTROLTRACE_HANDLE { + Value: session_handle, + }; + + let status = unsafe { + EnableTraceEx2( + h, + &SANDBOXING_PROVIDER_GUID, + EVENT_CONTROL_CODE_ENABLE_PROVIDER, + TRACE_LEVEL_VERBOSE as u8, + 0xFFFF_FFFF_FFFF_FFFF, // all keywords + 0, + 0, + None, + ) + }; + + if status != WIN32_ERROR(0) { + stop_session(session_handle); + return Err(format!( + "EnableTraceEx2 (Sandboxing provider) failed: error {}", + status.0 + )); + } + + Ok(()) +} + +fn stop_session(handle: u64) { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + let h = CONTROLTRACE_HANDLE { Value: handle }; + + unsafe { + let status = ControlTraceW(h, PCWSTR(name.as_ptr()), props, EVENT_TRACE_CONTROL_STOP); + // On a successful STOP the kernel fills the properties with final session + // stats. Surface EventsLost so lossy captures are never silent (an audit + // trail that silently drops events is worse than one that flags gaps). + if status == WIN32_ERROR(0) { + // EventsLost = kernel buffer overruns; RealTimeBuffersLost/LogBuffersLost + // = the real-time delivery queue overflowing because the consumer fell + // behind. The latter is what a slow callback causes, so surface all + // three — an audit trail that silently drops events is worse than one + // that flags gaps. + let events_lost = (*props).EventsLost; + let rt_lost = (*props).RealTimeBuffersLost; + let log_lost = (*props).LogBuffersLost; + if events_lost > 0 || rt_lost > 0 || log_lost > 0 { + tracing::warn!( + events_lost, + realtime_buffers_lost = rt_lost, + log_buffers_lost = log_lost, + session = SESSION_NAME, + "ETW session lost events (increase buffers / speed up consumer)" + ); + } else { + tracing::debug!(session = SESSION_NAME, "ETW session stopped; 0 events lost"); + } + } + } +} + +/// Best-effort stop of a same-named session left behind by a crashed run, so +/// `StartTraceW` doesn't fail with `ERROR_ALREADY_EXISTS`. +fn cleanup_stale_session() { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + + unsafe { + let _ = ControlTraceW( + CONTROLTRACE_HANDLE::default(), + PCWSTR(name.as_ptr()), + props, + EVENT_TRACE_CONTROL_STOP, + ); + } +} + +// --------------------------------------------------------------------------- +// ProcessTrace loop (dedicated blocking thread) +// --------------------------------------------------------------------------- + +/// Open the real-time consumer with `OpenTraceW` on the **caller** thread so the +/// result is synchronous (review #4). `tx_ptr` is the boxed event `Sender`; on +/// failure the caller reclaims it (we do not drop it here). On success the boxed +/// `Sender` and the `LoggerName` buffer are handed to the returned [`OpenedTrace`] +/// so they outlive the subsequent blocking `ProcessTrace`. +#[allow(clippy::field_reassign_with_default)] +fn open_trace(tx_ptr: *mut mpsc::Sender) -> Result { + let mut name = session_name_wide(); + + let mut logfile = EVENT_TRACE_LOGFILEW::default(); + logfile.LoggerName = PWSTR(name.as_mut_ptr()); + logfile.Anonymous1.ProcessTraceMode = + PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD; + logfile.Anonymous2.EventRecordCallback = Some(event_record_callback); + logfile.Context = tx_ptr.cast::(); + + let handle = unsafe { OpenTraceW(&mut logfile) }; + if handle.Value == u64::MAX { + return Err(format!( + "ETW OpenTraceW failed: {}", + std::io::Error::last_os_error() + )); + } + + Ok(OpenedTrace { + handle, + name, + tx_ptr, + }) +} + +/// Run the blocking `ProcessTrace` pump for an already-opened trace, then clean +/// up. Owns [`OpenedTrace`] for its whole lifetime so the `LoggerName` buffer and +/// boxed `Sender` stay valid until `ProcessTrace` returns. +/// +/// `ProcessTrace` blocks until the session stops. A deliberate stop (via +/// [`EtwSession::stop`], which sets `health.stopping`) is normal; any *other* +/// return means capture died and is recorded + logged at ERROR (review #4) so it +/// isn't silently discarded. +fn run_trace(opened: OpenedTrace, health: Arc) { + let OpenedTrace { + handle, + name, + tx_ptr, + } = opened; + + let status = unsafe { ProcessTrace(&[handle], None, None) }; + + // Record the outcome before any cleanup so a health query never races a + // still-"alive" state after the pump has actually returned. + health.exit_code.store(status.0, Ordering::SeqCst); + health.stopped.store(true, Ordering::SeqCst); + + let expected = health.stopping.load(Ordering::SeqCst); + if !expected { + // The session went away without anyone asking it to (e.g. an external + // `logman stop`, a provider error, or a dropped trace). Surface it — the + // OCSF audit trail is now blind until the driver is restarted. + tracing::error!( + target: "mxc_etw", + code = status.0, + "ETW ProcessTrace terminated unexpectedly; MXC OCSF capture is no longer running" + ); + } else { + tracing::debug!(target: "mxc_etw", code = status.0, "ETW ProcessTrace returned after stop"); + } + + unsafe { + let _ = CloseTrace(handle); + drop(Box::from_raw(tx_ptr)); + } + // Keep the LoggerName buffer alive until ProcessTrace has fully returned. + drop(name); +} + +unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) { + let event = unsafe { &*event_record }; + // Hot path — keep it minimal (decode runs on the consumer thread). We only + // enabled the Sandboxing provider, but guard anyway. + if event.EventHeader.ProviderId != SANDBOXING_PROVIDER_GUID { + return; + } + + // Hot path: copy raw bytes only, then hand off. No TDH decode here — keeping + // this callback cheap is what stops ETW dropping the create burst. + let tx = unsafe { &*(event.UserContext as *const mpsc::Sender) }; + let raw = unsafe { copy_raw(event_record) }; + let _ = tx.send(raw); +} + +/// Deep-copy a kernel `EVENT_RECORD` into an owned, `Send` [`RawEtwEvent`]. +/// Runs in the ETW callback, so it does the minimum: byte copies, no decode. +unsafe fn copy_raw(event_record: *const EVENT_RECORD) -> RawEtwEvent { + let ev = unsafe { &*event_record }; + let header = ev.EventHeader; + + let ulen = ev.UserDataLength as usize; + let user_data = if ev.UserData.is_null() || ulen == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(ev.UserData.cast::(), ulen) }.to_vec() + }; + + let ext_count = ev.ExtendedDataCount as usize; + let mut ext_items = Vec::with_capacity(ext_count); + let mut ext_bufs = Vec::with_capacity(ext_count); + if !ev.ExtendedData.is_null() { + for i in 0..ext_count { + let item = unsafe { *ev.ExtendedData.add(i) }; + let dsize = item.DataSize as usize; + let buf = if item.DataPtr == 0 || dsize == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(item.DataPtr as *const u8, dsize) }.to_vec() + }; + ext_items.push(item); + ext_bufs.push(buf); + } + } + + RawEtwEvent { + header, + user_data, + ext_items, + ext_bufs, + } +} + +/// Reconstruct an [`EVENT_RECORD`] over the owned buffers and TDH-decode it. +/// Runs on the consumer thread (off the real-time pump). +#[allow(clippy::field_reassign_with_default)] +fn decode_raw(raw: &mut RawEtwEvent) -> Option { + // Re-point each extended-data item at our owned copy (TraceLogging schema + // lives here, so TDH must be able to read it). + for (item, buf) in raw.ext_items.iter_mut().zip(raw.ext_bufs.iter()) { + item.DataPtr = if buf.is_empty() { + 0 + } else { + buf.as_ptr() as u64 + }; + } + + let mut rec = EVENT_RECORD::default(); + rec.EventHeader = raw.header; + rec.UserDataLength = u16::try_from(raw.user_data.len()).unwrap_or(u16::MAX); + rec.UserData = if raw.user_data.is_empty() { + std::ptr::null_mut() + } else { + raw.user_data.as_ptr() as *mut c_void + }; + rec.ExtendedDataCount = u16::try_from(raw.ext_items.len()).unwrap_or(u16::MAX); + rec.ExtendedData = if raw.ext_items.is_empty() { + std::ptr::null_mut() + } else { + raw.ext_items.as_mut_ptr() + }; + + decode_event(std::ptr::addr_of_mut!(rec)) +} + +// --------------------------------------------------------------------------- +// Event decoding (TDH) +// --------------------------------------------------------------------------- + +/// Decode a raw event record into a neutral [`DecodedEtwEvent`] via TDH. +/// Returns `None` only when TDH decoding fails entirely. +fn decode_event(event_record: *mut EVENT_RECORD) -> Option { + let mut buf_size: u32 = 0; + let status = unsafe { TdhGetEventInformation(event_record, None, None, &mut buf_size) }; + if status != ERROR_INSUFFICIENT_BUFFER { + return None; + } + + let mut buffer = vec![0u8; buf_size as usize]; + let info_ptr = buffer.as_mut_ptr().cast::(); + let status = + unsafe { TdhGetEventInformation(event_record, None, Some(info_ptr), &mut buf_size) }; + if status != 0 { + return None; + } + + let info = unsafe { &*info_ptr }; + + let event_name_offset = unsafe { info.Anonymous1.EventNameOffset }; + let event_name = wide_str_at(&buffer, event_name_offset) + .or_else(|| wide_str_at(&buffer, info.TaskNameOffset)) + .filter(|s| !s.is_empty()); + + let header = unsafe { &(*event_record).EventHeader }; + let props = decode_properties(&buffer, info, event_record); + + Some(DecodedEtwEvent { + provider: header.ProviderId, + event_id: header.EventDescriptor.Id, + level: header.EventDescriptor.Level, + opcode: header.EventDescriptor.Opcode, + process_id: header.ProcessId, + activity_id: header.ActivityId, + event_name, + props, + }) +} + +fn decode_properties( + info_buf: &[u8], + info: &TRACE_EVENT_INFO, + event_record: *mut EVENT_RECORD, +) -> Vec<(String, String)> { + let event = unsafe { &*event_record }; + let user_data = event.UserData as *const u8; + let user_data_len = event.UserDataLength as usize; + + if user_data.is_null() || user_data_len == 0 { + return Vec::new(); + } + + let prop_count = info.TopLevelPropertyCount as usize; + let mut results = Vec::with_capacity(prop_count); + let mut offset: usize = 0; + + for i in 0..prop_count { + let prop_info = unsafe { + let base = + std::ptr::addr_of!(info.EventPropertyInfoArray) as *const EVENT_PROPERTY_INFO; + &*base.add(i) + }; + + let prop_name = + wide_str_at(info_buf, prop_info.NameOffset).unwrap_or_else(|| format!("prop{i}")); + + // PropertyStruct flag: the header holds no data, but its child members + // occupy space in the user-data buffer, so decode+skip each to keep + // `offset` in sync. + if prop_info.Flags.0 & 1 != 0 { + let num_members = + unsafe { prop_info.Anonymous1.structType.NumOfStructMembers } as usize; + let start_index = unsafe { prop_info.Anonymous1.structType.StructStartIndex } as usize; + + for j in 0..num_members { + let child_prop = unsafe { + let base = std::ptr::addr_of!(info.EventPropertyInfoArray) + as *const EVENT_PROPERTY_INFO; + &*base.add(start_index + j) + }; + let child_in_type = unsafe { child_prop.Anonymous1.nonStructType.InType }; + let child_length = unsafe { child_prop.Anonymous3.length } as usize; + let remaining = user_data_len.saturating_sub(offset); + let data_ptr = if remaining > 0 { + unsafe { user_data.add(offset) } + } else { + std::ptr::null() + }; + let (_, consumed) = + format_property_value(child_in_type, child_length, data_ptr, remaining); + offset += consumed; + } + + results.push((prop_name, "".to_string())); + continue; + } + + let in_type = unsafe { prop_info.Anonymous1.nonStructType.InType }; + let prop_length = unsafe { prop_info.Anonymous3.length } as usize; + + let remaining = user_data_len.saturating_sub(offset); + let data_ptr = if remaining > 0 { + unsafe { user_data.add(offset) } + } else { + std::ptr::null() + }; + + let (value_str, consumed) = + format_property_value(in_type, prop_length, data_ptr, remaining); + offset += consumed; + results.push((prop_name, value_str)); + } + + results +} + +/// Decode a single property value, returning `(rendered, bytes_consumed)`. +fn format_property_value( + in_type: u16, + declared_length: usize, + data: *const u8, + available: usize, +) -> (String, usize) { + if data.is_null() || available == 0 { + return ("".to_string(), 0); + } + + match in_type { + TDH_INTYPE_UNICODESTRING => { + let max_wchars = available / 2; + let wchars = unsafe { std::slice::from_raw_parts(data.cast::(), max_wchars) }; + let len = wchars.iter().position(|&c| c == 0).unwrap_or(max_wchars); + let s = String::from_utf16_lossy(&wchars[..len]); + let consumed = (len + 1).min(max_wchars) * 2; + (format!("\"{s}\""), consumed) + } + TDH_INTYPE_ANSISTRING => { + let bytes = unsafe { std::slice::from_raw_parts(data, available) }; + let len = bytes.iter().position(|&b| b == 0).unwrap_or(available); + let s = String::from_utf8_lossy(&bytes[..len]); + let consumed = (len + 1).min(available); + (format!("\"{s}\""), consumed) + } + TDH_INTYPE_INT8 if available >= 1 => ((unsafe { *data } as i8).to_string(), 1), + TDH_INTYPE_UINT8 if available >= 1 => ((unsafe { *data }).to_string(), 1), + TDH_INTYPE_INT16 if available >= 2 => { + (i16::from_le_bytes(read_bytes::<2>(data)).to_string(), 2) + } + TDH_INTYPE_UINT16 if available >= 2 => { + (u16::from_le_bytes(read_bytes::<2>(data)).to_string(), 2) + } + TDH_INTYPE_INT32 if available >= 4 => { + (i32::from_le_bytes(read_bytes::<4>(data)).to_string(), 4) + } + TDH_INTYPE_UINT32 if available >= 4 => { + (u32::from_le_bytes(read_bytes::<4>(data)).to_string(), 4) + } + TDH_INTYPE_INT64 if available >= 8 => { + (i64::from_le_bytes(read_bytes::<8>(data)).to_string(), 8) + } + TDH_INTYPE_UINT64 if available >= 8 => { + (u64::from_le_bytes(read_bytes::<8>(data)).to_string(), 8) + } + TDH_INTYPE_FLOAT if available >= 4 => ( + format!("{:.4}", f32::from_le_bytes(read_bytes::<4>(data))), + 4, + ), + TDH_INTYPE_DOUBLE if available >= 8 => ( + format!("{:.4}", f64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_BOOLEAN if available >= 4 => ( + (i32::from_le_bytes(read_bytes::<4>(data)) != 0).to_string(), + 4, + ), + TDH_INTYPE_GUID if available >= 16 => { + let b = unsafe { std::slice::from_raw_parts(data, 16) }; + let d1 = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); + let d2 = u16::from_le_bytes([b[4], b[5]]); + let d3 = u16::from_le_bytes([b[6], b[7]]); + let s = format!( + "{{{d1:08x}-{d2:04x}-{d3:04x}-{:02x}{:02x}-\ + {:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}", + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] + ); + (s, 16) + } + TDH_INTYPE_HEXINT32 if available >= 4 => ( + format!("0x{:08X}", u32::from_le_bytes(read_bytes::<4>(data))), + 4, + ), + TDH_INTYPE_HEXINT64 if available >= 8 => ( + format!("0x{:016X}", u64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_POINTER if available >= 8 => ( + format!("0x{:016X}", u64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_FILETIME if available >= 8 => ( + format!( + "FILETIME(0x{:016X})", + u64::from_le_bytes(read_bytes::<8>(data)) + ), + 8, + ), + _ => { + let len = if declared_length > 0 { + declared_length.min(available) + } else { + available.min(32) + }; + let bytes = unsafe { std::slice::from_raw_parts(data, len) }; + let hex: String = bytes + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + (hex, len) + } + } +} + +// --------------------------------------------------------------------------- +// Attribution: MXC ETW event → OpenShell sandbox_id +// --------------------------------------------------------------------------- + +/// Runtime index that maps MXC's uneven ETW correlators back to an OpenShell +/// `sandbox_id`. Shared (`Arc>`) between the driver (which seeds +/// `pid → sandbox_id` as it spawns wxc-exec) and the ETW consumer thread. +/// +/// Attribution chain (grounded in the live `Sandboxing` capture): +/// - **pid anchor** — the wxc-exec pid we spawn is unique and driver-owned; it +/// emits `CreateProcessInSandbox`, which also carries `identity` + CV. +/// - from there we learn `identity → sandbox_id` and (`SandboxEngineCreate`) +/// `activity_id → sandbox_id`, so the payload-keyless `SandboxConfig` +/// (no identity/CV) resolves via the ETW `ActivityId` it shares. +/// - `commandLine` and a per-pid "last resolved" value are fallbacks. +/// An ETW event that could not yet be attributed, held so it can be replayed +/// once its sandbox's attribution is seeded. +struct PendingEvent { + at: Instant, + ev: DecodedEtwEvent, +} + +/// Max number of unattributed events buffered at once (memory bound). The +/// create/config burst is ~10 events per sandbox, so this comfortably holds +/// many concurrent racing launches while still capping worst-case memory. +const PENDING_MAX: usize = 4096; + +/// How long an unattributed event is held before being given up on. The +/// driver seeds attribution within milliseconds of spawning `wxc-exec`, so a +/// few seconds is ample; anything older is almost certainly genuinely +/// unattributable (e.g. an unrelated Sandboxing-provider consumer on the box). +const PENDING_TTL: Duration = Duration::from_secs(5); + +/// Grace window for trusting a PID match when *replaying* a buffered event. +/// The driver seeds `by_pid` within milliseconds of spawning `wxc-exec`, so a +/// legitimate seed event's registration lands at (or just after) the moment the +/// event was buffered. A recycled PID, by contrast, requires the prior +/// `wxc-exec` to exit and a new one to spawn — far longer than this window — so +/// a registration that is newer than the buffered event by more than this grace +/// is treated as a *different* (recycled) owner and the PID match is refused. +const REPLAY_PID_GRACE: Duration = Duration::from_secs(2); + +/// A `wxc-exec` PID registration: which sandbox owns the PID and *when* it was +/// registered. The timestamp lets the replay path (see [`AttributionIndex:: +/// resolve_replay`]) reject a PID that was recycled to a different sandbox after +/// a still-buffered event was captured. +struct PidReg { + sid: String, + at: Instant, +} + +#[derive(Default)] +pub(crate) struct AttributionIndex { + by_pid: HashMap, + by_identity: HashMap, + by_activity: HashMap, + by_cv: HashMap, + /// Command line → sandbox_id, but **only while that command line is unique**. + /// The instant a second sandbox registers the same command line it is moved to + /// [`Self::ambiguous_cmds`] and removed here, so an ambiguous command can never + /// misroute an event. Command line is a weak, last-resort key for exactly this + /// reason (two sandboxes commonly run the identical agent command). + by_cmd: HashMap, + /// Command lines seen for more than one sandbox — never usable for resolution. + ambiguous_cmds: std::collections::HashSet, + last_pid_sid: HashMap, + names: HashMap, + /// Sandboxes for which a lifecycle [6002] row has already been emitted, so + /// the two redundant create events don't double-count. + lifecycle_emitted: std::collections::HashSet, + /// Events that arrived before their sandbox's attribution was seeded. ETW + /// delivers the create/config burst the instant `wxc-exec` starts, which can + /// race the driver's `register_launch`; rather than drop those events we hold + /// them here and replay when a later registration/cross-link resolves them. + /// Bounded by [`PENDING_MAX`] and [`PENDING_TTL`]. + pending: VecDeque, +} + +impl AttributionIndex { + pub fn new() -> Self { + Self::default() + } + + /// Register a launched sandbox. `wxc_pid` (the process we spawned) is the + /// primary anchor — unique *while that process is alive* (Windows won't reuse + /// a live PID). `command_line` is only a weak fallback and is dropped the + /// moment it stops being unique (see [`Self::ambiguous_cmds`]). + pub fn register_launch( + &mut self, + sandbox_id: &str, + sandbox_name: &str, + wxc_pid: u32, + command_line: &str, + ) { + // PID-reuse guard: if this PID still maps to a *different* sandbox, the + // prior sandbox was never `forget()`-ten (e.g. a crash skipped `delete`) + // and Windows has recycled the number. Rebind to the new owner and drop + // the stale per-PID "last resolved" hint so it can't misroute. + if let Some(prev) = self.by_pid.get(&wxc_pid) { + if prev.sid != sandbox_id { + tracing::warn!( + target: "mxc_etw", + pid = wxc_pid, + prev = %prev.sid, + new = %sandbox_id, + "wxc-exec PID reused before prior sandbox was forgotten; rebinding attribution" + ); + } + } + self.by_pid.insert( + wxc_pid, + PidReg { + sid: sandbox_id.to_string(), + at: Instant::now(), + }, + ); + self.last_pid_sid.remove(&wxc_pid); + + // Command line is only trustworthy while unique. Promote to `by_cmd` on + // first sight; on a second, different owner, demote to ambiguous forever. + if !command_line.is_empty() && !self.ambiguous_cmds.contains(command_line) { + match self.by_cmd.get(command_line) { + Some(existing) if existing != sandbox_id => { + self.by_cmd.remove(command_line); + self.ambiguous_cmds.insert(command_line.to_string()); + } + Some(_) => {} // same owner re-registering; keep + None => { + self.by_cmd + .insert(command_line.to_string(), sandbox_id.to_string()); + } + } + } + + self.names + .insert(sandbox_id.to_string(), sandbox_name.to_string()); + } + + /// Drop all keys for a finished sandbox to bound memory. + pub fn forget(&mut self, sandbox_id: &str) { + self.by_pid.retain(|_, r| r.sid != sandbox_id); + self.by_identity.retain(|_, v| v != sandbox_id); + self.by_activity.retain(|_, v| v != sandbox_id); + self.by_cv.retain(|_, v| v != sandbox_id); + self.by_cmd.retain(|_, v| v != sandbox_id); + self.last_pid_sid.retain(|_, v| v != sandbox_id); + self.names.remove(sandbox_id); + self.lifecycle_emitted.remove(sandbox_id); + } + + /// Returns `true` the first time a lifecycle row should be emitted for this + /// sandbox. MXC emits two redundant create events (`SandboxEngineCreate` and + /// `SandboxCreateWithPolicyEnforcement`) and ETW drops them interchangeably + /// under load, so we anchor on whichever arrives first and dedupe here. + fn take_lifecycle_once(&mut self, sandbox_id: &str) -> bool { + self.lifecycle_emitted.insert(sandbox_id.to_string()) + } + + fn name_of(&self, sandbox_id: &str) -> String { + self.names + .get(sandbox_id) + .cloned() + .unwrap_or_else(|| sandbox_id.to_string()) + } + + /// Resolve an event to a `sandbox_id` via any known key, then cross-link the + /// other keys it carries so later keyless events attribute correctly. + fn resolve(&mut self, ev: &DecodedEtwEvent) -> Option { + let identity = ev.identity(); + let cv = ev.cv_base(); + let activity = guid_key(&ev.activity_id); + let cmd = ev.get_unquoted("commandLine"); + + let sid = self + .by_pid + .get(&ev.process_id) + .map(|registration| registration.sid.clone()) + .or_else(|| { + identity + .as_ref() + .and_then(|i| self.by_identity.get(i).cloned()) + }) + .or_else(|| { + activity + .as_ref() + .and_then(|a| self.by_activity.get(a).cloned()) + }) + .or_else(|| cv.as_ref().and_then(|c| self.by_cv.get(c).cloned())) + .or_else(|| cmd.as_ref().and_then(|c| self.by_cmd.get(c).cloned())) + .or_else(|| self.last_pid_sid.get(&ev.process_id).cloned())?; + + self.cross_link(&sid, identity, cv, activity, ev.process_id); + Some(sid) + } + + /// Resolve a *buffered* (replayed) event. Unlike [`Self::resolve`], this is + /// hardened against PID recycling and command-line ambiguity that can occur + /// during the buffer window ([`PENDING_TTL`]): + /// + /// - It **never** falls back to `by_cmd` or `last_pid_sid` — both are + /// recycle-/ambiguity-prone and a stale entry could bind a buffered event + /// to the wrong sandbox. + /// - A `by_pid` match is only trusted if the PID's registration is not newer + /// than the buffered event by more than [`REPLAY_PID_GRACE`]. If the PID + /// was recycled to a *different* sandbox after this event was captured, the + /// registration timestamp will be well beyond the grace window and the PID + /// match is refused (the event stays buffered and ages out rather than + /// being misattributed to the new owner). + /// + /// Strong, per-sandbox-unique correlators (`identity`, `activity`, CV) are + /// always trusted — they are cross-linked from the driver-owned PID anchor + /// and are not reused across sandboxes. + fn resolve_replay(&mut self, ev: &DecodedEtwEvent, buffered_at: Instant) -> Option { + let identity = ev.identity(); + let cv = ev.cv_base(); + let activity = guid_key(&ev.activity_id); + + let sid = identity + .as_ref() + .and_then(|i| self.by_identity.get(i).cloned()) + .or_else(|| { + activity + .as_ref() + .and_then(|a| self.by_activity.get(a).cloned()) + }) + .or_else(|| cv.as_ref().and_then(|c| self.by_cv.get(c).cloned())) + .or_else(|| { + self.by_pid.get(&ev.process_id).and_then(|r| { + // Refuse a PID that was (re)registered well after this event + // was buffered — that registration belongs to a recycled PID + // owned by a different sandbox, not this event's emitter. + if r.at <= buffered_at + REPLAY_PID_GRACE { + Some(r.sid.clone()) + } else { + None + } + }) + })?; + + self.cross_link(&sid, identity, cv, activity, ev.process_id); + Some(sid) + } + + /// Cross-link the strong keys an event carries to its resolved `sandbox_id` + /// so later keyless events for the same sandbox attribute correctly. + fn cross_link( + &mut self, + sid: &str, + identity: Option, + cv: Option, + activity: Option, + pid: u32, + ) { + if let Some(i) = identity { + self.by_identity.entry(i).or_insert_with(|| sid.to_string()); + } + if let Some(c) = cv { + self.by_cv.entry(c).or_insert_with(|| sid.to_string()); + } + if let Some(a) = activity { + self.by_activity.entry(a).or_insert_with(|| sid.to_string()); + } + self.last_pid_sid.insert(pid, sid.to_string()); + } + + /// Hold an event that didn't resolve yet, evicting expired and (if needed) + /// oldest entries first so the buffer stays bounded. + fn buffer_unresolved(&mut self, ev: DecodedEtwEvent) { + let now = Instant::now(); + while let Some(front) = self.pending.front() { + 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()); + } + } else { + break; + } + } + if self.pending.len() >= PENDING_MAX { + if 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.pending.push_back(PendingEvent { at: now, ev }); + } + + /// Re-resolve buffered events. Returns those that now attribute (removed + /// from the buffer, in arrival order, ready to emit) and drops any that have + /// aged past [`PENDING_TTL`] still unresolved. Callers emit the returned + /// events *after* releasing the index lock. + fn drain_resolved(&mut self) -> Vec<(String, String, DecodedEtwEvent)> { + if self.pending.is_empty() { + return Vec::new(); + } + let now = Instant::now(); + let drained = std::mem::take(&mut self.pending); + let mut ready = Vec::new(); + 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()); + continue; + } + match self.resolve_replay(&p.ev, p.at) { + Some(sid) => { + let name = self.name_of(&sid); + ready.push((sid, name, p.ev)); + } + None => keep.push_back(p), + } + } + self.pending = keep; + ready + } +} + +/// Consumer-thread entry point: attribute one decoded event and, for the mapped +/// classes, emit an OCSF row into the gateway trail. Unmapped/unresolved events +/// are debug-logged (checkpoint-2 behaviour) so nothing is silently dropped. +fn process_event(index: &Mutex, ev: DecodedEtwEvent) { + // Activity STOP is the empty twin of START — never a distinct OCSF row. + if ev.opcode == OPCODE_STOP { + return; + } + + let resolved = { + let mut idx = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match idx.resolve(&ev) { + Some(sid) => { + let name = idx.name_of(&sid); + Some((sid, name, ev)) + } + None => { + // Not attributable yet: ETW delivers the create/config burst the + // instant `wxc-exec` starts, which can beat the driver's + // `register_launch`. Hold the event for replay instead of dropping + // it (see `drain_and_emit`). + idx.buffer_unresolved(ev); + None + } + } + }; + + if let Some((sandbox_id, sandbox_name, ev)) = resolved { + emit_resolved(index, &sandbox_id, &sandbox_name, &ev); + } +} + +/// Re-resolve and emit any buffered events that have since become attributable. +/// Called by the consumer thread after each incoming event and on a periodic +/// tick, so a create/config burst that raced `register_launch` still lands in +/// the trail (and aged-out unresolvable events are dropped, bounded). +fn drain_and_emit(index: &Mutex) { + let ready = { + let mut idx = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idx.drain_resolved() + }; + for (sandbox_id, sandbox_name, ev) in ready { + emit_resolved(index, &sandbox_id, &sandbox_name, &ev); + } +} + +/// Map one attributed event to its OCSF class and emit it into the gateway trail. +fn emit_resolved( + index: &Mutex, + sandbox_id: &str, + sandbox_name: &str, + ev: &DecodedEtwEvent, +) { + // STOP twins are already filtered before buffering, so activity events + // reaching here are STARTs. + match ev.event_name.as_deref().unwrap_or("") { + // Lifecycle [6002]: MXC emits two create events per sandbox — + // `SandboxEngineCreate` and `SandboxCreateWithPolicyEnforcement` — and + // ETW drops them interchangeably under buffer pressure (observed: one run + // keeps the former, the next keeps the latter). Anchor on whichever + // arrives first and dedupe so the row is emitted exactly once. + "SandboxEngineCreate" | "SandboxCreateWithPolicyEnforcement" + if ev.opcode == OPCODE_START => + { + let first = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take_lifecycle_once(sandbox_id); + if first { + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_lifecycle_create(&ctx, sandbox_name)); + } + } + // Process [1007]: `CreateProcessInSandbox` carries the real agent command + // line + working directory. The activity fires once empty (probe) and + // once with the command — only emit for the populated one. + "CreateProcessInSandbox" if ev.opcode == OPCODE_START => { + if let Some(cmd) = ev.get_unquoted("commandLine") { + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_process_launch(&ctx, ev, &cmd)); + } else { + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); + } + } + // Process [1007]: `ProcessLaunched` is the confirmation twin of + // `CreateProcessInSandbox` — it carries the *actual* `processId`/`threadId` + // of the started in-sandbox process (the create event only has the request + + // command line). We emit it as a distinct PROC row so the trail records both + // the launch request (with cmd line) and the confirmed start (with real pid). + "ProcessLaunched" => { + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_process_started(&ctx, ev)); + } + // Config [5019]: several distinct config/hardening/setup state changes. Each + // is a genuine audit-worthy config event; `SandboxConfig` is the richest but + // drops intermittently, so the reliably-captured hardening events + // (`Win32kLockdownApplied`, `ApplyUILimits`, `EnforceOsPolicy`) guarantee + // coverage. `SandboxProxyConfigured` (network/proxy setup — the one + // network-plane event the provider emits) and `SandboxConsoleReferencePlumbed` + // (console-handle plumbing) are additional per-sandbox setup state changes. + "SandboxConfig" + | "Win32kLockdownApplied" + | "ApplyUILimits" + | "EnforceOsPolicy" + | "SandboxProxyConfigured" + | "SandboxConsoleReferencePlumbed" => { + // Dump the raw decoded field set for config-family events at debug so we + // can confirm the exact property names MXC emits (e.g. which key carries + // the proxy port on `SandboxProxyConfigured`). Guarded by `debug=true`. + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_config_state(&ctx, ev)); + } + // Finding [2004]: MXC surfaces WIL error/fallback activities during + // sandbox setup. Captured as informational (non-alert) findings so the + // audit trail records setup anomalies without crying wolf. + "ActivityError" | "FallbackError" => { + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_finding(&ctx, ev)); + } + _ => { + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); + } + } +} + +// --------------------------------------------------------------------------- +// OCSF mappers (checkpoint 3 subset: LIFECYCLE + CONFIG) +// --------------------------------------------------------------------------- + +/// `SandboxCreateWithPolicyEnforcement` (START) → Application Lifecycle [6002]. +fn map_lifecycle_create(ctx: &SandboxContext, sandbox_name: &str) -> OcsfEvent { + AppLifecycleBuilder::new(ctx) + .activity(ActivityId::Reset) // lifecycle label = "Start" + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(format!( + "MXC sandbox '{sandbox_name}' created with policy enforcement" + )) + .build() +} + +/// A sandbox config/hardening/setup ETW event → Device Config State Change [5019]. +/// +/// Handles the full family of per-sandbox config state changes the Sandboxing +/// provider emits: `SandboxConfig` (full posture snapshot), the hardening events +/// (`Win32kLockdownApplied`, `ApplyUILimits`, `EnforceOsPolicy`), +/// `SandboxProxyConfigured` (network/proxy setup) and +/// `SandboxConsoleReferencePlumbed` (console-handle plumbing). Whichever +/// config-ish fields the event carries ride along as `unmapped`, and +/// `security_level` reflects any hardening signal present. +fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let flag = |k: &str| ev.get(k).map(|v| v == "1").unwrap_or(false); + let nonzero = |k: &str| ev.get(k).map(|v| v != "0").unwrap_or(false); + let hardened = flag("useLeastPrivilege") || flag("useAppContainer") || nonzero("agenticFlags"); + let security_level = if hardened { + SecurityLevelId::Secure + } else { + SecurityLevelId::Unknown + }; + + let message = match ev.event_name.as_deref().unwrap_or("") { + "Win32kLockdownApplied" => "MXC sandbox win32k lockdown applied".to_string(), + "ApplyUILimits" => "MXC sandbox UI restrictions applied".to_string(), + "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 + // 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. + "SandboxProxyConfigured" => match ev.get_unquoted("proxyPort").as_deref() { + Some(port) if port != "0" => { + format!("MXC sandbox proxy configured (port {port})") + } + _ => "MXC sandbox proxy configured".to_string(), + }, + _ => "MXC sandbox OS policy configured".to_string(), + }; + + let mut builder = ConfigStateChangeBuilder::new(ctx) + .state(StateId::Enabled, "configured") + .security_level(security_level) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(message); + + // Superset of config-ish fields across all event shapes; only present + // fields are attached. + for key in [ + "useAppContainer", + "integrityMode", + "integrityLevel", + "uiRestrictions", + "useLeastPrivilege", + "readWritePathsCount", + "readOnlyPathsCount", + "capabilities", + "agenticFlags", + "processId", + "proxyPort", + "hasConsoleReference", + "creationFlags", + ] { + if let Some(v) = ev.get(key) { + builder = builder.unmapped(key, v.trim_matches('"').to_string()); + } + } + + builder.build() +} + +/// `CreateProcessInSandbox` (populated) → Process Activity [1007] "Launch". +/// +/// PRIVACY NOTE (review item #3): `cmd_line` is copied **verbatim** from MXC's +/// ETW event into the OCSF `process.cmd_line` field. This consumer performs **no +/// privacy/secret filtering** — if a caller passes credentials, tokens, or PII on +/// the command line, they will appear **unredacted** in the durable audit trail. +/// This is deliberate (audit fidelity), so the OCSF log must be treated as +/// sensitive at rest and in transit. +/// +/// Redaction is intentionally **not** done here and is owned by an upstream +/// privacy layer, not the ETW→OCSF path. Note that no general PII/secret scrubber +/// covers this field today: the only redaction that exists +/// (`openshell_core::secrets`, `${…}` → `[CREDENTIAL]`) is scoped to the network +/// proxy's HTTP-target logging, a separate egress path. If/when a general +/// audit-output PII filter lands, this field is where it must apply. +fn map_process_launch(ctx: &SandboxContext, ev: &DecodedEtwEvent, cmd_line: &str) -> OcsfEvent { + // The created process's own pid isn't in this event (it appears later in + // `ProcessLaunched`); the emitting pid is the sandbox host (wxc-exec). + let proc = Process::new(&exe_name(cmd_line), 0).with_cmd_line(cmd_line); + let cwd = ev.get_unquoted("currentDirectory").unwrap_or_default(); + let cwd_suffix = if cwd.is_empty() { + String::new() + } else { + format!(" (cwd: {cwd})") + }; + ProcessActivityBuilder::new(ctx) + .activity(ActivityId::Open) // process label = "Launch" + .launch_type(LaunchTypeId::Spawn) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .process(proc) + .actor_process(Process::new("wxc-exec", i64::from(ev.process_id))) + .message(format!( + "MXC sandbox launched process: {}{cwd_suffix}", + truncate(cmd_line, 160) + )) + .build() +} + +/// `ProcessLaunched` → Process Activity [1007] "Launch" (confirmed start). +/// +/// Unlike `CreateProcessInSandbox` (the request, which carries the command line +/// but not the resulting pid), this event carries the real `processId`/`threadId` +/// of the process that actually started. We give the process a distinct name +/// (`sandboxed-process`) so the shorthand row is visibly the confirmed-start twin, +/// not a duplicate of the launch-request row. +fn map_process_started(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let pid = ev + .get("processId") + .map(|v| v.trim_matches('"')) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let tid = ev.get_unquoted("threadId").unwrap_or_default(); + let tid_suffix = if tid.is_empty() { + String::new() + } else { + format!(", tid: {tid}") + }; + ProcessActivityBuilder::new(ctx) + .activity(ActivityId::Open) // process label = "Launch" + .launch_type(LaunchTypeId::Spawn) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .process(Process::new("sandboxed-process", pid)) + .actor_process(Process::new("wxc-exec", i64::from(ev.process_id))) + .message(format!( + "MXC sandbox process started (pid: {pid}{tid_suffix})" + )) + .build() +} + +/// `ActivityError` / `FallbackError` → Detection Finding [2004] (informational). +fn map_finding(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let kind = ev.event_name.as_deref().unwrap_or("SandboxError"); + let uid = ev + .cv_base() + .map(|cv| format!("{kind}:{cv}")) + .unwrap_or_else(|| format!("{kind}:{}", ev.process_id)); + DetectionFindingBuilder::new(ctx) + .activity(ActivityId::Open) // finding label = "Create" + .severity(SeverityId::Informational) + .is_alert(false) + .finding_info( + FindingInfo::new(&uid, &format!("MXC sandbox {kind}")) + .with_desc("MXC emitted a WIL error/fallback activity during sandbox setup."), + ) + .message(format!("MXC reported {kind} during sandbox setup")) + .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 { + let first = cmd_line + .trim() + .split_whitespace() + .next() + .unwrap_or("process") + .trim_matches('"'); + first + .rsplit(['\\', '/']) + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("process") + .to_string() +} + +/// Truncate at a char boundary with an ellipsis (keeps shorthand tidy). +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +// --------------------------------------------------------------------------- +// OCSF emit helpers +// --------------------------------------------------------------------------- + +/// Emit an OCSF event so it lands in BOTH gateway output planes from one +/// tracing event: +/// - the **routing bus** (`TracingLogBus`) picks up the `sandbox_id` + `message` +/// fields → stdout shorthand + per-sandbox gRPC stream, and +/// - the **JSONL audit layer** (`OcsfJsonlLayer`, installed in +/// `openshell-server`'s subscriber) picks up the full structured `OcsfEvent` +/// from the thread-local bridge → durable `openshell-ocsf..log`. +/// +/// Before cp6 this fired a bare `tracing::info!` that never populated the +/// bridge, so the structured event was silently dropped and no JSONL was +/// written. `emit_ocsf_event_routed` does both jobs from a single dispatch. +fn emit_ocsf(sandbox_id: &str, event: OcsfEvent) { + openshell_ocsf::emit_ocsf_event_routed(sandbox_id, event); +} + +/// The gateway host's machine name, resolved once. This becomes `device.hostname` +/// in every emitted OCSF event, so the audit trail attributes activity to the +/// real box (e.g. `7F203-MXC-001`) rather than a static placeholder. `COMPUTERNAME` +/// is always set on Windows; we fall back to a sentinel only if it is somehow empty. +fn gateway_hostname() -> &'static str { + static HOSTNAME: std::sync::OnceLock = std::sync::OnceLock::new(); + HOSTNAME.get_or_init(|| { + std::env::var("COMPUTERNAME") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "openshell-gateway".to_string()) + }) +} + +/// Build a per-event OCSF context (not the process-wide `ctx()` singleton, since +/// one gateway process hosts many sandboxes — wrinkle #1). +fn etw_ctx(sandbox_id: &str, sandbox_name: &str) -> SandboxContext { + SandboxContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: "mxc/appcontainer".to_string(), + hostname: gateway_hostname().to_string(), + product_version: env!("CARGO_PKG_VERSION").to_string(), + proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + proxy_port: 0, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Stable string key for an ETW `ActivityId` GUID, or `None` for the all-zero +/// GUID (which means "no activity" and must never be used as a correlation key). +fn guid_key(g: &GUID) -> Option { + if g.data1 == 0 && g.data2 == 0 && g.data3 == 0 && g.data4 == [0u8; 8] { + return None; + } + let tail: String = g.data4.iter().map(|b| format!("{b:02x}")).collect(); + Some(format!( + "{:08x}-{:04x}-{:04x}-{tail}", + g.data1, g.data2, g.data3 + )) +} + +fn read_bytes(ptr: *const u8) -> [u8; N] { + let mut out = [0u8; N]; + unsafe { + std::ptr::copy_nonoverlapping(ptr, out.as_mut_ptr(), N); + } + out +} + +fn wide_str_at(buf: &[u8], offset: u32) -> Option { + let off = offset as usize; + if off == 0 || off >= buf.len() { + return None; + } + + let remaining = &buf[off..]; + let max_wchars = remaining.len() / 2; + if max_wchars == 0 { + return None; + } + + let wchars = + unsafe { std::slice::from_raw_parts(remaining.as_ptr().cast::(), max_wchars) }; + let len = wchars.iter().position(|&c| c == 0).unwrap_or(max_wchars); + if len == 0 { + return None; + } + + Some(String::from_utf16_lossy(&wchars[..len])) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mk_event(pid: u32, name: &str) -> DecodedEtwEvent { + DecodedEtwEvent { + provider: GUID::from_u128(0), + event_id: 1, + level: 4, + opcode: OPCODE_START, + process_id: pid, + activity_id: GUID::from_u128(0), + event_name: Some(name.to_string()), + props: Vec::new(), + } + } + + // Shailendra #2: the create/config burst can reach the consumer before the + // driver's `register_launch` seeds attribution. An event that doesn't resolve + // must be held and replayed once attribution lands — not dropped. + #[test] + fn buffered_event_replays_after_registration() { + let mut idx = AttributionIndex::new(); + let ev = mk_event(1234, "SandboxConfig"); + + // Arrives before registration → unresolved → buffered, not dropped. + assert!(idx.resolve(&ev).is_none()); + idx.buffer_unresolved(ev); + assert!( + idx.drain_resolved().is_empty(), + "nothing to drain pre-registration" + ); + + // Driver seeds attribution for the wxc-exec pid we spawned. + idx.register_launch("sbx-1", "my-sandbox", 1234, "agent --run"); + + // The buffered event now attributes and is returned for emit, in order. + let ready = idx.drain_resolved(); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].0, "sbx-1"); + assert_eq!(ready[0].1, "my-sandbox"); + assert_eq!(ready[0].2.process_id, 1234); + + // And it's removed from the buffer (no double emit). + assert!(idx.drain_resolved().is_empty()); + } + + // Genuinely unattributable events (e.g. from unrelated Sandboxing activity) + // must never grow the buffer without bound. + #[test] + fn pending_buffer_is_bounded() { + let mut idx = AttributionIndex::new(); + for pid in 0..(PENDING_MAX as u32 + 50) { + idx.buffer_unresolved(mk_event(pid, "SandboxConfig")); + } + assert!( + idx.pending.len() <= PENDING_MAX, + "buffer exceeded PENDING_MAX" + ); + } + + // A buffered event that resolves via a cross-linked correlator (not just the + // pid) is also replayed: register one pid, then an event sharing only the + // activity id resolves after the first event cross-links it. + #[test] + fn buffered_event_replays_via_crosslink() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-9", "s9", 4321, "agent"); + + // First event carries the pid + an activity id → resolves and cross-links + // the activity id to sbx-9. + let mut anchor = mk_event(4321, "CreateProcessInSandbox"); + anchor.activity_id = GUID::from_u128(0xABCD); + assert_eq!(idx.resolve(&anchor).as_deref(), Some("sbx-9")); + + // A later payload-keyless event shares only the activity id (different + // pid) — it must now resolve via the cross-link. + let mut keyless = mk_event(0, "SandboxConfig"); + keyless.activity_id = GUID::from_u128(0xABCD); + assert_eq!(idx.resolve(&keyless).as_deref(), Some("sbx-9")); + } + + // Shailendra #1 (PID reuse): if a sandbox leaked (no `forget`) and Windows + // recycles its wxc-exec PID for a new sandbox, events on that PID must route + // to the *new* owner, never the dead one. + #[test] + fn pid_reuse_rebinds_to_new_sandbox() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-A", "A", 1000, "agent --a"); + let ev_a = mk_event(1000, "CreateProcessInSandbox"); + assert_eq!(idx.resolve(&ev_a).as_deref(), Some("sbx-A")); + + // A leaks (delete never ran). PID 1000 is recycled for B. + idx.register_launch("sbx-B", "B", 1000, "agent --b"); + let ev_b = mk_event(1000, "CreateProcessInSandbox"); + assert_eq!(idx.resolve(&ev_b).as_deref(), Some("sbx-B")); + } + + // 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 duplicate_command_line_is_not_used_for_resolution() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-1", "s1", 11, "agent --run"); + idx.register_launch("sbx-2", "s2", 22, "agent --run"); // same cmd → ambiguous + + // Event carrying ONLY the duplicate command line (unknown pid, no + // identity/activity) must NOT resolve — refusing beats misrouting. + let mut only_cmd = mk_event(999, "SandboxConfig"); + only_cmd + .props + .push(("commandLine".into(), "\"agent --run\"".into())); + assert!(idx.resolve(&only_cmd).is_none()); + + // A still-unique command line resolves via the fallback as before. + idx.register_launch("sbx-3", "s3", 33, "agent --unique"); + let mut uniq = mk_event(998, "SandboxConfig"); + uniq.props + .push(("commandLine".into(), "\"agent --unique\"".into())); + assert_eq!(idx.resolve(&uniq).as_deref(), Some("sbx-3")); + } + + // CodeRabbit (replay PID recycle): a buffered event whose only key is a PID + // must NOT be replayed onto a sandbox that registered that PID *after* the + // event was captured — that registration is a recycled PID owned by someone + // else. Refusing (event ages out) beats misattributing to the new owner. + #[test] + fn replayed_pid_match_refused_after_recycle() { + let mut idx = AttributionIndex::new(); + // Stale event for a now-dead sandbox, buffered a while ago. + let ev = mk_event(1000, "CreateProcessInSandbox"); + let buffered_at = Instant::now() - Duration::from_secs(3); + + // PID 1000 is recycled and registered to a brand-new sandbox *now*. + idx.register_launch("sbx-new", "new", 1000, "agent"); + + assert!( + idx.resolve_replay(&ev, buffered_at).is_none(), + "stale PID-only event must not bind to the recycled PID's new owner" + ); + } + + // The legitimate #2 seed race is preserved: an event buffered essentially + // when the driver seeds attribution still replays via its PID. + #[test] + fn replayed_pid_match_accepted_within_grace() { + let mut idx = AttributionIndex::new(); + let ev = mk_event(1000, "CreateProcessInSandbox"); + let buffered_at = Instant::now(); + idx.register_launch("sbx-1", "s1", 1000, "agent"); + assert_eq!( + idx.resolve_replay(&ev, buffered_at).as_deref(), + Some("sbx-1"), + "a seed event buffered at registration time must still replay" + ); + } + + // Replay must not lean on the weak fallbacks (`by_cmd` / `last_pid_sid`): + // a buffered event whose only match is a command line is refused on replay + // (it would be resolved on the live path, but is too weak to trust after a + // buffering delay). + #[test] + fn replay_ignores_weak_fallbacks() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-1", "s1", 11, "agent --unique"); + + let mut only_cmd = mk_event(999, "SandboxConfig"); + only_cmd + .props + .push(("commandLine".into(), "\"agent --unique\"".into())); + + // Live path would resolve it via by_cmd... + // (not asserted here to avoid mutating cross-links) + // ...but the replay path refuses the weak command-line key. + assert!(idx.resolve_replay(&only_cmd, Instant::now()).is_none()); + } +} diff --git a/crates/openshell-driver-mxc/src/grpc.rs b/crates/openshell-driver-mxc/src/grpc.rs new file mode 100644 index 0000000000..9e7dada94f --- /dev/null +++ b/crates/openshell-driver-mxc/src/grpc.rs @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Thin tonic adapter: delegates to `MxcComputeBackend` and maps errors to +//! gRPC `Status`. + +#![allow(clippy::result_large_err)] + +use crate::driver::MxcComputeBackend; +use futures::{Stream, StreamExt}; +use openshell_core::proto::compute::v1::{ + CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, + DeleteWorkspaceRequest, DeleteWorkspaceResponse, EnsureWorkspaceRequest, + EnsureWorkspaceResponse, GetCapabilitiesRequest, GetCapabilitiesResponse, + GetGatewayListenerRequirementsRequest, GetGatewayListenerRequirementsResponse, + GetSandboxRequest, GetSandboxResponse, ListSandboxesRequest, ListSandboxesResponse, + StartSandboxRequest, StartSandboxResponse, StopSandboxRequest, StopSandboxResponse, + ValidateSandboxCreateRequest, ValidateSandboxCreateResponse, WatchSandboxesEvent, + WatchSandboxesRequest, compute_driver_server::ComputeDriver, +}; +use std::pin::Pin; +use tonic::{Request, Response, Status}; + +#[derive(Debug)] +pub struct ComputeDriverService { + backend: MxcComputeBackend, +} + +impl ComputeDriverService { + pub fn new(backend: MxcComputeBackend) -> Self { + Self { backend } + } +} + +#[tonic::async_trait] +impl ComputeDriver for ComputeDriverService { + async fn get_capabilities( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(self.backend.capabilities())) + } + + async fn get_gateway_listener_requirements( + &self, + _request: Request, + ) -> Result, Status> { + // MXC is an in-process, single-host driver: it needs no extra gateway + // listeners (no relay/surrogate/remote endpoint), so it reports none. + Ok(Response::new(GetGatewayListenerRequirementsResponse { + requirements: Vec::new(), + })) + } + + async fn validate_sandbox_create( + &self, + request: Request, + ) -> Result, Status> { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.backend.validate_sandbox_create(&sandbox)?; + Ok(Response::new(ValidateSandboxCreateResponse {})) + } + + async fn get_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let sandbox = self + .backend + .get_sandbox(&req.sandbox_name) + .await + .ok_or_else(|| Status::not_found(format!("sandbox {} not found", req.sandbox_name)))?; + if !req.sandbox_id.is_empty() && req.sandbox_id != sandbox.id { + return Err(Status::failed_precondition( + "sandbox_id did not match the fetched sandbox", + )); + } + Ok(Response::new(GetSandboxResponse { + sandbox: Some(sandbox), + })) + } + + async fn list_sandboxes( + &self, + _request: Request, + ) -> Result, Status> { + let sandboxes = self.backend.list_sandboxes().await; + Ok(Response::new(ListSandboxesResponse { sandboxes })) + } + + async fn create_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let sandbox = request + .into_inner() + .sandbox + .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; + self.backend.create_sandbox(&sandbox).await?; + Ok(Response::new(CreateSandboxResponse {})) + } + + async fn stop_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + self.backend.stop_sandbox(&req.sandbox_name).await?; + Ok(Response::new(StopSandboxResponse {})) + } + + async fn start_sandbox( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "mxc driver does not support restarting stopped sandboxes", + )) + } + + async fn delete_sandbox( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.sandbox_id.is_empty() { + return Err(Status::invalid_argument("sandbox_id is required")); + } + if req.sandbox_name.is_empty() { + return Err(Status::invalid_argument("sandbox_name is required")); + } + let deleted = self + .backend + .delete_sandbox(&req.sandbox_id, &req.sandbox_name) + .await?; + Ok(Response::new(DeleteSandboxResponse { deleted })) + } + + type WatchSandboxesStream = + Pin> + Send + 'static>>; + + async fn watch_sandboxes( + &self, + _request: Request, + ) -> Result, Status> { + let stream = self.backend.watch_sandboxes().await; + let mapped = stream.map(|item| item.map_err(|e| Status::internal(e.to_string()))); + Ok(Response::new(Box::pin(mapped))) + } + + async fn ensure_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(EnsureWorkspaceResponse {})) + } + + async fn delete_workspace( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(DeleteWorkspaceResponse {})) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::driver::MxcComputeConfig; + + #[tokio::test] + async fn start_sandbox_reports_one_shot_lifecycle() { + let service = + ComputeDriverService::new(MxcComputeBackend::new(MxcComputeConfig::default())); + + let error = service + .start_sandbox(Request::new(StartSandboxRequest::default())) + .await + .expect_err("MXC must not restart a stopped one-shot workload"); + + assert_eq!(error.code(), tonic::Code::Unimplemented); + assert!(error.message().contains("does not support restarting")); + } + + #[tokio::test] + async fn workspace_lifecycle_is_an_idempotent_no_op() { + let service = + ComputeDriverService::new(MxcComputeBackend::new(MxcComputeConfig::default())); + + service + .ensure_workspace(Request::new(EnsureWorkspaceRequest::default())) + .await + .expect("MXC has no driver-owned workspace resource to provision"); + service + .delete_workspace(Request::new(DeleteWorkspaceRequest::default())) + .await + .expect("MXC workspace deletion must remain idempotent"); + } +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs new file mode 100644 index 0000000000..4b9d328e9f --- /dev/null +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `OpenShell` MXC compute driver. +//! +//! Implements the gateway's `ComputeDriver` gRPC contract backed by Microsoft +//! MXC (`wxc-exec`) on Windows. The driver is **in-process**, runs the agent +//! directly (exec-in-driver), and self-reports `Ready` — there is no +//! in-sandbox supervisor, no host-side surrogate, and no `ConnectSupervisor` +//! relay. +//! +//! This crate compiles to an **empty stub** on non-Windows targets so the +//! Linux build stays green. All implementation code is gated on +//! `#[cfg(target_os = "windows")]`. + +#![allow(clippy::result_large_err)] + +#[cfg(target_os = "windows")] +mod driver; +#[cfg(target_os = "windows")] +mod grpc; +#[cfg(target_os = "windows")] +mod mxc; +#[cfg(target_os = "windows")] +mod policy; +// Embedded mapper logic (source of truth; was the `openshell-policy-mapper` +// crate). Windows-only — MXC and the policy mapper are not built for Linux/WSL. +#[cfg(target_os = "windows")] +mod policy_map; +// Real-time ETW → OCSF audit consumer (Plane A). Consumes the OS Sandboxing +// provider MXC drives and emits OCSF through the gateway's tracing sink. +// Windows-only. +#[cfg(target_os = "windows")] +mod etw_consumer; + +#[cfg(target_os = "windows")] +pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; +#[cfg(target_os = "windows")] +pub use grpc::ComputeDriverService; +// 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")] +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, +}; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs new file mode 100644 index 0000000000..32d7006d90 --- /dev/null +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -0,0 +1,867 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `wxc-exec` invoker and MXC request/response types. +//! +//! Builds state-aware MXC config JSON, base64-encodes it, runs `wxc-exec`, +//! and parses the response envelope. The exec phase is special: its stdout is +//! live process output (not JSON) and its exit code is the agent exit code. + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use thiserror::Error; +use tokio::process::Command; +use tracing::debug; + +/// MXC config schema version. +pub const MXC_SCHEMA_VERSION: &str = "0.6.0-alpha"; + +/// Default `configurationId` for isolation session. Never use `"small"` (known OS bug). +pub const DEFAULT_CONFIGURATION_ID: &str = "composable"; + +/// Environment flag selecting the in-process mock `wxc-exec` shim. When set to +/// `"1"`, the invoker does NOT spawn the real `wxc-exec.exe`; instead it emits +/// canned provision/start/stop/deprovision results and simulates `AppContainer` +/// filesystem-policy enforcement for the exec phase. This is what makes the +/// full create → Ready → policy-proof round trip runnable off the demo box. +pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC"; + +fn mock_enabled() -> bool { + std::env::var(MOCK_ENV_VAR).is_ok_and(|value| value == "1") +} + +/// Normalize a path/command fragment to lowercase backslash form for the mock's +/// in-policy substring check. +fn mock_normalize(s: &str) -> String { + s.replace('/', "\\").to_lowercase() +} + +/// Per-process mock state: `iso:` sandbox id → granted read-write paths +/// (normalized). Populated by the mock provision, consumed by the mock exec to +/// decide whether the agent's write target is in-policy. +fn mock_grants() -> &'static Mutex>> { + static GRANTS: OnceLock>>> = OnceLock::new(); + GRANTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +// ── Request types ───────────────────────────────────────────────────────────── + +/// Filesystem shares for the sandbox. +/// +/// `isolation_session` honors `readwrite`/`readonly` (grant-only — it has no +/// deny primitive). `processContainer` additionally honors `denied_paths` +/// because the `AppContainer` backend can stamp deny ACEs; it is also genuinely +/// default-deny, so anything not granted is already inaccessible. +#[derive(Debug, Default)] +#[allow(clippy::struct_field_names)] +pub struct MxcFilesystem { + pub readwrite_paths: Vec, + pub readonly_paths: Vec, + pub denied_paths: Vec, +} + +/// Network redirect fragment emitted when governed egress is enabled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MxcNetwork { + pub default_policy: String, + pub proxy: Option, +} + +/// `processContainer`-specific knobs (one-shot `AppContainer` backend). +#[derive(Debug, Default, Clone)] +pub struct MxcProcessContainer { + /// Request a Less-Privileged `AppContainer` (stricter default-deny). + pub least_privilege: bool, + /// `AppContainer` capabilities to grant (e.g. `internetClient`). + pub capabilities: Vec, +} + +/// Process config for the exec phase. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MxcProcess { + pub command_line: String, + pub cwd: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub env: Vec, + /// 0 = no timeout (long-lived agent). + pub timeout: u64, +} + +fn network_json(network: &MxcNetwork) -> serde_json::Value { + // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. + // {"host": ..., "port": ...} and every other shape is rejected — verified + // empirically against the real wxc-exec 0.6.0-alpha binary via --dry-run. + // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. + // The MxcNetwork.proxy field remains SocketAddr so callers keep full + // precision; only the port is serialized into the localhost key. + let mut value = serde_json::json!({ + "defaultPolicy": network.default_policy.as_str(), + "allowedHosts": [], + "blockedHosts": [], + }); + if let Some(proxy) = network.proxy { + value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); + } + value +} + +fn provision_config_json( + configuration_id: &str, + filesystem: &MxcFilesystem, + network: Option<&MxcNetwork>, +) -> serde_json::Value { + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": &filesystem.readwrite_paths, + "readonlyPaths": &filesystem.readonly_paths, + }, + "experimental": { + "isolation_session": { + "configurationId": configuration_id, + "provision": {} + } + } + }); + if let Some(network) = network { + config["network"] = network_json(network); + } + config +} + +fn oneshot_config_json( + container_id: &str, + filesystem: &MxcFilesystem, + pc: &MxcProcessContainer, + process: &MxcProcess, + network: Option<&MxcNetwork>, +) -> serde_json::Value { + let mut filesystem_json = serde_json::Map::new(); + if !filesystem.readwrite_paths.is_empty() { + filesystem_json.insert( + "readwritePaths".into(), + filesystem.readwrite_paths.clone().into(), + ); + } + if !filesystem.readonly_paths.is_empty() { + filesystem_json.insert( + "readonlyPaths".into(), + filesystem.readonly_paths.clone().into(), + ); + } + if !filesystem.denied_paths.is_empty() { + filesystem_json.insert("deniedPaths".into(), filesystem.denied_paths.clone().into()); + } + + let mut pc_json = serde_json::Map::new(); + pc_json.insert("leastPrivilege".into(), pc.least_privilege.into()); + if !pc.capabilities.is_empty() { + pc_json.insert("capabilities".into(), pc.capabilities.clone().into()); + } + + let mut config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": container_id, + "containment": "processcontainer", + "process": { + "commandLine": process.command_line.as_str(), + "cwd": process.cwd.as_str(), + "env": &process.env, + "timeout": process.timeout, + }, + "processContainer": serde_json::Value::Object(pc_json), + "filesystem": serde_json::Value::Object(filesystem_json), + }); + if let Some(network) = network { + config["network"] = network_json(network); + } + config +} + +#[cfg(test)] +fn mock_configs() -> &'static Mutex> { + static CONFIGS: OnceLock>> = OnceLock::new(); + CONFIGS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +pub fn mock_recorded_config(id: &str) -> Option { + mock_configs().lock().unwrap().get(id).cloned() +} + +// ── Response envelope ───────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct ProvisionResult { + #[serde(rename = "sandboxId")] + pub sandbox_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum MxcEnvelope { + Ok { + #[allow(dead_code)] + result: serde_json::Value, + }, + Err { + error: MxcErrorBody, + }, +} + +#[derive(Debug, Deserialize)] +pub struct MxcErrorBody { + pub code: String, + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct ProvisionEnvelope { + pub result: Option, + pub error: Option, +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Error)] +pub enum InvokerError { + #[error("wxc-exec spawn failed: {0}")] + Spawn(#[from] std::io::Error), + #[error("wxc-exec config serialization failed: {0}")] + Serialize(#[from] serde_json::Error), + #[error("wxc-exec envelope parse failed (stdout={stdout:?}): {source}")] + Parse { + stdout: String, + source: serde_json::Error, + }, + #[error("wxc-exec process failed with no envelope (exit={exit_code}, stderr={stderr:?})")] + NoEnvelope { exit_code: i32, stderr: String }, + #[error("MXC error [{code}]: {message}")] + Mxc { code: String, message: String }, + /// Exec phase returned a non-zero exit code (the agent's own exit status). + /// Surfaced through the watch stream rather than as a gRPC error. + #[allow(dead_code)] + #[error("wxc-exec exec phase exited with code {0}")] + ExecNonZero(i32), +} + +impl InvokerError { + #[allow(dead_code)] + pub fn to_tonic_status(&self) -> tonic::Status { + match self { + Self::Mxc { code, message } => match code.as_str() { + "malformed_request" | "unsupported_phase" => { + tonic::Status::internal(format!("driver bug: {message}")) + } + "unsupported_containment" + | "not_provisioned" + | "not_started" + | "already_started" + | "already_stopped" => tonic::Status::failed_precondition(message.clone()), + "malformed_id" | "stale_id" => tonic::Status::not_found(message.clone()), + "policy_validation" => tonic::Status::invalid_argument(message.clone()), + "backend_unavailable" => tonic::Status::unavailable(message.clone()), + _ => tonic::Status::internal(message.clone()), + }, + Self::Spawn(e) => tonic::Status::internal(format!("wxc-exec spawn: {e}")), + Self::Serialize(e) => tonic::Status::internal(format!("config serialize: {e}")), + Self::Parse { .. } | Self::NoEnvelope { .. } => { + tonic::Status::internal(self.to_string()) + } + Self::ExecNonZero(code) => { + tonic::Status::internal(format!("agent exited with code {code}")) + } + } + } +} + +// ── Invoker ─────────────────────────────────────────────────────────────────── + +/// Wraps `wxc-exec` invocations for the MXC state-aware lifecycle. +#[derive(Debug, Clone)] +pub struct WxcExecInvoker { + exec_path: PathBuf, + debug: bool, + /// When true, use the in-process mock instead of spawning `wxc-exec.exe`. + mock: bool, +} + +impl WxcExecInvoker { + pub fn new(exec_path: impl Into, debug: bool) -> Self { + Self { + exec_path: exec_path.into(), + debug, + mock: mock_enabled(), + } + } + + /// Test-only constructor that forces mock mode without touching the + /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across + /// parallel tests under edition 2024's `unsafe` `set_var`). + #[cfg(test)] + pub(crate) fn mocked(exec_path: impl Into) -> Self { + Self { + exec_path: exec_path.into(), + debug: false, + mock: true, + } + } + + /// Encode `config` as base64 and invoke wxc-exec, returning the parsed envelope. + /// Use this for all **non-exec** phases (provision/start/stop/deprovision). + pub async fn run_phase(&self, config: &serde_json::Value) -> Result<(), InvokerError> { + if self.mock { + // Mock start/stop/deprovision: canned `{"result":{}}` success. + debug!(phase = ?config.get("phase"), "mock wxc-exec phase (no-op success)"); + return Ok(()); + } + let json = serde_json::to_string(config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64").arg(&b64).arg("--experimental"); + if self.debug { + cmd.arg("--debug"); + } + + debug!(config = %json, "wxc-exec phase"); + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if !output.status.success() { + if let Ok(MxcEnvelope::Err { error }) = serde_json::from_str::(&stdout) { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); + } + let code = output.status.code().unwrap_or(-1); + return Err(InvokerError::NoEnvelope { + exit_code: code, + stderr, + }); + } + + // Success — parse envelope to surface any embedded error field. + match serde_json::from_str::(&stdout) { + Ok(MxcEnvelope::Err { error }) => Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }), + Ok(MxcEnvelope::Ok { .. }) => Ok(()), + Err(_) if stdout.trim().is_empty() => { + // Some phases return empty stdout on success. + Ok(()) + } + Err(e) => Err(InvokerError::Parse { stdout, source: e }), + } + } + + /// Run the provision phase and return the `sandboxId` from the response. + pub async fn provision( + &self, + configuration_id: &str, + filesystem: MxcFilesystem, + network: Option, + ) -> Result { + if self.mock { + // Mock provision: mint a synthetic `iso:` id and record the granted + // read-write paths so the mock exec can enforce the policy. + let id = format!("iso:mock-{}", uuid::Uuid::new_v4()); + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + mock_grants().lock().unwrap().insert(id.clone(), grants); + #[cfg(test)] + { + let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); + mock_configs().lock().unwrap().insert(id.clone(), config); + } + debug!(sandbox_id = %id, "mock wxc-exec provision"); + return Ok(id); + } + let config = provision_config_json(configuration_id, &filesystem, network.as_ref()); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64").arg(&b64).arg("--experimental"); + if self.debug { + cmd.arg("--debug"); + } + + debug!(config = %json, "wxc-exec provision"); + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + if let Ok(ProvisionEnvelope { + error: Some(error), .. + }) = serde_json::from_str::(&stdout) + { + return Err(InvokerError::Mxc { + code: error.code, + message: error.message, + }); + } + return Err(InvokerError::NoEnvelope { + exit_code: code, + stderr, + }); + } + + let env: ProvisionEnvelope = + serde_json::from_str(&stdout).map_err(|e| InvokerError::Parse { + stdout: stdout.clone(), + source: e, + })?; + + if let Some(err) = env.error { + return Err(InvokerError::Mxc { + code: err.code, + message: err.message, + }); + } + + env.result + .map(|r| r.sandbox_id) + .ok_or_else(|| InvokerError::NoEnvelope { + exit_code: 0, + stderr: "provision result missing sandboxId".to_string(), + }) + } + + /// Run the start phase for an already-provisioned sandbox. + pub async fn start(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "start", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "start": {} + } + } + }); + self.run_phase(&config).await + } + + /// Spawn the exec phase (agent command). Returns the child process handle. + /// **Stdout is raw agent output, not a JSON envelope. Exit code == agent exit code.** + pub async fn spawn_exec( + &self, + iso_sandbox_id: &str, + process: MxcProcess, + ) -> Result { + if self.mock { + return Self::mock_spawn_exec(iso_sandbox_id, &process); + } + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "exec", + "sandboxId": iso_sandbox_id, + "process": { + "commandLine": process.command_line, + "cwd": process.cwd, + "env": process.env, + "timeout": process.timeout, + } + }); + + let json = serde_json::to_string(&config)?; + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + let mut cmd = Command::new(&self.exec_path); + cmd.arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if self.debug { + cmd.arg("--debug"); + } + + debug!(sandbox_id = %iso_sandbox_id, command = %process.command_line, "wxc-exec exec spawn"); + let child = cmd.spawn()?; + Ok(child) + } + + /// Mock exec: simulate `AppContainer` filesystem-policy enforcement. + /// + /// The agent's write target is considered **in-policy** iff the command line + /// references one of the granted read-write paths recorded at mock provision. + fn mock_spawn_exec( + iso_sandbox_id: &str, + process: &MxcProcess, + ) -> Result { + let grants = mock_grants() + .lock() + .unwrap() + .get(iso_sandbox_id) + .cloned() + .unwrap_or_default(); + Self::mock_spawn_with_grants(process, &grants) + } + + /// Shared mock enforcement used by both the `isolation_session` exec phase + /// and the one-shot `processContainer` path. + /// + /// In-policy → run the real agent command (so the positive-proof artifact, + /// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy + /// → refuse with an access-denied message on stderr and a non-zero exit, + /// mirroring how the `AppContainer` denies the write on the demo box. + fn mock_spawn_with_grants( + process: &MxcProcess, + 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 mut cmd = Command::new("cmd"); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if in_policy { + debug!(command = %process.command_line, "mock exec: in-policy, running agent"); + // `command_line` is already encoded with Windows quoting rules. + // Pass it raw so this mock matches wxc-exec/CreateProcess instead + // of asking Rust to quote the entire command as one cmd.exe argv. + cmd.raw_arg(format!("/d /s /c \"{}\"", process.command_line)); + } else { + debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying"); + cmd.arg("/c").arg( + "echo Access is denied. (out-of-policy write blocked by AppContainer) 1>&2& exit 1", + ); + } + let child = cmd.spawn()?; + Ok(child) + } + + /// Build a **one-shot** `processContainer` config (no `phase`) and spawn it. + /// + /// Unlike the `isolation_session` lifecycle (provision → start → exec → + /// stop → deprovision), `processContainer` is a single ephemeral + /// `AppContainer`: one `wxc-exec` invocation creates the container, runs the + /// one process, and tears down when it exits. The `AppContainer` is genuinely + /// default-deny, so a write to any ungranted path is denied by the OS. + /// + /// **Stdout is raw agent output; the exit code is the agent's own exit code.** + pub async fn run_oneshot( + &self, + container_id: &str, + filesystem: MxcFilesystem, + pc: MxcProcessContainer, + process: MxcProcess, + network: Option, + ) -> Result { + let config = + oneshot_config_json(container_id, &filesystem, &pc, &process, network.as_ref()); + if self.mock { + let grants: Vec = filesystem + .readwrite_paths + .iter() + .map(|p| mock_normalize(p)) + .collect(); + #[cfg(test)] + mock_configs() + .lock() + .unwrap() + .insert(container_id.to_owned(), config); + return Self::mock_spawn_with_grants(&process, &grants); + } + + let json = serde_json::to_string(&config)?; + 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); + if self.debug { + cmd.arg("--debug"); + } + + debug!(container_id = %container_id, command = %process.command_line, "wxc-exec one-shot processContainer spawn"); + let child = cmd.spawn()?; + Ok(child) + } + + /// Run the stop phase. + /// + /// `stop`/`deprovision` are **unit** variants in the wxc-exec schema: they + /// must serialize as `null`, not `{}`. Empirical (build 26300.8553, + /// wxc-exec 2026-06-10): `"stop": {}` is rejected with `malformed_request` + /// ("invalid type: map, expected unit"); `provision`/`start` accept maps. + pub async fn stop(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "stop", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "stop": null + } + } + }); + self.run_phase(&config).await + } + + /// Run the deprovision phase (unit variant — see [`Self::stop`]). + pub async fn deprovision(&self, iso_sandbox_id: &str) -> Result<(), InvokerError> { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "deprovision", + "sandboxId": iso_sandbox_id, + "experimental": { + "isolation_session": { + "deprovision": null + } + } + }); + self.run_phase(&config).await + } +} + +// ── Tests (pure serde — compile and run cross-platform) ────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provision_envelope_parse_success() { + let json = r#"{"result":{"sandboxId":"iso:wxc-abc123","metadata":{}}}"#; + let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(env.result.unwrap().sandbox_id, "iso:wxc-abc123"); + assert!(env.error.is_none()); + } + + #[test] + fn provision_envelope_parse_error() { + let json = + r#"{"error":{"code":"backend_unavailable","message":"IsoSessionApp.dll missing"}}"#; + let env: ProvisionEnvelope = serde_json::from_str(json).unwrap(); + assert!(env.result.is_none()); + let err = env.error.unwrap(); + assert_eq!(err.code, "backend_unavailable"); + } + + #[test] + fn mxc_envelope_success_variant() { + let json = r#"{"result":{}}"#; + let env: MxcEnvelope = serde_json::from_str(json).unwrap(); + assert!(matches!(env, MxcEnvelope::Ok { .. })); + } + + #[test] + fn mxc_envelope_error_variant() { + let json = r#"{"error":{"code":"not_provisioned","message":"call provision first"}}"#; + let env: MxcEnvelope = serde_json::from_str(json).unwrap(); + assert!(matches!(env, MxcEnvelope::Err { .. })); + } + + #[test] + fn provision_config_json_shape() { + // Verify the JSON we send wxc-exec has the expected shape. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "readonlyPaths": [], + }, + "experimental": { + "isolation_session": { + "configurationId": DEFAULT_CONFIGURATION_ID, + "provision": {} + } + } + }); + assert_eq!(config["phase"], "provision"); + assert_eq!(config["containment"], "isolation_session"); + assert_eq!( + config["experimental"]["isolation_session"]["configurationId"], + "composable" + ); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + } + + #[test] + fn oneshot_processcontainer_config_json_shape() { + // Mirror the JSON `run_oneshot` builds for the one-shot processContainer + // path: no `phase` (routes to one-shot), `containment: processcontainer`, + // a `process` block, the `processContainer` knobs, and filesystem grants + // incl. deniedPaths. + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "containerId": "sb-1", + "containment": "processcontainer", + "process": { + "commandLine": "C:\\work\\demo\\agent.exe", + "cwd": "C:\\work\\demo", + "env": Vec::::new(), + "timeout": 0, + }, + "processContainer": { "leastPrivilege": true }, + "filesystem": { + "readwritePaths": ["C:\\work\\demo"], + "deniedPaths": ["C:\\secret"], + }, + }); + assert_eq!(config["containment"], "processcontainer"); + assert!( + config.get("phase").is_none(), + "one-shot config must omit phase" + ); + assert_eq!(config["processContainer"]["leastPrivilege"], true); + assert_eq!(config["filesystem"]["readwritePaths"][0], "C:\\work\\demo"); + assert_eq!(config["filesystem"]["deniedPaths"][0], "C:\\secret"); + } + + #[test] + fn provision_config_json_includes_network_proxy_when_supplied() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), + denied_paths: Vec::new(), + }; + let network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + }; + let config = provision_config_json(DEFAULT_CONFIGURATION_ID, &filesystem, Some(&network)); + + assert_eq!(config["network"]["defaultPolicy"], "block"); + assert!( + config["network"]["allowedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + config["network"]["blockedHosts"] + .as_array() + .unwrap() + .is_empty() + ); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + assert_eq!(config["network"]["proxy"]["localhost"], 18080); + assert!( + config["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + config["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + } + + #[test] + fn network_json_emits_localhost_port_shape() { + // MXC 0.6.0-alpha rejects {"host":...,"port":...} and accepts only + // {"proxy": {"localhost": N}} — verified against the real binary via + // --dry-run. This test pins the exact emitted JSON shape. + let network = MxcNetwork { + default_policy: "block".into(), + proxy: Some("127.0.0.1:18080".parse().unwrap()), + }; + let value = network_json(&network); + assert_eq!(value["proxy"]["localhost"], 18080); + assert!(value["proxy"].get("host").is_none()); + assert!(value["proxy"].get("port").is_none()); + } + + #[test] + fn oneshot_config_json_omits_network_without_proxy() { + let filesystem = MxcFilesystem { + readwrite_paths: vec!["C:\\work\\demo".into()], + readonly_paths: Vec::new(), + denied_paths: Vec::new(), + }; + 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 config = oneshot_config_json("sb-1", &filesystem, &pc, &process, None); + + assert!(config.get("network").is_none()); + } + + #[test] + fn stop_and_deprovision_serialize_as_unit_variants() { + // Pins the empirical schema contract (test box, build 26300.8553): + // stop/deprovision are unit variants and must be `null`; `{}` is + // rejected with malformed_request "invalid type: map, expected unit". + for phase in ["stop", "deprovision"] { + let config = serde_json::json!({ + "version": MXC_SCHEMA_VERSION, + "phase": phase, + "sandboxId": "iso:wxc-test", + "experimental": { + "isolation_session": { + phase: null + } + } + }); + assert!( + config["experimental"]["isolation_session"][phase].is_null(), + "{phase} must serialize as null (unit variant)" + ); + } + } + + #[test] + fn invoker_error_maps_backend_unavailable_to_unavailable() { + let err = InvokerError::Mxc { + code: "backend_unavailable".into(), + message: "missing DLL".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::Unavailable); + } + + #[test] + fn invoker_error_maps_policy_validation_to_invalid_argument() { + let err = InvokerError::Mxc { + code: "policy_validation".into(), + message: "path denied".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + } + + #[test] + fn invoker_error_maps_stale_id_to_not_found() { + let err = InvokerError::Mxc { + code: "stale_id".into(), + message: "session expired".into(), + }; + let status = err.to_tonic_status(); + assert_eq!(status.code(), tonic::Code::NotFound); + } +} diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs new file mode 100644 index 0000000000..1f26d186e5 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! `PolicyMapper` seam: `SandboxPolicy` → MXC `ContainerConfig` fragment. +//! +//! This file does **not** write the actual policy mapping rules — that logic is +//! **embedded** as the [`crate::policy_map`] module (the source of truth; it was +//! 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. +//! +//! **Rule: never silently drop policy.** Unmappable rules surface as +//! `MapError::Unsupported` and are rejected by `CreateSandbox` before lifecycle side effects. + +use std::net::SocketAddr; + +use openshell_core::proto::SandboxPolicy; +use thiserror::Error; + +/// The MXC config fragment derived from a `SandboxPolicy`. +/// +/// Carries filesystem share lists for the MXC provision phase, plus the +/// Pattern-C governed-egress handoff when enabled. +#[derive(Debug, Default, Clone)] +pub struct MappedConfig { + /// Paths granted read-write access inside the sandbox. + pub readwrite_paths: Vec, + /// Paths granted read-only access inside the sandbox. + pub readonly_paths: Vec, + /// Network-only policy for the host CONNECT proxy. `None` on the coarse + /// filesystem-only path. + pub trimmed_policy: Option, + /// Loopback address MXC redirects sandbox egress to. `None` when governed + /// egress is disabled. + pub proxy_addr: Option, +} + +/// Context passed to the mapper alongside the policy. +#[derive(Debug)] +pub struct MapCtx { + /// Sandbox ID (gateway-assigned). Used as the MXC `containerId` and to + /// correlate diagnostics. + pub sandbox_id: String, + /// Pattern-C governed-egress redirect address. When set, the embedded + /// mapper uses `split_policy`; otherwise it uses the coarse MXC map. + pub egress: Option, +} + +/// A policy rule that the active mapper cannot enforce. +#[derive(Debug, Clone)] +pub struct LossItem { + pub rule_kind: String, + pub detail: String, +} + +/// Error returned when policy translation fails or is incomplete. +#[derive(Debug, Error)] +pub enum MapError { + #[error("policy rule(s) cannot be enforced by the MXC driver: {}", format_loss(.0))] + Unsupported(Vec), + #[error("policy mapper internal error: {0}")] + Internal(String), +} + +fn format_loss(items: &[LossItem]) -> String { + items + .iter() + .map(|i| format!("{}: {}", i.rule_kind, i.detail)) + .collect::>() + .join("; ") +} + +/// Translates an `OpenShell` `SandboxPolicy` into an MXC `ContainerConfig` +/// fragment, returning a loss report of anything unrepresentable. +pub trait PolicyMapper: Send + Sync { + /// `policy` is `None` only when the gateway failed to stage one (the MXC + /// path treats that as a hard error — the demo's whole point is enforcement). + fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result; +} + +// ── Path normalization ────────────────────────────────────────────────────── + +/// Normalize forward-slash paths to Windows backslash form. Path normalization +/// lives here, in one place — the embedded mapper copies path strings through +/// unchanged. +fn normalize_path(p: &str) -> String { + p.replace('/', "\\") +} + +// ── Embedded mapper (primary impl) ────────────────────────────────────────── + +/// Primary `PolicyMapper`: calls the embedded `policy_map` module (the source of +/// truth) directly on the typed `SandboxPolicy` proto. +pub struct EmbeddedPolicyMapper; + +fn extract_paths(config: &serde_json::Value, key: &str) -> Vec { + config["filesystem"][key] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +impl PolicyMapper for EmbeddedPolicyMapper { + fn map(&self, policy: Option<&SandboxPolicy>, ctx: &MapCtx) -> Result { + let policy = policy.ok_or_else(|| { + MapError::Internal( + "MXC driver requires a sandbox policy, but none was staged for this sandbox" + .to_owned(), + ) + })?; + + 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. + let opts = crate::policy_map::MxcMappingOptions { + containment: "processcontainer".to_owned(), + container_id: ctx.sandbox_id.clone(), + proxy_redirect: Some(addr), + ..Default::default() + }; + let result = crate::policy_map::split_policy(policy, &opts).ok_or_else(|| { + MapError::Internal( + "egress proxy was enabled but no proxy address was supplied".into(), + ) + })?; + ( + result.mxc_config, + result.loss, + Some(result.proxy_policy), + Some(addr), + ) + } else { + // 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. + let opts = crate::policy_map::MxcMappingOptions { + containment: "isolation_session".to_owned(), + container_id: ctx.sandbox_id.clone(), + ..Default::default() + }; + let result = crate::policy_map::map_to_mxc(policy, &opts); + (result.config, result.loss, None, None) + }; + + // Reject the create on any error-severity loss. Warnings/info (e.g. the + // filesystem default-deny note) are advisory and do not block. + let errors: Vec = loss + .iter() + .filter(|i| i.severity == "error") + .map(|i| LossItem { + rule_kind: i.path.clone(), + detail: i.message.clone(), + }) + .collect(); + if !errors.is_empty() { + return Err(MapError::Unsupported(errors)); + } + + // The embedded mapper copies paths verbatim; normalize them to Windows + // backslash form here, in one place. + let readwrite: Vec = extract_paths(&config, "readwritePaths") + .iter() + .map(|p| normalize_path(p)) + .collect(); + let readonly: Vec = extract_paths(&config, "readonlyPaths") + .iter() + .map(|p| normalize_path(p)) + .collect(); + + Ok(MappedConfig { + readwrite_paths: readwrite, + readonly_paths: readonly, + trimmed_policy, + proxy_addr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::FilesystemPolicy; + + fn demo_ctx() -> MapCtx { + MapCtx { + sandbox_id: "sb-test".into(), + egress: None, + } + } + + fn fs_policy(rw: &[&str], ro: &[&str]) -> SandboxPolicy { + SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: ro.iter().map(ToString::to_string).collect(), + read_write: rw.iter().map(ToString::to_string).collect(), + }), + ..Default::default() + } + } + + #[test] + 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 config = mapper.map(Some(&policy), &ctx).unwrap(); + // Forward slashes normalized to Windows backslashes by the bridge. + assert!( + config + .readwrite_paths + .contains(&"C:\\work\\openshell-mxc-demo".to_string()) + ); + assert_eq!(config.readonly_paths, vec!["C:\\tools"]); + } + + #[test] + fn embedded_rejects_missing_policy() { + let mapper = EmbeddedPolicyMapper; + let ctx = demo_ctx(); + let err = mapper.map(None, &ctx).unwrap_err(); + assert!(matches!(err, MapError::Internal(_))); + } + + #[test] + fn embedded_rejects_network_policy_on_isolation_session() { + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &[]); + policy.network_policies.insert( + "api".to_string(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let ctx = demo_ctx(); + let err = mapper.map(Some(&policy), &ctx).unwrap_err(); + assert!(matches!(err, MapError::Unsupported(_))); + } + + #[test] + fn embedded_split_normalizes_paths_and_returns_proxy_handoff() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &["C:/tools"]); + policy.version = 1; + policy.network_policies.insert( + "api".to_string(), + 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 proxy_addr = "127.0.0.1:18080".parse().unwrap(); + let ctx = MapCtx { + sandbox_id: "sb-egress".into(), + + egress: Some(proxy_addr), + }; + + let config = mapper.map(Some(&policy), &ctx).unwrap(); + assert_eq!(config.readwrite_paths, vec!["C:\\work\\demo"]); + assert_eq!(config.readonly_paths, vec!["C:\\tools"]); + assert_eq!(config.proxy_addr, Some(proxy_addr)); + let trimmed = config.trimmed_policy.expect("trimmed policy"); + assert_eq!(trimmed.version, policy.version); + assert_eq!(trimmed.network_policies, policy.network_policies); + assert!(trimmed.filesystem.is_none()); + } +} diff --git a/crates/openshell-driver-mxc/src/policy_map/config.rs b/crates/openshell-driver-mxc/src/policy_map/config.rs new file mode 100644 index 0000000000..f64b64a54a --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/config.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MXC `ContainerConfig` defaults and backend-specific behavior. + +use openshell_core::proto::SandboxPolicy; +use serde_json::{Value, json}; + +use super::loss::{LossItem, add_loss}; + +/// Placeholder command written into `process.commandLine` when the caller does +/// not supply a real workload command. +pub const DEFAULT_COMMAND: &str = "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\""; + +/// Default MXC schema version emitted in `version`. +pub const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha"; + +/// 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 { + match containment { + "bubblewrap" => "Bubblewrap policy is not strict OpenShell filesystem parity: MXC \ + may bind host root read-only and overlay policy mounts." + .to_owned(), + "lxc" => "LXC exposes the container rootfs and bind-mounts selected host \ + paths; this is not identical to OpenShell Landlock." + .to_owned(), + "wslc" => "WSLC mounts selected Windows paths, but default-deny behavior is \ + runner/backend specific." + .to_owned(), + "seatbelt" => "Seatbelt starts from a deny-default profile with baseline system \ + allowances, not OpenShell Landlock." + .to_owned(), + _ => "MXC filesystem behavior is backend-specific and not equivalent to \ + OpenShell Landlock by construction." + .to_owned(), + } +} + +/// Add backend-specific config blocks (and reject unsupported backends). +pub fn add_backend_specific_config( + config: &mut Value, + containment: &str, + allowed_hosts: &[String], + items: &mut Vec, +) { + match containment { + "processcontainer" | "process" if !allowed_hosts.is_empty() => { + 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() => { + add_loss( + items, + "containment", + "error", + &format!("{backend} is not a v0 target for OpenShell network policy mapping."), + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for this backend.", + ); + } + "microvm" if !allowed_hosts.is_empty() => { + add_loss( + items, + "containment", + "error", + "microvm network policy enforcement is not defined for this mapper.", + "OpenShell network policy", + "MXC network behavior is unsupported or unknown for microvm.", + ); + } + _ => {} + } +} + +/// Add a backend-specific advisory about host-allowlist fidelity. Only fires +/// when the source policy declares network rules. +pub fn add_backend_network_loss( + policy: &SandboxPolicy, + containment: &str, + items: &mut Vec, +) { + if policy.network_policies.is_empty() { + return; + } + match containment { + "seatbelt" => 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.", + ), + "vm" | "windows_sandbox" => add_loss( + items, + "network_policies", + "error", + "MXC Windows Sandbox / vm cannot faithfully enforce arbitrary allowedHosts.", + "host allowlist", + "Network policy enforcement is unsupported or unknown for this backend.", + ), + _ => {} + } +} diff --git a/crates/openshell-driver-mxc/src/policy_map/loss.rs b/crates/openshell-driver-mxc/src/policy_map/loss.rs new file mode 100644 index 0000000000..9e83c20a78 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/loss.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Loss-report model shared by the coarse map and the lossless split. + +use std::collections::HashSet; + +use serde::Serialize; + +/// A single mapping observation: something that could not be represented in +/// MXC, was delegated elsewhere, or is informational. +/// +/// `severity` is one of `"error"`, `"warning"`, or `"info"`. `"error"` marks a +/// semantic broadening or an unsupported parity gap; `"warning"` marks lost +/// information that does not obviously broaden access; `"info"` is advisory. +#[derive(Clone, Debug, Serialize)] +pub struct LossItem { + pub path: String, + pub severity: String, + pub message: String, + pub openshell_feature: String, + pub mxc_impact: String, +} + +/// 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 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.", + "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.", +]; + +pub fn add_loss( + items: &mut Vec, + path: &str, + severity: &str, + message: &str, + openshell_feature: &str, + mxc_impact: &str, +) { + items.push(LossItem { + path: path.to_owned(), + severity: severity.to_owned(), + message: message.to_owned(), + openshell_feature: openshell_feature.to_owned(), + mxc_impact: mxc_impact.to_owned(), + }); +} + +/// Distinct `OpenShell` features that were lost or degraded, in first-seen order. +pub fn summarize_missing_mxc(items: &[LossItem]) -> Vec { + let mut seen: HashSet<&str> = HashSet::new(); + let mut summary = Vec::new(); + for item in items { + if (item.severity == "error" || item.severity == "warning") + && seen.insert(item.openshell_feature.as_str()) + { + summary.push(item.openshell_feature.clone()); + } + } + summary +} diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs new file mode 100644 index 0000000000..7d9025e5a6 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -0,0 +1,704 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! 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). + +use std::net::SocketAddr; + +use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; +use serde_json::{Value, json}; + +use super::config::{ + DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_network_loss, + add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message, +}; +use super::loss::{LossItem, add_loss}; + +/// Options controlling the generated MXC config. Fields not relevant to the +/// coarse map (e.g. `proxy_redirect`) are reserved for the lossless split. +#[derive(Clone, Debug)] +pub struct MxcMappingOptions { + /// MXC schema version written into `version`. + pub mxc_version: String, + /// MXC containment backend. + pub containment: String, + /// `process.commandLine` value. + pub command: String, + /// Resolved `containerId`. + pub container_id: String, + /// Working directory; resolves `filesystem_policy.include_workdir`. + pub cwd: Option, + /// `KEY=VALUE` entries added to `process.env`. + 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 lossless split, not the + /// coarse map). + pub proxy_redirect: Option, +} + +impl Default for MxcMappingOptions { + fn default() -> Self { + Self { + mxc_version: DEFAULT_MXC_VERSION.to_owned(), + containment: DEFAULT_CONTAINMENT.to_owned(), + command: DEFAULT_COMMAND.to_owned(), + container_id: "openshell-policy".to_owned(), + cwd: None, + env: Vec::new(), + timeout_ms: 0, + allow_wildcards: false, + proxy_redirect: None, + } + } +} + +/// Result of a coarse mapping: the MXC config plus the loss items. +#[derive(Clone, Debug)] +pub struct MxcMappingResult { + pub config: Value, + pub loss: Vec, +} + +/// Result of the lossless 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. +#[derive(Clone, Debug)] +pub struct SplitPolicyResult { + /// MXC `ContainerConfig` with filesystem grants and `network.proxy` redirect. + /// + /// `network.allowedHosts` is empty — direct egress is blocked at the MXC + /// layer. All outbound connections flow through the proxy; the proxy 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 + /// filesystem rules. + pub proxy_policy: SandboxPolicy, + /// Loss items from the filesystem side only. Network rules produce no losses + /// here — they are delegated to the proxy rather than approximated. + pub loss: Vec, +} + +/// Map an `OpenShell` policy to a coarse MXC `ContainerConfig`. +pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappingResult { + let mut loss = Vec::new(); + let config = build_mxc_config(policy, opts, &mut loss); + MxcMappingResult { config, loss } +} + +/// Lossless 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 leaves `allowedHosts` empty — 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 the network side. +/// +/// 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. +pub fn split_policy(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> Option { + let proxy_addr = opts.proxy_redirect?; + let mut loss = Vec::new(); + let mxc_config = build_split_mxc_config(policy, opts, proxy_addr, &mut loss); + let proxy_policy = SandboxPolicy { + version: policy.version, + network_policies: policy.network_policies.clone(), + network_middlewares: policy.network_middlewares.clone(), + ..Default::default() + }; + Some(SplitPolicyResult { + mxc_config, + proxy_policy, + loss, + }) +} + +fn build_split_mxc_config( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + proxy_addr: SocketAddr, + items: &mut Vec, +) -> Value { + let mut process = json!({ + "commandLine": opts.command, + "timeout": opts.timeout_ms, + }); + if let Some(cwd) = &opts.cwd { + process["cwd"] = json!(cwd); + } + if !opts.env.is_empty() { + process["env"] = json!(opts.env); + } + + let filesystem = map_filesystem(policy, opts, items); + + let proxy_supported = matches!(opts.containment.as_str(), "processcontainer" | "process"); + if !proxy_supported { + add_loss( + items, + "containment", + "error", + &format!( + "`network.proxy` is not supported on `{}`; governed egress requires processcontainer until MXC M1 lands.", + opts.containment + ), + "governed egress proxy redirect", + "The generated MXC config omits network.proxy for this backend.", + ); + } + if !policy.network_policies.is_empty() { + add_loss( + items, + "network_policies", + "info", + &format!( + "{} network rule(s) delegated to the OpenShell host CONNECT proxy.", + policy.network_policies.len() + ), + "governed egress", + "The host proxy receives the trimmed policy and enforces network rules.", + ); + } + + // Direct egress is blocked; all outbound flows through the OpenShell proxy. + // allowedHosts is intentionally empty — the proxy enforces the full policy. + // + // 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. + if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { + add_loss( + items, + "network.proxy", + "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." + ), + "per-sandbox egress attribution", + "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", + ); + } + let mut network = json!({ + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + }); + if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { + network["proxy"] = json!({ "localhost": proxy_addr.port() }); + } + + let mut config = json!({ + "version": opts.mxc_version, + "containerId": opts.container_id, + "containment": opts.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + // 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_static_policy_loss(policy, opts, items); + config +} + +fn build_mxc_config( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Value { + let mut process = json!({ + "commandLine": opts.command, + "timeout": opts.timeout_ms, + }); + if let Some(cwd) = &opts.cwd { + process["cwd"] = json!(cwd); + } + if !opts.env.is_empty() { + process["env"] = json!(opts.env); + } + + 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); + } + + let mut config = json!({ + "version": opts.mxc_version, + "containerId": opts.container_id, + "containment": opts.containment, + "lifecycle": { + "destroyOnExit": true, + "preservePolicy": false, + }, + "process": process, + "filesystem": filesystem, + "network": network, + "ui": { + "disable": true, + "clipboard": "none", + "injection": false, + }, + }); + + add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items); + add_static_policy_loss(policy, opts, items); + config +} + +fn map_filesystem( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Value { + let mut readwrite: Vec = Vec::new(); + let mut readonly: Vec = Vec::new(); + + match &policy.filesystem { + Some(fs) => { + readwrite.clone_from(&fs.read_write); + readonly.clone_from(&fs.read_only); + if fs.include_workdir { + if let Some(cwd) = &opts.cwd { + append_unique(&mut readwrite, cwd.clone()); + } else { + add_loss( + items, + "filesystem_policy.include_workdir", + "info", + "OpenShell includes the runtime workdir, but no --cwd was supplied.", + "include_workdir", + "The generated MXC config cannot add the workdir path grant.", + ); + } + } + } + None => add_loss( + items, + "filesystem_policy", + "warning", + "No OpenShell filesystem_policy was present.", + "default filesystem policy", + "MXC receives empty filesystem lists; backend defaults determine visibility.", + ), + } + + add_loss( + items, + "filesystem_policy", + "warning", + &filesystem_default_deny_message(&opts.containment), + "OpenShell Landlock/default-deny filesystem model", + "MXC filesystem default-deny parity is backend-specific.", + ); + + json!({ + "readwritePaths": readwrite, + "readonlyPaths": readonly, + "deniedPaths": [], + }) +} + +fn map_network( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) -> Vec { + if !policy.network_middlewares.is_empty() { + add_loss( + items, + "network_middlewares", + "error", + &format!( + "{} network middleware config(s) require the OpenShell host proxy and cannot be enforced by MXC directly.", + policy.network_middlewares.len() + ), + "network egress middleware", + "Middleware transformations and failure behavior would not be applied on the coarse MXC path.", + ); + } + + if policy.network_policies.is_empty() { + add_backend_network_loss(policy, &opts.containment, items); + return Vec::new(); + } + + // The proto map is unordered; sort by rule key for deterministic output. + 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(); + + for (key, rule) in rules { + let rule_path = format!("network_policies.{key}"); + + if rule.endpoints.is_empty() { + add_loss( + items, + &format!("{rule_path}.endpoints"), + "error", + "OpenShell policy entry has no endpoints.", + "network endpoints", + "No MXC host allowlist entries 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); + } + + if rule.binaries.is_empty() { + add_loss( + items, + &format!("{rule_path}.binaries"), + "error", + "OpenShell requires binary-scoped network grants; this entry has no binaries.", + "binary-scoped network policy", + "MXC cannot represent per-binary grants and scopes network to the sandbox.", + ); + } else { + for (index, binary) in rule.binaries.iter().enumerate() { + add_loss( + items, + &format!("{rule_path}.binaries[{index}].path"), + "error", + &format!( + "Binary scope is not representable in MXC: '{}'.", + binary.path + ), + "binary-scoped network policy", + "Dropping this would broaden access from one executable to the whole sandbox.", + ); + } + } + } + + add_backend_network_loss(policy, &opts.containment, items); + allowed_hosts +} + +fn map_endpoint( + endpoint: &NetworkEndpoint, + path: &str, + allowed_hosts: &mut Vec, + opts: &MxcMappingOptions, + 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()); + ( + format!( + "Wildcard host emitted despite non-portable MXC semantics: {}.", + endpoint.host + ), + "Backend behavior is not portable and may fail or broaden access.", + ) + } else { + ( + format!( + "Wildcard host omitted because MXC has no portable syntax: {}.", + endpoint.host + ), + "Generated MXC config is more restrictive for this endpoint.", + ) + }; + add_loss( + items, + &format!("{path}.host"), + "error", + &message, + "OpenShell wildcard host matching", + impact, + ); + } 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()) + } 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.", + ); + } + + // allowed_ips + for ip in &endpoint.allowed_ips { + append_unique(allowed_hosts, ip.clone()); + add_loss( + items, + &format!("{path}.allowed_ips"), + "warning", + &format!( + "MXC can carry CIDR/IP '{ip}', but cannot bind it to DNS for '{}'.", + endpoint.host + ), + "DNS result pinning / SSRF override", + "The CIDR/IP becomes a standalone allowed destination.", + ); + } + + report_endpoint_l7_losses(endpoint, path, items); +} + +fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut Vec) { + if !endpoint.protocol.is_empty() { + add_loss( + items, + &format!("{path}.protocol"), + "error", + &format!( + "MXC has no protocol-aware policy equivalent for '{}'.", + endpoint.protocol + ), + "protocol-aware proxy policy", + "MXC host filtering cannot enforce REST/WebSocket/GraphQL semantics.", + ); + } + + if !endpoint.tls.is_empty() { + let severity = if endpoint.tls == "skip" { + "warning" + } else { + "error" + }; + add_loss( + items, + &format!("{path}.tls"), + severity, + &format!( + "MXC has no OpenShell TLS inspection mode equivalent for '{}'.", + endpoint.tls + ), + "TLS inspection mode", + "MXC network policy is host-level only.", + ); + } + + if !endpoint.enforcement.is_empty() { + if endpoint.enforcement == "audit" { + add_loss( + items, + &format!("{path}.enforcement"), + "error", + "MXC has no audit-only network policy mode.", + "audit-mode endpoint", + "Generated MXC config enforces host-level default block instead.", + ); + } else { + add_loss( + items, + &format!("{path}.enforcement"), + "warning", + "MXC enforcementMode is backend-wide, not per endpoint.", + "per-endpoint enforcement", + "The mapper chooses a backend-level enforcement mode.", + ); + } + } + + if !endpoint.access.is_empty() { + add_loss( + items, + &format!("{path}.access"), + "error", + &format!( + "MXC has no access preset equivalent for '{}'.", + endpoint.access + ), + "REST/WebSocket/GraphQL access preset", + "MXC cannot enforce method or operation-level access.", + ); + } + + if !endpoint.rules.is_empty() { + add_loss( + items, + &format!("{path}.rules"), + "error", + "MXC has no L7 allow-rule equivalent.", + "REST/WebSocket/GraphQL allow rules", + "Method/path/query/operation restrictions are lost.", + ); + } + + if !endpoint.deny_rules.is_empty() { + add_loss( + items, + &format!("{path}.deny_rules"), + "error", + "MXC has no L7 deny-rule equivalent.", + "L7 deny rules", + "Deny precedence over broad allows is lost.", + ); + } + + let bool_losses: &[(bool, &str, &str)] = &[ + ( + endpoint.allow_encoded_slash, + "allow_encoded_slash", + "encoded slash handling", + ), + ( + endpoint.websocket_credential_rewrite, + "websocket_credential_rewrite", + "WebSocket credential rewrite", + ), + ( + endpoint.request_body_credential_rewrite, + "request_body_credential_rewrite", + "request-body credential rewrite", + ), + ]; + for (set, field, feature) in bool_losses { + if *set { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC has no equivalent for {feature}."), + feature, + "Generated config cannot preserve this proxy behavior.", + ); + } + } + + // GraphQL + if !endpoint.persisted_queries.is_empty() { + add_graphql_loss(items, path, "persisted_queries"); + } + if !endpoint.graphql_persisted_queries.is_empty() { + add_graphql_loss(items, path, "graphql_persisted_queries"); + } + if endpoint.graphql_max_body_bytes > 0 { + add_graphql_loss(items, path, "graphql_max_body_bytes"); + } +} + +fn add_graphql_loss(items: &mut Vec, path: &str, field: &str) { + add_loss( + items, + &format!("{path}.{field}"), + "error", + &format!("MXC has no GraphQL policy equivalent for {field}."), + "GraphQL operation policy", + "GraphQL inspection and persisted-query behavior is lost.", + ); +} + +fn add_static_policy_loss( + policy: &SandboxPolicy, + opts: &MxcMappingOptions, + items: &mut Vec, +) { + if policy.landlock.is_some() { + add_loss( + items, + "landlock", + "warning", + "MXC has no Landlock compatibility mode field.", + "Landlock LSM enforcement", + "Backend filesystem controls may not fail like OpenShell best_effort/hard_requirement.", + ); + } + + if let Some(process) = &policy.process { + if !process.run_as_user.is_empty() { + add_process_identity_loss(items, "run_as_user"); + } + if !process.run_as_group.is_empty() { + add_process_identity_loss(items, "run_as_group"); + } + } + + if opts.containment == "processcontainer" + && let Some(fs) = &policy.filesystem + { + let any_linux_path = fs + .read_only + .iter() + .chain(fs.read_write.iter()) + .any(|p| p.starts_with('/')); + if any_linux_path { + add_loss( + items, + "filesystem_policy", + "warning", + "OpenShell example paths are Linux paths; Windows ProcessContainer expects Windows paths.", + "filesystem path syntax", + "Run with path translation or target a Linux-like MXC backend.", + ); + } + } +} + +fn add_process_identity_loss(items: &mut Vec, field: &str) { + add_loss( + items, + &format!("process.{field}"), + "warning", + &format!("MXC has no portable equivalent for OpenShell {field}."), + "process identity", + "MXC backend identity is selected outside this policy mapping.", + ); +} + +fn append_unique(list: &mut Vec, value: String) { + if !list.contains(&value) { + list.push(value); + } +} + +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 new file mode 100644 index 0000000000..52dc7f186c --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/mod.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Map an `OpenShell` sandbox policy to a Microsoft MXC `ContainerConfig`. +//! +//! This module is the **source of truth** for the OpenShell→MXC policy mapping +//! (it was the standalone `openshell-policy-mapper` crate). It is embedded in the +//! MXC driver as a module, consumed by the [`crate::policy`] seam. +//! +//! It reuses the canonical typed [`SandboxPolicy`] from `openshell-policy` +//! (obtained via `openshell_policy::parse_sandbox_policy`) rather than re-parsing +//! YAML, so the policy schema has a single source of truth. +//! +//! 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. +//! - [`split_policy`] — the *lossless* split for the Windows MXC compute +//! driver: MXC handles filesystem + containment + a `network.proxy` redirect, +//! while the full `OpenShell` network policy is preserved in a trimmed policy +//! enforced by the host CONNECT proxy. +//! +//! The report/loss-report helpers are only exercised by the example and the +//! integration tests, so the Windows lib build would otherwise warn on them; +//! `#![allow(dead_code)]` keeps the module quiet without per-item churn. +//! +//! [`SandboxPolicy`]: openshell_core::proto::SandboxPolicy + +#![allow(dead_code)] + +mod config; +mod loss; +mod map; +mod report; + +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 new file mode 100644 index 0000000000..0b1ec37b77 --- /dev/null +++ b/crates/openshell-driver-mxc/src/policy_map/report.rs @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Loss-report JSON and human-readable README rendering. + +use serde_json::{Value, json}; + +use super::loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS, summarize_missing_mxc}; + +/// Build the structured `loss-report.json` value. +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 { + let count = |severity: &str| items.iter().filter(|i| i.severity == severity).count(); + + json!({ + "sourcePolicy": source_policy, + "generatedConfig": generated_config, + "target": { + "schemaVersion": mxc_version, + "containment": containment, + }, + "schemaValidation": { + "schema": schema, + "valid": schema_errors.is_empty(), + }, + "lossy": !items.is_empty(), + "counts": { + "error": count("error"), + "warning": count("warning"), + "info": count("info"), + }, + "items": items, + "openShellFieldsNotInMxc": summarize_missing_mxc(items), + "mxcFieldsNotInOpenShellPolicy": OPEN_SHELL_SUPERSET_GAPS, + }) +} + +/// Render the human-readable `README.md` summarizing the mapping. +pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> String { + let container_id = config["containerId"].as_str().unwrap_or(""); + let containment = config["containment"].as_str().unwrap_or(""); + let schema_valid = report["schemaValidation"]["valid"] + .as_bool() + .unwrap_or(false); + + let join_strs = |value: &Value| -> String { + let parts: Vec<&str> = value + .as_array() + .map(|a| a.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + if parts.is_empty() { + "(none)".to_owned() + } else { + parts.join(", ") + } + }; + + let allowed = join_strs(&config["network"]["allowedHosts"]); + let rw = join_strs(&config["filesystem"]["readwritePaths"]); + let ro = join_strs(&config["filesystem"]["readonlyPaths"]); + + let mut lines: Vec = vec![ + format!("# {container_id}"), + String::new(), + "## Generated Files".to_owned(), + String::new(), + format!( + "- `mxc-config.json`: direct MXC `ContainerConfig` generated from `{source_policy}`." + ), + "- `loss-report.json`: structured mapping loss report.".to_owned(), + String::new(), + "## MXC Consumption".to_owned(), + String::new(), + "The generated config is intended to be consumable by MXC's direct JSON path.".to_owned(), + "It uses a harmless placeholder `process.commandLine`; replace it with the".to_owned(), + "real workload command before running anything meaningful.".to_owned(), + String::new(), + format!("- Containment: `{containment}`"), + format!("- Schema validation: `{schema_valid}`"), + format!("- Allowed hosts: `{allowed}`"), + format!("- Read-write paths: `{rw}`"), + format!("- Read-only paths: `{ro}`"), + String::new(), + "## Missing In MXC For This OpenShell Policy".to_owned(), + String::new(), + ]; + + let notable: Vec<&Value> = report["items"] + .as_array() + .map(|a| { + a.iter() + .filter(|item| matches!(item["severity"].as_str(), Some("error" | "warning"))) + .collect() + }) + .unwrap_or_default(); + + if notable.is_empty() { + lines.push("- No lossy OpenShell-to-MXC policy mappings were detected.".to_owned()); + } else { + for item in notable { + lines.push(format!( + "- `{}` `{}`: {} Impact: {}", + item["severity"].as_str().unwrap_or(""), + item["path"].as_str().unwrap_or(""), + item["message"].as_str().unwrap_or(""), + item["mxc_impact"].as_str().unwrap_or(""), + )); + } + } + + lines.extend([ + String::new(), + "## Missing In OpenShell Policy For MXC".to_owned(), + String::new(), + ]); + if let Some(gaps) = report["mxcFieldsNotInOpenShellPolicy"].as_array() { + for gap in gaps.iter().filter_map(Value::as_str) { + lines.push(format!("- {gap}")); + } + } + + lines.extend([ + String::new(), + "## 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(), + "- Treat any `error` item in the loss report as a semantic broadening or unsupported parity gap.".to_owned(), + String::new(), + ]); + + lines.join("\n") +} diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs new file mode 100644 index 0000000000..4d3a3cfa5c --- /dev/null +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -0,0 +1,492 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Parity/invariant tests for the embedded coarse map over the repository's +//! example policies. +//! +//! Windows-only: the embedded mapper API is gated on `target_os = "windows"`, +//! so this whole file compiles to nothing elsewhere and runs in full on a +//! Windows test lane. +//! +//! 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. + +#![cfg(target_os = "windows")] + +use std::path::{Path, PathBuf}; + +use openshell_driver_mxc::{MxcMappingOptions, map_to_mxc, split_policy}; +use openshell_policy::{parse_sandbox_policy, serialize_sandbox_policy, validate_sandbox_policy}; +use serde_json::Value; + +fn examples_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples") +} + +fn discover(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + discover(&path, out); + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name.contains("policy") + && path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("yaml")) + { + out.push(path); + } + } +} + +fn str_list(value: &Value) -> Vec { + value + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn proxy_addr() -> std::net::SocketAddr { + "127.0.0.1:18080".parse().unwrap() +} + +#[test] +fn all_example_policies_map_with_invariants() { + let root = examples_root(); + let mut policies = Vec::new(); + discover(&root, &mut policies); + policies.sort(); + assert!( + !policies.is_empty(), + "no example policies found under {}", + root.display() + ); + + for path in &policies { + let yaml = std::fs::read_to_string(path).expect("read policy"); + let policy = parse_sandbox_policy(&yaml) + .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display())); + + 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() + ); + + // Filesystem fidelity: read_write / read_only copied exactly. + if let Some(fs) = &policy.filesystem { + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + fs.read_write, + "readwrite mismatch for {}", + path.display() + ); + assert_eq!( + str_list(&cfg["filesystem"]["readonlyPaths"]), + fs.read_only, + "readonly mismatch for {}", + path.display() + ); + } + + // Every non-wildcard endpoint host appears in allowedHosts. + let allowed = str_list(&cfg["network"]["allowedHosts"]); + for rule in policy.network_policies.values() { + for ep in &rule.endpoints { + if !ep.host.is_empty() && !ep.host.contains('*') { + assert!( + allowed.contains(&ep.host), + "{} missing host {} in allowedHosts", + path.display(), + ep.host + ); + } + } + } + + // allowedHosts is deduplicated. + let mut sorted = allowed.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + allowed.len(), + "duplicate hosts for {}", + path.display() + ); + + // Deterministic: mapping twice yields identical config. + let again = map_to_mxc(&policy, &MxcMappingOptions::default()); + assert_eq!( + result.config, + again.config, + "non-deterministic for {}", + path.display() + ); + } +} + +#[test] +fn all_example_policies_split_with_lossless_invariants() { + let root = examples_root(); + let mut policies = Vec::new(); + discover(&root, &mut policies); + policies.sort(); + assert!( + !policies.is_empty(), + "no example policies found under {}", + root.display() + ); + + for path in &policies { + let yaml = std::fs::read_to_string(path).expect("read policy"); + let policy = parse_sandbox_policy(&yaml) + .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display())); + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + }; + let result = split_policy(&policy, &opts) + .unwrap_or_else(|| panic!("split returned None for {}", path.display())); + let cfg = &result.mxc_config; + + assert_eq!( + result.proxy_policy.network_policies, + policy.network_policies, + "proxy_policy must carry network rules verbatim for {}", + path.display() + ); + assert_eq!( + result.proxy_policy.version, + policy.version, + "proxy_policy must preserve version for {}", + path.display() + ); + validate_sandbox_policy(&result.proxy_policy).unwrap_or_else(|e| { + panic!("trimmed policy must validate for {}: {e:?}", path.display()) + }); + let serialized = serialize_sandbox_policy(&result.proxy_policy) + .unwrap_or_else(|e| panic!("serialize trimmed policy for {}: {e}", path.display())); + let round_trip = parse_sandbox_policy(&serialized) + .unwrap_or_else(|e| panic!("parse trimmed round-trip for {}: {e}", path.display())); + assert_eq!( + round_trip, + result.proxy_policy, + "trimmed policy must round-trip for {}", + path.display() + ); + + if let Some(fs) = &policy.filesystem { + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + fs.read_write, + "split readwrite mismatch for {}", + path.display() + ); + assert_eq!( + str_list(&cfg["filesystem"]["readonlyPaths"]), + fs.read_only, + "split readonly mismatch for {}", + path.display() + ); + } else { + assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty()); + 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!( + result.loss.iter().all(|i| i.severity != "error"), + "processcontainer split must not emit error losses for {}: {:?}", + path.display(), + result.loss + ); + } +} + +#[test] +fn quickstart_coarse_mapping() { + 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"); + 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_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. + 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].protocol"), + "expected protocol loss" + ); + assert!( + has("error", "endpoints[0].access"), + "expected access preset loss" + ); + assert!( + has("error", "binaries[0].path"), + "expected binary-scope loss" + ); +} + +#[test] +fn split_policy_routes_network_to_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"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.2:8080".parse().unwrap()), + ..Default::default() + }; + 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.) + 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") + ); + let has_proxy_loss = result + .loss + .iter() + .any(|i| i.path == "network.proxy" && i.severity == "error"); + assert!( + has_proxy_loss, + "non-127.0.0.1 redirect must produce an error loss item" + ); + + // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). + assert_eq!(cfg["network"]["defaultPolicy"], "block"); + assert!( + str_list(&cfg["network"]["allowedHosts"]).is_empty(), + "split path must not populate allowedHosts" + ); + + // Filesystem grants are preserved unchanged. + assert_eq!( + str_list(&cfg["filesystem"]["readwritePaths"]), + policy.filesystem.as_ref().unwrap().read_write + ); + + // Network policy is returned verbatim for the proxy. + assert_eq!( + result.proxy_policy.network_policies, policy.network_policies, + "proxy_policy must carry all network rules verbatim" + ); + assert_eq!(result.proxy_policy.version, policy.version); + assert!( + result.proxy_policy.filesystem.is_none(), + "proxy_policy must not carry filesystem rules" + ); + + // No binary-scope, port, or protocol losses — those are delegated to the proxy. + let net_losses: Vec<_> = result + .loss + .iter() + .filter(|i| i.path.starts_with("network_policies") && i.severity != "info") + .collect(); + assert!( + net_losses.is_empty(), + "split path must not generate lossy network items: {net_losses:?}" + ); + assert!(result.loss.iter().any(|i| { + i.path == "network_policies" && i.severity == "info" && i.message.contains("delegated") + })); +} + +#[test] +fn split_policy_returns_none_without_proxy_addr() { + let opts = MxcMappingOptions::default(); + let policy = parse_sandbox_policy("").unwrap_or_default(); + assert!( + split_policy(&policy, &opts).is_none(), + "split_policy must return None when proxy_redirect is not set" + ); +} + +#[test] +fn split_policy_deterministic() { + 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"); + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.1:9999".parse().unwrap()), + ..Default::default() + }; + let a = split_policy(&policy, &opts).unwrap(); + let b = split_policy(&policy, &opts).unwrap(); + assert_eq!( + a.mxc_config, b.mxc_config, + "split_policy must be deterministic" + ); +} + +#[test] +fn split_policy_rejects_proxy_redirect_on_isolation_session() { + 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"); + let opts = MxcMappingOptions { + containment: "isolation_session".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).unwrap(); + let errors: Vec<_> = result + .loss + .iter() + .filter(|i| i.severity == "error") + .collect(); + assert_eq!( + errors.len(), + 1, + "expected one containment error: {errors:?}" + ); + assert_eq!(errors[0].path, "containment"); + assert!(errors[0].message.contains("MXC M1")); + assert!(result.mxc_config["network"].get("proxy").is_none()); +} + +#[test] +fn network_only_policy_has_empty_filesystem() { + // policy-advisor is a network-only seed (no filesystem_policy). + let path = examples_root().join("policy-advisor/sandbox-policy.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read policy-advisor"); + let policy = parse_sandbox_policy(&yaml).expect("parse policy-advisor"); + let result = map_to_mxc(&policy, &MxcMappingOptions::default()); + let cfg = &result.config; + + 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()] + ); +} + +// ── 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. + 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"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split returns Some"); + let cfg = &result.mxc_config; + + 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" + ); + // No error losses — 127.0.0.1 is representable. + assert!( + result.loss.iter().all(|i| i.severity != "error"), + "127.0.0.1 proxy must not emit error losses: {:?}", + result + .loss + .iter() + .filter(|i| i.severity == "error") + .collect::>() + ); +} + +#[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. + 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"); + + let opts = MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some("127.0.0.5:18080".parse().unwrap()), + ..Default::default() + }; + let result = split_policy(&policy, &opts).expect("split returns Some"); + let cfg = &result.mxc_config; + + // Proxy block must be absent. + 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") + ); + + // An error loss for "network.proxy" must be present. + let proxy_loss = result + .loss + .iter() + .find(|i| i.path == "network.proxy" && i.severity == "error"); + assert!( + proxy_loss.is_some(), + "non-127.0.0.1 redirect must produce an error loss item on network.proxy: {:?}", + 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 + ); +} diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs new file mode 100644 index 0000000000..7f83181380 --- /dev/null +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -0,0 +1,1332 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Tier-0 coverage matrix + schema drift guard for the OpenShell→MXC policy mapper. +//! +//! Three quadrants: +//! A — mappable fields (OpenShell ∩ MXC): assert exact MXC output. +//! B — OpenShell-only features MXC cannot express: assert one loss item per +//! field with the documented severity. +//! C — MXC restrictive defaults ("default deny posture"): empty policy maps +//! to the most-restrictive possible MXC config. +//! +//! Plus: `handled_fields_inventory` — the schema drift guard that fails when +//! `openshell-policy` gains a serialized field the mapper does not account for. + +#![cfg(target_os = "windows")] +#![allow( + clippy::doc_link_with_quotes, + clippy::doc_markdown, + clippy::needless_collect, + clippy::uninlined_format_args +)] + +use openshell_core::proto::{ + FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, + MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, +}; +use openshell_driver_mxc::{ + EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc, + split_policy, +}; +use openshell_policy::{serialize_sandbox_policy, validate_sandbox_policy}; +use serde_json::Value; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +fn middleware_config() -> NetworkMiddlewareConfig { + NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/regex".into(), + config: None, + on_error: "fail_closed".into(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api.example.com".into()], + exclude: Vec::new(), + }), + order: 0, + } +} + +fn str_list(v: &Value) -> Vec { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|e| e.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn default_opts() -> MxcMappingOptions { + MxcMappingOptions::default() // bubblewrap containment +} + +fn bubblewrap_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "bubblewrap".to_owned(), + ..Default::default() + } +} + +fn proxy_addr() -> std::net::SocketAddr { + "127.0.0.1:18080".parse().unwrap() +} + +fn pc_split_opts() -> MxcMappingOptions { + MxcMappingOptions { + containment: "processcontainer".to_owned(), + proxy_redirect: Some(proxy_addr()), + ..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 { + let mut p = SandboxPolicy::default(); + p.network_policies.insert( + key.to_owned(), + NetworkPolicyRule { + name: key.to_owned(), + endpoints: vec![ep], + binaries: Vec::new(), + }, + ); + p +} + +/// Assert exactly one loss item whose `path` contains `needle` and whose +/// `severity` equals `want_severity`. +fn assert_single_loss( + loss: &[openshell_driver_mxc::LossItem], + needle: &str, + want_severity: &str, + context: &str, +) { + let matching: Vec<_> = loss + .iter() + .filter(|i| i.path.contains(needle) && i.severity == want_severity) + .collect(); + assert!( + !matching.is_empty(), + "{context}: expected a '{want_severity}' loss with path containing '{needle}', got: {loss:?}" + ); + // There should not be more than one item with a DIFFERENT severity for the same path. + let other_severity: Vec<_> = loss + .iter() + .filter(|i| i.path.contains(needle) && i.severity != want_severity) + .collect(); + assert!( + other_severity.is_empty(), + "{context}: unexpected additional loss item(s) for '{needle}' with wrong severity: {other_severity:?}" + ); +} + +// ─── QUADRANT A: mappable fields, assert exact MXC output ─────────────────── + +/// filesystem.read_write → readwritePaths verbatim, order preserved. +#[test] +fn a_rw_paths_verbatim() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["/work".into(), "/tmp".into(), "/data".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_eq!( + str_list(&r.config["filesystem"]["readwritePaths"]), + vec!["/work", "/tmp", "/data"], + "readwritePaths must be verbatim, in order" + ); +} + +/// filesystem.read_only → readonlyPaths verbatim, order preserved. +#[test] +fn a_ro_paths_verbatim() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_only: vec!["/usr".into(), "/lib".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_eq!( + str_list(&r.config["filesystem"]["readonlyPaths"]), + vec!["/usr", "/lib"], + "readonlyPaths must be verbatim, in order" + ); +} + +/// include_workdir=true + opts.cwd set → cwd appended (unique) to readwritePaths. +#[test] +fn a_include_workdir_with_cwd_appended_unique() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let opts = MxcMappingOptions { + cwd: Some("/work".to_owned()), // already present — must not duplicate + ..default_opts() + }; + let r = map_to_mxc(&policy, &opts); + let rw = str_list(&r.config["filesystem"]["readwritePaths"]); + assert!( + rw.contains(&"/work".to_owned()), + "cwd must appear in readwritePaths" + ); + let count = rw.iter().filter(|p| p.as_str() == "/work").count(); + assert_eq!(count, 1, "cwd must not be duplicated"); + + // Also test where cwd is new. + let opts2 = MxcMappingOptions { + cwd: Some("/newcwd".to_owned()), + ..default_opts() + }; + let policy2 = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let r2 = map_to_mxc(&policy2, &opts2); + let rw2 = str_list(&r2.config["filesystem"]["readwritePaths"]); + assert!( + rw2.contains(&"/newcwd".to_owned()), + "new cwd must be appended to readwritePaths" + ); +} + +/// include_workdir=true, no cwd → an "info" loss item, no extra path added. +#[test] +fn a_include_workdir_no_cwd_emits_info_loss() { + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: true, + read_write: vec!["/work".into()], + ..Default::default() + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); // cwd is None + let has_info = r + .loss + .iter() + .any(|i| i.severity == "info" && i.path.contains("include_workdir")); + assert!( + has_info, + "expected info loss for include_workdir without cwd; got: {:?}", + r.loss + ); + // The path list must not have grown beyond the source list. + let rw = str_list(&r.config["filesystem"]["readwritePaths"]); + assert_eq!(rw, vec!["/work"]); +} + +/// Plain endpoint host → appears in allowedHosts. +#[test] +fn a_plain_host_in_allowed_hosts() { + let policy = net_policy( + "api", + NetworkEndpoint { + host: "api.example.com".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:?}" + ); +} + +/// endpoint.allowed_ips → each IP appended to allowedHosts + a "warning" loss. +#[test] +fn a_allowed_ips_appended_with_warning() { + let policy = net_policy( + "api", + NetworkEndpoint { + host: "db.internal".into(), + allowed_ips: vec!["10.0.0.1".into(), "10.0.0.2".into()], + ..Default::default() + }, + ); + 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 + ); +} + +/// Duplicate hosts across rules → deduplicated in allowedHosts. +#[test] +fn a_duplicate_hosts_deduplicated() { + let mut policy = SandboxPolicy::default(); + let ep_a = NetworkEndpoint { + host: "shared.example.com".into(), + ..Default::default() + }; + let ep_b = NetworkEndpoint { + host: "shared.example.com".into(), + ..Default::default() + }; + policy.network_policies.insert( + "rule_a".to_owned(), + NetworkPolicyRule { + name: "rule_a".to_owned(), + endpoints: vec![ep_a], + binaries: Vec::new(), + }, + ); + policy.network_policies.insert( + "rule_b".to_owned(), + NetworkPolicyRule { + name: "rule_b".to_owned(), + endpoints: vec![ep_b], + binaries: Vec::new(), + }, + ); + 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!( + count, 1, + "duplicate hosts must be deduplicated; got: {hosts:?}" + ); +} + +/// Determinism: a policy with 3+ network rules (unordered map) maps twice → identical config JSON. +#[test] +fn a_deterministic_with_multiple_rules() { + let mut policy = SandboxPolicy::default(); + for (key, host) in &[ + ("rule_z", "z.example.com"), + ("rule_a", "a.example.com"), + ("rule_m", "m.example.com"), + ("rule_b", "b.example.com"), + ] { + policy.network_policies.insert( + key.to_string(), + NetworkPolicyRule { + name: key.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + } + let r1 = map_to_mxc(&policy, &bubblewrap_opts()); + let r2 = map_to_mxc(&policy, &bubblewrap_opts()); + assert_eq!( + r1.config, r2.config, + "map_to_mxc must be deterministic across two calls" + ); +} + +/// split path: proxy_policy.network_policies == source's, proxy_policy.version preserved. +#[test] +fn a_split_network_verbatim_and_version_preserved() { + let mut policy = SandboxPolicy { + version: 42, + ..Default::default() + }; + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert_eq!( + result.proxy_policy.network_policies, policy.network_policies, + "split: proxy_policy.network_policies must equal source" + ); + assert_eq!( + result.proxy_policy.version, 42, + "split: proxy_policy.version must equal source" + ); +} + +/// split path: mxc_config["network"]["proxy"]["localhost"] == port (new schema). +#[test] +fn a_split_proxy_localhost_port() { + let policy = SandboxPolicy::default(); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert_eq!( + result.mxc_config["network"]["proxy"]["localhost"], 18080, + "split must emit network.proxy.localhost == port" + ); + // allowedHosts stays empty on the split path. + assert!( + str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), + "split path must have empty allowedHosts" + ); +} + +// ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── +// +// For each field: build a minimal policy setting only that field (plus the +// minimum needed to reach the code path), map with default bubblewrap +// containment, assert exactly one loss item with the documented path fragment +// and severity. + +/// endpoint.ports → "error" (path contains ".port"). +#[test] +fn b_ports_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".port", "error", "endpoint.ports"); +} + +/// endpoint.protocol → "error". +#[test] +fn b_protocol_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + protocol: "rest".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".protocol", "error", "endpoint.protocol"); +} + +/// endpoint.tls = "skip" → "warning". +#[test] +fn b_tls_skip_warning() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + tls: "skip".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".tls", "warning", "endpoint.tls=skip"); +} + +/// endpoint.tls = "full" (any non-skip) → "error". +#[test] +fn b_tls_non_skip_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + tls: "terminate".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".tls", "error", "endpoint.tls=terminate"); +} + +/// endpoint.enforcement = "audit" → "error". +#[test] +fn b_enforcement_audit_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + enforcement: "audit".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".enforcement", + "error", + "endpoint.enforcement=audit", + ); +} + +/// endpoint.enforcement = "enforce" (non-audit) → "warning". +#[test] +fn b_enforcement_non_audit_warning() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + enforcement: "enforce".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".enforcement", + "warning", + "endpoint.enforcement=enforce", + ); +} + +/// endpoint.access → "error". +#[test] +fn b_access_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + access: "read-only".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".access", "error", "endpoint.access"); +} + +/// endpoint.rules (one allow rule) → "error". +#[test] +fn b_rules_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".into(), + path: "/api".into(), + ..Default::default() + }), + }], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".rules", "error", "endpoint.rules"); +} + +/// endpoint.deny_rules → "error". +#[test] +fn b_deny_rules_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + deny_rules: vec![L7DenyRule { + method: "POST".into(), + path: "/admin".into(), + ..Default::default() + }], + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".deny_rules", "error", "endpoint.deny_rules"); +} + +/// endpoint.allow_encoded_slash=true → "error". +#[test] +fn b_allow_encoded_slash_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + allow_encoded_slash: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".allow_encoded_slash", + "error", + "allow_encoded_slash", + ); +} + +/// endpoint.websocket_credential_rewrite=true → "error". +#[test] +fn b_websocket_credential_rewrite_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + websocket_credential_rewrite: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".websocket_credential_rewrite", + "error", + "websocket_credential_rewrite", + ); +} + +/// endpoint.request_body_credential_rewrite=true → "error". +#[test] +fn b_request_body_credential_rewrite_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + request_body_credential_rewrite: true, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".request_body_credential_rewrite", + "error", + "request_body_credential_rewrite", + ); +} + +/// endpoint.persisted_queries → "error". +#[test] +fn b_persisted_queries_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + persisted_queries: "deny".into(), + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss(&r.loss, ".persisted_queries", "error", "persisted_queries"); +} + +/// endpoint.graphql_persisted_queries → "error". +#[test] +fn b_graphql_persisted_queries_error() { + let mut ep = NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }; + ep.graphql_persisted_queries.insert( + "abc".to_owned(), + GraphqlOperation { + operation_type: "query".into(), + ..Default::default() + }, + ); + let policy = net_policy("r", ep); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".graphql_persisted_queries", + "error", + "graphql_persisted_queries", + ); +} + +/// endpoint.graphql_max_body_bytes > 0 → "error". +#[test] +fn b_graphql_max_body_bytes_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "api.example.com".into(), + graphql_max_body_bytes: 65536, + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + assert_single_loss( + &r.loss, + ".graphql_max_body_bytes", + "error", + "graphql_max_body_bytes", + ); +} + +/// Wildcard host ("*.example.com") with allow_wildcards=false → "error", host NOT in allowedHosts. +#[test] +fn b_wildcard_host_deny_wildcards_error_host_absent() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "*.example.com".into(), + ..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 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; 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:?}" + ); +} + +/// Wildcard host with allow_wildcards=true → "error" (semantics warning), host IS in allowedHosts. +#[test] +fn b_wildcard_host_allow_wildcards_error_host_present() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: "*.example.com".into(), + ..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:?}" + ); +} + +/// rule.binaries non-empty → "error" per binary. +#[test] +fn b_binaries_error_per_binary() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: vec![ + NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }, + NetworkBinary { + path: "/usr/bin/wget".into(), + ..Default::default() + }, + ], + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let binary_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains("binaries[")) + .collect(); + assert_eq!( + binary_errors.len(), + 2, + "expected one error loss per binary; got: {:?}", + r.loss + ); +} + +/// rule with empty endpoints → "error". +#[test] +fn b_empty_endpoints_error() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: Vec::new(), // empty + binaries: Vec::new(), + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let endpoint_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".endpoints")) + .collect(); + assert!( + !endpoint_errors.is_empty(), + "expected error loss for empty endpoints; got: {:?}", + r.loss + ); +} + +/// endpoint with empty host → "error". +#[test] +fn b_empty_host_error() { + let policy = net_policy( + "r", + NetworkEndpoint { + host: String::new(), // empty + ..Default::default() + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let host_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".host")) + .collect(); + assert!( + !host_errors.is_empty(), + "expected error loss for empty host; got: {:?}", + r.loss + ); +} + +/// rule with empty binaries → "error". +#[test] +fn b_empty_binaries_error() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "r".to_owned(), + NetworkPolicyRule { + name: "r".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), // empty + }, + ); + let r = map_to_mxc(&policy, &bubblewrap_opts()); + let bin_errors: Vec<_> = r + .loss + .iter() + .filter(|i| i.severity == "error" && i.path.contains(".binaries")) + .collect(); + assert!( + !bin_errors.is_empty(), + "expected error loss for empty binaries; got: {:?}", + r.loss + ); +} + +/// policy.landlock = Some(default) → "warning". +#[test] +fn b_landlock_warning() { + let policy = SandboxPolicy { + landlock: Some(LandlockPolicy { + compatibility: "best_effort".into(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "landlock", "warning", "landlock"); +} + +/// process.run_as_user non-empty → "warning". +#[test] +fn b_run_as_user_warning() { + let policy = SandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: String::new(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + assert_single_loss(&r.loss, "run_as_user", "warning", "process.run_as_user"); +} + +/// process.run_as_group non-empty → "warning". +#[test] +fn b_run_as_group_warning() { + let policy = SandboxPolicy { + process: Some(ProcessPolicy { + run_as_user: String::new(), + run_as_group: "sandboxers".into(), + }), + ..Default::default() + }; + let r = map_to_mxc(&policy, &default_opts()); + 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(_)). +#[test] +fn b_seam_returns_unsupported_on_error_field() { + let mapper = EmbeddedPolicyMapper; + // isolation_session containment + network policy → error loss from add_backend_specific_config. + let mut policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + read_write: vec!["C:/work".into()], + ..Default::default() + }), + ..Default::default() + }; + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ports: vec![443], + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let ctx = MapCtx { + sandbox_id: "sb-test".into(), + egress: None, // coarse path → isolation_session → network policy errors + }; + let err = mapper.map(Some(&policy), &ctx).unwrap_err(); + assert!( + matches!(err, MapError::Unsupported(_)), + "seam must return MapError::Unsupported for error-class losses; got: {err:?}" + ); +} + +// ─── QUADRANT C: restrictive defaults ("default deny posture") ─────────────── + +/// Empty SandboxPolicy (all None/empty) with default options produces the most +/// restrictive possible MXC config. +#[test] +fn c_empty_policy_default_deny_posture() { + let policy = SandboxPolicy::default(); + 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" + ); + + // UI: fully locked down. + assert_eq!(cfg["ui"]["disable"], true, "ui.disable must be true"); + assert_eq!( + cfg["ui"]["clipboard"], "none", + "ui.clipboard must be 'none'" + ); + assert_eq!(cfg["ui"]["injection"], false, "ui.injection must be false"); + + // Lifecycle: destroyOnExit + no policy preservation. + assert_eq!( + cfg["lifecycle"]["destroyOnExit"], true, + "lifecycle.destroyOnExit must be true" + ); + assert_eq!( + cfg["lifecycle"]["preservePolicy"], false, + "lifecycle.preservePolicy must be false" + ); + + // Filesystem: all lists empty, deniedPaths empty. + assert!( + str_list(&cfg["filesystem"]["readwritePaths"]).is_empty(), + "readwritePaths must be [] on empty policy" + ); + assert!( + str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty(), + "readonlyPaths must be [] on empty policy" + ); + assert!( + str_list(&cfg["filesystem"]["deniedPaths"]).is_empty(), + "deniedPaths must be [] on empty policy" + ); + + // No processContainer key when no hosts are granted. + assert!( + cfg.get("processContainer").is_none(), + "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" + ); +} + +/// Split path with network rules present: allowedHosts stays empty. +#[test] +fn c_split_empty_allowed_hosts_with_network_rules() { + let mut policy = SandboxPolicy::default(); + policy.network_policies.insert( + "api".to_owned(), + NetworkPolicyRule { + name: "api".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some"); + assert!( + str_list(&result.mxc_config["network"]["allowedHosts"]).is_empty(), + "split path allowedHosts must be empty even with network rules; got: {:?}", + result.mxc_config["network"]["allowedHosts"] + ); + // But proxy redirect is present. + assert_eq!( + result.mxc_config["network"]["proxy"]["localhost"], 18080, + "split must emit network.proxy.localhost" + ); +} + +// ─── DRIFT GUARD ───────────────────────────────────────────────────────────── +// +// Serialize policies via openshell_policy::serialize_sandbox_policy, collect +// YAML keys, compare against HANDLED_* const slices. Fails when a new field +// is added to the schema without updating the mapper. + +/// Top-level fields of the YAML policy schema that the mapper handles today. +/// Derived from what map.rs and build_split_mxc_config actually read. +/// +/// "version" — emitted into mxc_config["version"] (not a loss) +/// "filesystem_policy" — mapped via map_filesystem +/// "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 +const HANDLED_TOPLEVEL: &[&str] = &[ + "version", + "filesystem_policy", + "landlock", + "process", + "network_policies", + "network_middlewares", +]; + +/// Per-rule keys under each network_policies entry that the mapper handles. +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 +/// "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 +/// "deny_rules" — error loss +/// "allow_encoded_slash" — error loss +/// "websocket_credential_rewrite" — error loss +/// "request_body_credential_rewrite" — error loss +/// "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. +const HANDLED_ENDPOINT_KEYS: &[&str] = &[ + "host", + "port", + "ports", + "protocol", + "tls", + "enforcement", + "access", + "rules", + "allowed_ips", + "deny_rules", + "allow_encoded_slash", + "websocket_credential_rewrite", + "request_body_credential_rewrite", + "persisted_queries", + "graphql_persisted_queries", + "graphql_max_body_bytes", + "path", +]; + +#[test] +fn handled_fields_inventory() { + use std::collections::BTreeSet; + + // ── (1) Top-level keys: serialize a SandboxPolicy with every section + // present-but-minimal, then collect YAML keys. ────────────────────────── + let full_toplevel_policy = SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: vec!["/usr".into()], + read_write: vec!["/work".into()], + }), + landlock: Some(LandlockPolicy { + compatibility: "best_effort".into(), + }), + process: Some(ProcessPolicy { + run_as_user: "sandbox".into(), + run_as_group: "sandbox".into(), + }), + network_policies: { + let mut m = std::collections::HashMap::new(); + m.insert( + "rule".to_owned(), + NetworkPolicyRule { + name: "rule".to_owned(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + port: 443, + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + m + }, + network_middlewares: { + let mut m = std::collections::HashMap::new(); + m.insert("redactor".to_owned(), middleware_config()); + m + }, + }; + + // Validate so the test itself doesn't carry a bad policy. + validate_sandbox_policy(&full_toplevel_policy).expect("test policy must be valid"); + + let yaml_toplevel = + serialize_sandbox_policy(&full_toplevel_policy).expect("serialize full_toplevel_policy"); + let top_value: Value = + serde_yml::from_str::(&yaml_toplevel).expect("re-parse as JSON value"); + let top_obj = top_value + .as_object() + .expect("top-level must be a JSON object"); + let observed_toplevel: BTreeSet<&str> = top_obj.keys().map(String::as_str).collect(); + let expected_toplevel: BTreeSet<&str> = HANDLED_TOPLEVEL.iter().copied().collect(); + + let unhandled_top: Vec<&&str> = observed_toplevel + .iter() + .filter(|k| !expected_toplevel.contains(**k)) + .collect(); + assert!( + unhandled_top.is_empty(), + "openshell-policy gained top-level field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_TOPLEVEL in this test.", + unhandled_top + ); + + let missing_top: Vec<&&str> = expected_toplevel + .iter() + .filter(|k| !observed_toplevel.contains(**k)) + .collect(); + assert!( + missing_top.is_empty(), + "HANDLED_TOPLEVEL lists field(s) {:?} that are no longer emitted by \ + serialize_sandbox_policy — remove them from HANDLED_TOPLEVEL.", + missing_top + ); + + // ── (2) Per-rule and per-endpoint keys: serialize a policy with one fully- + // populated NetworkPolicyRule / NetworkEndpoint. ───────────────────────── + // + // Note: `port` (scalar) and `ports` (array) are mutually exclusive in the + // serialized form — single port emits `port`; multiple ports emit `ports`. + // To cover both variants we use TWO endpoints: the first with multi-port + // (triggers `ports` key), the second with single-port (triggers `port`). + // The drift guard collects the UNION of all endpoint keys observed. + let mut full_ep = NetworkEndpoint { + host: "api.example.com".into(), + path: "/graphql".into(), + // Two ports → serializes as `ports: [80, 443]` (array form). + ports: vec![80, 443], + protocol: "graphql".into(), + tls: "skip".into(), + enforcement: "enforce".into(), + access: "full".into(), + allowed_ips: vec!["10.0.0.1".into()], + allow_encoded_slash: true, + websocket_credential_rewrite: true, + request_body_credential_rewrite: true, + persisted_queries: "deny".into(), + graphql_max_body_bytes: 65536, + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".into(), + path: "/foo".into(), + ..Default::default() + }), + }], + deny_rules: vec![L7DenyRule { + method: "POST".into(), + path: "/bar".into(), + ..Default::default() + }], + ..Default::default() + }; + full_ep.graphql_persisted_queries.insert( + "abc".to_owned(), + GraphqlOperation { + operation_type: "query".into(), + ..Default::default() + }, + ); + // Second endpoint: single port → serializes as `port: 443` (scalar form). + let single_port_ep = NetworkEndpoint { + host: "other.example.com".into(), + ports: vec![443], + ..Default::default() + }; + + let full_rule_policy = SandboxPolicy { + version: 1, + network_policies: { + let mut m = std::collections::HashMap::new(); + m.insert( + "rule".to_owned(), + NetworkPolicyRule { + name: "rule".to_owned(), + endpoints: vec![full_ep, single_port_ep], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + m + }, + ..Default::default() + }; + + let yaml_rule = + serialize_sandbox_policy(&full_rule_policy).expect("serialize full_rule_policy"); + let rule_value: Value = + serde_yml::from_str::(&yaml_rule).expect("re-parse rule policy as JSON value"); + + // Collect per-rule keys. + let network_policies_obj = rule_value["network_policies"] + .as_object() + .expect("network_policies must be an object"); + let first_rule = network_policies_obj + .values() + .next() + .expect("at least one rule") + .as_object() + .expect("rule must be an object"); + let observed_rule_keys: BTreeSet<&str> = first_rule.keys().map(String::as_str).collect(); + let expected_rule_keys: BTreeSet<&str> = HANDLED_RULE_KEYS.iter().copied().collect(); + + let unhandled_rule: Vec<&&str> = observed_rule_keys + .iter() + .filter(|k| !expected_rule_keys.contains(**k)) + .collect(); + assert!( + unhandled_rule.is_empty(), + "openshell-policy gained network rule field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_RULE_KEYS in this test.", + unhandled_rule + ); + + let missing_rule: Vec<&&str> = expected_rule_keys + .iter() + .filter(|k| !observed_rule_keys.contains(**k)) + .collect(); + assert!( + missing_rule.is_empty(), + "HANDLED_RULE_KEYS lists field(s) {:?} that are no longer emitted — remove them.", + missing_rule + ); + + // Collect per-endpoint keys: union across ALL endpoints so that mutually- + // exclusive fields like `port` (single-port form) and `ports` (multi-port + // form) are both captured. + let endpoints_arr = first_rule["endpoints"] + .as_array() + .expect("endpoints must be an array"); + let observed_ep_keys: BTreeSet<&str> = endpoints_arr + .iter() + .filter_map(|ep| ep.as_object()) + .flat_map(|obj| obj.keys().map(String::as_str)) + .collect(); + let expected_ep_keys: BTreeSet<&str> = HANDLED_ENDPOINT_KEYS.iter().copied().collect(); + + let unhandled_ep: Vec<&&str> = observed_ep_keys + .iter() + .filter(|k| !expected_ep_keys.contains(**k)) + .collect(); + assert!( + unhandled_ep.is_empty(), + "openshell-policy gained endpoint field(s) {:?} that the policy mapper does not handle \ + — map it, delegate it, or add a loss item, then update HANDLED_ENDPOINT_KEYS in this test.", + unhandled_ep + ); + + let missing_ep: Vec<&&str> = expected_ep_keys + .iter() + .filter(|k| !observed_ep_keys.contains(**k)) + .collect(); + assert!( + missing_ep.is_empty(), + "HANDLED_ENDPOINT_KEYS lists field(s) {:?} that are no longer emitted — remove them.", + missing_ep + ); +} diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs new file mode 100644 index 0000000000..dd16836e4b --- /dev/null +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -0,0 +1,795 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real-`wxc-exec` integration tests (Tier 2). +//! +//! These tests drive the actual `wxc-exec.exe` binary — no mock shim. Every +//! test is `#[ignore = "requires real wxc-exec"]` so the regular `cargo test` +//! suite (`windows:test:x64`) never blocks on hardware. Run them with: +//! +//! ```powershell +//! $env:OPENSHELL_WXC_EXEC_PATH = "C:\mxc\wxc-exec.exe" +//! cargo test -p openshell-driver-mxc --test wxc_exec_real -- --ignored --test-threads=1 +//! ``` +//! +//! Two families: +//! +//! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on +//! schema acceptance. Some `wxc-exec` builds select the DACL fallback during +//! dry-run and validate filesystem grants, so these tests use owned temporary +//! directories with concrete Windows paths. +//! +//! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason +//! and return early when the backend is not live. The probe distinguishes +//! "binary absent", "`backend_error` / velocity keys not enabled", and +//! "`backend_unavailable`". +//! +//! IMPORTANT: `OPENSHELL_MXC_MOCK_WXC` must NOT be set when running this file. +//! The probe-gated enforcement tests assert that it is absent so a stale env +//! var can never silently re-mock a "real" run. + +#![cfg(target_os = "windows")] + +use base64::Engine as _; +use std::path::PathBuf; +use std::process::Command; + +// ── Path resolution ────────────────────────────────────────────────────────── + +/// Resolve the path to `wxc-exec.exe`. +/// +/// Checks `OPENSHELL_WXC_EXEC_PATH` first, then the canonical demo-box +/// location `C:\mxc\wxc-exec.exe`. Returns `None` when neither path exists so +/// callers can skip rather than fail. +fn wxc_path() -> Option { + if let Ok(p) = std::env::var("OPENSHELL_WXC_EXEC_PATH") { + let pb = PathBuf::from(&p); + if pb.exists() { + return Some(pb); + } + // Env var was set but path is absent — still treat as "not found" so + // tests skip with a clear reason rather than erroring on spawn. + eprintln!("SKIP: OPENSHELL_WXC_EXEC_PATH={p} does not exist"); + return None; + } + let default = PathBuf::from(r"C:\mxc\wxc-exec.exe"); + if default.exists() { + return Some(default); + } + None +} + +// ── Dry-run helper ──────────────────────────────────────────────────────────── + +/// 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) { + 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) + .arg("--config-base64") + .arg(&b64) + .arg("--dry-run") + .output() + .expect("wxc-exec spawn"); + + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + (code, stdout, stderr) +} + +// ── (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. + +/// Minimal processcontainer one-shot config accepted by `--dry-run`. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_minimal_processcontainer_config() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-minimal", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "minimal processcontainer config rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// Network block without proxy (defaultPolicy block, empty host lists) accepted. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_network_block_without_proxy() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-net-block", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + }, + }); + + let (code, stdout, stderr) = dry_run(&wxc, &config); + assert_eq!( + code, 0, + "network block without proxy rejected by --dry-run\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// 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 tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-proxy-localhost", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + "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 config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-proxy-hostport", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + "network": { + "defaultPolicy": "block", + "allowedHosts": [], + "blockedHosts": [], + // 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"] +fn dryrun_rejects_unknown_containment() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "test-bad-containment", + "containment": "nonsense", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "%TEMP%", + "timeout": 0, + }, + "filesystem": { + "readwritePaths": ["%TEMP%"], + }, + }); + + let (code, _stdout, _stderr) = dry_run(&wxc, &config); + assert_ne!(code, 0, "unknown containment 'nonsense' should be rejected"); +} + +/// The most important dry-run test: parse the quickstart example policy with +/// `openshell_policy`, run `split_policy` (`proxy_redirect` 127.0.0.1:18080, +/// containment "processcontainer"), take the resulting `mxc_config`, inject a +/// real process block with a valid cwd, and verify that `--dry-run` exits 0. +/// +/// This proves that the mapper's emitted JSON is accepted by the real binary — +/// the central contract of the policy-mapper integration. +#[test] +#[ignore = "requires real wxc-exec"] +fn dryrun_accepts_split_policy_output() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + // Find the quickstart policy relative to CARGO_MANIFEST_DIR. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let policy_path = manifest_dir.join("../../examples/sandbox-policy-quickstart/policy.yaml"); + + if !policy_path.exists() { + eprintln!( + "SKIP: quickstart policy not found at {}", + policy_path.display() + ); + return; + } + + let yaml = std::fs::read_to_string(&policy_path).expect("read policy YAML"); + let policy = openshell_policy::parse_sandbox_policy(&yaml).expect("parse quickstart policy"); + + let opts = openshell_driver_mxc::MxcMappingOptions { + containment: "processcontainer".to_string(), + proxy_redirect: Some("127.0.0.1:18080".parse().unwrap()), + ..Default::default() + }; + + let result = openshell_driver_mxc::split_policy(&policy, &opts) + .expect("split_policy must return Some when proxy_redirect is set"); + // The quickstart policy has network_policies with error-level losses on + // isolation_session, but on processcontainer there should be zero error + // losses from the split itself. Warn if there are any error losses so the + // test is informative even when it proceeds. + let error_losses: Vec<_> = result + .loss + .iter() + .filter(|l| l.severity == "error") + .collect(); + if !error_losses.is_empty() { + eprintln!( + "split_policy emitted {} error loss item(s); proceeding to dry-run:\n{:#?}", + error_losses.len(), + error_losses + ); + } + + // Take the mapper's MXC config and inject the required process block. + // The split config does not include a process block (that comes from the + // gateway TOML at runtime); wxc-exec --dry-run requires one. + // + // The quickstart policy uses sandbox-internal Unix paths. Replace only the + // environment-dependent filesystem paths with an owned Windows directory: + // this test verifies the mapper's MXC JSON shape, while mapper unit tests + // cover the exact filesystem translation. + let tmpdir = tempfile::tempdir().expect("tempdir"); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let mut mxc_config = result.mxc_config.clone(); + mxc_config["filesystem"] = serde_json::json!({ + "readwritePaths": [tmpdir_str], + "readonlyPaths": [], + "deniedPaths": [], + }); + mxc_config["process"] = serde_json::json!({ + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 0, + }); + // containerId is also required for processcontainer. + mxc_config["containerId"] = serde_json::json!("split-policy-dryrun"); + + let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); + assert_eq!( + code, + 0, + "split_policy output rejected by --dry-run; \ + this proves the mapper emits valid MXC JSON\n\ + config={}\nstdout={stdout}\nstderr={stderr}", + serde_json::to_string_pretty(&mxc_config).unwrap_or_default() + ); +} + +// ── (b) Enforcement tests — probe-gated ─────────────────────────────────────── +// +// These skip on this box (processcontainer velocity keys not enabled; +// isolation_session backend absent). They PASS where backends are live. + +/// Probe the processcontainer backend. +/// +/// Runs a trivial one-shot (`cmd /c exit 0`, owned temporary-directory grant). +/// Returns `Ok(())` when the backend is live, or `Err(reason)` when it is not (the +/// caller prints SKIP + reason and returns from the test). +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").is_ok_and(|value| value == "1") { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } + + let tmpdir = tempfile::tempdir().map_err(|error| format!("tempdir failed: {error}"))?; + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "probe-pc", + "containment": "processcontainer", + "process": { + "commandLine": "cmd /c exit 0", + "cwd": tmpdir_str, + "timeout": 10, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + }); + + 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() + .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; + + let stdout = String::from_utf8_lossy(&out.stdout).to_lowercase(); + let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase(); + let combined = format!("{stdout} {stderr}"); + + if combined.contains("backend_error") + || combined.contains("e_notimpl") + || combined.contains("velocity") + || combined.contains("not enabled") + { + // Extract the message if possible for a more useful skip reason. + let reason = + serde_json::from_str::(&String::from_utf8_lossy(&out.stdout)) + .map_or_else( + |_| "backend_error (velocity keys not enabled)".to_string(), + |value| { + value["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + }, + ); + return Err(reason); + } + + if !out.status.success() { + return Err(format!( + "processcontainer probe returned exit {}: stdout={} stderr={}", + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + )); + } + + Ok(()) +} + +/// 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").is_ok_and(|value| value == "1") { + return Err( + "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" + .to_string(), + ); + } + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "provision", + "containment": "isolation_session", + "filesystem": { + "readwritePaths": [], + "readonlyPaths": [], + }, + "experimental": { + "isolation_session": { + "configurationId": "composable", + "provision": {} + } + } + }); + + 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) + .arg("--experimental") + .output() + .map_err(|e| format!("wxc-exec spawn failed: {e}"))?; + + let stdout_raw = String::from_utf8_lossy(&out.stdout).into_owned(); + let stdout_lower = stdout_raw.to_lowercase(); + let stderr_lower = String::from_utf8_lossy(&out.stderr).to_lowercase(); + let combined = format!("{stdout_lower} {stderr_lower}"); + + if combined.contains("backend_unavailable") || combined.contains("0x80040154") { + return Err( + "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553".to_string(), + ); + } + + if !out.status.success() { + return Err(format!( + "isolation_session provision failed (exit {}): {}", + out.status.code().unwrap_or(-1), + stdout_raw + )); + } + + // Parse the sandboxId from {"result":{"sandboxId":"iso:..."}} + let env: serde_json::Value = serde_json::from_str(&stdout_raw) + .map_err(|e| format!("provision envelope parse failed: {e}: {stdout_raw}"))?; + + let sandbox_id = env["result"]["sandboxId"] + .as_str() + .ok_or_else(|| format!("sandboxId missing in provision result: {stdout_raw}"))? + .to_string(); + + Ok(sandbox_id) +} + +/// RAII guard that best-effort deprovisioning on drop — protects the +/// single-session backend against orphaned sessions. +struct DeprovisionGuard<'a> { + wxc: &'a PathBuf, + sandbox_id: Option, +} + +impl<'a> DeprovisionGuard<'a> { + fn new(wxc: &'a PathBuf, sandbox_id: String) -> Self { + Self { + wxc, + sandbox_id: Some(sandbox_id), + } + } + + fn disarm(&mut self) { + self.sandbox_id = None; + } + + fn deprovision_now(&mut self) { + if let Some(id) = self.sandbox_id.take() { + Self::run_deprovision(self.wxc, &id); + } + } + + 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 + } + } + }); + let json = serde_json::to_string(&config).unwrap_or_default(); + let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + // Best-effort: ignore errors so the test does not panic in drop. + let _ = Command::new(wxc) + .arg("--config-base64") + .arg(&b64) + .arg("--experimental") + .output(); + } +} + +impl Drop for DeprovisionGuard<'_> { + fn drop(&mut self) { + if let Some(id) = self.sandbox_id.take() { + Self::run_deprovision(self.wxc, &id); + } + } +} + +// ── Processcontainer enforcement tests ─────────────────────────────────────── + +/// Write a file inside the granted temp dir; assert the file appears and the +/// exit code is 0. Requires the processcontainer backend to be live. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_in_policy_write_succeeds() { + 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 tmpdir = tempfile::tempdir().expect("tempdir"); + let target = tmpdir.path().join("pc-in-policy.txt"); + let target_str = target.to_string_lossy().into_owned(); + let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-in-policy-write", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /c echo hello > \"{target_str}\""), + "cwd": tmpdir_str, + "timeout": 30, + }, + "filesystem": { + "readwritePaths": [tmpdir_str], + }, + "processContainer": { + "leastPrivilege": 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 stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + + assert_eq!( + code, 0, + "in-policy write should exit 0\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + target.exists(), + "in-policy write: file should exist at {target_str}\nstdout={stdout}\nstderr={stderr}" + ); +} + +/// 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. +#[test] +#[ignore = "requires real wxc-exec"] +fn pc_oneshot_out_of_policy_write_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_dir = tempfile::tempdir().expect("denied tempdir"); + let denied_file = denied_dir.path().join("pc-out-of-policy.txt"); + let denied_file_str = denied_file.to_string_lossy().into_owned(); + let granted_str = granted_dir.path().to_string_lossy().into_owned(); + + let config = serde_json::json!({ + "version": "0.6.0-alpha", + "containerId": "pc-out-of-policy-write", + "containment": "processcontainer", + "process": { + "commandLine": format!("cmd /c echo denied > \"{denied_file_str}\""), + "cwd": granted_str, + "timeout": 30, + }, + "filesystem": { + // Only the granted_dir is in policy — denied_dir is NOT granted. + "readwritePaths": [granted_str], + }, + "processContainer": { + "leastPrivilege": 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 stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + + assert_ne!( + code, 0, + "out-of-policy write should be denied (non-zero exit)\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + !denied_file.exists(), + "out-of-policy file must be absent at {denied_file_str} (OS default-deny proof)\n\ + stdout={stdout}\nstderr={stderr}" + ); +} + +// ── Isolation session enforcement tests ────────────────────────────────────── + +/// 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] +#[ignore = "requires real wxc-exec"] +fn iso_lifecycle_round_trip() { + let Some(wxc) = wxc_path() else { + eprintln!("SKIP: wxc-exec not found"); + return; + }; + + let sandbox_id = match probe_isolation_session(&wxc) { + Ok(id) => id, + Err(reason) => { + eprintln!("SKIP: isolation_session not live: {reason}"); + return; + } + }; + + // Guard ensures deprovision even on panic. + let mut guard = DeprovisionGuard::new(&wxc, sandbox_id.clone()); + + // start + let start_config = serde_json::json!({ + "version": "0.6.0-alpha", + "phase": "start", + "sandboxId": sandbox_id, + "experimental": { + "isolation_session": { + "start": {} + } + } + }); + 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") + .output() + .expect("start"); + assert!( + out.status.success(), + "start failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // exec. timeout is MILLISECONDS; 0 = no timeout. Empirical (test box, + // build 26300.8553, wxc-exec 2026-06-10): a small positive value (30) is + // rejected by RunProcessWithOptionsAsync with "Invalid timeout value" + // (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, + "process": { + "commandLine": "cmd /c exit 0", + "cwd": "C:\\Windows\\Temp", + "env": [], + "timeout": 0, + } + }); + let json = serde_json::to_string(&exec_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") + .output() + .expect("exec"); + assert_eq!( + out.status.code().unwrap_or(-1), + 0, + "exec phase should exit 0: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // 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 + } + } + }); + 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") + .output() + .expect("stop"); + assert!( + out.status.success(), + "stop failed: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // deprovision (also disarms the guard so Drop does not double-deprovision) + guard.deprovision_now(); + guard.disarm(); +} diff --git a/crates/openshell-extension-core/src/transport.rs b/crates/openshell-extension-core/src/transport.rs index 2f9a633237..e411bc805b 100644 --- a/crates/openshell-extension-core/src/transport.rs +++ b/crates/openshell-extension-core/src/transport.rs @@ -225,13 +225,15 @@ mod tests { #[test] fn accepts_supported_endpoint_forms() { - for endpoint in [ - "http://127.0.0.1:50051", - "https://middleware.example:443", - "unix:///run/openshell/middleware.sock", - ] { + for endpoint in ["http://127.0.0.1:50051", "https://middleware.example:443"] { validate_config(&ExtensionChannelConfig::new(endpoint)).unwrap(); } + + #[cfg(unix)] + validate_config(&ExtensionChannelConfig::new( + "unix:///run/openshell/middleware.sock", + )) + .unwrap(); } #[test] diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index e63b2be88f..415ade2f7d 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -222,10 +222,11 @@ impl SandboxContext { } } - /// Build the OCSF `Device` object. + /// Build the OCSF `Device` object, stamped with the host OS this build runs + /// on (Linux for the in-sandbox supervisor, Windows for the MXC gateway). #[must_use] pub fn device(&self) -> Device { - Device::linux(&self.hostname) + Device::for_current_os(&self.hostname) } /// Build the `proxy_endpoint` object for the Network Proxy profile. diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index 345ea57175..2101beffee 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -64,5 +64,6 @@ pub use builders::{ // --- Tracing layers --- pub use tracing_layers::{ - OCSF_TARGET, OcsfJsonlLayer, OcsfShorthandLayer, clone_current_event, emit_ocsf_event, + OCSF_TARGET, OcsfJsonlLayer, OcsfShorthandLayer, clear_current_event, clone_current_event, + emit_ocsf_event, emit_ocsf_event_routed, set_current_event, }; diff --git a/crates/openshell-ocsf/src/objects/device.rs b/crates/openshell-ocsf/src/objects/device.rs index 4c42fb4a1f..0f38ef446e 100644 --- a/crates/openshell-ocsf/src/objects/device.rs +++ b/crates/openshell-ocsf/src/objects/device.rs @@ -34,6 +34,34 @@ impl Device { }), } } + + /// Create a Windows device with the given hostname. + #[must_use] + pub fn windows(hostname: &str) -> Self { + Self { + hostname: hostname.to_string(), + os: Some(OsInfo { + name: "Windows".to_string(), + }), + } + } + + /// Create a device stamped with the OS this build is running on. + /// + /// The gateway (Windows) and the Linux supervisor emit through the same + /// builders; the `device.os.name` should reflect the host each runs on — + /// an OS-appropriate difference, not a divergence. + #[must_use] + pub fn for_current_os(hostname: &str) -> Self { + #[cfg(target_os = "windows")] + { + Self::windows(hostname) + } + #[cfg(not(target_os = "windows"))] + { + Self::linux(hostname) + } + } } #[cfg(test)] @@ -47,4 +75,23 @@ mod tests { assert_eq!(json["hostname"], "sandbox-abc123"); assert_eq!(json["os"]["name"], "Linux"); } + + #[test] + fn test_device_windows() { + let device = Device::windows("gateway-host"); + let json = serde_json::to_value(&device).unwrap(); + assert_eq!(json["hostname"], "gateway-host"); + assert_eq!(json["os"]["name"], "Windows"); + } + + #[test] + fn test_device_for_current_os() { + let device = Device::for_current_os("host"); + let json = serde_json::to_value(&device).unwrap(); + assert_eq!(json["hostname"], "host"); + #[cfg(target_os = "windows")] + assert_eq!(json["os"]["name"], "Windows"); + #[cfg(not(target_os = "windows"))] + assert_eq!(json["os"]["name"], "Linux"); + } } diff --git a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs index c07cd64b53..58f5f554b0 100644 --- a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs +++ b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs @@ -36,20 +36,54 @@ pub fn clone_current_event() -> Option { /// Both layers receive the event — `clone_current_event()` is non-consuming. pub fn emit_ocsf_event(event: OcsfEvent) { // Store the event in thread-local so layers can access it - CURRENT_EVENT.with(|cell| { - *cell.borrow_mut() = Some(event); - }); + set_current_event(event); // Emit a tracing event with the `ocsf` target. // The layers detect this target and clone the OcsfEvent from thread-local. tracing::info!(target: "ocsf", "ocsf_event"); // Clear the thread-local after dispatch completes. + clear_current_event(); +} + +/// Store an `OcsfEvent` in the thread-local bridge so OCSF layers +/// (`OcsfJsonlLayer` / `OcsfShorthandLayer`) can `clone_current_event()` it +/// during tracing dispatch. Pair with [`clear_current_event`] after the emit. +/// +/// Exposed so callers that need to attach extra tracing fields to the *same* +/// event (e.g. the gateway's per-sandbox `sandbox_id` routing field — see +/// [`emit_ocsf_event_routed`]) can drive the bridge directly. +pub fn set_current_event(event: OcsfEvent) { + CURRENT_EVENT.with(|cell| { + *cell.borrow_mut() = Some(event); + }); +} + +/// Clear the thread-local bridge slot. Call after the `ocsf`-target tracing +/// event has been dispatched so it does not leak into the next emit. +pub fn clear_current_event() { CURRENT_EVENT.with(|cell| { cell.borrow_mut().take(); }); } +/// Emit an `OcsfEvent` that is BOTH picked up by the structured OCSF layers +/// (via the thread-local bridge → `OcsfJsonlLayer` writes full JSON) AND +/// routed by the gateway's `TracingLogBus` (via the `sandbox_id` + `message` +/// tracing fields → per-sandbox stream / stdout shorthand). +/// +/// This is the gateway/multi-sandbox counterpart of [`emit_ocsf_event`]: the +/// Linux in-sandbox supervisor uses the process-wide `ctx()` singleton and the +/// bare `emit_ocsf_event`, but the gateway hosts many sandboxes, so it stamps a +/// per-event `sandbox_id` field here instead. One tracing event feeds both the +/// JSONL audit file and the routing bus. +pub fn emit_ocsf_event_routed(sandbox_id: &str, event: OcsfEvent) { + let message = event.format_shorthand(); + set_current_event(event); + tracing::info!(target: "ocsf", sandbox_id = %sandbox_id, message = %message); + clear_current_event(); +} + /// Convenience macro for emitting an `OcsfEvent`. /// /// ```ignore @@ -129,4 +163,71 @@ mod tests { // Should be empty now assert!(clone_current_event().is_none()); } + + /// A `Write` sink that appends into a shared buffer we can inspect. + #[derive(Clone)] + struct SharedWriter(std::sync::Arc>>); + + impl std::io::Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + // cp6: the gateway routed emit must (a) drive the JSONL layer with the FULL + // structured event (parity with the Linux `ocsf_emit!` path) and (b) leave + // no residue in the thread-local afterward. + #[test] + fn test_routed_emit_writes_full_json_and_clears() { + use crate::tracing_layers::OcsfJsonlLayer; + use tracing_subscriber::prelude::*; + + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let layer = OcsfJsonlLayer::new(SharedWriter(buf.clone())); + let subscriber = tracing_subscriber::registry().with(layer); + + tracing::subscriber::with_default(subscriber, || { + emit_ocsf_event_routed("sb-parity-1", test_event()); + }); + + let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); + // Exactly one JSONL line, and it is valid full OCSF JSON (not shorthand). + assert_eq!(out.matches('\n').count(), 1, "one JSONL line expected"); + let parsed: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(parsed["class_uid"], 0); + assert!(parsed.get("metadata").is_some()); + + // Thread-local must be clear after the routed emit (no bleed). + assert!(clone_current_event().is_none()); + } + + // cp6 parity: the routed path and the bare Linux path serialize the SAME + // structured event identically — routing fields don't alter the JSON body. + #[test] + fn test_routed_and_bare_paths_emit_equivalent_json() { + use crate::tracing_layers::OcsfJsonlLayer; + use tracing_subscriber::prelude::*; + + fn capture(f: impl FnOnce()) -> String { + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let layer = OcsfJsonlLayer::new(SharedWriter(buf.clone())); + let subscriber = tracing_subscriber::registry().with(layer); + tracing::subscriber::with_default(subscriber, f); + String::from_utf8(buf.lock().unwrap().clone()).unwrap() + } + + let bare = capture(|| emit_ocsf_event(test_event())); + let routed = capture(|| emit_ocsf_event_routed("sb-1", test_event())); + + let bare_json: serde_json::Value = serde_json::from_str(bare.trim()).unwrap(); + let routed_json: serde_json::Value = serde_json::from_str(routed.trim()).unwrap(); + assert_eq!( + bare_json, routed_json, + "routed emit must match the bare path JSON" + ); + } } diff --git a/crates/openshell-ocsf/src/tracing_layers/mod.rs b/crates/openshell-ocsf/src/tracing_layers/mod.rs index c8e5d9f2e4..b57ba1364a 100644 --- a/crates/openshell-ocsf/src/tracing_layers/mod.rs +++ b/crates/openshell-ocsf/src/tracing_layers/mod.rs @@ -11,6 +11,9 @@ pub(crate) mod event_bridge; mod jsonl_layer; mod shorthand_layer; -pub use event_bridge::{OCSF_TARGET, clone_current_event, emit_ocsf_event}; +pub use event_bridge::{ + OCSF_TARGET, clear_current_event, clone_current_event, emit_ocsf_event, emit_ocsf_event_routed, + set_current_event, +}; pub use jsonl_layer::OcsfJsonlLayer; pub use shorthand_layer::OcsfShorthandLayer; diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 772590d1b0..a9cb5166b5 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -72,6 +72,7 @@ anyhow = { workspace = true } # Logging tracing = { workspace = true } tracing-subscriber = { workspace = true } +tracing-appender = { workspace = true } # OpenTelemetry (OTLP trace export, opt-in via [openshell.gateway.otlp]) opentelemetry = { workspace = true } @@ -119,6 +120,11 @@ openshell-driver-docker = { path = "../openshell-driver-docker" } openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-podman = { path = "../openshell-driver-podman" } +# MXC is the Windows-only in-process compute backend (openshell-driver-mxc is a +# no-op stub on other targets). It is only linked into the gateway on Windows. +[target.'cfg(target_os = "windows")'.dependencies] +openshell-driver-mxc = { path = "../openshell-driver-mxc" } + [features] default = ["telemetry"] ## Compile in anonymous telemetry emission (forwards to openshell-core/telemetry). diff --git a/crates/openshell-server/src/auth/k8s_sa.rs b/crates/openshell-server/src/auth/k8s_sa.rs index 32cb2e119c..e41212280e 100644 --- a/crates/openshell-server/src/auth/k8s_sa.rs +++ b/crates/openshell-server/src/auth/k8s_sa.rs @@ -26,6 +26,7 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::Error as KubeError; use kube::api::{Api, ApiResource, PostParams}; use kube::core::{DynamicObject, gvk::GroupVersionKind}; +#[cfg(not(target_os = "windows"))] use openshell_driver_kubernetes::OperatorNamespaceAllowlist; use std::sync::Arc; use tonic::Status; @@ -146,6 +147,7 @@ pub enum NamespaceValidator { /// (`openshell-{gateway_id}-`). Prefix(String), /// Operator mode: accept namespaces in the dynamic allowlist. + #[cfg(not(target_os = "windows"))] Allowlist(OperatorNamespaceAllowlist), } @@ -154,6 +156,7 @@ impl NamespaceValidator { match self { Self::Exact(expected) => namespace == expected, Self::Prefix(prefix) => namespace.starts_with(prefix.as_str()), + #[cfg(not(target_os = "windows"))] Self::Allowlist(al) => al.contains(namespace), } } @@ -840,6 +843,7 @@ mod tests { assert!(!v.accepts("other")); } + #[cfg(not(target_os = "windows"))] #[test] fn namespace_validator_allowlist_accepts_known_namespaces() { let al = OperatorNamespaceAllowlist::from_set(std::collections::BTreeSet::from([ diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 4a1df2a63b..67c4476b69 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -796,7 +796,12 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches fn is_singleplayer_driver(driver: Option) -> bool { matches!( driver, - Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm) + Some( + ComputeDriverKind::Docker + | ComputeDriverKind::Podman + | ComputeDriverKind::Vm + | ComputeDriverKind::Mxc + ) ) } @@ -1667,6 +1672,7 @@ ssh_session_ttl_secs = 1234 openshell_core::ComputeDriverKind::Docker, openshell_core::ComputeDriverKind::Podman, openshell_core::ComputeDriverKind::Vm, + openshell_core::ComputeDriverKind::Mxc, ] { assert!( super::is_singleplayer_driver(Some(driver)), diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index f0eb6f98b4..51fd903504 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -12,7 +12,11 @@ pub mod builtin; use crate::config_file; use crate::defaults::LocalTlsPaths; +#[cfg(target_os = "windows")] +use openshell_core::ComputeDriverKind; use openshell_core::{Error, Result}; +#[cfg(target_os = "windows")] +use openshell_driver_mxc::MxcComputeConfig; use serde::Deserialize; use std::collections::BTreeMap; use std::path::PathBuf; @@ -49,6 +53,15 @@ pub struct DriverStartupContext<'a> { pub endpoint_overrides: &'a BTreeMap, } +/// Build the selected MXC config from TOML. MXC is Windows-only and has no +/// runtime-default overlay; the driver reads its own settings from the config. +/// The Linux built-in driver configs now live in the `builtin` submodule +/// (compiled only off Windows). +#[cfg(target_os = "windows")] +pub fn mxc_config_from_context(context: DriverStartupContext<'_>) -> Result { + driver_config_from_context(context, ComputeDriverKind::Mxc.as_str()) +} + pub fn remote_driver_config_from_context( context: DriverStartupContext<'_>, name: &str, diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 7f1dc19a2f..f9012dee2e 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -31,6 +31,8 @@ use futures::{Stream, StreamExt}; #[cfg(unix)] use hyper_util::rt::TokioIo; use openshell_core::ComputeDriverKind; +#[cfg(target_os = "windows")] +use openshell_core::proto::SandboxPolicy; use openshell_core::proto::compute::v1::{ CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements, DriverSandbox, @@ -56,6 +58,8 @@ use openshell_driver_kubernetes::{ ComputeDriverService as KubernetesDriverService, KubernetesComputeDriver, OperatorNamespaceAllowlist, }; +#[cfg(target_os = "windows")] +use openshell_driver_mxc::{ComputeDriverService as MxcDriverService, MxcComputeConfig}; #[cfg(not(target_os = "windows"))] use openshell_driver_podman::{ComputeDriverService as PodmanDriverService, PodmanComputeDriver}; use prost::Message; @@ -567,6 +571,14 @@ pub struct ComputeRuntime { lifecycle_gates: Arc, gateway_listener_requirements: Vec, replica_id: String, + /// A1 policy side channel for the in-process MXC driver. `create_sandbox` + /// stages the typed `SandboxPolicy` here by sandbox id immediately before + /// dispatching to the driver, which consumes it. `None` for all other + /// drivers. The proto driver contract has no `policy` field and there is no + /// driver-side `GetSandboxConfig`, so this in-process map is how the policy + /// reaches the MXC backend without changing the cross-process contract. + #[cfg(target_os = "windows")] + mxc_policy_sink: Option>>>, } impl fmt::Debug for ComputeRuntime { @@ -685,6 +697,8 @@ impl ComputeRuntime { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements, replica_id: lease::replica_id(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, }) } @@ -818,6 +832,38 @@ impl ComputeRuntime { .await } + /// Construct a `ComputeRuntime` backed by the MXC compute driver. + /// + /// MXC is Windows-only, in-process, and self-reports `Ready` — there is + /// no supervisor session argument because no surrogate or relay is used. + #[cfg(target_os = "windows")] + pub async fn new_mxc( + mxc_config: MxcComputeConfig, + store: Arc, + sandbox_index: SandboxIndex, + sandbox_watch_bus: SandboxWatchBus, + tracing_log_bus: TracingLogBus, + supervisor_sessions: Arc, + ) -> Result { + let backend = openshell_driver_mxc::MxcComputeBackend::new(mxc_config); + // Grab the A1 policy side channel before moving `backend` into the service. + let sink = backend.policy_sink(); + let service: SharedComputeDriver = Arc::new(MxcDriverService::new(backend)); + let mut runtime = Self::from_driver( + ComputeDriverKind::Mxc.as_str().to_string(), + service, + None, + store, + sandbox_index, + sandbox_watch_bus, + tracing_log_bus, + supervisor_sessions, + ) + .await?; + runtime.mxc_policy_sink = Some(sink); + Ok(runtime) + } + #[must_use] pub fn default_image(&self) -> &str { &self.default_image @@ -943,6 +989,16 @@ impl ComputeRuntime { { spec.sandbox_token = token; } + // A1: stage the typed SandboxPolicy out-of-band into the MXC backend's + // side channel, keyed by sandbox id (== DriverSandbox.id), immediately + // before dispatch. The driver removes/consumes it in create_sandbox. The + // proto driver contract has no policy field, so this is the only path. + #[cfg(target_os = "windows")] + if let Some(sink) = &self.mxc_policy_sink + && let Some(p) = sandbox.spec.as_ref().and_then(|s| s.policy.clone()) + { + sink.lock().await.insert(sandbox_id.clone(), p); + } match self .driver .call( @@ -4216,6 +4272,8 @@ 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(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, } } @@ -4900,6 +4958,8 @@ mod tests { lifecycle_gates: Arc::new(LifecycleGateRegistry::default()), gateway_listener_requirements: Vec::new(), replica_id: "test-replica".to_string(), + #[cfg(target_os = "windows")] + mxc_policy_sink: None, } } diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 00b7a2f64d..6758e42c85 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -281,6 +281,8 @@ impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { } } +// Keep ConfigFileError structured so callers retain the offending path and TOML source. +#[allow(clippy::result_large_err)] fn sanitize_ca_cert_pem(name: &str, path: &Path, pem: &[u8]) -> Result, ConfigFileError> { let mut sanitized = Vec::new(); let mut certificate_count = 0; @@ -483,7 +485,9 @@ fn inheritable_keys(driver_name: &str) -> &'static [&'static str] { "guest_tls_cert", "guest_tls_key", ], - None => &[], + // MXC reads its own settings from the driver config table and has no + // gateway-inherited required fields. + Some(ComputeDriverKind::Mxc) | None => &[], } } @@ -708,16 +712,17 @@ allow_unauthenticated_users = true .expect("CA tempfile"); ca.write_all(certificate.cert.pem().as_bytes()) .expect("write CA"); + let ca_path = toml::Value::String(ca.path().display().to_string()).to_string(); let toml = r#" [[openshell.supervisor.middleware]] name = "local-guard" grpc_endpoint = "https://127.0.0.1:50051" -tls_ca_cert_path = "CA_PATH" +tls_ca_cert_path = CA_PATH audience = "urn:openshell:middleware:local-guard" max_payload_bytes = 262144 timeout = "2s" "# - .replace("CA_PATH", &ca.path().display().to_string()); + .replace("CA_PATH", &ca_path); let tmp = write_tmp(&toml); let file = load(tmp.path()).expect("valid middleware registration parses"); assert_eq!( @@ -736,10 +741,8 @@ timeout = "2s" SupervisorMiddlewareService::try_from(&file.openshell.supervisor.middleware[0]) .expect("valid CA resolves"); assert_eq!(registration.timeout, "2s"); - assert_eq!( - registration.tls_ca_cert_pem, - certificate.cert.pem().as_bytes() - ); + let expected_pem = certificate.cert.pem().replace("\r\n", "\n"); + assert_eq!(registration.tls_ca_cert_pem, expected_pem.as_bytes()); assert_eq!( registration.audience, "urn:openshell:middleware:local-guard" diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 71dd11acb7..460389a0c1 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2606,6 +2606,20 @@ pub(super) async fn handle_get_sandbox_provider_environment( // Update config handler (policy + settings mutations) // --------------------------------------------------------------------------- +fn validate_live_policy_update_support( + driver_kind: Option, + has_policy: bool, + has_merge_ops: bool, +) -> Result<(), Status> { + if (has_policy || has_merge_ops) && driver_kind == Some(openshell_core::ComputeDriverKind::Mxc) + { + return Err(Status::failed_precondition( + "live policy updates are not supported for MXC sandboxes; recreate the sandbox so the new policy is mapped before launch", + )); + } + Ok(()) +} + pub(super) async fn handle_update_config( state: &Arc, request: Request, @@ -2680,6 +2694,7 @@ async fn handle_update_config_inner( "one of policy, setting_key, or merge_operations must be provided", )); } + validate_live_policy_update_support(state.compute.driver_kind(), has_policy, has_merge_ops)?; if req.global { if !req.annotations.is_empty() { return Err(Status::invalid_argument( @@ -5879,6 +5894,27 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; + #[test] + fn mxc_rejects_sandbox_policy_replacement_and_merge_updates() { + for (has_policy, has_merge_ops) in [(true, false), (false, true)] { + let error = validate_live_policy_update_support( + Some(openshell_core::ComputeDriverKind::Mxc), + has_policy, + has_merge_ops, + ) + .expect_err("MXC must reject policy mutations after launch"); + assert_eq!(error.code(), Code::FailedPrecondition); + } + + let error = validate_live_policy_update_support( + Some(openshell_core::ComputeDriverKind::Mxc), + true, + false, + ) + .expect_err("global policy replacement also changes desired state for live MXC sandboxes"); + assert_eq!(error.code(), Code::FailedPrecondition); + } + /// Wrap a request with a user `Principal` so handler scope guards treat /// the test caller as a CLI user. Most handler tests exercise /// user-facing behavior and should not trip sandbox equality checks. diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 0547714809..ca15500708 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -504,6 +504,9 @@ pub(crate) async fn run_server( (None, None) }; + // Preserve the structured config-file error until this boundary so the + // diagnostic retains its path and TOML source details. + #[allow(clippy::result_large_err)] let middleware_registrations = config_file .as_ref() .map(|file| { @@ -598,6 +601,8 @@ pub(crate) async fn run_server( shutdown_rx.clone(), ) .await?; + #[cfg(target_os = "windows")] + let _ = &operator_allowlist; let gateway_interceptors = if let Some(issuer) = sandbox_jwt_issuer.as_ref() { let mut slots = BTreeMap::new(); for interceptor in &config.gateway_interceptors { @@ -1076,7 +1081,10 @@ fn unsupported_builtin_compute_driver(driver: ComputeDriverKind) -> compute::Com )) } +#[cfg(not(target_os = "windows"))] type OperatorAllowlistArc = Option; +#[cfg(target_os = "windows")] +type OperatorAllowlistArc = Option<()>; pub use compute::{DriverWatchStream, SharedComputeDriver}; /// Opaque result returned by a compiled compute-driver factory. @@ -1290,18 +1298,26 @@ pub fn install_default_compute_drivers() -> ComputeDriverRegistry { .expect("unique vm registration"); } #[cfg(target_os = "windows")] - for name in ["kubernetes", "podman", "docker", "vm"] { + { registry .install( - ComputeDriverRegistration::new( - name, - u16::MAX, - None, - UnsupportedComputeDriverFactory, - ) - .expect("valid unsupported registration"), + ComputeDriverRegistration::new("mxc", u16::MAX, None, MxcComputeDriverFactory) + .expect("valid mxc registration"), ) - .expect("unique unsupported registration"); + .expect("unique mxc registration"); + for name in ["kubernetes", "podman", "docker", "vm"] { + registry + .install( + ComputeDriverRegistration::new( + name, + u16::MAX, + None, + UnsupportedComputeDriverFactory, + ) + .expect("valid unsupported registration"), + ) + .expect("unique unsupported registration"); + } } registry } @@ -1384,6 +1400,35 @@ impl ComputeDriverBuildContext<'_> { } } +#[cfg(target_os = "windows")] +#[derive(Clone, Copy)] +struct MxcComputeDriverFactory; + +#[cfg(target_os = "windows")] +#[async_trait::async_trait] +impl ComputeDriverFactory for MxcComputeDriverFactory { + async fn build( + &self, + context: ComputeDriverBuildContext<'_>, + ) -> Result { + let mxc_config = compute::driver_config::mxc_config_from_context(context.driver_startup)?; + let runtime = ComputeRuntime::new_mxc( + mxc_config, + context.store, + context.sandbox_index, + context.sandbox_watch_bus, + context.tracing_log_bus, + context.supervisor_sessions, + ) + .await + .map_err(|error| Error::execution(format!("failed to create compute runtime: {error}")))?; + Ok(ComputeDriverBuildOutput { + runtime, + operator_allowlist: None, + }) + } +} + #[cfg(target_os = "windows")] #[derive(Clone, Copy)] struct UnsupportedComputeDriverFactory; @@ -1649,6 +1694,7 @@ fn resolve_configured_compute_driver( Ok(ConfiguredComputeDriver::Remote { name }) } +#[cfg(not(target_os = "windows"))] fn kubernetes_sandbox_jwt_expiry_disabled(config: &Config) -> bool { config .gateway_jwt @@ -1656,6 +1702,7 @@ fn kubernetes_sandbox_jwt_expiry_disabled(config: &Config) -> bool { .is_some_and(|jwt| jwt.ttl_secs == 0) } +#[cfg(not(target_os = "windows"))] fn warn_if_kubernetes_sandbox_jwt_expiry_disabled(config: &Config) { if kubernetes_sandbox_jwt_expiry_disabled(config) { warn!( @@ -1729,8 +1776,7 @@ mod tests { BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, ExtensionKind, GatewayListenerScope, MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, bind_gateway_listeners, classify_initial_bytes, - is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, - mint_gateway_extension_credential, serve_gateway_listener, + is_benign_tls_handshake_failure, mint_gateway_extension_credential, serve_gateway_listener, }; use openshell_core::{ ComputeDriverKind, Config, @@ -2364,6 +2410,22 @@ mod tests { )); } + #[cfg(target_os = "windows")] + #[test] + fn configured_compute_driver_accepts_mxc() { + let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Mxc]); + let driver = select_compute_driver( + &test_compute_drivers(), + &config, + test_driver_startup(&config, None), + ) + .unwrap(); + assert!(matches!( + driver, + ConfiguredComputeDriver::Registered(registration) if registration.name == "mxc" + )); + } + #[test] fn configured_compute_driver_resolves_named_remote() { let config = Config::new(None).with_compute_drivers(["kyma"]); @@ -2424,6 +2486,7 @@ mod tests { )); } + #[cfg(not(target_os = "windows"))] #[test] fn kubernetes_sandbox_jwt_expiry_disabled_warns_for_zero_ttl() { fn config_with_jwt_ttl(ttl_secs: u64) -> Config { diff --git a/crates/openshell-server/src/otel_tracing.rs b/crates/openshell-server/src/otel_tracing.rs index d29f3b77f8..726fe62aa9 100644 --- a/crates/openshell-server/src/otel_tracing.rs +++ b/crates/openshell-server/src/otel_tracing.rs @@ -90,6 +90,7 @@ pub fn provider_for(cfg: Option<&OtlpConfig>) -> (Option, Opt /// /// Events stay on the gateway's logging layers. Spans emitted by the /// OpenTelemetry crates are excluded to prevent recursive export traffic. +#[cfg(not(target_os = "windows"))] pub fn layer(provider: &SdkTracerProvider) -> openshell_otel::TargetOtlpLayer where S: Subscriber + for<'span> LookupSpan<'span>, @@ -101,6 +102,14 @@ where ) } +#[cfg(target_os = "windows")] +pub fn layer(provider: &SdkTracerProvider) -> openshell_otel::OtlpLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + openshell_otel::layer(provider, INSTRUMENTATION_SCOPE) +} + /// Isolated in-memory span exporters for tracing tests. #[cfg(test)] pub mod test_exporter { diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index 41b4e619ca..c269e41e00 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -7,6 +7,7 @@ //! `OpenShell` product telemetry collected for maintainers is handled by //! [`crate::telemetry`]. +use openshell_ocsf::OcsfJsonlLayer; use opentelemetry_sdk::trace::SdkTracerProvider; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; @@ -51,17 +52,28 @@ pub fn install( enable_podman_export: bool, ) -> (TracingHandle, Option) { let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config); - let podman_endpoint = enable_podman_export - .then_some(otlp_config) - .flatten() - .map(|config| config.endpoint.as_str()); - let (podman_tracer_provider, podman_setup_error) = - openshell_driver_podman::otel_tracing::provider_for(podman_endpoint); + let (jsonl_layer, jsonl_dir) = build_ocsf_jsonl_layer(); + #[cfg(not(target_os = "windows"))] + let (podman_tracer_provider, podman_setup_error) = { + let podman_endpoint = enable_podman_export + .then_some(otlp_config) + .flatten() + .map(|config| config.endpoint.as_str()); + openshell_driver_podman::otel_tracing::provider_for(podman_endpoint) + }; + #[cfg(target_os = "windows")] + let (podman_tracer_provider, podman_setup_error) = { + let _ = enable_podman_export; + (None::, None::) + }; + + #[cfg(not(target_os = "windows"))] tracing_subscriber::registry() .with(env_filter) .with(tracing_subscriber::fmt::layer()) .with(tracing_log_bus.layer()) + .with(jsonl_layer) .with(tracer_provider.as_ref().map(crate::otel_tracing::layer)) .with( podman_tracer_provider @@ -70,6 +82,27 @@ pub fn install( ) .init(); + #[cfg(target_os = "windows")] + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer()) + .with(tracing_log_bus.layer()) + .with(jsonl_layer) + .with(tracer_provider.as_ref().map(crate::otel_tracing::layer)) + .init(); + + match jsonl_dir { + Some(dir) => tracing::info!( + target: "openshell_server", + ocsf_jsonl_dir = %dir.display(), + "OCSF JSONL audit log enabled (openshell-ocsf..log, daily rotation, keep 3)" + ), + None => tracing::debug!( + target: "openshell_server", + "OCSF JSONL audit log disabled" + ), + } + ( TracingHandle { tracer_provider, @@ -79,6 +112,84 @@ pub fn install( ) } +/// Build the OCSF JSONL audit layer for the gateway, plus the directory it +/// writes into (for a one-line startup log). Returns `(None, None)` when +/// disabled via `OPENSHELL_OCSF_JSON` or when the target directory/appender +/// cannot be opened. +/// +/// The appender is *synchronous* (not wrapped in `tracing_appender::non_blocking`) +/// so each event is written straight through to the OS on emit. This trades a +/// little throughput for durability: unlike the sandbox supervisor (which flushes +/// its non-blocking guard on graceful shutdown), the gateway's ETW capture path +/// can be force-killed by the harness, and we do not want to lose the tail of the +/// audit trail. +fn build_ocsf_jsonl_layer() -> ( + Option>, + Option, +) { + let disabled = std::env::var("OPENSHELL_OCSF_JSON") + .map(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ) + }) + .unwrap_or(false); + if disabled { + return (None, None); + } + + let dir = ocsf_log_dir(); + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!( + "openshell: could not create OCSF JSONL log dir {}: {e}", + dir.display() + ); + return (None, None); + } + + match tracing_appender::rolling::RollingFileAppender::builder() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix("openshell-ocsf") + .filename_suffix("log") + .max_log_files(3) + .build(&dir) + { + Ok(roller) => (Some(OcsfJsonlLayer::new(roller)), Some(dir)), + Err(e) => { + eprintln!( + "openshell: could not open OCSF JSONL appender in {}: {e}", + dir.display() + ); + (None, None) + } + } +} + +/// Resolve the directory for the OCSF JSONL audit file. +/// +/// Precedence: `OPENSHELL_OCSF_LOG_DIR` (harness / operator override) → +/// `%PROGRAMDATA%\OpenShell\logs` on Windows → `/var/log` elsewhere. +fn ocsf_log_dir() -> std::path::PathBuf { + if let Ok(dir) = std::env::var("OPENSHELL_OCSF_LOG_DIR") { + let trimmed = dir.trim(); + if !trimmed.is_empty() { + return std::path::PathBuf::from(trimmed); + } + } + #[cfg(target_os = "windows")] + { + if let Ok(pd) = std::env::var("ProgramData") { + return std::path::PathBuf::from(pd).join("OpenShell").join("logs"); + } + std::env::temp_dir().join("openshell").join("logs") + } + #[cfg(not(target_os = "windows"))] + { + std::path::PathBuf::from("/var/log") + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/about/how-it-works.mdx b/docs/about/how-it-works.mdx index 5223072a28..bc3bf1ddfb 100644 --- a/docs/about/how-it-works.mdx +++ b/docs/about/how-it-works.mdx @@ -68,6 +68,10 @@ flowchart TB ROUTER -->|"managed inference"| MODEL["Inference backends"] ``` + +The Windows MXC compute driver is an exec-in-driver exception to this supervisor data path. It launches and monitors a one-shot workload without a supervisor session, interactive connect, live policy delivery, or governed egress. + + ## Deployment Models OpenShell can run on a single local machine or in a remote Kubernetes cluster. @@ -98,7 +102,7 @@ device plugins without changing the gateway and sandbox contract. The gateway and sandbox split control-plane authority from runtime enforcement. The gateway owns durable platform state: sandboxes, policy revisions, runtime settings, provider records, inference configuration, session records, and authorization decisions. A sandbox owns the local execution boundary: process identity, filesystem access, network egress, credential injection, local logs, and the agent child process. -The relationship is supervisor initiated. Each sandbox supervisor connects outbound to a known gateway endpoint, authenticates as a sandbox workload, and keeps a live session open for control traffic and relays. This avoids requiring every compute driver to solve gateway-to-sandbox reachability through pod IPs, bridge networks, port mappings, NAT traversal, or custom tunnels. +For supervisor-backed drivers, the relationship is supervisor initiated. Each sandbox supervisor connects outbound to a known gateway endpoint, authenticates as a sandbox workload, and keeps a live session open for control traffic and relays. This avoids requiring those compute drivers to solve gateway-to-sandbox reachability through pod IPs, bridge networks, port mappings, NAT traversal, or custom tunnels. The Windows MXC driver has no supervisor session and supports only its documented create-time workload lifecycle. The gateway delivers desired state. The supervisor applies it locally, keeps last-known-good config when refresh fails, and leaves static isolation controls in place until the sandbox is recreated. Live operations such as config refresh, policy updates, credential delivery, log push, connect, exec, file sync, and relay setup use the same authenticated gateway-supervisor relationship. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index cefae0b5cb..a1ff7df092 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -553,6 +553,32 @@ In managed workspace mode, the Kubernetes driver copies each explicitly named namespace on sandbox creation. Shared and operator modes require the Secret to already exist in the sandbox namespace. +### Windows MXC + +The native Windows gateway links the MXC compute driver in-process. Gateway configuration contains host runtime settings only; workload command, working directory, and environment are sandbox-scoped. + +```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 is the default and enforces default-deny filesystem access. +# isolation_session is an explicit grant-only compatibility mode. +backend = "process_container" +pc_least_privilege = false +pc_capabilities = [] +default_configuration_id = "composable" +debug = false +``` + +Unknown MXC fields are rejected. Network policies and live policy mutations fail closed until the driver has a bound enforcement path. See [Sandbox Compute Drivers](sandbox-compute-drivers.mdx#windows-mxc-driver) for per-sandbox workload configuration. + ### Docker Sandboxes run as containers on a local bridge network. The supervisor binary is bind-mounted from the host (no in-cluster image pull required); guest mTLS material is supplied as host paths. diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 656ae43bb6..22845c3eeb 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -3,20 +3,22 @@ # SPDX-License-Identifier: Apache-2.0 title: "Sandbox Compute Drivers" sidebar-title: "Compute Drivers" -description: "Reference for Docker, Podman, MicroVM, and Kubernetes sandbox compute drivers." -keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Docker, Podman, MicroVM, Kubernetes, Reference" +description: "Reference for Docker, Podman, MicroVM, Kubernetes, and Windows MXC sandbox compute drivers." +keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Docker, Podman, MicroVM, Kubernetes, Windows, MXC, Reference" position: 4 --- -The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, stop, start, and delete sandboxes through the gateway API. +The gateway's configured compute driver determines how OpenShell creates each sandbox. Docker, Podman, MicroVM, and Kubernetes support the full create, connect, inspect, stop, start, and delete workflow. Windows MXC supports create, inspect, stop, and delete, but its one-shot exec-in-driver model does not expose an interactive connection or restart stopped compute. -Every compute driver runs the OpenShell supervisor inside the sandbox workload. The supervisor launches the agent process, applies policy, routes egress through the proxy, injects configured credentials, and maintains the gateway session. +Docker, Podman, Kubernetes, and MicroVM drivers run the OpenShell supervisor inside the sandbox workload. The Windows MXC driver is a one-shot, exec-in-driver integration without a supervisor or interactive gateway session. Stop stops compute but retains the sandbox record and the driver's -persistent workspace boundary. Start reactivates the same driver resource. -Delete remains independent and removes compute plus driver-owned persistent -state. While a sandbox is stopped, gateway access paths and exposed services -remain unavailable. +persistent workspace boundary. For Docker, Podman, Kubernetes, and MicroVM, +Start reactivates the same driver resource. Windows MXC does not support Start; +delete and recreate a stopped MXC sandbox to run another workload. Delete +remains independent and removes compute plus driver-owned persistent state. +While a sandbox is stopped, gateway access paths and exposed services remain +unavailable. Restarting the gateway preserves this intent. The gateway does not stop Docker or Podman containers during shutdown. At startup it sends idempotent start @@ -40,7 +42,7 @@ Configure the compute driver on the gateway. Current releases accept one driver compute_drivers = ["docker"] ``` -Reserved built-in values are `docker`, `podman`, `kubernetes`, and `vm`. +Reserved built-in values are `docker`, `podman`, `kubernetes`, `vm`, and `mxc`. Non-reserved names select an extension driver and require a `socket_path` in `[openshell.drivers.]`. @@ -50,7 +52,7 @@ Common gateway options: | Gateway TOML option | Description | |---|---| -| `compute_drivers = [""]` | Select the compute driver. Built-in values are `docker`, `podman`, `kubernetes`, and `vm`; custom names require `[openshell.drivers.].socket_path`. | +| `compute_drivers = [""]` | Select the compute driver. Built-in values are `docker`, `podman`, `kubernetes`, `vm`, and `mxc`; custom names require `[openshell.drivers.].socket_path`. | Set driver-specific values such as sandbox images, callback endpoints, network names, TLS material, and VM sizing in the gateway TOML file. See the [Gateway Configuration File](./gateway-config) reference for the full `[openshell.drivers.]` schema. @@ -138,6 +140,33 @@ HTTP requests. A `PermissionDenied` response from an additional callback-only listener is expected for those requests. Do not broaden the primary listener to `0.0.0.0` solely to make sandbox callbacks reachable. +## Windows MXC Driver + +The MXC driver runs in-process in a native Windows gateway and invokes `wxc-exec.exe`. Configure the gateway runtime separately from each sandbox workload: + +```toml +[openshell.gateway] +compute_drivers = ["mxc"] + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +backend = "process_container" +``` + +`process_container` is the default because Windows AppContainer enforces default-deny filesystem access. `isolation_session` is an explicit compatibility mode that grants configured paths but cannot deny access to omitted paths. + +Supply the workload command and optional working directory in per-sandbox driver config: + +```powershell +$config = '{"mxc":{"command":["cmd","/c","echo hello > C:\\\\work\\\\demo\\\\hello.txt"],"cwd":"C:\\\\work\\\\demo"}}' +openshell sandbox create --name mxc-demo --policy demo.yaml ` + --driver-config-json $config --env MODE=demo --no-tty +``` + +The command array preserves Windows argument boundaries. The sandbox policy is the only source of filesystem grants. MXC rejects network policies and live policy replacement or merge updates; delete and recreate the sandbox to change policy. + +MXC does not provide the supervisor, SSH, interactive exec, port forwarding, provider credential refresh, or governed egress. Stop and delete terminate and reap the workload process before reporting success. + ## Docker Driver [Docker](https://www.docker.com/get-started/)-backed sandboxes run as containers on the gateway host. Use Docker for local development, single-machine gateways, and hosts that already use Docker Desktop or Docker Engine. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index ead6533294..00c6aa9a76 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -517,9 +517,9 @@ restart that process. ## Sandbox Compute Drivers -The gateway's configured compute driver determines how OpenShell creates each sandbox. The CLI workflow stays the same across drivers: you create, connect to, inspect, and delete sandboxes through the gateway API. +The gateway's configured compute driver determines how OpenShell creates each sandbox. Docker, Podman, MicroVM, and Kubernetes support create, connect, inspect, and delete through the gateway. Windows MXC supports create, inspect, stop, and delete, but its one-shot exec-in-driver model does not expose an interactive connection. -For Docker, Podman, MicroVM, and Kubernetes behavior, refer to [Sandbox Compute Drivers](/reference/sandbox-compute-drivers). +For Docker, Podman, MicroVM, Kubernetes, and Windows MXC behavior, refer to [Sandbox Compute Drivers](/reference/sandbox-compute-drivers). ## Next Steps diff --git a/tasks/typescript.toml b/tasks/typescript.toml index 823a881359..b8364eac81 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -24,7 +24,7 @@ run = "npm run gen" ["proto:lint"] description = "Lint proto/ with buf (repo-level buf.yaml)" depends = ["sdk:ts:install"] -run = "./sdk/typescript/node_modules/.bin/buf lint" +run = "npm --prefix sdk/typescript exec -- buf lint" ["sdk:ts:typecheck"] description = "Type-check the TypeScript SDK" diff --git a/tasks/windows.toml b/tasks/windows.toml index c7a2e51f99..59c2a8f2c2 100644 --- a/tasks/windows.toml +++ b/tasks/windows.toml @@ -63,3 +63,18 @@ run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts description = "Run Windows MSVC checks, release builds, x64 tests, and unsupported-driver contract tests" 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)" +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" + +["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" +run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1" + +["windows:e2e:mxc:mock"] +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"