diff --git a/Cargo.lock b/Cargo.lock index 011730760d..3771bf0186 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4029,7 +4029,9 @@ dependencies = [ "base64 0.22.1", "futures", "openshell-core", + "openshell-ocsf", "openshell-policy", + "openshell-supervisor-network", "serde", "serde_json", "serde_yml", @@ -4040,6 +4042,7 @@ dependencies = [ "tonic", "tracing", "uuid", + "windows", ] [[package]] @@ -4415,6 +4418,7 @@ dependencies = [ "tower 0.5.3", "tower-http 0.6.8", "tracing", + "tracing-appender", "tracing-opentelemetry", "tracing-subscriber", "url", @@ -5629,6 +5633,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", + "serde_yaml", "spin", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 57b77d0716..b2593b2484 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/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 2f82e50e3b..9da4819cbe 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -15,6 +15,10 @@ 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 } @@ -26,6 +30,11 @@ tracing = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } +# ETW/TDH real-time consumer and host CONNECT proxy integration. +[target.'cfg(target_os = "windows")'.dependencies] +openshell-supervisor-network = { path = "../openshell-supervisor-network" } +windows = { workspace = true } + [dev-dependencies] tokio = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 6496dfe286..c7f3221bb0 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -13,13 +13,16 @@ 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. | +| Capability | MXC driver | Closing it requires | +|---|---|---| +| Filesystem policy (read-write / read-only grants) | ✅ provision-time AppContainer shares | — | +| Governed egress (CONNECT proxy + OPA + L7) | Available behind `egress_proxy` on `process_container`; the driver starts a per-sandbox host CONNECT proxy, generates HTTPS MITM trust material, and injects the CA bundle into the sandbox process env | Gateway event-bus wiring follow-on | +| Network policy | Split into MXC `network.proxy` + trimmed OpenShell policy on `process_container`; `isolation_session` still rejects network config | MXC feedback item M1 for persistent sessions | +| Process policy (seccomp, uid/gid) | ❌ host-side governance design; OS isolation only | not pursued | +| Interactive exec/connect/forward | ❌ exec runs in-driver, no client attach | gateway interactive-exec surgery (follow-on) | +| Bundled agent image | ❌ no OCI image; relies on Windows host install | — | +| Restart durability | ❌ in-memory registry; restart orphans live sessions | follow-on | +| Concurrent sandboxes | ⚠️ isolation_session v1 is single-session | MXC backend feature | The filesystem enforcement proof has two paths: @@ -38,9 +41,17 @@ backend = "process_container" default_configuration_id = "composable" pc_least_privilege = false pc_capabilities = [] +# Pattern-C governed egress. The address is a loopback seed; each sandbox +# receives a unique ephemeral proxy port. +egress_proxy = false +egress_proxy_addr = "" debug = false ``` +When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback +`IP:PORT` seed. The driver preserves the configured IP and allocates a unique +ephemeral port for each sandbox's `network.proxy` redirect. + 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 @@ -51,7 +62,7 @@ openshell sandbox create --name mxc-demo --policy demo.yaml ` 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. +The host CONNECT proxy enforces network policy when governed egress is enabled. Live policy replacement or merge updates remain unsupported; delete and recreate the sandbox to apply a different policy. ## Prerequisites (live runs) @@ -66,9 +77,25 @@ no isolation session needed), set `OPENSHELL_MXC_MOCK_WXC=1`. 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. +When `egress_proxy` is enabled, `EmbeddedPolicyMapper` uses `split_policy` +instead: MXC receives filesystem grants plus a loopback `network.proxy` +redirect, and the driver starts a host CONNECT proxy from the trimmed +network-only `SandboxPolicy`. The proxy uses the configured agent command as +the static sandbox process identity because MXC does not expose Linux-style +procfs socket ownership. For HTTPS L7 inspection, the host proxy generates a +per-sandbox CA and injects `NODE_EXTRA_CA_CERTS`, `DENO_CERT`, `SSL_CERT_FILE`, +`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO` into the agent +process env. It does not add the generated CA directory to MXC read-only grants: +released `wxc-exec` BaseContainer builds require `WRITE_DAC` on every such +grant and reject the user-owned proxy temp directory. The driver seeds only +`SYSTEMROOT`, `WINDIR`, `PATH`, `COMSPEC`, and `LOCALAPPDATA` from the gateway +host before applying sandbox and TLS overrides, so required Windows bootstrap +values remain available without exposing the gateway's full environment. The +development export surface remains the +[`policy-to-mxc`](examples/policy-to-mxc.rs) example; there is no production +`openshell policy export-mxc` subcommand yet. + +If governed egress is disabled, any network rule fails closed rather than launching without an enforcement path. Parity and matrix tests under [`tests/`](tests/) cover the mapper on the Windows MSVC lane. The driver performs this mapping automatically; there is no separate policy-export command or example. @@ -98,6 +125,10 @@ exits 0 rather than failing. and a `verdicts` object). Run it before the real-MXC lane to understand what will PASS vs SKIP on a given host: +The probe uses a unique, user-owned Windows temp directory for every run. +MXC treats config paths literally (it does not expand `%TEMP%`), and the +per-run directory keeps AppContainer+DACL fallback mutations narrowly scoped. + ```powershell powershell -NoProfile -ExecutionPolicy Bypass ` -File crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -112,7 +143,6 @@ 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. +- **Interactive exec/connect/forward** — gateway interactive-exec surgery (follow-on) - **Restart durability** (deprovision orphaned sessions on startup) → follow-on - **GPU passthrough** → not pursued in host-side-governance design 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/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 index 4a20e7b86c..343e88e88d 100644 --- a/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 +++ b/crates/openshell-driver-mxc/examples/probe-mxc-host.ps1 @@ -119,6 +119,10 @@ $pcTrialMessage = "wxc-exec not found" $isoTrialResult = "absent" $isoTrialMessage = "wxc-exec not found" +$probeTempPath = Join-Path ([System.IO.Path]::GetTempPath()) ("openshell-mxc-probe-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $probeTempPath | Out-Null + +try { if ($wxcInfo.exists) { # --probe $probeResult = Invoke-WxcProbe -wxc $WxcExecPath @@ -131,11 +135,11 @@ if ($wxcInfo.exists) { containment = "processcontainer" process = @{ commandLine = "cmd /c exit 0" - cwd = "%TEMP%" + cwd = $probeTempPath timeout = 0 } filesystem = @{ - readwritePaths = @("%TEMP%") + readwritePaths = @($probeTempPath) } } $dryResult = Invoke-WxcDryRun -wxc $WxcExecPath -config $dryConfig @@ -149,15 +153,22 @@ if ($wxcInfo.exists) { containment = "processcontainer" process = @{ commandLine = "cmd /c exit 0" - cwd = "%TEMP%" - timeout = 10 + cwd = $probeTempPath + timeout = 30000 } filesystem = @{ - readwritePaths = @("%TEMP%") + readwritePaths = @($probeTempPath) } processContainer = @{ leastPrivilege = $false } + # cmd.exe needs the Win32k calls represented by disable = false. + # Filesystem and network restrictions remain default-deny. + ui = @{ + disable = $false + clipboard = "none" + injection = $false + } } $pcResult = Invoke-WxcPhase -wxc $WxcExecPath -config $pcConfig $pcOutput = $pcResult.Output @@ -260,6 +271,9 @@ if ($wxcInfo.exists) { $isoTrialMessage = "exit $($isoResult.ExitCode): $isoOutput" } } +} finally { + Remove-Item -LiteralPath $probeTempPath -Recurse -Force -ErrorAction SilentlyContinue +} # ── Verdicts ────────────────────────────────────────────────────────────────── diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 9f8baa3446..562b34737a 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -1,43 +1,45 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# 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. +# Runs a table of policy scenarios against the OpenShell MXC driver, emits +# per-scenario PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero +# only on FAIL. # -# PowerShell 5.1-compatible (no && / || / ternary operators). +# Every run collects its logs into a timestamped results-e2e-\ folder and +# zips it (mirrors the sibling run-*.ps1 scripts). The bundle contains the console +# transcript, the per-scenario gateway stdout/stderr, the exact TOML rendered for +# each scenario, the policy fixture used, and a summary.txt with the verdict table. # -# Usage examples: +# The gateway restarts per scenario to keep logs and the in-memory database +# isolated. Workload command/cwd are supplied per sandbox through +# --driver-config-json; they are never patched into gateway configuration. # -# # Real mode (probe-gated — backends that are absent are SKIPped): -# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 +# Scoring (why we do NOT gate on `sandbox create` exit code): +# The ground truth is the on-disk artifact, so positive scenarios pass on +# artifact PRESENT and deny scenarios pass on the denied write being ABSENT. +# A CONTROL write proves the agent ran when the policy grants a writable path; +# an empty policy instead requires explicit driver-launch evidence. # -# # Mock mode (wiring-only; no real wxc-exec or enforcement): -# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -Mock +# PowerShell 5.1-compatible (no && / || / ternary operators). ASCII only. # -# # Choose backend / filter scenarios: -# .\run-mxc-e2e.ps1 -Backend process_container -Scenario fs-rw +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-mxc-e2e.ps1 -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe +# .\run-mxc-e2e.ps1 -Mock # wiring-only, no real backend +# .\run-mxc-e2e.ps1 -Scenario fs-rw # single scenario # # Scenarios & expected verdicts: -# 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. +# fs-rw - in-policy write to DemoDir succeeds. +# fs-readonly - write to read-only dir is denied; control write succeeds. +# fs-default-deny - ungranted write is denied after the agent launches. +# processcontainer only. +# network-reject - network_policies rule makes sandbox create fail. [CmdletBinding()] param( [string] $DemoDir = "C:\work\openshell-mxc-e2e", - [string] $WxcExecPath = "C:\mxc\wxc-exec.exe", + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", [ValidateSet("isolation_session", "process_container")] [string] $Backend = "process_container", [string] $Scenario, @@ -50,8 +52,21 @@ param( $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } +# --- Results bundle ----------------------------------------------------------- +# Collect every log + the exact rendered config per scenario into a timestamped +# results\ folder, then zip it (mirrors the sibling run-*.ps1 scripts). Created up +# front so the transcript (started inside the guarded region below) captures the +# whole run, including pre-flight failures. +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-e2e-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +$transcriptStarted = $false + 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 } @@ -59,66 +74,208 @@ 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 ──────────────────────────────────────────────────────────────── +# Double backslashes so a Windows path is a valid TOML/JSON basic-string element. +function Esc([string]$p) { return $p.Replace('\', '\\') } + +# Build one CreateProcess-compatible command-line argument. Windows PowerShell +# 5.1 can split JSON values at embedded spaces when invoking native commands +# through the call operator, even when PowerShell holds the JSON as one string. +function Quote-NativeArgument([string]$value) { + if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { return $value } + + $quoted = New-Object System.Text.StringBuilder + [void]$quoted.Append('"') + $backslashes = 0 + foreach ($ch in $value.ToCharArray()) { + if ($ch -eq '\') { + $backslashes++ + continue + } + if ($ch -eq '"') { + [void]$quoted.Append(('\' * (2 * $backslashes + 1))) + [void]$quoted.Append('"') + } else { + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * $backslashes)) } + [void]$quoted.Append($ch) + } + $backslashes = 0 + } + if ($backslashes -gt 0) { [void]$quoted.Append(('\' * (2 * $backslashes))) } + [void]$quoted.Append('"') + return $quoted.ToString() +} -# 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." +function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $filePath + $startInfo.Arguments = (($argumentList | ForEach-Object { Quote-NativeArgument $_ }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "failed to start $filePath" } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + + return @{ + ExitCode = $process.ExitCode + Output = @($stdout.Result, $stderr.Result) | Where-Object { $_ } } } -$gateway = Join-Path $here "openshell-gateway.exe" -$cli = Join-Path $here "openshell.exe" -$toml = Join-Path $here "mxc-gateway.toml" +# --- Path variables ----------------------------------------------------------- + +$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." +$cmdExe = "C:\Windows\System32\cmd.exe" +$demoDirFwd = $DemoDir.Replace('\', '/') +$roSrc = "$DemoDir-ro-src" # matches e2e-policies/fs-readonly.yaml read_only path +$denyProbe = "$DemoDir-deny-probe" # ungranted, NOT the share: used to prove default-deny + +$script:registered = $false + +# Pristine TOML captured once inside the try (below); every scenario renders a +# fresh copy from it. Per-scenario gateway logs are assigned inside the loop so +# each scenario's stdout/stderr lands in its own file under $resultDir. +$tomlBase = $null +$gwLog = $null +$gwErrLog = $null + +# --- Helpers ------------------------------------------------------------------ + +# Render host-runtime settings from the pristine base. Sandbox workload +# settings are create-time driver config, not gateway-wide TOML. +function Render-Toml { + $t = $tomlBase + $t = [regex]::Replace($t, '(?m)^\s*#?\s*backend\s*=.*$', "backend = `"$Backend`"") + if (-not $Mock) { + $wxcLine = "wxc_exec_path = `"$(Esc $WxcExecPath)`"" + $t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine) + } + Set-Content $toml -Value $t -Encoding UTF8 +} + +function Start-Gw { + Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + # Ephemeral in-memory DB: this is a test harness, so it must NOT write sandbox + # records to the persistent default store (%LOCALAPPDATA%\openshell\gateway\ + # openshell.db). Without this, sandbox names persist across gateway restarts + # and across runs, colliding on `create` ("already exists") and leaving orphan + # records behind. In-memory means every gateway starts clean and leaves nothing. + # Config path goes through the env var (clap: OPENSHELL_GATEWAY_CONFIG), NOT a + # --config token: Start-Process -ArgumentList does not quote array elements, so a + # config path containing a space gets split and the gateway's arg parser rejects it. + $env:OPENSHELL_GATEWAY_CONFIG = $toml + $p = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--db-url", "sqlite::memory:", "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ($p.HasExited) { + Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($p.ExitCode)). See $gwLog." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { + return $p + } + Start-Sleep -Milliseconds 400 } + # Timed out but the process is still alive (never bound $Port). $gw is not yet + # assigned in the caller, so the finally block can't reap it - kill it here to + # avoid leaving an orphan gateway holding the port for the next run. + if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + throw "gateway did not start within 30 s." } -if (-not (Test-Path $policyDir)) { - throw "e2e-policies/ directory not found at $policyDir" + +function Stop-Gw($p) { + if ($p -and -not $p.HasExited) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Milliseconds 700 # let the listen socket release before the next start } -# ── Backend probe ───────────────────────────────────────────────────────────── +function Register-Cli { + if ($script:registered) { return } + $env:OPENSHELL_GATEWAY = "" -# 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" } + $addResult = Invoke-NativeCaptured $cli @( + "gateway", "add", "http://127.0.0.1:$Port", "--local", "--name", $GatewayName + ) + $addText = ($addResult.Output -join "`n") + if ($addText) { $addResult.Output | ForEach-Object { Info $_ } } + if ($addResult.ExitCode -ne 0 -and $addText -notmatch '(?i)already exists') { + throw "gateway add failed (exit $($addResult.ExitCode)): $addText" + } + + $selectResult = Invoke-NativeCaptured $cli @("gateway", "select", $GatewayName) + $selectText = ($selectResult.Output -join "`n") + if ($selectText) { $selectResult.Output | ForEach-Object { Info $_ } } + if ($selectResult.ExitCode -ne 0) { + throw "gateway select failed (exit $($selectResult.ExitCode)): $selectText" } - if (-not (Test-Path $wxc)) { - return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } + + $script:registered = $true +} + +function Wait-File([string]$path, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline -and -not (Test-Path $path)) { + Start-Sleep -Milliseconds 400 } + return (Test-Path $path) +} + +# Detect an agent *launch* failure (binary not found / not implemented) vs a +# legitimate policy denial. Used to avoid false-passing a deny scenario when the +# agent never actually ran. +function Launch-Failed([string]$gwText) { + if ($null -eq $gwText) { return $false } + return ($gwText -match 'CreateProcessW failed error:2' ` + -or $gwText -match 'error:2' ` + -or $gwText -match 'exited -1' ` + -or $gwText -match 'The system cannot find the file' ` + -or $gwText -match 'E_NOTIMPL' ` + -or $gwText -match 'velocity') +} + +function Launch-Succeeded([string]$gwText) { + if ($null -eq $gwText) { return $false } + return ($gwText -match 'MXC agent launched') +} + +# --- Backend probe ------------------------------------------------------------ + +function Probe-Backend([string] $backendName, [string] $wxc) { + if ($Mock) { return @{ Live = $true; Reason = "mock mode" } } + if (-not (Test-Path $wxc)) { return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } } if ($backendName -eq "process_container") { + # Use a REAL directory + absolute cmd.exe: the canonical wxc-exec passes + # cwd straight to CreateProcessW and does NOT expand %TEMP% (that yields + # 0x8007010B "directory name is invalid"). + $probeDir = Join-Path $env:TEMP "mxc-e2e-probe" + New-Item -ItemType Directory -Force $probeDir | Out-Null $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%") } + process = @{ commandLine = "C:\Windows\System32\cmd.exe /c exit 0"; cwd = $probeDir; timeout = 30000 } # ms (MXC process.timeout is milliseconds) + filesystem = @{ readwritePaths = @($probeDir) } processContainer = @{ leastPrivilege = $false } } - $json = $config | ConvertTo-Json -Depth 20 -Compress - $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) - $b64 = [Convert]::ToBase64String($bytes) + $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($config | ConvertTo-Json -Depth 20 -Compress))) $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" } - } + 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)" @@ -151,7 +308,7 @@ function Probe-Backend([string] $backendName, [string] $wxc) { if ($exitCode -ne 0) { return @{ Live = $false; Reason = "isolation_session probe failed: exit $exitCode" } } - # Provision succeeded — deprovision immediately. + # Provision succeeded - deprovision immediately. $sandboxId = $null try { $rawOut = ($outObj -join "`n") @@ -179,303 +336,390 @@ function Probe-Backend([string] $backendName, [string] $wxc) { return @{ Live = $false; Reason = "unknown backend: $backendName" } } -# ── Mode setup ──────────────────────────────────────────────────────────────── +# --- Run ---------------------------------------------------------------------- +# Everything that can throw runs inside this try so the finally always produces +# the results bundle (summary + transcript + zip), even on a pre-flight failure. -$mode = if ($Mock) { "MOCK" } else { "REAL" } -Step "Pre-flight (mode=$mode, backend=$Backend)" +$results = @() +$gw = $null +$harnessError = $null +$backendProbe = @{ Live = $false; Reason = "not probed" } +# Unique per-run suffix so a stale sandbox record from an earlier run can never +# collide with this run's `sandbox create` (the gateway persists names on disk). +# Sandbox names are limited to 19 characters, so keep the timestamp compact. +$runId = Get-Date -Format 'MMddHHmmss' -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." +try { + # Start the transcript inside the guarded region so a Start-Transcript failure + # is caught and the results bundle is still produced. Pre-flight runs + # immediately below, so the transcript still captures the whole run. + Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + $transcriptStarted = $true + + # --- Pre-flight ----------------------------------------------------------- + + if (-not $Mock) { + if ($env:OPENSHELL_MXC_MOCK_WXC -eq "1") { + throw "OPENSHELL_MXC_MOCK_WXC=1 is set but -Mock was not passed. " + + "Unset OPENSHELL_MXC_MOCK_WXC or pass -Mock." + } } - $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") + # -KeepRunning leaves the gateway up and breaks after the FIRST scenario (so the + # next one cannot collide on the port). A full-suite run would therefore execute + # only one scenario yet still report the suite as PASS. Require a single, + # explicitly-selected scenario so a partial run can never be mislabeled complete. + if ($KeepRunning -and -not $Scenario) { + throw "-KeepRunning requires -Scenario: it stops after the first scenario, so a full-suite run would report PASS on partial results. Re-run with e.g. -Scenario fs-rw-positive-negative -KeepRunning." } -} -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 ─────────────────────────────────────────────────────────── + 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" + } -Step "Prepare DemoDir $DemoDir" -New-Item -ItemType Directory -Force $DemoDir | Out-Null -Ok "DemoDir ready" + # Capture the pristine TOML once; every scenario renders a fresh copy from this. + $tomlBase = Get-Content $toml -Raw -$env:OPENSHELL_DRIVERS = "mxc" -$env:OPENSHELL_MXC_SHARE_DIR = $DemoDir + # --- Mode setup ----------------------------------------------------------- -# ── Start gateway ───────────────────────────────────────────────────────────── + Step "Pre-flight (mode=$(if ($Mock) {'MOCK'} else {'REAL'}), backend=$Backend)" -Step "Start gateway" -$gwLog = Join-Path $here "gateway.e2e.log" -$gwErrLog = "$gwLog.err" -Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue + 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" + } -$gw = Start-Process -FilePath $gateway ` - -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` - -WorkingDirectory $here -PassThru -NoNewWindow ` - -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + $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." + } -Info "gateway pid $($gw.Id); logs: $gwLog" + 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" -$results = @() + Step "Prepare DemoDir + read-only source + deny-probe dir" + New-Item -ItemType Directory -Force $DemoDir | Out-Null + New-Item -ItemType Directory -Force $roSrc | Out-Null + New-Item -ItemType Directory -Force $denyProbe | Out-Null + Set-Content -Path (Join-Path $roSrc "seed.txt") -Value "read-only seed" -Encoding UTF8 + Ok "DemoDir=$DemoDir roSrc=$roSrc denyProbe=$denyProbe" -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" + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_MXC_SHARE_DIR = $DemoDir - # 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 + # --- Scenario definitions ------------------------------------------------- + # Kind: positive | deny | create-fail + # For deny: ControlTarget (granted, must be PRESENT) + DenyTarget (must be ABSENT). $allScenarios = @( @{ - Name = "fs-rw" - PolicyFile = Join-Path $policyDir "fs-rw.yaml" - Backends = "both" - ExpectFail = $false - ExpectArtifact = $true + Name = "fs-rw"; PolicyFile = Join-Path $policyDir "fs-rw.yaml" + SandboxId = "rw" + Backends = "both"; Kind = "positive" + PosTarget = (Join-Path $DemoDir "fs-rw-result.txt") 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-readonly"; PolicyFile = Join-Path $policyDir "fs-readonly.yaml" + SandboxId = "ro" + Backends = "both"; Kind = "deny" + ControlTarget = (Join-Path $DemoDir "fs-readonly-control.txt") + DenyTarget = (Join-Path $roSrc "fs-readonly-denied.txt") + Description = "write to read-only dir denied; control write to rw dir succeeds" }, @{ - 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 = "fs-default-deny"; PolicyFile = Join-Path $policyDir "fs-empty.yaml" + SandboxId = "fd" + Backends = "process_container"; Kind = "deny" + DenyTarget = (Join-Path $denyProbe "fs-default-deny-denied.txt") + Description = "empty policy; ungranted write denied" }, @{ - Name = "network-reject" - PolicyFile = Join-Path $policyDir "network-reject.yaml" - Backends = "both" - ExpectFail = $true + Name = "network-reject"; PolicyFile = Join-Path $policyDir "network-reject.yaml" + SandboxId = "net" + Backends = "both"; Kind = "create-fail" Description = "network_policies rule causes sandbox create to fail (no live backend needed)" } ) - # Apply optional scenario filter. if ($Scenario) { - $filtered = $allScenarios | Where-Object { $_.Name -eq $Scenario } + # Wrap in @() so an exact single match stays an array: without it a lone + # match is a bare hashtable, its .Count is unreliable on PS 5.1, and + # $allScenarios would no longer be an array for the loop below. + $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)" + # --- Scenario loop -------------------------------------------------------- + + try { + foreach ($sc in $allScenarios) { + Step "Scenario: $($sc.Name)" + Info $sc.Description + + # Backend gate (deny/positive scenarios need a live backend; create-fail does not). + $skipReason = $null + if ($sc.Kind -ne "create-fail") { + $backendMatches = ($sc.Backends -eq "both") -or ($sc.Backends -eq $Backend) + if (-not $backendMatches) { + $skipReason = "scenario requires backend=$($sc.Backends); current backend=$Backend" + } elseif (-not $backendProbe.Live -and -not $Mock) { + $skipReason = "backend not live: $($backendProbe.Reason)" + } + } + if ($null -ne $skipReason) { + Skip "$($sc.Name): $skipReason" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "SKIP"; Reason = $skipReason } + continue } - } - - 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 + 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 } - } | 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" } + + # Per-scenario gateway logs land in the bundle under their own names. + $gwLog = Join-Path $resultDir "gateway.$($sc.Name).log" + $gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log" + + # Build the per-sandbox workload command and clean prior artifacts. + if ($sc.Kind -eq "positive") { + Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue + $command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))") + } elseif ($sc.Kind -eq "deny") { + Remove-Item $sc.DenyTarget -Force -ErrorAction SilentlyContinue + $denied = $sc.DenyTarget.Replace('\', '/') + if ($sc.ControlTarget) { + Remove-Item $sc.ControlTarget -Force -ErrorAction SilentlyContinue + $control = $sc.ControlTarget.Replace('\', '/') + $command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied") + } else { + $command = @($cmdExe, "/c", "echo denied 1> $denied") + } } else { - Bad "$($sc.Name): create succeeded but should have failed" - Info "output: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } + $command = @($cmdExe, "/c", "exit 0") } - } 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 - } + + $driverConfig = @{ + mxc = @{ + command = $command + cwd = $demoDirFwd } - $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" } + } | ConvertTo-Json -Compress -Depth 4 + + Render-Toml + # Preserve the exact rendered config + policy fixture used for this scenario. + Copy-Item $toml (Join-Path $resultDir "mxc-gateway.$($sc.Name).toml") -Force -ErrorAction SilentlyContinue + Copy-Item $sc.PolicyFile (Join-Path $resultDir "policy.$($sc.Name).yaml") -Force -ErrorAction SilentlyContinue + + $gw = Start-Gw + Info "gateway pid $($gw.Id)" + Register-Cli + + # Unique per-run sandbox name within the 19-character routable-name limit. + $sandboxName = "mxc-$($sc.SandboxId)-$runId" + try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} + + # Run sandbox create. Its exit status is only authoritative for the + # create-fail scenario; artifacts score workload scenarios. + $createOut = $null; $createExitCode = 0 + try { + $createResult = Invoke-NativeCaptured $cli @( + "sandbox", "create", "--name", $sandboxName, + "--policy", [string]$sc.PolicyFile, + "--driver-config-json", $driverConfig, + "--no-tty" + ) + $createOut = $createResult.Output + $createExitCode = $createResult.ExitCode + } catch { + $createOut = $_.Exception.Message; $createExitCode = 1 + } + $createOutStr = ($createOut -join "`n") + Info "create exit: $createExitCode (not used for scoring on non-create-fail scenarios)" + + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + + # Evaluate. + if ($sc.Kind -eq "create-fail") { + # A non-zero exit alone is NOT sufficient: gateway-registration, + # transport, or malformed-fixture errors also exit non-zero and would + # false-pass this scenario. Require a genuine policy-rejection signal + # (the driver rejects the network rule with invalid_argument naming + # network_policies) AND confirm it is not an infrastructure failure. + $rejected = ($createOutStr -match '(?i)network' ` + -or $createOutStr -match '(?i)invalid[_ -]?argument' ` + -or $createOutStr -match '(?i)policy' ` + -or $gwText -match '(?i)network_policies') + $infraFail = ($createOutStr -match '(?i)connection refused' ` + -or $createOutStr -match '(?i)not registered' ` + -or $createOutStr -match '(?i)transport error' ` + -or $createOutStr -match '(?i)failed to connect') + if ($createExitCode -ne 0 -and $rejected -and -not $infraFail) { + Ok "$($sc.Name): create correctly rejected by policy (exit $createExitCode)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "policy rejection" } + } elseif ($createExitCode -ne 0) { + Bad "$($sc.Name): create failed but not with a policy-rejection signal (possible harness/infra error)" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "non-rejection failure" } } else { - Bad "$($sc.Name): 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)" } + Bad "$($sc.Name): create succeeded but should have failed" + Info "output: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create succeeded unexpectedly" } } - } 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" } + } elseif ($sc.Kind -eq "positive") { + $present = Wait-File $sc.PosTarget 30 + if ($present) { + Ok "$($sc.Name): in-policy write produced artifact" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "artifact present" } } else { - Bad "$($sc.Name): FAIL (create=$createExitCode, artifact=$(Test-Path $target))" + Bad "$($sc.Name): artifact absent ($($sc.PosTarget))" Info "createOut: $createOutStr" - $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "create=$createExitCode, artifact=$(Test-Path $target)" } + if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure (not a policy result)" } + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "artifact absent" } + } + } else { + # deny + if ($sc.ControlTarget) { + $controlPresent = Wait-File $sc.ControlTarget 30 + # Snapshot the deny target only AFTER the control artifact lands, so a + # late denied write (enforcement regression racing the control write) + # cannot be recorded as PASS. + $denyPresent = Test-Path $sc.DenyTarget + if ($controlPresent -and -not $denyPresent) { + Ok "$($sc.Name): control write succeeded; denied write correctly blocked" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "control present, deny absent" } + } elseif (-not $controlPresent) { + Bad "$($sc.Name): control write absent - agent did not run correctly (inconclusive denial)" + Info "createOut: $createOutStr" + if (Launch-Failed $gwText) { Info "gateway log shows an agent-launch failure" } + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "control absent (agent did not run)" } + } else { + Bad "$($sc.Name): denied write was NOT blocked (artifact present)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } + } + } else { + # An empty policy has no writable control path. Require both an absent + # artifact and an explicit driver launch message so launch failures + # cannot false-pass the denial. + Start-Sleep -Seconds 3 + $denyPresent = Test-Path $sc.DenyTarget + $gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n" + if ($denyPresent) { + Bad "$($sc.Name): denied write was NOT blocked (artifact present)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "deny target present (not enforced)" } + } elseif (Launch-Failed $gwText) { + Bad "$($sc.Name): artifact absent but agent failed to launch - inconclusive" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "agent launch failed (inconclusive)" } + } elseif (-not (Launch-Succeeded $gwText)) { + Bad "$($sc.Name): artifact absent but no agent-launch evidence was recorded - inconclusive" + Info "createOut: $createOutStr" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "agent launch not confirmed (inconclusive)" } + } else { + Ok "$($sc.Name): write correctly denied (artifact absent, agent launched)" + $results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "deny absent (default-deny enforced)" } + } } } - } - } -} 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)" + if ($KeepRunning) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stopping after the first scenario so the next one doesn't collide on port $Port" + break + } else { + # Keep the sandbox alive until artifact-based scoring finishes. + # Real MXC startup is asynchronous and can otherwise be canceled + # before the workload writes its positive/control proof. + try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {} + Stop-Gw $gw + $gw = $null + } + } + } finally { + if ($gw -and -not $KeepRunning) { Stop-Gw $gw } + if ($KeepRunning -and $gw) { Info "gateway pid $($gw.Id) left running (-KeepRunning)" } } } +catch { + $harnessError = $_.Exception.Message + Bad "harness error: $harnessError" +} +finally { + # --- Summary + results bundle --------------------------------------------- + Step "Summary" + $results | Format-Table -AutoSize + + # Wrap in @() so a single match still yields an array with a .Count (PS 5.1). + $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" + + $verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" } + $tableText = ($results | Format-Table -AutoSize | Out-String) + $summary = @" +OpenShell MXC e2e scenario run +============================== +timestamp : $stamp +machine : $env:COMPUTERNAME +verdict : $verdict +mode : $(if ($Mock) { 'MOCK' } else { 'REAL' }) +backend : $Backend +backend_live : $($backendProbe.Live) ($($backendProbe.Reason)) +wxc_exec : $WxcExecPath +gateway_port : $Port +totals : PASS=$passCount FAIL=$failCount SKIP=$skipCount +$(if ($harnessError) { "harness_error: $harnessError" }) + +Per-scenario results: +$tableText +Files in this bundle ($resultDir): + summary.txt this summary + transcript.txt full console transcript + gateway..log / .err.log per-scenario gateway stdout/stderr + mxc-gateway..toml the exact gateway config rendered per scenario + policy..yaml the exact sandbox policy fixture used per scenario + +What PASS means: every non-skipped scenario met its expected verdict - positive +writes produced their artifact, deny writes were blocked with either a control +write or driver-launch evidence, and network-reject was refused by policy. +"@ + Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" })) + + if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch {} } + + # Zip the bundle for easy return (defensive; never throw out of finally). + try { + $zip = Join-Path $here "results-e2e-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } +} -# ── 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) { +if ($harnessError -or $failCount -gt 0) { Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red exit 1 } else { 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..fa2eb23ac9 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -0,0 +1,374 @@ +# 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 + # Config path goes through the env var (clap: OPENSHELL_GATEWAY_CONFIG), NOT a + # --config token: Start-Process -ArgumentList does not quote array elements, so a + # config path containing a space gets split and the gateway's arg parser rejects it. + $env:OPENSHELL_GATEWAY_CONFIG = $toml + 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", "--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 index d8e981a46d..c2d6f2318a 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -4,7 +4,7 @@ //! MXC compute backend: lifecycle logic, in-memory registry, exec-in-driver, //! and self-reported readiness. -use crate::mxc::{MxcFilesystem, MxcProcess, MxcProcessContainer, WxcExecInvoker}; +use crate::mxc::{MxcFilesystem, MxcNetwork, MxcProcess, MxcProcessContainer, WxcExecInvoker}; use crate::policy::{EmbeddedPolicyMapper, MapCtx, MappedConfig, PolicyMapper}; use futures::Stream; use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; @@ -17,6 +17,8 @@ use openshell_core::proto::compute::v1::{ use openshell_core::proto_struct::struct_to_json_value; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use tokio::sync::{Mutex, broadcast, mpsc, watch}; @@ -65,9 +67,21 @@ pub struct MxcComputeConfig { /// MXC `configurationId` for isolation session. Default: `"composable"`. /// Never use `"small"` (known OS bug). pub default_configuration_id: String, + /// Enable Pattern-C governed egress. When true, MXC receives filesystem + /// grants plus a `network.proxy` redirect and the host CONNECT proxy + /// receives the trimmed network-only policy. + pub egress_proxy: bool, + /// Loopback `IP:PORT` seed for MXC `network.proxy` while governed egress is + /// enabled. The driver preserves the loopback IP and allocates a unique + /// ephemeral port per sandbox. + pub egress_proxy_addr: String, /// Enable `--debug` flag on `wxc-exec` invocations. pub 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 { @@ -78,8 +92,11 @@ impl Default for MxcComputeConfig { pc_least_privilege: false, pc_capabilities: Vec::new(), default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), + egress_proxy: false, + egress_proxy_addr: String::new(), debug: false, + etw_audit: false, } } } @@ -113,6 +130,9 @@ struct SandboxEntry { lifecycle_gate: Arc>, monitor_cancel: Option>, monitor_task: Option>, + trimmed_policy: Option, + proxy_addr: Option, + host_proxy: Option, } impl std::fmt::Debug for SandboxEntry { @@ -184,6 +204,17 @@ pub struct MxcComputeBackend { /// 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 { @@ -217,15 +248,30 @@ fn sandbox_config(sandbox: &DriverSandbox) -> Result 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()); + // Released wxc-exec ProcessContainer builds start from the explicit + // process environment. Seed only the non-secret Windows bootstrap values; + // copying the gateway's full environment would leak unrelated host secrets + // into untrusted sandbox workloads. + let mut environment = MINIMAL_WINDOWS_BOOTSTRAP_ENV + .iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| ((*key).to_string(), value)) + }) + .collect::>(); + if let Some(spec) = sandbox.spec.as_ref() { + if let Some(template) = spec.template.as_ref() { + environment.extend(template.environment.clone()); + } + environment.extend(spec.environment.clone()); + } let mut environment = environment .into_iter() .map(|(key, value)| format!("{key}={value}")) @@ -234,6 +280,43 @@ fn sandbox_environment(sandbox: &DriverSandbox) -> Vec { environment } +fn configured_egress_addr(config: &MxcComputeConfig) -> Result, tonic::Status> { + if !config.egress_proxy { + return Ok(None); + } + if config.backend == MxcBackend::IsolationSession { + return Err(tonic::Status::invalid_argument( + "mxc governed egress requires process_container; network.proxy is not supported on isolation_session until MXC M1 lands", + )); + } + let raw = config.egress_proxy_addr.trim(); + if raw.is_empty() { + return Err(tonic::Status::invalid_argument( + "mxc egress_proxy_addr is required when egress_proxy is enabled", + )); + } + let addr = raw.parse::().map_err(|error| { + tonic::Status::invalid_argument(format!( + "mxc egress_proxy_addr must be an IP:PORT socket address: {error}" + )) + })?; + if addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) { + return Err(tonic::Status::invalid_argument(format!( + "mxc egress_proxy_addr must be 127.0.0.1:PORT because MXC 0.6.0-alpha can encode only a localhost proxy port (got {})", + addr.ip() + ))); + } + Ok(Some(addr)) +} + +fn allocate_sandbox_proxy_addr( + configured: SocketAddr, +) -> std::io::Result<(SocketAddr, std::net::TcpListener)> { + let reservation = std::net::TcpListener::bind(SocketAddr::new(configured.ip(), 0))?; + let addr = reservation.local_addr()?; + Ok((addr, reservation)) +} + fn encode_windows_command_line(args: &[String]) -> String { args.iter() .map(|arg| quote_windows_argument(arg)) @@ -267,10 +350,71 @@ fn quote_windows_argument(arg: &str) -> String { quoted.push('"'); quoted } +fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf { + config + .command + .first() + .filter(|command| !command.trim().is_empty()) + .map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from) +} + +const TLS_ENV_KEYS: [&str; 6] = [ + "NODE_EXTRA_CA_CERTS", + "DENO_CERT", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", +]; + +fn append_tls_env_vars(env: &mut Vec, ca_paths: Option<&(PathBuf, PathBuf)>) { + let Some((ca_cert_path, combined_bundle_path)) = ca_paths else { + return; + }; + + env.retain(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + !TLS_ENV_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) + }); + + let ca_cert_path = ca_cert_path.display().to_string(); + let combined_bundle_path = combined_bundle_path.display().to_string(); + env.extend([ + format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"), + format!("DENO_CERT={ca_cert_path}"), + format!("SSL_CERT_FILE={combined_bundle_path}"), + format!("REQUESTS_CA_BUNDLE={combined_bundle_path}"), + format!("CURL_CA_BUNDLE={combined_bundle_path}"), + format!("GIT_SSL_CAINFO={combined_bundle_path}"), + ]); +} + 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, @@ -280,6 +424,8 @@ impl MxcComputeBackend { // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), pending_policies: Arc::new(Mutex::new(HashMap::new())), + etw_session, + attribution, } } @@ -351,6 +497,19 @@ impl MxcComputeBackend { let policy = self.pending_policies.lock().await.remove(&sandbox_id); self.validate_sandbox_create(sandbox)?; let sandbox_config = sandbox_config(sandbox)?; + let (egress_addr, reserved_proxy_listener) = match configured_egress_addr(&self.config)? { + Some(configured_addr) => { + let (addr, reservation) = allocate_sandbox_proxy_addr(configured_addr).map_err( + |error| { + tonic::Status::internal(format!( + "failed to allocate sandbox-unique MXC host egress proxy address from {configured_addr}: {error}" + )) + }, + )?; + (Some(addr), Some(reservation)) + } + None => (None, None), + }; // Policy translation is deterministic and side-effect free. Do it before // inserting the registry entry or launching MXC so invalid requests fail @@ -361,7 +520,7 @@ impl MxcComputeBackend { policy.as_ref(), &MapCtx { sandbox_id: sandbox_id.clone(), - egress: None, + egress: egress_addr, }, ) .map_err(|error| tonic::Status::invalid_argument(error.to_string()))?; @@ -412,6 +571,9 @@ impl MxcComputeBackend { lifecycle_gate, monitor_cancel: None, monitor_task: None, + trimmed_policy: None, + proxy_addr: None, + host_proxy: None, }, ); } @@ -420,6 +582,7 @@ impl MxcComputeBackend { 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( @@ -427,9 +590,11 @@ impl MxcComputeBackend { config, registry, watch_tx, + attribution, sandbox, sandbox_config, mapped, + reserved_proxy_listener, startup_guard, ) .await; @@ -482,6 +647,7 @@ impl MxcComputeBackend { let mut registry = self.registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { entry.isolation_stopped = isolation_stopped; + entry.host_proxy = None; entry.phase_state = PhaseState::Stopped; entry.sandbox = make_sandbox_with_condition( &entry.sandbox, @@ -558,6 +724,9 @@ impl MxcComputeBackend { 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); } @@ -619,32 +788,103 @@ async fn run_lifecycle( config: MxcComputeConfig, registry: Arc>>, watch_tx: Arc>, + attribution: Arc>, sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, mapped: MappedConfig, + mut reserved_proxy_listener: Option, _startup_guard: tokio::sync::OwnedMutexGuard<()>, ) { let sandbox_id = sandbox.id.clone(); let sandbox_name = sandbox.name.clone(); + let trimmed_policy = mapped.trimmed_policy.clone(); + let proxy_addr = mapped.proxy_addr; + let host_proxy = if !invoker.is_mock() + && let (Some(addr), Some(proxy_policy)) = (proxy_addr, trimmed_policy.clone()) + { + drop(reserved_proxy_listener.take()); + match openshell_supervisor_network::host::start_host_proxy( + openshell_supervisor_network::host::HostProxyConfig { + bind_addr: addr, + policy: proxy_policy, + binary_path: host_proxy_binary_path(&sandbox_config), + sandbox_id: Some(sandbox_id.clone()), + sandbox_name: Some(sandbox_name.clone()), + openshell_endpoint: None, + inference_routes: None, + provider_credentials: None, + agent_proposals: openshell_core::proposals::AgentProposals::default(), + denial_tx: None, + activity_tx: None, + }, + ) + .await + { + Ok(handle) => Some(handle), + Err(error) => { + set_failed( + ®istry, + &watch_tx, + &sandbox, + &sandbox_id, + &format!("failed to start MXC host egress proxy at {addr}: {error}"), + ) + .await; + return; + } + } + } else { + None + }; + let host_proxy_ca_paths = host_proxy.as_ref().and_then(|proxy| proxy.ca_file_paths()); + drop(reserved_proxy_listener.take()); + if let Some(addr) = proxy_addr { + { + let mut registry = registry.lock().await; + if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.trimmed_policy = trimmed_policy; + entry.proxy_addr = Some(addr); + entry.host_proxy = host_proxy; + } + } + let _ = watch_tx.send(platform_event( + sandbox_id.clone(), + "EgressRedirect", + format!("MXC egress redirected to OpenShell host CONNECT proxy at {addr}"), + )); + } + + // Do not add the generated TLS directory to readonly_paths. Released + // wxc-exec BaseContainer builds require WRITE_DAC on every read-only grant; + // the user-owned proxy temp directory otherwise makes sandbox launch fail. + // CA paths remain available through the TLS trust environment variables. + let readonly_paths = mapped.readonly_paths; let filesystem = MxcFilesystem { readwrite_paths: mapped.readwrite_paths, - readonly_paths: mapped.readonly_paths, + 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 mut environment = sandbox_environment(&sandbox); + append_tls_env_vars(&mut environment, host_proxy_ca_paths.as_ref()); + info!(sandbox = %sandbox_name, count = environment.len(), "MXC process env vars"); let process = MxcProcess { command_line: command_line.clone(), cwd: sandbox_config.cwd, - env: sandbox_environment(&sandbox), + env: environment, timeout: 0, }; + let network = proxy_addr.map(|addr| MxcNetwork { + default_policy: "block".into(), + proxy: Some(addr), + }); let child = match config.backend { MxcBackend::IsolationSession => { let iso_sandbox_id = match invoker - .provision(&config.default_configuration_id, filesystem, None) + .provision(&config.default_configuration_id, filesystem, network) .await { Ok(id) => id, @@ -702,7 +942,7 @@ async fn run_lifecycle( capabilities: config.pc_capabilities.clone(), }; match invoker - .run_oneshot(&sandbox_id, filesystem, process_container, process, None) + .run_oneshot(&sandbox_id, filesystem, process_container, process, network) .await { Ok(child) => child, @@ -739,19 +979,37 @@ async fn run_lifecycle( // process exit. Holding the registry lock while spawning prevents a // completed child from being overwritten with AgentRunning. let mut registry_guard = registry.lock().await; - if let Some(entry) = registry_guard.get_mut(&sandbox_id) { - 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 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)); } @@ -796,6 +1054,7 @@ async fn monitor_exec( ); let mut registry = registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.host_proxy = None; entry.sandbox = done.clone(); entry.phase_state = PhaseState::Running; } @@ -823,6 +1082,7 @@ async fn monitor_exec( ); let mut registry = registry.lock().await; if let Some(entry) = registry.get_mut(&sandbox_id) { + entry.host_proxy = None; entry.sandbox = failed.clone(); entry.phase_state = PhaseState::Failed(format!("exit code {code}")); } @@ -855,6 +1115,7 @@ async fn set_failed( ); let mut reg = registry.lock().await; if let Some(entry) = reg.get_mut(sandbox_id) { + entry.host_proxy = None; entry.sandbox = failed.clone(); entry.phase_state = PhaseState::Failed(message.to_string()); } @@ -972,14 +1233,22 @@ mod lifecycle_tests { #[test] fn mxc_config_defaults_to_default_deny_process_container() { - assert_eq!( - MxcComputeConfig::default().backend, - MxcBackend::ProcessContainer - ); + let config = MxcComputeConfig::default(); + assert_eq!(config.backend, MxcBackend::ProcessContainer); + assert!(!config.egress_proxy); + assert!(config.egress_proxy_addr.is_empty()); } #[test] - fn sandbox_environment_uses_sandbox_scope_with_spec_precedence() { + fn sandbox_proxy_addr_uses_ephemeral_loopback_port() { + let configured = "127.0.0.1:18080".parse().unwrap(); + let (addr, _reservation) = allocate_sandbox_proxy_addr(configured).unwrap(); + assert_eq!(addr.ip(), configured.ip()); + assert_ne!(addr.port(), 0); + } + + #[test] + fn sandbox_environment_inherits_host_with_spec_precedence() { let mut sandbox = driver_sandbox("sb-env"); let spec = sandbox.spec.as_mut().unwrap(); spec.template @@ -989,10 +1258,50 @@ mod lifecycle_tests { .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()] + let environment = sandbox_environment(&sandbox); + assert!(environment.contains(&"SHARED=spec".to_string())); + assert!(environment.contains(&"TOKEN=value".to_string())); + for key in MINIMAL_WINDOWS_BOOTSTRAP_ENV { + if let Ok(value) = std::env::var(key) { + assert!(environment.contains(&format!("{key}={value}"))); + } + } + assert!(environment.iter().all(|entry| { + let key = entry.split_once('=').map_or(entry.as_str(), |(key, _)| key); + key == "SHARED" || key == "TOKEN" || MINIMAL_WINDOWS_BOOTSTRAP_ENV.contains(&key) + })); + } + + #[test] + fn tls_env_vars_replace_user_trust_overrides() { + let tls_dir = std::env::temp_dir().join("openshell-mxc-tls-test"); + let ca_cert = tls_dir.join("openshell-ca.pem"); + let bundle = tls_dir.join("ca-bundle.pem"); + let ca_cert_path = ca_cert.display().to_string(); + let bundle_path = bundle.display().to_string(); + let mut env = vec![ + "FOO=bar".to_string(), + "SSL_CERT_FILE=C:\\old\\bundle.pem".to_string(), + "node_extra_ca_certs=C:\\old\\ca.pem".to_string(), + ]; + + append_tls_env_vars(&mut env, Some(&(ca_cert, bundle))); + + assert!(env.contains(&"FOO=bar".to_string())); + assert!( + !env.iter() + .any(|entry| entry == "SSL_CERT_FILE=C:\\old\\bundle.pem") ); + assert!( + !env.iter() + .any(|entry| entry == "node_extra_ca_certs=C:\\old\\ca.pem") + ); + assert!(env.contains(&format!("NODE_EXTRA_CA_CERTS={ca_cert_path}"))); + assert!(env.contains(&format!("DENO_CERT={ca_cert_path}"))); + assert!(env.contains(&format!("SSL_CERT_FILE={bundle_path}"))); + assert!(env.contains(&format!("REQUESTS_CA_BUNDLE={bundle_path}"))); + assert!(env.contains(&format!("CURL_CA_BUNDLE={bundle_path}"))); + assert!(env.contains(&format!("GIT_SSL_CAINFO={bundle_path}"))); } #[test] @@ -1122,6 +1431,119 @@ mod lifecycle_tests { ); } + #[tokio::test] + async fn split_path_provisions_with_proxy_redirect() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + + let tmp = tempfile::tempdir().unwrap(); + let share = tmp.path().to_string_lossy().replace('\\', "/"); + let hello = format!("{share}/hello.txt"); + let cmd = vec![ + "powershell".into(), + "-NoProfile".into(), + "-Command".into(), + format!("Set-Content -LiteralPath {hello} -Value hi"), + ]; + let mut config = MxcComputeConfig::default(); + config.backend = MxcBackend::ProcessContainer; + config.egress_proxy = true; + config.egress_proxy_addr = "127.0.0.1:18080".into(); + let backend = MxcComputeBackend::new_mocked(config); + let mut stream = backend.watch_sandboxes().await; + + let mut policy = fs_policy(&[&share]); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "example.com".into(), + ports: vec![443], + protocol: "rest".into(), + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + ..Default::default() + }], + }, + ); + backend + .policy_sink() + .lock() + .await + .insert("sb-egress".into(), policy.clone()); + + let sandbox = driver_sandbox_with_command("sb-egress", &share, cmd); + backend + .create_sandbox(&sandbox) + .await + .expect("create accepted"); + + let ready = wait_for(&backend, "sb-egress", |s| { + ready_condition(s).is_some_and(|c| c.status == "True" && c.reason == "AgentRunning") + }) + .await; + assert!( + ready.is_some(), + "egress split sandbox should reach Ready=True" + ); + + let recorded = crate::mxc::mock_recorded_config("sb-egress").expect("mock recorded config"); + assert_eq!(recorded["network"]["defaultPolicy"], "block"); + assert!(recorded["network"].get("allowedHosts").is_none()); + assert!(recorded["network"].get("blockedHosts").is_none()); + // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. + let proxy_port = recorded["network"]["proxy"]["localhost"] + .as_u64() + .expect("proxy localhost port"); + assert!(proxy_port > 0); + assert!(proxy_port <= u64::from(u16::MAX)); + assert!( + recorded["network"]["proxy"].get("host").is_none(), + "proxy must not contain 'host' key" + ); + assert!( + recorded["network"]["proxy"].get("port").is_none(), + "proxy must not contain 'port' key" + ); + + let reg = backend.registry.lock().await; + let entry = reg.get("sb-egress").expect("registry entry"); + let entry_proxy_addr = entry.proxy_addr.expect("proxy addr"); + assert_eq!( + entry_proxy_addr.ip(), + std::net::IpAddr::from([127, 0, 0, 1]) + ); + assert_eq!(u64::from(entry_proxy_addr.port()), proxy_port); + assert_eq!( + entry.trimmed_policy.as_ref().unwrap().network_policies, + policy.network_policies + ); + drop(reg); + + let mut saw_redirect = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(500), stream.next()).await { + Ok(Some(Ok(ev))) => { + if let Some(watch_sandboxes_event::Payload::PlatformEvent(pe)) = ev.payload + && pe + .event + .as_ref() + .is_some_and(|e| e.reason == "EgressRedirect") + { + saw_redirect = true; + break; + } + } + Ok(_) => break, + Err(_) => continue, + } + } + assert!(saw_redirect, "expected EgressRedirect platform event"); + } + #[tokio::test] async fn negative_out_of_policy_write_is_denied_with_event() { let share_tmp = tempfile::tempdir().unwrap(); 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/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index dc251649f8..4b9d328e9f 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -27,6 +27,11 @@ mod policy; // 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}; diff --git a/crates/openshell-driver-mxc/src/mxc.rs b/crates/openshell-driver-mxc/src/mxc.rs index 32d7006d90..0a77565f83 100644 --- a/crates/openshell-driver-mxc/src/mxc.rs +++ b/crates/openshell-driver-mxc/src/mxc.rs @@ -99,10 +99,10 @@ fn network_json(network: &MxcNetwork) -> serde_json::Value { // See also docs/reference/mxc-compute-driver-design.mdx §network.proxy. // The MxcNetwork.proxy field remains SocketAddr so callers keep full // precision; only the port is serialized into the localhost key. + // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, + // even when they are empty. The host proxy enforces the L7 allowlist. let mut value = serde_json::json!({ "defaultPolicy": network.default_policy.as_str(), - "allowedHosts": [], - "blockedHosts": [], }); if let Some(proxy) = network.proxy { value["proxy"] = serde_json::json!({ "localhost": proxy.port() }); @@ -302,6 +302,10 @@ impl WxcExecInvoker { } } + pub(crate) const fn is_mock(&self) -> bool { + self.mock + } + /// Test-only constructor that forces mock mode without touching the /// process-global `OPENSHELL_MXC_MOCK_WXC` env var (avoids races/UB across /// parallel tests under edition 2024's `unsafe` `set_var`). @@ -754,18 +758,8 @@ mod tests { 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() - ); + assert!(config["network"].get("allowedHosts").is_none()); + assert!(config["network"].get("blockedHosts").is_none()); // MXC 0.6.0-alpha accepts only {"proxy": {"localhost": N}}. assert_eq!(config["network"]["proxy"]["localhost"], 18080); assert!( @@ -788,6 +782,8 @@ mod tests { proxy: Some("127.0.0.1:18080".parse().unwrap()), }; let value = network_json(&network); + assert!(value.get("allowedHosts").is_none()); + assert!(value.get("blockedHosts").is_none()); assert_eq!(value["proxy"]["localhost"], 18080); assert!(value["proxy"].get("host").is_none()); assert!(value["proxy"].get("port").is_none()); diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index 7d9025e5a6..c9030f9664 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -75,9 +75,9 @@ pub struct MxcMappingResult { 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. + /// Direct egress is blocked at the MXC layer. Unsupported host-list fields + /// are omitted; all outbound connections flow through the proxy, which + /// enforces the full `OpenShell` network policy. 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 @@ -99,9 +99,9 @@ pub fn map_to_mxc(policy: &SandboxPolicy, opts: &MxcMappingOptions) -> MxcMappin /// `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 +/// `opts.proxy_redirect` and omits unsupported host-list fields. Direct egress +/// is blocked at the MXC layer and all outbound connections flow through the +/// proxy. [`SplitPolicyResult::proxy_policy`] carries the original /// `network_policies` verbatim; no binary-scope, port, protocol, or wildcard /// loss items are generated for the network side. /// @@ -172,7 +172,8 @@ fn build_split_mxc_config( } // Direct egress is blocked; all outbound flows through the OpenShell proxy. - // allowedHosts is intentionally empty — the proxy enforces the full policy. + // Released wxc-exec rejects allowedHosts and blockedHosts as unsupported, + // even when empty, so the proxy path omits both fields. // // MXC 0.6.0-alpha schema accepts ONLY {"proxy": {"localhost": }}. // {"host": ..., "port": ...} and every other shape is rejected — verified @@ -191,11 +192,7 @@ fn build_split_mxc_config( "The redirect cannot be emitted; use a 127.0.0.1:PORT address.", ); } - let mut network = json!({ - "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], - }); + let mut network = json!({ "defaultPolicy": "block" }); if proxy_supported && proxy_addr.ip() == std::net::IpAddr::from([127, 0, 0, 1]) { network["proxy"] = json!({ "localhost": proxy_addr.port() }); } diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs index 4d3a3cfa5c..68b4f90098 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_examples.rs @@ -297,12 +297,11 @@ fn split_policy_routes_network_to_proxy() { "non-127.0.0.1 redirect must produce an error loss item" ); - // Direct egress is blocked; allowedHosts is empty (proxy enforces the list). + // Direct egress is blocked; unsupported host-list fields are omitted and + // the proxy enforces the full list. assert_eq!(cfg["network"]["defaultPolicy"], "block"); - assert!( - str_list(&cfg["network"]["allowedHosts"]).is_empty(), - "split path must not populate allowedHosts" - ); + assert!(cfg["network"].get("allowedHosts").is_none()); + assert!(cfg["network"].get("blockedHosts").is_none()); // Filesystem grants are preserved unchanged. assert_eq!( diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 39a94bee9b..592c682250 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -393,11 +393,9 @@ fn a_split_proxy_localhost_port() { 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" - ); + // Released wxc-exec rejects host-list fields, even when empty. + assert!(result.mxc_config["network"].get("allowedHosts").is_none()); + assert!(result.mxc_config["network"].get("blockedHosts").is_none()); } // ─── QUADRANT B: OpenShell features MXC cannot express ────────────────────── @@ -1011,7 +1009,7 @@ fn c_empty_policy_default_deny_posture() { ); } -/// Split path with network rules present: allowedHosts stays empty. +/// Split path with network rules present: unsupported host lists stay absent. #[test] fn c_split_empty_allowed_hosts_with_network_rules() { let mut policy = SandboxPolicy::default(); @@ -1027,11 +1025,8 @@ fn c_split_empty_allowed_hosts_with_network_rules() { }, ); 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"] - ); + assert!(result.mxc_config["network"].get("allowedHosts").is_none()); + assert!(result.mxc_config["network"].get("blockedHosts").is_none()); // But proxy redirect is present. assert_eq!( result.mxc_config["network"]["proxy"]["localhost"], 18080, diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index dd16836e4b..0e65b7f486 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -15,9 +15,9 @@ //! Two families: //! //! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on -//! schema acceptance. 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. +//! schema acceptance. These pass on this box even though no enforcement +//! backend is live (dry-run validates the JSON schema without spinning up the +//! `AppContainer` or isolation session). //! //! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason //! and return early when the backend is not live. The probe distinguishes @@ -31,6 +31,7 @@ #![cfg(target_os = "windows")] use base64::Engine as _; +use openshell_core::proto::{FilesystemPolicy, SandboxPolicy}; use std::path::PathBuf; use std::process::Command; @@ -59,6 +60,17 @@ fn wxc_path() -> Option { None } +/// Create a real, user-owned Windows directory for MXC filesystem grants. +/// +/// MXC config values are literal paths: it does not expand `%TEMP%`. A unique +/// directory also keeps AppContainer+DACL fallback mutations scoped to test +/// data the current user owns. +fn temp_fixture() -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().expect("create MXC temp fixture"); + let path = dir.path().to_string_lossy().into_owned(); + (dir, path) +} + // ── Dry-run helper ──────────────────────────────────────────────────────────── /// Invoke `wxc-exec --config-base64 --dry-run` synchronously. @@ -94,19 +106,18 @@ fn dryrun_accepts_minimal_processcontainer_config() { return; }; - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-minimal", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, }); @@ -126,24 +137,21 @@ fn dryrun_accepts_network_block_without_proxy() { return; }; - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); 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, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], }, }); @@ -165,24 +173,21 @@ fn dryrun_accepts_localhost_proxy_shape() { return; }; - let tmpdir = tempfile::tempdir().expect("tempdir"); - let tmpdir_str = tmpdir.path().to_string_lossy().into_owned(); + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-proxy-localhost", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": tmpdir_str, + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], "proxy": { "localhost": 18080 }, }, }); @@ -205,22 +210,21 @@ fn dryrun_rejects_host_port_proxy_shape() { return; }; + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-proxy-hostport", "containment": "processcontainer", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [temp_path], }, "network": { "defaultPolicy": "block", - "allowedHosts": [], - "blockedHosts": [], // MXC 0.6.0-alpha rejects {"host","port"} — verified empirically. "proxy": { "host": "127.0.0.1", "port": 18080 }, }, @@ -243,17 +247,18 @@ fn dryrun_rejects_unknown_containment() { return; }; + let (_tempdir, temp_path) = temp_fixture(); let config = serde_json::json!({ "version": "0.6.0-alpha", "containerId": "test-bad-containment", "containment": "nonsense", "process": { "commandLine": "cmd /c exit 0", - "cwd": "%TEMP%", + "cwd": temp_path, "timeout": 0, }, "filesystem": { - "readwritePaths": ["%TEMP%"], + "readwritePaths": [temp_path], }, }); @@ -261,10 +266,9 @@ fn dryrun_rejects_unknown_containment() { 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. +/// The most important dry-run test: build a typed Windows policy, run +/// `split_policy` (proxy_redirect 127.0.0.1:18080, containment +/// "processcontainer"), and verify the resulting config with `--dry-run`. /// /// This proves that the mapper's emitted JSON is accepted by the real binary — /// the central contract of the policy-mapper integration. @@ -276,33 +280,28 @@ fn dryrun_accepts_split_policy_output() { 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 (_tempdir, temp_path) = temp_fixture(); + let policy = SandboxPolicy { + filesystem: Some(FilesystemPolicy { + include_workdir: false, + read_only: Vec::new(), + read_write: vec![temp_path.clone()], + }), + ..Default::default() + }; let opts = openshell_driver_mxc::MxcMappingOptions { containment: "processcontainer".to_string(), + command: "cmd /c exit 0".to_string(), + container_id: "split-policy-dryrun".to_string(), + cwd: Some(temp_path), 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. + // There should be no error losses on the processcontainer split path. let error_losses: Vec<_> = result .loss .iter() @@ -316,29 +315,7 @@ fn dryrun_accepts_split_policy_output() { ); } - // 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 mxc_config = result.mxc_config; let (code, stdout, stderr) = dry_run(&wxc, &mxc_config); assert_eq!( @@ -358,32 +335,39 @@ fn dryrun_accepts_split_policy_output() { /// 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 +/// Runs a trivial one-shot (`cmd /c exit 0`, user-owned temp 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") { + if std::env::var("OPENSHELL_MXC_MOCK_WXC") + .map(|v| v == "1") + .unwrap_or(false) + { 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 (_tempdir, temp_path) = temp_fixture(); 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, + "cwd": temp_path, + "timeout": 30_000, }, "filesystem": { - "readwritePaths": [tmpdir_str], + "readwritePaths": [temp_path], + }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, }, }); @@ -406,17 +390,16 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { || combined.contains("not enabled") { // Extract the message if possible for a more useful skip reason. - let reason = + let reason = if let Ok(v) = 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() - }, - ); + { + v["error"]["message"] + .as_str() + .unwrap_or("backend_error (E_NOTIMPL)") + .to_string() + } else { + "backend_error (velocity keys not enabled)".to_string() + }; return Err(reason); } @@ -432,12 +415,15 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> { Ok(()) } -/// Probe the `isolation_session` backend. +/// Probe the isolation_session backend. /// /// Attempts a `provision` phase. Returns `Ok(sandbox_id)` when live, or /// `Err(reason)` when the backend is unavailable (caller prints SKIP). fn probe_isolation_session(wxc: &PathBuf) -> Result { - if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|value| value == "1") { + if std::env::var("OPENSHELL_MXC_MOCK_WXC") + .map(|v| v == "1") + .unwrap_or(false) + { return Err( "OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests" .to_string(), @@ -586,7 +572,7 @@ fn pc_oneshot_in_policy_write_succeeds() { "process": { "commandLine": format!("cmd /c echo hello > \"{target_str}\""), "cwd": tmpdir_str, - "timeout": 30, + "timeout": 30_000, }, "filesystem": { "readwritePaths": [tmpdir_str], @@ -594,6 +580,11 @@ fn pc_oneshot_in_policy_write_succeeds() { "processContainer": { "leastPrivilege": false, }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, }); let json = serde_json::to_string(&config).unwrap(); @@ -620,7 +611,7 @@ fn pc_oneshot_in_policy_write_succeeds() { } /// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent. -/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write +/// This is the genuine OS default-deny proof — the AppContainer blocks the write /// without requiring any host ACL lockdown. The mock can only fake this. #[test] #[ignore = "requires real wxc-exec"] @@ -648,7 +639,7 @@ fn pc_oneshot_out_of_policy_write_denied() { "process": { "commandLine": format!("cmd /c echo denied > \"{denied_file_str}\""), "cwd": granted_str, - "timeout": 30, + "timeout": 30_000, }, "filesystem": { // Only the granted_dir is in policy — denied_dir is NOT granted. @@ -657,6 +648,11 @@ fn pc_oneshot_out_of_policy_write_denied() { "processContainer": { "leastPrivilege": false, }, + "ui": { + "disable": false, + "clipboard": "none", + "injection": false, + }, }); let json = serde_json::to_string(&config).unwrap(); @@ -685,7 +681,7 @@ fn pc_oneshot_out_of_policy_write_denied() { // ── Isolation session enforcement tests ────────────────────────────────────── -/// Full `isolation_session` round trip: provision → start → exec → stop → +/// Full isolation_session round trip: provision → start → exec → stop → /// deprovision. `deprovision` runs in a drop-guard even on panic so the /// single-session backend is never left orphaned. #[test] diff --git a/crates/openshell-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 ae35fc0fbf..75e433697a 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 } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index b85641c986..e49a5df2ed 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1406,10 +1406,11 @@ impl ComputeRuntime { let sandbox_id = transition.object_id().to_string(); let expected_resource_version = sandbox_resource_version(transition); let session_connected = self.supervisor_sessions.has_session(&sandbox_id); + let has_supervisor = self.driver_kind() != Some(ComputeDriverKind::Mxc); match self .store .update_message_cas::(&sandbox_id, expected_resource_version, |sandbox| { - apply_driver_snapshot(sandbox, snapshot, session_connected); + apply_driver_snapshot(sandbox, snapshot, session_connected, has_supervisor); }) .await { @@ -1853,11 +1854,14 @@ impl ComputeRuntime { match observed { Ok(Some(snapshot)) if snapshot.id == sandbox_id && snapshot.status.is_some() => { let session_connected = self.supervisor_sessions.has_session(sandbox_id); + let has_supervisor = self.driver_kind() != Some(ComputeDriverKind::Mxc); self.write_delete_recovery_with_retry( sandbox_id, deleting_resource_version, "reconcile observed backend snapshot", - |sandbox| apply_driver_snapshot(sandbox, &snapshot, session_connected), + |sandbox| { + apply_driver_snapshot(sandbox, &snapshot, session_connected, has_supervisor) + }, ) .await; } @@ -2823,12 +2827,13 @@ impl ComputeRuntime { existing_phase: SandboxPhase, ) -> Result<(), String> { let session_connected = self.supervisor_sessions.has_session(&incoming.id); + let has_supervisor = self.driver_kind() != Some(ComputeDriverKind::Mxc); let sandbox = self .store .update_message_cas::( &incoming.id, expected_resource_version, - |sandbox| apply_driver_snapshot(sandbox, &incoming, session_connected), + |sandbox| apply_driver_snapshot(sandbox, &incoming, session_connected, has_supervisor), ) .await .map_err(|e| match e { @@ -3762,7 +3767,12 @@ fn public_status_from_driver( } } -fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, session_connected: bool) { +fn apply_driver_snapshot( + sandbox: &mut Sandbox, + incoming: &DriverSandbox, + session_connected: bool, + has_supervisor: bool, +) { let old_phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown); let sandbox_name = &incoming.name; @@ -3794,7 +3804,7 @@ fn apply_driver_snapshot(sandbox: &mut Sandbox, incoming: &DriverSandbox, sessio (phase, status) }, |incoming_status| { - let composed = ComposedPhase::new(incoming_status, session_connected); + let composed = ComposedPhase::new(incoming_status, session_connected, has_supervisor); let mut status = Some(public_status_from_driver( incoming_status, composed.phase, @@ -3926,21 +3936,30 @@ struct ComposedPhase { } impl ComposedPhase { - fn new(incoming_status: &DriverSandboxStatus, session_connected: bool) -> Self { + fn new( + incoming_status: &DriverSandboxStatus, + session_connected: bool, + has_supervisor: bool, + ) -> Self { let backend_phase = derive_phase(Some(incoming_status)); // A live supervisor session is a stronger readiness signal than the backend phase. // set_supervisor_session_state may have already promoted the store record to Ready // before this driver snapshot arrived. Keep Ready rather than letting a lagging // backend phase overwrite it. + // + // Backends without a supervisor (e.g. MXC) have no session to wait for; treat the + // backend phase as authoritative so they can reach Ready without a supervisor. let phase = match backend_phase { SandboxPhase::Error | SandboxPhase::Deleting | SandboxPhase::Stopped => backend_phase, _ if session_connected => SandboxPhase::Ready, + SandboxPhase::Ready if !has_supervisor => SandboxPhase::Ready, _ => SandboxPhase::Provisioning, }; Self { phase, session_connected, - backend_ready_without_session: backend_phase == SandboxPhase::Ready + backend_ready_without_session: has_supervisor + && backend_phase == SandboxPhase::Ready && !session_connected, } } diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index edcf303072..4206bba856 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::*; @@ -168,11 +169,13 @@ pub fn install( .map(|config| config.endpoint.as_str()); let (driver_tracer_provider, driver_setup_error) = in_process_driver_provider(selected_driver, driver_endpoint, gateway.name()); + let (jsonl_layer, jsonl_dir) = build_ocsf_jsonl_layer(); 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(|provider| { crate::otel_tracing::layer_excluding_driver( provider, @@ -185,6 +188,18 @@ pub fn install( )) .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, @@ -194,6 +209,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/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 34d9c32a47..b1d07a2d14 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -32,7 +32,7 @@ ipnet = "2" miette = { workspace = true } prost-types = { workspace = true } rcgen = { workspace = true } -regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob"] } +regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob", "yaml"] } reqwest = { workspace = true } rustls = { workspace = true } rustls-native-certs = { workspace = true } @@ -43,6 +43,7 @@ serde_yml = { workspace = true } sha1 = "0.10" sha2 = { workspace = true } spiffe = { workspace = true } +tempfile = "3" thiserror = { workspace = true } tokio = { workspace = true } tokio-rustls = { workspace = true } @@ -57,7 +58,6 @@ bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } -tempfile = "3" tonic = { workspace = true } temp-env = "0.3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/openshell-supervisor-network/src/host.rs b/crates/openshell-supervisor-network/src/host.rs new file mode 100644 index 0000000000..d7f8d17c2f --- /dev/null +++ b/crates/openshell-supervisor-network/src/host.rs @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Host-side proxy startup for compute drivers that do not run the Linux +//! in-sandbox supervisor. +//! +//! MXC uses this on Windows: MXC's `network.proxy` redirects sandbox egress to a +//! per-sandbox loopback listener in the gateway process, and this module starts +//! the existing OpenShell CONNECT proxy against the trimmed network-only +//! `SandboxPolicy`. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use miette::Result; +use openshell_core::activity::ActivitySender; +use openshell_core::denial::DenialEvent; +use openshell_core::policy::ProxyPolicy; +use openshell_core::proposals::AgentProposals; +use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; +use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_ocsf::{ + ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, +}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::l7::tls::{ + CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, + write_ca_files, +}; +use crate::opa::OpaEngine; +use crate::policy_local::PolicyLocalContext; +use crate::proxy::{ProxyHandle, ProxyIdentityMode}; + +/// Configuration for a host-side OpenShell CONNECT proxy. +pub struct HostProxyConfig { + /// Exact socket the compute driver will redirect sandbox egress to. + pub bind_addr: SocketAddr, + /// Network-only policy produced by the compute driver's policy split. + pub policy: ProtoSandboxPolicy, + /// Static process identity used when the platform cannot recover the + /// socket-owning sandbox process. Policy binaries must match this path for + /// L4/L7 allow rules to pass. + pub binary_path: PathBuf, + pub sandbox_id: Option, + pub sandbox_name: Option, + pub openshell_endpoint: Option, + pub inference_routes: Option, + pub provider_credentials: Option, + /// Shared feature state for the policy.local agent proposal surface. + pub agent_proposals: AgentProposals, + pub denial_tx: Option>, + pub activity_tx: Option, +} + +/// RAII handle for a host-side proxy. Dropping it aborts the proxy accept loop. +pub struct HostProxyHandle { + proxy: ProxyHandle, + ca_file_paths: Option<(PathBuf, PathBuf)>, + #[allow(dead_code)] + tls_dir: Option, + pub policy_local_ctx: Arc, +} + +impl HostProxyHandle { + #[must_use] + pub const fn http_addr(&self) -> Option { + self.proxy.http_addr() + } + + #[must_use] + pub fn ca_file_paths(&self) -> Option<(PathBuf, PathBuf)> { + self.ca_file_paths.clone() + } +} + +/// Start a host-side proxy for one sandbox. +/// +/// Linux supervisor mode should continue to use `run::run_networking`; this API +/// is for host-side compute-driver integrations such as Windows MXC. +pub async fn start_host_proxy(config: HostProxyConfig) -> Result { + if !config.bind_addr.ip().is_loopback() { + return Err(miette::miette!( + "host proxy bind address must be loopback-only: {}", + config.bind_addr + )); + } + + let engine = Arc::new(OpaEngine::from_proto(&config.policy)?); + let (_workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); + let policy_local_ctx = Arc::new(PolicyLocalContext::new( + Some(config.policy.clone()), + config.openshell_endpoint.clone(), + config + .sandbox_name + .clone() + .or_else(|| config.sandbox_id.clone()), + config.agent_proposals, + workspace_rx, + )); + let inference_ctx = crate::inference_routes::build_inference_context( + config.sandbox_id.as_deref(), + config.openshell_endpoint.as_deref(), + config.inference_routes.as_deref(), + ) + .await?; + + let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true); + let proxy_policy = ProxyPolicy { + http_addr: Some(config.bind_addr), + }; + let upstream_proxy_args = crate::upstream_proxy::UpstreamProxyArgs::default(); + let (tls_state, ca_file_paths, tls_dir) = match tempfile::Builder::new() + .prefix("openshell-mxc-tls-") + .tempdir() + { + Ok(tls_dir) => match SandboxCa::generate() { + Ok(ca) => { + let system_ca_bundle = read_system_ca_bundle(); + match write_ca_files(&ca, tls_dir.path(), &system_ca_bundle) { + Ok(paths) => match build_upstream_client_config(&system_ca_bundle) { + Ok(upstream_config) => { + let cert_cache = CertCache::new(ca); + let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config)); + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "enabled") + .message( + "Host proxy TLS termination enabled: ephemeral CA generated" + ) + .build() + ); + (Some(state), Some(paths), Some(tls_dir)) + } + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to build host proxy upstream TLS config, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + }, + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to write host proxy CA files, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + } + } + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to generate host proxy ephemeral CA, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, Some(tls_dir)) + } + }, + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "disabled") + .message(format!( + "Failed to create host proxy TLS trust directory, TLS termination disabled: {e}" + )) + .build() + ); + (None, None, None) + } + }; + let proxy = ProxyHandle::start_with_bind_addr( + &proxy_policy, + Some(config.bind_addr), + engine, + Arc::new(ProxyIdentityMode::static_binary(config.binary_path)?), + tls_state, + inference_ctx, + config.provider_credentials, + Some(policy_local_ctx.clone()), + config.denial_tx, + config.activity_tx, + ready_rx, + &upstream_proxy_args, + ) + .await?; + + Ok(HostProxyHandle { + proxy, + ca_file_paths, + tls_dir, + policy_local_ctx, + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use openshell_core::proposals::AgentProposals; + use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpStream; + + use super::*; + + fn test_config(bind_addr: SocketAddr, binary_path: PathBuf) -> HostProxyConfig { + HostProxyConfig { + bind_addr, + policy: ProtoSandboxPolicy { + version: 1, + ..Default::default() + }, + binary_path, + sandbox_id: Some("sandbox-123".to_string()), + sandbox_name: Some("agent-box".to_string()), + openshell_endpoint: None, + inference_routes: None, + provider_credentials: None, + agent_proposals: AgentProposals::new(true), + denial_tx: None, + activity_tx: None, + } + } + + #[tokio::test] + async fn rejects_non_loopback_bind_addr() { + let result = start_host_proxy(test_config( + ([192, 0, 2, 1], 0).into(), + PathBuf::from("missing-agent.exe"), + )) + .await; + + let Err(err) = result else { + panic!("host proxy should reject non-loopback bind addresses"); + }; + assert!( + err.to_string().contains("loopback-only"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn starts_loopback_proxy_and_serves_policy_local() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + + let handle = start_host_proxy(test_config( + ([127, 0, 0, 1], 0).into(), + binary.path().to_path_buf(), + )) + .await + .unwrap(); + + let addr = handle.http_addr().expect("proxy should report bound addr"); + assert!(addr.ip().is_loopback()); + assert_ne!(addr.port(), 0); + let (ca_cert, bundle) = handle.ca_file_paths().expect("CA paths"); + assert!(ca_cert.exists(), "standalone CA cert should exist"); + assert!(bundle.exists(), "combined CA bundle should exist"); + assert!( + std::fs::read_to_string(ca_cert) + .unwrap() + .contains("BEGIN CERTIFICATE") + ); + assert!( + std::fs::read_to_string(bundle) + .unwrap() + .contains("BEGIN CERTIFICATE") + ); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client + .write_all( + b"GET http://policy.local/v1/policy/current HTTP/1.1\r\n\ + Host: policy.local\r\n\ + Connection: close\r\n\ + \r\n", + ) + .await + .unwrap(); + + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + + let response = String::from_utf8(response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 200 OK"), + "unexpected response: {response}" + ); + let (_, body) = response.split_once("\r\n\r\n").expect("response body"); + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["format"], "yaml"); + assert!( + body["policy_yaml"] + .as_str() + .unwrap_or_default() + .contains("version: 1"), + "unexpected policy payload: {body}" + ); + } +} diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index 5e89c35031..643db06f5b 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -21,10 +21,8 @@ use tracing::debug; #[derive(Clone)] struct FileFingerprint { len: u64, - mtime_sec: i64, - mtime_nsec: i64, - ctime_sec: i64, - ctime_nsec: i64, + mtime: Option<(i64, i64)>, + ctime: Option<(i64, i64)>, #[cfg(unix)] dev: u64, #[cfg(unix)] @@ -33,12 +31,20 @@ struct FileFingerprint { impl FileFingerprint { fn from_metadata(metadata: &Metadata) -> Self { + #[cfg(unix)] + let (mtime, ctime) = ( + Some((metadata.mtime(), metadata.mtime_nsec())), + Some((metadata.ctime(), metadata.ctime_nsec())), + ); + #[cfg(not(unix))] + let (mtime, ctime) = ( + metadata.modified().ok().and_then(system_time_parts), + metadata.created().ok().and_then(system_time_parts), + ); Self { len: metadata.len(), - mtime_sec: metadata.mtime(), - mtime_nsec: metadata.mtime_nsec(), - ctime_sec: metadata.ctime(), - ctime_nsec: metadata.ctime_nsec(), + mtime, + ctime, #[cfg(unix)] dev: metadata.dev(), #[cfg(unix)] @@ -47,13 +53,24 @@ impl FileFingerprint { } } +#[cfg(not(unix))] +fn system_time_parts(time: std::time::SystemTime) -> Option<(i64, i64)> { + let duration = time.duration_since(std::time::UNIX_EPOCH).ok()?; + Some(( + duration.as_secs() as i64, + i64::from(duration.subsec_nanos()), + )) +} + impl PartialEq for FileFingerprint { fn eq(&self, other: &Self) -> bool { self.len == other.len - && self.mtime_sec == other.mtime_sec - && self.mtime_nsec == other.mtime_nsec - && self.ctime_sec == other.ctime_sec - && self.ctime_nsec == other.ctime_nsec + && self.mtime.is_some() + && other.mtime.is_some() + && self.mtime == other.mtime + && self.ctime.is_some() + && other.ctime.is_some() + && self.ctime == other.ctime && { #[cfg(unix)] { diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 4fec48b300..80c27ab894 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,6 +8,8 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. +#[cfg(target_os = "windows")] +pub mod host; pub mod identity; pub mod inference_routes; pub mod l7; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index dc2736a4ea..5b6966428a 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7,6 +7,7 @@ pub(crate) mod destination; mod egress; mod relay; +#[cfg(target_os = "linux")] use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; @@ -34,7 +35,8 @@ use std::mem::size_of; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; +#[cfg(target_os = "linux")] +use std::sync::atomic::AtomicU32; use tokio::io::{ AsyncRead as TokioAsyncRead, AsyncReadExt, AsyncWrite as TokioAsyncWrite, AsyncWriteExt, }; @@ -93,10 +95,10 @@ fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { ocsf_emit!(finding); } -/// Hostnames injected by compute drivers as `/etc/hosts` aliases for the host +/// Hostnames injected by compute drivers as hosts-file aliases for the host /// machine. Traffic to these names is eligible for the trusted-gateway SSRF /// exemption when the resolved IP matches the driver-injected value read from -/// `/etc/hosts` at proxy startup. +/// the platform hosts file at proxy startup. const HOST_GATEWAY_ALIASES: &[&str] = &[ "host.openshell.internal", "host.containers.internal", @@ -235,6 +237,57 @@ pub struct ProxyHandle { exited_rx: Option>, } +#[derive(Clone)] +pub(crate) enum ProxyIdentityMode { + /// Linux supervisor mode: bind a CONNECT request to the process that owns + /// the redirected TCP socket via procfs. + #[cfg(target_os = "linux")] + Procfs { + identity_cache: Arc, + entrypoint_pid: Arc, + }, + /// Host-side mode for platforms where procfs socket ownership is + /// unavailable. MXC uses this on Windows: every connection redirected to + /// the per-sandbox listener is evaluated as the configured sandbox agent + /// identity. + Static { + binary_path: PathBuf, + binary_sha256: String, + }, +} + +impl ProxyIdentityMode { + #[cfg(target_os = "linux")] + pub(crate) fn procfs( + identity_cache: Arc, + entrypoint_pid: Arc, + ) -> Self { + Self::Procfs { + identity_cache, + entrypoint_pid, + } + } + + pub(crate) fn static_binary(path: impl Into) -> Result { + let binary_path = path.into(); + let binary_sha256 = crate::procfs::file_sha256(&binary_path)?; + Ok(Self::Static { + binary_path, + binary_sha256, + }) + } + + fn entrypoint_pid(&self) -> u32 { + match self { + #[cfg(target_os = "linux")] + Self::Procfs { entrypoint_pid, .. } => { + entrypoint_pid.load(std::sync::atomic::Ordering::Acquire) + } + Self::Static { .. } => 0, + } + } +} + impl ProxyHandle { /// Start the proxy with OPA engine for policy evaluation. /// @@ -245,8 +298,7 @@ impl ProxyHandle { policy: &ProxyPolicy, bind_addr: Option, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, tls_state: Option>, inference_ctx: Option>, provider_credentials: Option, @@ -283,14 +335,14 @@ impl ProxyHandle { ocsf_emit!(event); } - // Detect the trusted host gateway IP from /etc/hosts before user code - // runs. This is read once at startup so later /etc/hosts modifications - // by sandbox workloads cannot influence the stored value. + // Detect the trusted host gateway IP from the platform hosts file + // before user code runs. This is read once at startup so later hosts + // file modifications by sandbox workloads cannot influence it. let trusted_host_gateway: Arc> = Arc::new(detect_trusted_host_gateway()); if let Some(ref ip) = *trusted_host_gateway { tracing::info!( %ip, - "Trusted host gateway detected from /etc/hosts; \ + "Trusted host gateway detected from platform hosts file; \ host-gateway aliases exempt from SSRF always-blocked check" ); } @@ -377,8 +429,7 @@ impl ProxyHandle { consecutive_unknown_errors = 0; set_tcp_nodelay_best_effort(&stream); let opa = opa_engine.clone(); - let cache = identity_cache.clone(); - let spid = entrypoint_pid.clone(); + let identity = identity_mode.clone(); let tls = tls_state.clone(); let inf = inference_ctx.clone(); let policy_local = policy_local_ctx.clone(); @@ -401,8 +452,7 @@ impl ProxyHandle { if let Err(err) = handle_tcp_connection( stream, opa, - cache, - spid, + identity, tls, inf, policy_local, @@ -625,7 +675,7 @@ async fn handle_transparent_tcp_connection( let cache = identity_cache.clone(); let pid = entrypoint_pid.clone(); let decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &engine, &cache, &pid, intent) + authorize_egress_intent_procfs(connection, &engine, &cache, &pid, intent) }) .await .map_err(|error| miette::miette!("identity resolution task panicked: {error}"))?; @@ -1685,8 +1735,7 @@ async fn deny_forward_destination( async fn handle_tcp_connection( mut client: TcpStream, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, tls_state: Option>, inference_ctx: Option>, policy_local_ctx: Option>, @@ -1758,8 +1807,7 @@ async fn handle_tcp_connection( used, &mut client, opa_engine, - identity_cache, - entrypoint_pid, + identity_mode, policy_local_ctx, agent_proposals, trusted_host_gateway, @@ -1811,11 +1859,10 @@ async fn handle_tcp_connection( // Wrapped in spawn_blocking because identity resolution does heavy sync I/O: // /proc scanning + SHA256 hashing of binaries (e.g. node at 124MB). let opa_clone = opa_engine.clone(); - let cache_clone = identity_cache.clone(); - let pid_clone = entrypoint_pid.clone(); + let identity_clone = identity_mode.clone(); let intent = EgressIntent::connect(host_lc.clone(), port); let mut decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) + authorize_egress_intent(connection, &opa_clone, &identity_clone, intent) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?; @@ -1933,7 +1980,7 @@ async fn handle_tcp_connection( let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; - let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); + let sandbox_entrypoint_pid = identity_mode.entrypoint_pid(); match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { Ok(()) => {} @@ -2607,7 +2654,7 @@ fn resolve_process_identity( /// Evaluate OPA policy for a TCP connection with identity binding via /proc/net/tcp. #[cfg(target_os = "linux")] -fn authorize_egress_intent( +fn authorize_egress_intent_procfs( connection: crate::procfs::WorkloadProxyTcpConnection, engine: &OpaEngine, identity_cache: &BinaryIdentityCache, @@ -2780,33 +2827,67 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres } } -/// Non-Linux stub: OPA identity binding requires /proc. -#[cfg(not(target_os = "linux"))] fn authorize_egress_intent( _connection: crate::procfs::WorkloadProxyTcpConnection, engine: &OpaEngine, - _identity_cache: &BinaryIdentityCache, - _entrypoint_pid: &AtomicU32, + identity_mode: &ProxyIdentityMode, intent: EgressIntent, ) -> EgressDecision { if !crate::opa::network_binary_identity_required() { return evaluate_endpoint_only_opa(engine, intent); } - EgressDecision { - intent, - action: NetworkAction::Deny { - reason: "identity binding unavailable on this platform".into(), - }, - policy_generation: engine.current_generation(), - identity: ProcessIdentityEvidence::Unavailable( - IdentityUnavailableReason::UnsupportedPlatform, + match identity_mode { + #[cfg(target_os = "linux")] + ProxyIdentityMode::Procfs { + identity_cache, + entrypoint_pid, + } => authorize_egress_intent_procfs( + _connection, + engine, + identity_cache, + entrypoint_pid, + intent, ), - endpoint: EndpointDecision::default(), - binary: None, - binary_pid: None, - ancestors: vec![], - cmdline_paths: vec![], + ProxyIdentityMode::Static { + binary_path, + binary_sha256, + } => { + let input = crate::opa::NetworkInput { + host: intent.destination.host.clone(), + port: intent.destination.port, + binary_path: binary_path.clone(), + binary_sha256: binary_sha256.clone(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + match engine.evaluate_network_action_with_generation(&input) { + Ok((action, generation)) => EgressDecision { + intent, + action, + policy_generation: generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::default(), + binary: Some(binary_path.clone()), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + Err(error) => EgressDecision { + intent, + action: NetworkAction::Deny { + reason: format!("policy evaluation error: {error}"), + }, + policy_generation: engine.current_generation(), + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::default(), + binary: Some(binary_path.clone()), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }, + } + } } } @@ -3653,16 +3734,27 @@ fn is_cloud_metadata_ip(ip: IpAddr) -> bool { } } -/// Read the proxy's own `/etc/hosts` at startup and return the IP mapped to +/// Read the proxy's own platform hosts file at startup and return the IP mapped to /// `host.openshell.internal`, if present and safe. /// /// This is called once before user code runs, so the returned value is immune -/// to later `/etc/hosts` tampering by sandbox workloads. Returns `None` if no -/// entry exists, the entry cannot be parsed, or the mapped IP is a cloud -/// metadata address. -#[cfg(any(target_os = "linux", test))] +/// to later hosts-file tampering by sandbox workloads. Returns `None` if the +/// hosts file cannot be read, no entry exists, the entry cannot be parsed, or +/// the mapped IP is a cloud metadata address. +#[cfg(any(target_os = "linux", target_os = "windows", test))] pub(crate) fn detect_trusted_host_gateway() -> Option { - let contents = std::fs::read_to_string("/etc/hosts").ok()?; + let hosts_path = platform_hosts_path(); + let contents = match std::fs::read_to_string(&hosts_path) { + Ok(contents) => contents, + Err(error) => { + warn!( + path = %hosts_path.display(), + %error, + "failed to read platform hosts file; trusted-gateway SSRF exemption disabled" + ); + return None; + } + }; let ips = parse_hosts_file_for_host(&contents, "host.openshell.internal"); // Multiple distinct IPs for the alias is unexpected — compute drivers @@ -3674,7 +3766,7 @@ pub(crate) fn detect_trusted_host_gateway() -> Option { if ips.len() > 1 { warn!( ips = ?ips, - "host.openshell.internal has {} distinct IPs in /etc/hosts; \ + "host.openshell.internal has {} distinct IPs in the platform hosts file; \ expected exactly one. Using first entry. \ Connections resolving to any other IP will be rejected.", ips.len() @@ -3708,11 +3800,38 @@ pub(crate) fn detect_trusted_host_gateway() -> Option { Some(ip) } -#[cfg(not(any(target_os = "linux", test)))] +#[cfg(not(any(target_os = "linux", target_os = "windows", test)))] pub(crate) fn detect_trusted_host_gateway() -> Option { None } +#[cfg(target_os = "linux")] +fn platform_hosts_path() -> PathBuf { + PathBuf::from("/etc/hosts") +} + +#[cfg(target_os = "windows")] +fn platform_hosts_path() -> PathBuf { + windows_hosts_path_from_system_root(std::env::var("SystemRoot").ok().as_deref()) +} + +#[cfg(target_os = "windows")] +fn windows_hosts_path_from_system_root(system_root: Option<&str>) -> PathBuf { + let root = system_root + .filter(|value| !value.trim().is_empty()) + .unwrap_or(r"C:\Windows"); + PathBuf::from(root) + .join("System32") + .join("drivers") + .join("etc") + .join("hosts") +} + +#[cfg(all(test, not(any(target_os = "linux", target_os = "windows"))))] +fn platform_hosts_path() -> PathBuf { + PathBuf::from("/etc/hosts") +} + /// Resolve `host:port` and validate that every resolved address matches the /// trusted host gateway IP. /// @@ -3779,7 +3898,7 @@ fn resolve_ip_literal(host: &str, port: u16) -> Option> { .map(|ip| vec![SocketAddr::new(ip, port)]) } -#[cfg(any(target_os = "linux", test))] +#[cfg(any(target_os = "linux", target_os = "windows", test))] fn parse_hosts_file_for_host(contents: &str, host: &str) -> Vec { let lookup_host = normalize_host_lookup_key(host); let mut addrs = Vec::new(); @@ -4820,8 +4939,7 @@ async fn handle_forward_proxy( used: usize, client: &mut TcpStream, opa_engine: Arc, - identity_cache: Arc, - entrypoint_pid: Arc, + identity_mode: Arc, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, @@ -4927,11 +5045,10 @@ async fn handle_forward_proxy( let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); let opa_clone = opa_engine.clone(); - let cache_clone = identity_cache.clone(); - let pid_clone = entrypoint_pid.clone(); + let identity_clone = identity_mode.clone(); let intent = EgressIntent::forward_http(host_lc.clone(), port); let mut decision = tokio::task::spawn_blocking(move || { - authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) + authorize_egress_intent(connection, &opa_clone, &identity_clone, intent) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?; @@ -5022,7 +5139,7 @@ async fn handle_forward_proxy( action = ?decision.action, "Forward proxy L4 policy decision" ); - let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); + let sandbox_entrypoint_pid = identity_mode.entrypoint_pid(); let forward_generation_guard = match relay::pin_policy_generation( &opa_engine, decision.policy_generation, @@ -6348,6 +6465,9 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { mod tests { use super::*; use openshell_core::proposals::AgentProposals; + use openshell_core::proto::{ + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy as ProtoSandboxPolicy, + }; use std::collections::HashMap as TestHashMap; use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -6522,8 +6642,7 @@ network_policies: {} Box::pin(handle_tcp_connection( server, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new(ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap()), None, None, None, @@ -6637,8 +6756,9 @@ network_policies: request.len(), &mut proxy_connection, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new( + ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap(), + ), None, AgentProposals::default(), Arc::new(None), @@ -6770,8 +6890,9 @@ network_policies: request.len(), &mut proxy_connection, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new( + ProxyIdentityMode::static_binary(std::env::current_exe().unwrap()).unwrap(), + ), None, AgentProposals::default(), Arc::new(None), @@ -7338,6 +7459,100 @@ network_policies: ); } + #[test] + fn static_binary_hashes_configured_file() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"abc").unwrap(); + + match ProxyIdentityMode::static_binary(tmp.path()).unwrap() { + ProxyIdentityMode::Static { + binary_path, + binary_sha256, + } => { + assert_eq!(binary_path, tmp.path()); + assert_eq!( + binary_sha256, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + #[cfg(target_os = "linux")] + ProxyIdentityMode::Procfs { .. } => panic!("expected static identity mode"), + } + } + + #[test] + fn static_identity_evaluate_opa_tcp_allows_and_denies_with_proto_policy() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"static-agent").unwrap(); + let identity = ProxyIdentityMode::static_binary(tmp.path()).unwrap(); + let binary_path = tmp.path().to_string_lossy().into_owned(); + let policy_name = "static_agent"; + let engine = OpaEngine::from_proto(&ProtoSandboxPolicy { + version: 1, + network_policies: std::collections::HashMap::from([( + policy_name.to_string(), + NetworkPolicyRule { + name: policy_name.to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.test".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: binary_path, + ..Default::default() + }], + }, + )]), + ..Default::default() + }) + .unwrap(); + let peer_addr: SocketAddr = ([127, 0, 0, 1], 49152).into(); + let connection = crate::procfs::WorkloadProxyTcpConnection::new( + peer_addr, + ([127, 0, 0, 1], 18080).into(), + ); + + let allowed = authorize_egress_intent( + connection, + &engine, + &identity, + EgressIntent::connect("api.example.test".to_string(), 443), + ); + match allowed.action { + NetworkAction::Allow { matched_policy } => { + assert_eq!(matched_policy.as_deref(), Some(policy_name)); + } + NetworkAction::Deny { reason } => panic!("expected allow, got deny: {reason}"), + } + assert_eq!(allowed.binary.as_deref(), Some(tmp.path())); + assert_eq!(allowed.binary_pid, None); + assert!(allowed.ancestors.is_empty()); + assert!(allowed.cmdline_paths.is_empty()); + + let denied = authorize_egress_intent( + connection, + &engine, + &identity, + EgressIntent::connect("blocked.example.test".to_string(), 443), + ); + match denied.action { + NetworkAction::Allow { matched_policy } => { + panic!("expected deny, got allow from policy {matched_policy:?}"); + } + NetworkAction::Deny { reason } => { + assert!( + reason.contains("endpoint blocked.example.test:443 is not allowed"), + "unexpected deny reason: {reason}" + ); + } + } + assert_eq!(denied.binary.as_deref(), Some(tmp.path())); + assert_eq!(denied.binary_pid, None); + assert!(denied.ancestors.is_empty()); + assert!(denied.cmdline_paths.is_empty()); + } + #[test] fn tunnel_protocol_prefix_detection_waits_for_partial_supported_prefixes() { assert!(could_be_supported_tunnel_protocol_prefix(&[0x16])); @@ -12083,8 +12298,7 @@ network_policies: }); let (server, _peer) = listener.accept().await.unwrap(); - let entrypoint_pid = Arc::new(AtomicU32::new(std::process::id())); - let cache = Arc::new(BinaryIdentityCache::new()); + let identity_mode = Arc::new(ProxyIdentityMode::static_binary(exe).unwrap()); let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); let completed = tokio::time::timeout( @@ -12092,8 +12306,7 @@ network_policies: Box::pin(handle_tcp_connection( server, engine, - cache, - entrypoint_pid, + identity_mode, None, // tls_state — ephemeral CA unavailable None, // inference_ctx None, // policy_local_ctx @@ -12130,15 +12343,15 @@ network_policies: const POLICY_REGO: &str = include_str!("../data/sandbox-policy.rego"); let exe = std::env::current_exe().expect("current_exe"); + let exe_yaml = serde_json::to_string(&exe.to_string_lossy()).expect("serialize exe path"); let data = format!( r#"network_policies: test_allow: name: test_allow endpoints: {endpoint_yaml} binaries: - - {{ path: "{exe}" }} + - {{ path: {exe_yaml} }} "#, - exe = exe.display(), ); let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, &data).expect("load policy")); @@ -12159,8 +12372,7 @@ network_policies: Box::pin(handle_tcp_connection( server, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(std::process::id())), + Arc::new(ProxyIdentityMode::static_binary(exe).unwrap()), None, None, None, diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 186d156086..bc2f0e1e6c 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -381,28 +381,27 @@ network_policies: #[cfg(not(target_os = "linux"))] #[test] -fn identity_required_mode_is_explicitly_unsupported_off_linux() { +fn static_identity_is_supported_off_linux() { let engine = OpaEngine::from_strings( include_str!("../../../data/sandbox-policy.rego"), "network_policies: {}\n", ) .unwrap(); + let binary = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(binary.path(), b"agent").unwrap(); + let identity = ProxyIdentityMode::static_binary(binary.path()).unwrap(); let decision = authorize_egress_intent( crate::procfs::WorkloadProxyTcpConnection::new( "127.0.0.1:41000".parse().unwrap(), "127.0.0.1:3000".parse().unwrap(), ), &engine, - &BinaryIdentityCache::new(), - &AtomicU32::new(1), + &identity, EgressIntent::connect("target.example".to_string(), 443), ); assert!(matches!(decision.action, NetworkAction::Deny { .. })); - assert_eq!( - decision.identity, - ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::UnsupportedPlatform) - ); + assert_eq!(decision.identity, ProcessIdentityEvidence::Available); } #[test] @@ -541,8 +540,12 @@ network_policies: Box::pin(handle_tcp_connection( stream, engine, - Arc::new(BinaryIdentityCache::new()), - Arc::new(AtomicU32::new(0)), + Arc::new( + ProxyIdentityMode::static_binary( + std::env::current_exe().unwrap(), + ) + .unwrap(), + ), None, None, None, diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 2a71702b4b..16f9308eb4 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -29,6 +29,7 @@ use openshell_core::denial::DenialEvent; use openshell_core::proposals::AgentProposals; use tokio::sync::mpsc::UnboundedSender; +#[cfg(target_os = "linux")] use crate::identity::BinaryIdentityCache; use crate::l7::tls::{ CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle, @@ -36,7 +37,7 @@ use crate::l7::tls::{ }; use crate::opa::OpaEngine; use crate::policy_local::PolicyLocalContext; -use crate::proxy::ProxyHandle; +use crate::proxy::{ProxyHandle, ProxyIdentityMode}; #[cfg(target_os = "linux")] pub struct TransparentRuntimeSetup { @@ -161,6 +162,13 @@ pub struct Networking { _transparent_tcp: Option, } +#[cfg(not(target_os = "linux"))] +fn current_exe_static_identity_path() -> Result { + std::env::current_exe().map_err(|e| { + miette::miette!("failed to resolve supervisor executable for static proxy identity: {e}") + }) +} + /// Set up the networking stack: ephemeral CA + TLS state, proxy server, /// and the SSH-side proxy URL / netns FD. /// @@ -309,8 +317,9 @@ pub async fn run_networking( let _ = engine_ready_tx.send(true); } - // Identity cache for SHA256 TOFU when OPA is active. Only consumed by - // the proxy, so it's owned here. + // Linux procfs identity mode uses a SHA256 TOFU cache. Windows host mode + // uses an explicit static sandbox identity instead. + #[cfg(target_os = "linux")] let identity_cache = opa_engine.map(|_| Arc::new(BinaryIdentityCache::new())); // Generate ephemeral CA and TLS state for HTTPS L7 inspection. @@ -411,10 +420,6 @@ pub async fn run_networking( miette::miette!("Proxy mode requires an OPA engine (--rego-policy and --rego-data)") })?; - let cache = identity_cache.clone().ok_or_else(|| { - miette::miette!("Proxy mode requires an identity cache (OPA engine must be configured)") - })?; - // If the orchestrator gave us a proxy bind IP (the host-side veth IP // from the workload's netns on Linux), use it so only traffic // originating inside the namespace can reach the proxy. Otherwise the @@ -433,12 +438,25 @@ pub async fn run_networking( ) .await?; + #[cfg(target_os = "linux")] + let identity_mode = { + let cache = identity_cache.clone().ok_or_else(|| { + miette::miette!( + "Proxy mode requires an identity cache (OPA engine must be configured)" + ) + })?; + ProxyIdentityMode::procfs(cache, entrypoint_pid.clone()) + }; + #[cfg(target_os = "windows")] + let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; + #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] + let identity_mode = ProxyIdentityMode::static_binary(current_exe_static_identity_path()?)?; + let proxy_handle = ProxyHandle::start_with_bind_addr( proxy_policy, bind_addr, engine, - cache, - entrypoint_pid.clone(), + Arc::new(identity_mode), tls_state, inference_ctx, Some(provider_credentials.clone()), diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 32d93aa2e6..c0bc1ffea4 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -759,6 +759,38 @@ health_check_interval_secs = 10 # proxy_ca_bundle = "/etc/openshell/tls/proxy-ca.pem" ``` +### MXC + +The MXC driver is Windows-only and opt-in. It links into the gateway, invokes Microsoft MXC through `wxc-exec.exe`, and runs each sandbox's configured command in-driver instead of using the Linux sandbox supervisor. + +```toml +[openshell] +version = 1 + +[openshell.gateway] +bind_address = "127.0.0.1:17670" +log_level = "info" +compute_drivers = ["mxc"] + +[openshell.drivers.mxc] +wxc_exec_path = "C:\\mxc\\wxc-exec.exe" +# process_container (default) or isolation_session. +backend = "process_container" +default_configuration_id = "composable" +pc_least_privilege = false +pc_capabilities = [] +# Pattern-C governed egress. The address is a loopback seed; the driver +# allocates a unique ephemeral port for each sandbox. +egress_proxy = false +egress_proxy_addr = "" +debug = false +etw_audit = false +``` + +Set `egress_proxy = true` with a loopback seed such as `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern-C split. MXC redirects sandbox traffic to a sandbox-unique port on the configured loopback IP, where the host CONNECT proxy enforces the trimmed network policy. Governed egress requires `process_container` until MXC supports `network.proxy` for isolation sessions. + +Supply the workload command and optional working directory through the sandbox's `mxc` driver configuration, for example `{"mxc":{"command":["cmd","/c","echo hello"],"cwd":"C:\\work"}}`. + ### MicroVM Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. diff --git a/tasks/scripts/windows-msvc.ps1 b/tasks/scripts/windows-msvc.ps1 index cf1b4c8686..312b610f53 100644 --- a/tasks/scripts/windows-msvc.ps1 +++ b/tasks/scripts/windows-msvc.ps1 @@ -63,7 +63,7 @@ if (-not [int]::TryParse($BuildJobsValue, [ref] $WindowsBuildJobs) -or $WindowsB } $WindowsCargoMutex = [System.Threading.Mutex]::new($false, "Local\OpenShellWindowsMsvcCargo") -$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-supervisor-network --exclude openshell-supervisor-process --exclude openshell-vfio" +$UnsupportedDriverPackageExcludes = "--exclude openshell-driver-docker --exclude openshell-driver-kubernetes --exclude openshell-driver-kubernetes-secrets --exclude openshell-driver-podman --exclude openshell-driver-vault --exclude openshell-driver-vm --exclude openshell-sandbox --exclude openshell-supervisor-process --exclude openshell-vfio" $WindowsClippyPackageExcludes = $UnsupportedDriverPackageExcludes $WindowsClippyLintArgs = "-D warnings -A dead-code -A unused-imports -A clippy::unused-async" $BundledZ3WorkspaceFeatures = "--features openshell-prover/bundled-z3"