fix(mxc): repair Windows runtime launch and validation

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-09-30 14:09:22 -07:00
parent 7228e69364
commit 1a5a7a9db3
14 changed files with 373 additions and 101 deletions
@@ -303,6 +303,22 @@ validation; GitHub Actions does not re-run it after the full suite.
## Test Accounting Guidance
For MXC validation, also run `windows:e2e:mxc:mock` and `windows:e2e:mxc`
after the native release build. The harness uses .NET port discovery, disposable
TOML and CLI registration, and owner-only Ed25519 keys generated by OpenSSL on
PATH. Results live under `target/windows-e2e-results`; signing keys stay outside
bundles and are deleted unless `-KeepRunning` is explicitly requested.
The ordinary mock has no AppContainer token: its authenticated runtime must
reject audit before workload launch. Count `mock-audit-deny` as fail-closed wiring
coverage only. Workload scenarios skip in mock mode; never bypass audit to make
them pass. Real runtime scenarios require native ProcessContainer PSEC egress
filtering and ingress host-loopback support. `wxc-exec --probe` is authoritative,
not the Windows build number. All-skipped real runs are not passes. Separate
test-internal `SKIP` messages from Cargo's passed count in the real integration
suite, and report a verified unsupported-host rejection independently from
positive admission coverage.
When reporting `windows:ci`, distinguish these categories:
- Passed tests from the full x64 workspace test log.
+19
View File
@@ -197,6 +197,25 @@ native rather than emulated coverage.
## Validation Contract
Static MXC preflight runs before the gateway mints launch credentials. Actual
provisioning requires the validated authentication bundle and uses its runtime
generation for both the descriptor and state paths; the driver must not invent
another generation.
Legacy ProcessContainer configuration accepts the IsolationSession-only
`default_configuration_id` field without applying it. This parsing compatibility
does not enable the unsupported IsolationSession backend.
Real runtime E2E requires the native ProcessContainer PSEC contract, including
directional egress filters and ingress host-loopback support. An AppContainer
fallback or a sufficiently new OS build number alone does not establish this
contract. Probe-gated skips are not isolation coverage.
The local E2E harness uses .NET listener discovery rather than account-restricted
CIM queries. It renders disposable configuration and owner-only JWT keys without
rewriting the tracked TOML. Its nonisolating mock must fail boundary audit before
executing workloads; mock results cannot certify filesystem or network isolation.
A successful Windows build report should include:
- x64 and ARM64 `cargo check` status.
+27 -4
View File
@@ -70,6 +70,10 @@ etw_audit = false
Only `process_container` supports this architecture. `isolation_session` is
rejected during sandbox validation.
Legacy configurations may retain `default_configuration_id`; it remains unused
by ProcessContainer and does not enable IsolationSession. New configurations
should omit that field.
Supply the workload command and working directory per sandbox:
```powershell
@@ -102,13 +106,32 @@ into the host supervisor; driver-owned copies of these fields are rejected.
Run the Windows build lane on a native Windows MSVC host:
```powershell
mise run windows:check:x64
mise run windows:lint:x64
mise run windows:build:x64
mise run windows:test:mxc-real:x64
mise run --skip-tools windows:check:x64
mise run --skip-tools windows:lint:x64
mise run --skip-tools windows:build:x64
mise run --skip-tools windows:test:mxc-real:x64
mise run --skip-tools windows:e2e:mxc:mock
mise run --skip-tools windows:e2e:mxc
```
The real-MXC tests are skip-safe when `wxc-exec.exe` or the required host
capabilities are absent. A complete integration run still requires a qualified
Windows MXC host; cross-compilation validates code shape but cannot validate
ProcessContainer networking or DACL behavior.
The real runtime requires native ProcessContainer directional egress and ingress
host-loopback support, reported by `wxc-exec.exe --probe`. AppContainer fallback
cannot supply these guarantees. Count test-internal `SKIP` messages separately
from Cargo's passed count.
The E2E runner uses native release artifacts (override with `-BinaryDir`), OpenSSL
on PATH for disposable Ed25519 keys, and per-run writable fixtures under
`target/windows-e2e-results`. It preserves the tracked gateway TOML and isolates
CLI registration via `XDG_CONFIG_HOME`. Signing keys have an owner-only DACL,
remain outside result bundles, and are removed unless `-KeepRunning` is requested.
Mock E2E verifies authenticated rejection of a non-AppContainer boundary before
workload execution. Workload filesystem and network-policy scenarios skip in
mock mode; do not treat the mock as isolation or successful-runtime coverage.
`network-policy` checks admission and workload execution, not real network
enforcement. All-skipped real runs report `SKIP`, not `PASS`.
@@ -1,15 +1,9 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# network-reject.yaml — Network policy rejection scenario.
#
# A filesystem grant plus a network_policies rule. The driver rejects sandbox
# create at map time with invalid_argument naming the network rule on every
# backend until MXC has a bound, enforcing egress path.
#
# This scenario requires NO live backend — it passes even on this box and in
# mock mode because the rejection happens in the policy mapper before wxc-exec
# is invoked. It is the only scenario that never SKIPs.
# A filesystem grant plus a supervisor-owned network policy. The historical
# filename is retained for existing demo packages. The network-policy scenario
# checks admission and execution, not HTTP or network enforcement.
version: 1
filesystem_policy:
@@ -20,10 +14,12 @@ filesystem_policy:
network_policies:
test_rule:
name: test-network-reject
name: test-network-policy
endpoints:
- host: api.example.com
port: 443
protocol: rest
enforcement: enforce
access: read-only
binaries:
- path: "C:/Windows/System32/cmd.exe"
@@ -1,6 +1,9 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
[openshell]
version = 2
# MXC gateway runtime configuration.
#
# Commands, working directories, and environment variables are sandbox-scoped.
@@ -7,7 +7,7 @@
# per-scenario PASS/FAIL/SKIP(reason), prints a summary table, and exits non-zero
# only on FAIL.
#
# Every run collects its logs into a timestamped results-e2e-<stamp>\ folder and
# Every run collects its logs beneath target/windows-e2e-results and
# zips it (mirrors the sibling run-*.ps1 scripts). The bundle contains the console
# transcript, the per-scenario gateway stdout/stderr, the exact TOML rendered for
# each scenario, the policy fixture used, and a summary.txt with the verdict table.
@@ -16,9 +16,9 @@
# isolated. Workload command/cwd are supplied per sandbox through
# --driver-config-json; they are never patched into gateway configuration.
#
# Scoring (why we do NOT gate on `sandbox create` exit code):
# The ground truth is the on-disk artifact, so positive scenarios pass on
# artifact PRESENT and deny scenarios pass on the denied write being ABSENT.
# Scoring:
# Positive scenarios require successful creation and a workload artifact.
# Deny scenarios require absent denied writes plus execution evidence.
# A CONTROL write proves the agent ran when the policy grants a writable path;
# an empty policy instead requires explicit driver-launch evidence.
#
@@ -34,11 +34,13 @@
# fs-readonly - write to read-only dir is denied; control write succeeds.
# fs-default-deny - ungranted write is denied after the agent launches.
# processcontainer only.
# network-reject - network_policies rule makes sandbox create fail.
# network-policy - supervisor-owned network policy permits admission.
# mock-audit-deny - mock's non-AppContainer token fails closed before execution.
[CmdletBinding()]
param(
[string] $DemoDir = "C:\work\openshell-mxc-e2e",
[string] $DemoDir,
[string] $BinaryDir,
[string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe",
[ValidateSet("isolation_session", "process_container")]
[string] $Backend = "process_container",
@@ -63,8 +65,22 @@ $here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
# front so the transcript (started inside the guarded region below) captures the
# whole run, including pre-flight failures.
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$resultDir = Join-Path $here "results-e2e-$stamp"
$repoRoot = [IO.Path]::GetFullPath((Join-Path $here "../../.."))
$resultsRoot = Join-Path $repoRoot "target/windows-e2e-results"
$resultDir = Join-Path $resultsRoot "results-e2e-$stamp"
New-Item -ItemType Directory -Force $resultDir | Out-Null
if (-not $DemoDir) { $DemoDir = Join-Path $resultDir "work" }
if (-not $BinaryDir) {
$targetRoot = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $repoRoot "target" }
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
$target = if ($arch -eq "Arm64") { "aarch64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" }
$BinaryDir = Join-Path $targetRoot "$target/release"
}
$savedEnv = @{}
foreach ($name in @("OPENSHELL_GATEWAY_CONFIG", "OPENSHELL_GATEWAY", "OPENSHELL_MXC_MOCK_WXC", "OPENSHELL_WXC_EXEC_PATH", "OPENSHELL_COMPUTE_DRIVER", "OPENSHELL_MXC_SHARE_DIR", "XDG_CONFIG_HOME")) {
$savedEnv[$name] = [Environment]::GetEnvironmentVariable($name, "Process")
}
$secretDir = Join-Path $resultsRoot "keys-$stamp-$([guid]::NewGuid().ToString('N'))"
$transcriptStarted = $false
function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan }
@@ -139,9 +155,10 @@ function Invoke-NativeCaptured([string]$filePath, [string[]]$argumentList) {
# --- Path variables -----------------------------------------------------------
$gateway = Join-Path $here "openshell-gateway.exe"
$cli = Join-Path $here "openshell.exe"
$toml = Join-Path $here "mxc-gateway.toml"
$gateway = Join-Path $BinaryDir "openshell-gateway.exe"
$cli = Join-Path $BinaryDir "openshell.exe"
$tomlTemplate = Join-Path $here "mxc-gateway.toml"
$toml = Join-Path $resultDir "mxc-gateway.toml"
$policyDir = Join-Path $here "e2e-policies"
$cmdExe = "C:\Windows\System32\cmd.exe"
@@ -170,9 +187,24 @@ function Render-Toml {
$wxcLine = "wxc_exec_path = `"$(Esc $WxcExecPath)`""
$t = [regex]::Replace($t, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', $wxcLine)
}
$t += @"
[openshell.gateway.auth]
allow_unauthenticated_users = true
[openshell.gateway.gateway_jwt]
signing_key_path = '$(Join-Path $secretDir 'signing.pem')'
public_key_path = '$(Join-Path $secretDir 'public.pem')'
kid_path = '$(Join-Path $secretDir 'kid')'
gateway_id = 'mxc-e2e'
"@
Set-Content $toml -Value $t -Encoding UTF8
}
function Test-PortListening {
return @([Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners() | Where-Object { $_.Port -eq $Port }).Count -gt 0
}
function Start-Gw {
Remove-Item $gwLog, $gwErrLog -Force -ErrorAction SilentlyContinue
# Ephemeral in-memory DB: this is a test harness, so it must NOT write sandbox
@@ -194,7 +226,7 @@ function Start-Gw {
Get-Content $gwLog, $gwErrLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Info $_ }
throw "gateway exited early (code $($p.ExitCode)). See $gwLog."
}
if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) {
if (Test-PortListening) {
return $p
}
Start-Sleep -Milliseconds 400
@@ -269,6 +301,12 @@ function Probe-Backend([string] $backendName, [string] $wxc) {
if (-not (Test-Path $wxc)) { return @{ Live = $false; Reason = "wxc-exec not found at $wxc" } }
if ($backendName -eq "process_container") {
$probeResult = Invoke-NativeCaptured $wxc @("--probe")
if ($probeResult.ExitCode -ne 0) { throw "MXC capability probe failed: $($probeResult.Output -join ' ')" }
$capabilities = ($probeResult.Output -join "`n") | ConvertFrom-Json
if (-not $capabilities.probes.baseContainerSupportsIngressHostLoopbackAllow) {
return @{ Live = $false; Reason = "native ProcessContainer host-loopback support unavailable (processmodel PSEC contract required)" }
}
# Use a REAL directory + absolute cmd.exe: the canonical wxc-exec passes
# cwd straight to CreateProcessW and does NOT expand %TEMP% (that yields
# 0x8007010B "directory name is invalid").
@@ -384,7 +422,7 @@ try {
throw "-KeepRunning requires -Scenario: it stops after the first scenario, so a full-suite run would report PASS on partial results. Re-run with e.g. -Scenario fs-rw-positive-negative -KeepRunning."
}
foreach ($f in @($gateway, $cli, $toml)) {
foreach ($f in @($gateway, $cli, $tomlTemplate, (Join-Path $BinaryDir 'openshell-supervisor.exe'), (Join-Path $BinaryDir 'openshell-sandbox.exe'))) {
if (-not (Test-Path $f)) {
throw "Missing artifact: $f`nBuild first or run from a demo-package folder."
}
@@ -394,7 +432,21 @@ try {
}
# Capture the pristine TOML once; every scenario renders a fresh copy from this.
$tomlBase = Get-Content $toml -Raw
$tomlBase = Get-Content $tomlTemplate -Raw
$env:XDG_CONFIG_HOME = Join-Path $resultDir "cli-config"
$openssl = (Get-Command openssl -ErrorAction Stop).Source
New-Item -ItemType Directory $secretDir | Out-Null
$ownerSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
& icacls.exe $secretDir /inheritance:r /grant:r "*$($ownerSid):(OI)(CI)F" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "cannot protect disposable signing-key directory" }
foreach ($argsForKey in @(
@('genpkey', '-algorithm', 'ED25519', '-out', (Join-Path $secretDir 'signing.pem')),
@('pkey', '-in', (Join-Path $secretDir 'signing.pem'), '-pubout', '-out', (Join-Path $secretDir 'public.pem'))
)) {
$keyResult = Invoke-NativeCaptured $openssl $argsForKey
if ($keyResult.ExitCode -ne 0) { throw "disposable JWT key generation failed" }
}
Set-Content (Join-Path $secretDir 'kid') -Value 'mxc-e2e' -Encoding ASCII
# --- Mode setup -----------------------------------------------------------
@@ -408,12 +460,9 @@ try {
if ($Mock) {
$env:OPENSHELL_MXC_MOCK_WXC = "1"
Info "OPENSHELL_MXC_MOCK_WXC=1 - mock mode: enforcement simulated"
Info "OPENSHELL_MXC_MOCK_WXC=1 - authenticated runtime wiring only; NO OS isolation"
} 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"
}
@@ -423,12 +472,11 @@ try {
Ok "Backend '$Backend' is live: $($backendProbe.Reason)"
} else {
Warn "Backend '$Backend' is not live: $($backendProbe.Reason)"
Warn "Enforcement scenarios will SKIP; network-reject scenario will still run."
Warn "Runtime scenarios will SKIP; this is not a real-isolation PASS."
}
Step "Check gateway port $Port"
$busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue
if ($busy) { throw "port $Port in use (pid $($busy.OwningProcess)). Stop stale gateway first." }
if (Test-PortListening) { throw "port $Port in use. Stop stale gateway first." }
Ok "port $Port free"
Step "Prepare DemoDir + read-only source + deny-probe dir"
@@ -442,10 +490,16 @@ try {
$env:OPENSHELL_MXC_SHARE_DIR = $DemoDir
# --- Scenario definitions -------------------------------------------------
# Kind: positive | deny | create-fail
# Kind: positive | deny | audit-reject
# For deny: ControlTarget (granted, must be PRESENT) + DenyTarget (must be ABSENT).
$allScenarios = @(
@{
Name = "mock-audit-deny"; PolicyFile = Join-Path $policyDir "fs-rw.yaml"
SandboxId = "au"; Backends = "both"; Kind = "audit-reject"
PosTarget = (Join-Path $DemoDir "mock-must-not-run.txt")
Description = "authenticated mock boundary must fail audit before workload launch"
},
@{
Name = "fs-rw"; PolicyFile = Join-Path $policyDir "fs-rw.yaml"
SandboxId = "rw"
@@ -469,10 +523,11 @@ try {
Description = "empty policy; ungranted write denied"
},
@{
Name = "network-reject"; PolicyFile = Join-Path $policyDir "network-reject.yaml"
Name = "network-policy"; PolicyFile = Join-Path $policyDir "network-reject.yaml"
SandboxId = "net"
Backends = "both"; Kind = "create-fail"
Description = "network_policies rule causes sandbox create to fail (no live backend needed)"
Backends = "both"; Kind = "positive"
PosTarget = (Join-Path $DemoDir "network-policy-result.txt")
Description = "supervisor-owned network policy is accepted and workload runs (not an egress assertion)"
}
)
@@ -494,14 +549,18 @@ try {
Step "Scenario: $($sc.Name)"
Info $sc.Description
# Backend gate (deny/positive scenarios need a live backend; create-fail does not).
# Never claim isolated workload coverage from an ordinary mock token.
$skipReason = $null
if ($sc.Kind -ne "create-fail") {
if ($sc.Kind -eq "audit-reject") {
if (-not $Mock) { $skipReason = "mock-only fail-closed assertion" }
} else {
$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)"
} elseif ($Mock) {
$skipReason = "mock has no AppContainer token; runtime audit correctly rejects it"
}
}
if ($null -ne $skipReason) {
@@ -534,7 +593,7 @@ try {
$gwErrLog = Join-Path $resultDir "gateway.$($sc.Name).err.log"
# Build the per-sandbox workload command and clean prior artifacts.
if ($sc.Kind -eq "positive") {
if ($sc.Kind -eq "positive" -or $sc.Kind -eq "audit-reject") {
Remove-Item $sc.PosTarget -Force -ErrorAction SilentlyContinue
$command = @($cmdExe, "/c", "echo ok 1> $($sc.PosTarget.Replace('\', '/'))")
} elseif ($sc.Kind -eq "deny") {
@@ -543,7 +602,7 @@ try {
if ($sc.ControlTarget) {
Remove-Item $sc.ControlTarget -Force -ErrorAction SilentlyContinue
$control = $sc.ControlTarget.Replace('\', '/')
$command = @($cmdExe, "/c", "echo ok 1> $control & echo denied 1> $denied")
$command = @($cmdExe, "/c", "echo denied 1> $denied & echo ok 1> $control")
} else {
$command = @($cmdExe, "/c", "echo denied 1> $denied")
}
@@ -572,8 +631,8 @@ try {
$sandboxName = "mxc-$($sc.SandboxId)-$runId"
try { Invoke-NativeCaptured $cli @("sandbox", "delete", $sandboxName) | Out-Null } catch {}
# Run sandbox create. Its exit status is only authoritative for the
# create-fail scenario; artifacts score workload scenarios.
# Run sandbox create; require the exact audit rejection for the mock
# negative test, and artifact evidence for live workload tests.
$createOut = $null; $createExitCode = 0
try {
$createResult = Invoke-NativeCaptured $cli @(
@@ -588,30 +647,27 @@ try {
$createOut = $_.Exception.Message; $createExitCode = 1
}
$createOutStr = ($createOut -join "`n")
Info "create exit: $createExitCode (not used for scoring on non-create-fail scenarios)"
Info "create exit: $createExitCode"
$gwText = (Get-Content $gwLog, $gwErrLog -Raw -ErrorAction SilentlyContinue) -join "`n"
# Evaluate.
if ($sc.Kind -eq "create-fail") {
if ($sc.Kind -eq "audit-reject") {
# 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')
# false-pass this scenario. Require the exact boundary audit
# failure and prove that the workload did not execute.
# miette wraps the error over separately attributed log lines.
$rejected = $gwText -match '(?s)MXC sandbox[^\r\n]*\r?\n[^\r\n]*audit evidence is incomplete|MXC sandbox audit evidence is incomplete'
$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" }
if ($createExitCode -ne 0 -and $rejected -and -not $infraFail -and -not (Test-Path $sc.PosTarget)) {
Ok "$($sc.Name): authenticated boundary rejected unisolated token; workload did not run"
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "non-AppContainer boundary fails closed before workload" }
} elseif ($createExitCode -ne 0) {
Bad "$($sc.Name): create failed but not with a policy-rejection signal (possible harness/infra error)"
Bad "$($sc.Name): expected audit rejection and absent workload artifact were not both verified"
Info "output: $createOutStr"
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = "FAIL"; Reason = "non-rejection failure" }
} else {
@@ -621,7 +677,7 @@ try {
}
} elseif ($sc.Kind -eq "positive") {
$present = Wait-File $sc.PosTarget 30
if ($present) {
if ($present -and $createExitCode -eq 0) {
Ok "$($sc.Name): in-policy write produced artifact"
$results += [pscustomobject]@{ Scenario = $sc.Name; Result = "PASS"; Reason = "artifact present" }
} else {
@@ -707,7 +763,7 @@ finally {
$skipCount = @($results | Where-Object { $_.Result -eq "SKIP" }).Count
Write-Host "PASS=$passCount FAIL=$failCount SKIP=$skipCount"
$verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } else { "PASS" }
$verdict = if ($harnessError -or $failCount -gt 0) { "FAIL" } elseif ($passCount -eq 0) { "SKIP" } else { "PASS" }
$tableText = ($results | Format-Table -AutoSize | Out-String)
$summary = @"
OpenShell MXC e2e scenario run
@@ -734,7 +790,9 @@ Files in this bundle ($resultDir):
What PASS means: every non-skipped scenario met its expected verdict - positive
writes produced their artifact, deny writes were blocked with either a control
write or driver-launch evidence, and network-reject was refused by policy.
write or driver-launch evidence. Mock PASS proves authenticated fail-closed
wiring only, not successful workload execution or isolation;
network-policy proves admission and execution, not network enforcement.
"@
Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8
Write-Host $summary -ForegroundColor ($(if ($verdict -eq "PASS") { "Green" } else { "Red" }))
@@ -743,17 +801,28 @@ write or driver-launch evidence, and network-reject was refused by policy.
# Zip the bundle for easy return (defensive; never throw out of finally).
try {
$zip = Join-Path $here "results-e2e-$stamp.zip"
$zip = Join-Path $resultsRoot "results-e2e-$stamp.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force
Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow
} catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red }
if (-not ($KeepRunning -and $gw)) {
foreach ($keyFile in @('signing.pem', 'public.pem', 'kid')) {
Remove-Item -LiteralPath (Join-Path $secretDir $keyFile) -Force -ErrorAction SilentlyContinue
}
if (Test-Path $secretDir) { Remove-Item -LiteralPath $secretDir -ErrorAction SilentlyContinue }
}
foreach ($name in $savedEnv.Keys) { [Environment]::SetEnvironmentVariable($name, $savedEnv[$name], "Process") }
}
if ($harnessError -or $failCount -gt 0) {
Write-Host "`nSOME SCENARIOS FAILED" -ForegroundColor Red
exit 1
} else {
Write-Host "`nALL SCENARIOS PASSED (or SKIPPED)" -ForegroundColor Green
if ($passCount -eq 0) {
Write-Host "`nALL SCENARIOS SKIPPED - NO RUNTIME COVERAGE" -ForegroundColor Yellow
} else {
Write-Host "`nNO FAILURES: PASS=$passCount SKIP=$skipCount (see coverage limitations)" -ForegroundColor Green
}
exit 0
}
+106 -4
View File
@@ -72,6 +72,10 @@ pub struct MxcComputeConfig {
pub state_dir: PathBuf,
pub grpc_endpoint: String,
pub backend: MxcBackend,
/// Legacy isolation-session setting, accepted for existing
/// `ProcessContainer` configurations but unused by that backend.
#[serde(skip_serializing)]
pub default_configuration_id: Option<String>,
pub pc_least_privilege: bool,
pub pc_capabilities: Vec<String>,
pub pc_allow_local_network: bool,
@@ -103,6 +107,7 @@ impl Default for MxcComputeConfig {
state_dir,
grpc_endpoint: String::new(),
backend: MxcBackend::ProcessContainer,
default_configuration_id: None,
pc_least_privilege: false,
pc_capabilities: Vec::new(),
pc_allow_local_network: true,
@@ -278,9 +283,6 @@ impl MxcComputeBackend {
"mxc state_dir must be an absolute Windows path",
));
}
launch_authentication(sandbox)?
.validate()
.map_err(|error| tonic::Status::failed_precondition(error.to_string()))?;
let policy = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref());
self.map_sandbox_policy(
&sandbox.id,
@@ -328,9 +330,18 @@ impl MxcComputeBackend {
pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), tonic::Status> {
self.validate_sandbox_create(sandbox)?;
// Gateway preflight runs before it mints generation-scoped credentials.
// Require authentication at the provisioning boundary, before any state
// directories, registry entries, or runtime processes are created.
let launch = launch_authentication(sandbox)?;
launch
.validate()
.map_err(|error| tonic::Status::failed_precondition(error.to_string()))?;
let sandbox_id = sandbox.id.clone();
let sandbox_config = sandbox_config(sandbox)?;
let generation = uuid::Uuid::new_v4().to_string();
// The gateway owns the authenticated generation. A driver-local UUID
// would make the boundary descriptor disagree with the signed bundle.
let generation = safe_component(launch.supervisor.runtime_generation.as_str())?.to_string();
let host_state_dir = self
.config
.state_dir
@@ -1151,12 +1162,103 @@ fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSan
mod tests {
use super::*;
fn preflight_fixture() -> (MxcComputeBackend, DriverSandbox, tempfile::TempDir) {
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
let dir = tempfile::tempdir().expect("test directory");
let binary = std::env::current_exe().expect("test executable");
let backend = MxcComputeBackend::new(MxcComputeConfig {
grpc_endpoint: "http://127.0.0.1:1".into(),
supervisor_binary_path: binary.display().to_string(),
sandbox_binary_path: binary.display().to_string(),
state_dir: dir.path().join("not-created"),
..Default::default()
});
let serde_json::Value::Object(config) = serde_json::json!({
"command": ["C:\\Windows\\System32\\cmd.exe", "/c", "exit 0"],
"cwd": dir.path(),
}) else {
unreachable!()
};
let sandbox = DriverSandbox {
id: "preflight".into(),
name: "preflight".into(),
spec: Some(DriverSandboxSpec {
policy: Some(SandboxPolicy {
version: 1,
..Default::default()
}),
template: Some(DriverSandboxTemplate {
driver_config: Some(
openshell_core::proto_struct::json_object_to_struct(config)
.expect("config"),
),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
(backend, sandbox, dir)
}
#[test]
fn preflight_does_not_require_not_yet_minted_authentication() {
let (backend, sandbox, _dir) = preflight_fixture();
backend
.validate_sandbox_create(&sandbox)
.expect("static preflight");
assert!(!backend.config.state_dir.exists());
}
#[tokio::test]
async fn provisioning_rejects_missing_authentication_before_side_effects() {
let (backend, sandbox, _dir) = preflight_fixture();
let error = backend
.create_sandbox(&sandbox)
.await
.expect_err("authentication required");
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
assert!(error.message().contains("launch authentication"));
assert!(!backend.config.state_dir.exists());
assert!(backend.get_sandbox(&sandbox.id).await.is_none());
}
#[test]
fn capabilities_delegate_readiness_to_supervisor() {
let backend = MxcComputeBackend::new(MxcComputeConfig::default());
assert!(!backend.capabilities().driver_reports_runtime_readiness);
}
#[test]
fn legacy_isolation_setting_does_not_change_processcontainer_contract() {
let config: MxcComputeConfig = serde_json::from_value(serde_json::json!({
"backend": "process_container",
"default_configuration_id": "composable",
}))
.expect("legacy ProcessContainer config");
assert_eq!(config.backend, MxcBackend::ProcessContainer);
assert_eq!(
config.default_configuration_id.as_deref(),
Some("composable")
);
assert!(
serde_json::to_value(&config)
.expect("config")
.get("default_configuration_id")
.is_none()
);
let (mut backend, sandbox, _dir) = preflight_fixture();
backend.config.backend = MxcBackend::IsolationSession;
backend.config.default_configuration_id = config.default_configuration_id;
assert!(
backend
.validate_sandbox_create(&sandbox)
.expect_err("IsolationSession still unsupported")
.message()
.contains("requires process_container")
);
}
#[test]
fn command_line_quotes_spaces() {
assert_eq!(
+19 -13
View File
@@ -16,8 +16,8 @@ use tracing::{debug, info};
pub const MXC_SCHEMA_VERSION: &str = "0.8.0-alpha";
/// Environment flag selecting the in-process mock `wxc-exec` shim. When set to
/// `"1"`, the invoker does not spawn `wxc-exec.exe`; it simulates AppContainer
/// filesystem enforcement for the one-shot ProcessContainer launch.
/// `"1"`, the invoker does not spawn `wxc-exec.exe`. This wiring shim has no
/// `AppContainer` token and must fail runtime audit before workload execution.
pub const MOCK_ENV_VAR: &str = "OPENSHELL_MXC_MOCK_WXC";
fn mock_enabled() -> bool {
@@ -266,30 +266,36 @@ impl WxcExecInvoker {
self.mock
}
/// Mock enforcement for the one-shot `processContainer` path.
///
/// In-policy → run the real agent command (so the positive-proof artifact,
/// e.g. `hello.txt`, actually appears on the host shared folder). Out-of-policy
/// → refuse with an access-denied message on stderr and a non-zero exit,
/// mirroring how the `AppContainer` denies the write on the demo box.
/// Launch the real boundary runtime without OS isolation for wiring tests.
/// Filesystem denial must be tested with real MXC, not this mock.
fn mock_spawn_with_grants(
process: &MxcProcess,
grants: &[String],
) -> Result<tokio::process::Child, InvokerError> {
let cmd_norm = mock_normalize(&process.command_line);
// The RFC 0012 boundary is launched with its granted bootstrap directory.
// This check concerns bootstrap wiring, never workload authorization.
let in_policy = grants.iter().any(|g| !g.is_empty() && cmd_norm.contains(g));
let mut cmd = Command::new("cmd");
// Spawn the encoded executable directly: a cmd.exe intermediary would
// survive only as a parent handle while kill_on_drop orphaned its child.
let (program, arguments) = process
.command_line
.strip_prefix('"')
.map_or_else(
|| process.command_line.split_once(char::is_whitespace),
|quoted| quoted.split_once('"'),
)
.unwrap_or((&process.command_line, ""));
let mut cmd = Command::new(if in_policy { program } else { "cmd" });
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true);
if in_policy {
debug!(command = %process.command_line, "mock exec: in-policy, running agent");
// `command_line` is already encoded with Windows quoting rules.
// Pass it raw so this mock matches wxc-exec/CreateProcess instead
// of asking Rust to quote the entire command as one cmd.exe argv.
cmd.raw_arg(format!("/d /s /c \"{}\"", process.command_line));
// Retain the already-encoded Windows argv without another quoting pass.
cmd.raw_arg(arguments.trim_start());
} else {
debug!(command = %process.command_line, "mock exec: OUT-OF-POLICY, denying");
cmd.arg("/c").arg(
@@ -224,23 +224,18 @@ fn all_example_policies_split_with_expected_invariants() {
.iter()
.filter(|item| item.severity == "error")
.collect();
if policy.network_middlewares.is_empty() {
assert!(
errors.is_empty(),
"processcontainer split must not emit error losses for {}: {:?}",
path.display(),
result.loss
);
} else {
assert_eq!(
errors.len(),
1,
"middleware policy must have one fail-closed loss for {}: {:?}",
path.display(),
result.loss
);
assert_eq!(errors[0].path, "network_middlewares");
}
assert!(
errors.is_empty(),
"processcontainer split must not emit error losses for {}: {:?}",
path.display(),
result.loss
);
assert_eq!(
result.proxy_policy.network_middlewares,
policy.network_middlewares,
"supervisor handoff must preserve middleware for {}",
path.display()
);
}
}
@@ -228,7 +228,8 @@ fn dryrun_current_schema_rejects_isolation_session_ui() {
return;
}
let base = serde_json::json!({
let mut base = serde_json::json!({
"version": "0.8.0-alpha",
"phase": "provision",
"containment": "isolation_session",
"network": {
@@ -236,7 +237,18 @@ fn dryrun_current_schema_rejects_isolation_session_ui() {
"allowLocalNetwork": true,
},
});
let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, &["--experimental"]);
let args: &[&str] = if (major, minor) >= (0, 9) {
base.as_object_mut().unwrap().remove("phase");
base["version"] = serde_json::json!("0.9.0-alpha");
base["network"] = serde_json::json!({
"egress": { "default": "allow" },
"ingress": { "default": "allow", "hostLoopback": "allow" },
});
&["--experimental", "--operation", "provision"]
} else {
&["--experimental"]
};
let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, args);
let output = format!("{stdout} {stderr}").to_ascii_lowercase();
if code != 0
&& output.contains("backend_unavailable")
@@ -252,7 +264,7 @@ fn dryrun_current_schema_rejects_isolation_session_ui() {
let mut with_ui = base;
with_ui["ui"] = serde_json::json!({ "disable": true });
let (code, stdout, stderr) = dry_run_with_args(&wxc, &with_ui, &["--experimental"]);
let (code, stdout, stderr) = dry_run_with_args(&wxc, &with_ui, args);
assert_ne!(
code, 0,
"current isolation_session schema unexpectedly accepted UI\nversion={raw_version}\nstdout={stdout}\nstderr={stderr}"
@@ -462,7 +474,30 @@ fn dryrun_accepts_split_policy_output() {
);
assert!(mxc_config.get("runtimeConfig").is_none());
// --dry-run also resolves host capabilities; it is not schema-only.
// Verify explicit rejection on an AppContainer-only host rather than
// weakening the mapper's required loopback fence to obtain a green test.
let probe = Command::new(&wxc)
.arg("--probe")
.output()
.expect("MXC probe");
let probe_json = serde_json::from_slice::<serde_json::Value>(&probe.stdout).ok();
let loopback_supported = probe_json
.as_ref()
.and_then(|value| value.pointer("/probes/baseContainerSupportsIngressHostLoopbackAllow"))
.and_then(serde_json::Value::as_bool);
let (code, stdout, stderr) = dry_run(&wxc, &mxc_config);
if loopback_supported == Some(false) {
assert_ne!(code, 0, "unsupported host must reject the loopback fence");
assert!(
stderr.contains("hostLoopback"),
"unexpected rejection: {stderr}"
);
eprintln!(
"SKIP: positive split-policy admission requires native host-loopback support; unsupported-host rejection verified"
);
return;
}
assert_eq!(
code,
0,
@@ -971,6 +971,7 @@ mod tests {
));
}
#[cfg(unix)]
#[tokio::test]
async fn terminal_pump_reads_output_and_writes_input() {
let (session, mut slave) = MainSession::terminal_for_test();
+5 -1
View File
@@ -1290,7 +1290,11 @@ fn persist_main_exit_marker(path: &std::path::Path, exit_code: i32) -> std::io::
writeln!(file, "exit_code={exit_code}")?;
file.sync_all()?;
std::fs::rename(&temporary, path)?;
std::fs::File::open(parent)?.sync_all()
// Windows cannot open directories through File::open. The marker data is
// flushed above and rename still atomically replaces the previous value.
#[cfg(unix)]
std::fs::File::open(parent)?.sync_all()?;
Ok(())
}
/// Flush aggregated denial summaries to the gateway via `SubmitPolicyAnalysis`.
+2 -1
View File
@@ -554,6 +554,7 @@ mod tests {
#[test]
fn completion_marker_must_be_absolute() {
assert!(validate_main_exit_marker(Some(Path::new("relative"))).is_err());
assert!(validate_main_exit_marker(Some(Path::new("/run/openshell/main-exit"))).is_ok());
let absolute = std::env::temp_dir().join("openshell-main-exit");
assert!(validate_main_exit_marker(Some(&absolute)).is_ok());
}
}
+2
View File
@@ -927,6 +927,8 @@ etw_audit = false
The packaged supervisor and sandbox binaries default to siblings of `openshell-gateway.exe`; explicit paths are useful for development layouts. The driver protects host supervisor tokens and descriptors with an owner-only Windows DACL.
Existing ProcessContainer configurations may retain `default_configuration_id`. This legacy IsolationSession-only field is accepted for configuration compatibility but has no effect on ProcessContainer. It does not enable the unsupported `isolation_session` backend; omit it in new configurations.
Supply the workload command and working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["C:\\Windows\\System32\\cmd.exe","/d","/c","echo hello"],"cwd":"C:\\work"}}`. Both are required. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration.
The driver assigns distinct Sandbox Protocol and proxy listeners plus fresh credentials to every generation. The host proxy rejects missing, invalid, duplicate, or cross-sandbox proxy credentials before forwarding. MXC denies direct Internet egress and permits only the `127.0.0.1/32` route required by the authenticated transport and proxy. That loopback exception does not isolate unrelated host services. The current explicit-proxy path attributes descendant traffic to the admitted main workload binary rather than resolving each Windows socket owner. Treat the gateway host as trusted and avoid policies that rely on different network rights for child executables.