From 61cfb186a6e65be84671db6753c7c45c5156874a Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 22:16:17 +0800 Subject: [PATCH 01/14] test(windows): close packaged sandbox lifecycle evidence --- docs/architecture/windows-sandbox-rfc-v1.md | 37 +- .../windows-sandbox-rfc-v1.zh-CN.md | 33 +- docs/windows-support.md | 7 +- .../adversarial-matrix-smoke.ps1 | 260 ++++++++++++++ .../windows-sandbox/launcher/Cargo.toml | 2 + .../launcher/src/broker_client.rs | 69 +++- .../windows-sandbox/launcher/src/main.rs | 175 +++++++++- .../launcher/src/windows_launcher.rs | 52 ++- scripts/verify-windows-harness.test.mjs | 79 ++++- scripts/verify-windows-sandbox-e2e.mjs | 323 +++++++++++++++++- 10 files changed, 1014 insertions(+), 23 deletions(-) create mode 100644 experiments/windows-sandbox/adversarial-matrix-smoke.ps1 diff --git a/docs/architecture/windows-sandbox-rfc-v1.md b/docs/architecture/windows-sandbox-rfc-v1.md index 62fda5d35a..ba6e9ef57b 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.md +++ b/docs/architecture/windows-sandbox-rfc-v1.md @@ -238,7 +238,7 @@ Lexical prefix checks are never authorization evidence. retains only the exit result, so setup version and failure stage are not yet propagated — deferred with the structured unavailable reasons, see §6.5.)_ -### 6.5 Preview implementation status (2026-08-17) +### 6.5 Preview implementation status (2026-08-24) The first product slice — the packaged Windows 11 x64 AppContainer backend in [#2961](https://github.com/maka-agent/maka-agent/pull/2961), merged 2026-08-17 — enforces a subset @@ -260,6 +260,14 @@ Enforced (merged in #2961 unless tagged with a follow-up PR): - inheritance limited to declared stdio/protocol handles through `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` (§6.3); - a closed, sorted, allowlisted environment (§6.3); +- a kernel-observed Runtime Host owner handle on the packaged one-shot broker: owner exit interrupts + the first launch, terminates and drains the AppContainer Job, and releases the launch ledger/ACEs; +- a packaged 64-launch repeated-wave concurrency soak with disjoint launch identities, followed by + process and ACL-ledger residue assertions; +- a packaged malicious-child matrix covering recursive junction and multi-hard-link admission, + outside-file access, TCP/UDP/listener creation, host named-pipe access, ambient environment, + host HKCU values, parent-token access, descendant AppContainer/Job inheritance, and quarantined + identity non-reuse; - per-launch private-desktop **placement** (§6.3) **(#3174)**: each production launch and the readiness probe create an alternate desktop on the current window station whose DACL grants only the launching user, Local System, and that launch's AppContainer SID — the SID getting only minimal non-interactive @@ -336,6 +344,9 @@ Designed but deferred as later gates (not enforced in the preview slice): is composed once when a candidate is built — so recovery from a transient negative in an already-running host is scoped to a new composition build or a restart. An active readiness retry with dynamic worker publication is deferred. +- Direct Windows Credential Manager/DPAPI isolation evidence. The packaged W1 matrix proves that + ambient credential files and environment secrets are not granted or inherited, but direct + `CredRead`/DPAPI probes remain a W2/W3 hardening gate. Deferral narrows readiness richness and desktop-layer defense-in-depth, not the enforcement boundary: an unavailable, drifted, or failed backend still fails closed, and a restricted managed @@ -425,7 +436,8 @@ closed. The authorized path can call only the AppContainer atomic launcher. - [x] compose capability detection into Runtime Host managed execution; - [x] package and verify the x64 native resource; - [x] fail closed when the resource or capability is unavailable; -- [ ] finish cancellation, parent-death, concurrency, and residual-state release tests. +- [x] finish cancellation, parent-death, concurrency, and residual-state release tests through the + packaged `FilesystemWorkerClient`/broker path. This is the first user-visible sandbox milestone. Remaining unchecked evidence limits the support claim; it does not permit an unsandboxed fallback. @@ -445,6 +457,12 @@ claim; it does not permit an unsandboxed fallback. - document unsupported environments and recovery; - only then mark Phase 4 complete or advertise restricted profiles as supported. +The packaged W1 matrix is release-blocking and machine-readable. It closes the executable evidence +for the currently shipped filesystem-worker surface, not the wider W2 general-command claim. +Authenticode identity, direct Credential Manager/DPAPI probes, no-Win32k, dedicated window-station +and clipboard isolation, and power-loss automatic recovery remain explicit later gates. Independent +human security review remains mandatory even when every automated row is green. + ## 10. Required release evidence The Windows sandbox job must execute positive and negative child-process tests for: @@ -460,6 +478,21 @@ The Windows sandbox job must execute positive and negative child-process tests f - every durable setup, ACL, firewall/WFP, and marker publication failpoint; - installer/upgrade/uninstall verification of the exact signed launcher and complete state cleanup. +For the W1 preview, the packaged verifier maps the supported attack surface to executable evidence: + +| Category | Packaged evidence | +| --- | --- | +| Filesystem aliases | outside denial plus recursive junction and multi-hard-link admission refusal | +| Network channels | TCP connect, UDP bind, and TCP listener denial without network capabilities | +| IPC | host named-pipe denial and an explicit inherited-handle list | +| Descendants | child-created descendant retains the AppContainer token and kill-on-close Job | +| Environment/credentials | ambient host secret and outside credential file are unavailable | +| Registry/parent | host HKCU value and parent process token are unavailable | +| Lifecycle | timeout, cancellation, Runtime Host death, broker death, 64-launch soak, quarantine non-reuse | + +Rows that require a feature the W1 preview does not expose remain fail-closed and explicitly deferred +above; they are not counted as passing evidence for a broader shell/general-command tier. + Generated flags and unit tests are necessary but are not security evidence. A passing test must show that the denied operation fails in a real child and that no process or unknown durable authorization remains. diff --git a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md index 830108c8da..bde5139fa3 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md +++ b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md @@ -162,7 +162,7 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 - restricted managed profile 在 `auto`/`require` 下绝不 fallback host execution; - diagnostics 只暴露 backend、setup version 与 failure stage,不暴露 path、SID、credential、env 或 firewall detail。 _(后续门禁:probe 以 `stdio: 'ignore'` 运行且只保留退出结果,setup version 与 failure stage 尚未传播,与结构化 unavailable reason 一并暂缓 —— 见 §6.5。)_ -### 6.5 预览实现状态(2026-08-17) +### 6.5 预览实现状态(2026-08-24) 首个预览切片 [#2961](https://github.com/maka-agent/maka-agent/pull/2961) 已于 2026-08-17 合并,强制上述保证的一个子集。本节把文档与已交付代码对齐,使 RFC 不 overclaim:§6.3/§6.4 中尚未强制的保证在此显式标为后续门禁。标注 `(#3161)` 的条目落在 readiness-probe 后续 PR,而非已合并的 #2961 切片;其余条目由 #2961 当前强制。 @@ -175,6 +175,13 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 - 创建时原子附加、close 时杀整棵树的 kill-on-close Job(§6.3); - 仅通过 `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` 继承声明的 handle(§6.3); - 封闭、排序后的 allowlist 环境(§6.3); +- 打包 one-shot broker 持有由内核进程表确认的 Runtime Host 父进程 wait handle:Host 退出会 + 中断首次启动、终止并 drain AppContainer Job,并释放本次 ledger/ACE; +- 打包路径执行 64 次、按波次重复的并发 soak,每次使用互不相同的启动 identity,最后断言无进程与 + ACL-ledger 残留; +- 打包恶意 child 矩阵覆盖递归 junction 与多硬链接准入、outside 文件、TCP/UDP/listener、宿主 named + pipe、ambient 环境、宿主 HKCU、父进程 token、descendant 的 AppContainer/Job 继承,以及 quarantine + identity 不复用; - 按启动的 private desktop **放置(placement)**(§6.3)**(#3174)**:每次生产启动与 readiness probe 均在当前 window station 上创建 alternate desktop,其 DACL 仅授予发起用户、Local System 与该次启动的 AppContainer SID(且只给该 SID 最小非交互权限;并以前置 deny ACE 从 AppContainer 子进程有效携带的发起用户 SID 上剥离 `DESKTOP_SWITCHDESKTOP`/`DESKTOP_HOOKCONTROL`/journal 录制回放),并以 `STARTUPINFOW.lpDesktop` 指向它启动子进程,建不出或授不了即 fail closed。桌面钉在 Low integrity(`S:(ML;;NW;;;LW)`)使授予权限对 Low-IL 子进程通过 MIC,且 heap 经 `CreateDesktopExW` 按启动限额(512 KiB)使受支持并发不会耗尽系统 desktop heap。由于 `lpDesktop` 只选择*初始*桌面,这把 worker 放置到交互 `Default` 桌面之外并对私有桌面做 DACL 保护;这是 placement 加 DACL 保护、**不是**防逃逸边界——没有结构性机制阻止进程内代码 `OpenDesktopW("Default")` + `SetThreadDesktop` 重新挂回,clipboard 也归 window station、仍为共用(no-Win32k mitigation、独立 window station 与 token 边界见下方暂缓门禁); - 生产 identity readiness probe(§6.4)**(#3161)**:`--readiness-probe` 真正建立 AppContainer identity/token、kill-on-close Job 与 private desktop 并在该桌面上启动抛弃式受限子进程(`cmd.exe /d /c exit 0`,以 `/d` 关闭 AutoRun 使宿主 shell 定制不能扭曲结果),使可用性在宿主无法创建边界时 fail closed,而非仅凭打包二进制存在;成功时输出机器可读 attestation(精确 SID 匹配、特定 Job membership、settlement、private-desktop placement),发布冒烟逐字段断言,使该 gate 不会静默退化为空洞的 exit-0 检查; - 专属且跨进程串行的 readiness profile 生命周期(§6.4)**(#3161)**:probe profile 位于与生产不相交的命名空间,其保留 `requestId` 被 validation 拒绝,一个 DACL 加固的按用户命名互斥量串行其 delete→create→probe→drop 生命周期,未证清空的 probe 按周期 fail closed 而非宣称边界干净(清理依赖 kill-on-close Job 与零权限 identity,而非持久隔离),负可用性按有界 TTL 缓存以限制一次瞬时失败毒化 module 缓存的时长——由下一次 composition 构建重探,而非运行中宿主原地恢复; @@ -195,6 +202,8 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 端到端竞态 harness。 - 未证清空的 readiness identity 的持久隔离(§6.4):probe 无法证明其 Job 清空时按该周期 fail closed,下一次 probe 在锁下删除并重建那个固定 identity。残余风险有界——readiness 子进程是被授零 filesystem root 的 `cmd.exe /c exit 0`,一个假设存活的子进程既不 spawn 任何东西也继承不到任何 ACE 权限,且 kill-on-close Job 会终止整树——但该 identity 未被持久隔离。持久隔离(或每次 probe 用唯一 identity 加 orphan/对账 ledger)暂缓。 - 运行中宿主的主动 readiness 恢复(§6.4):负可用性结果由 TTL 限时,使其不会长时间毒化 module 缓存,并由**下一次 composition 构建**重探。运行中的 Runtime Host 不会主动重探或热发布 filesystem worker——worker 在候选构建时一次性组装——故已判负的运行中宿主对瞬时负结果的恢复被限定到新 composition 构建或重启。带动态 worker 发布的主动 readiness 重试暂缓。 +- Windows Credential Manager/DPAPI 的直接隔离证据:打包 W1 矩阵已证明 ambient credential 文件与 + 环境 secret 不会被授权或继承,但直接 `CredRead`/DPAPI probe 仍是 W2/W3 后续加固门禁。 暂缓收窄的是 readiness 丰富度与 desktop 层的 defense-in-depth,而非强制边界本身:backend 不可用、identity drift 或启动失败仍然 fail closed,受限 managed profile 也绝不回退到宿主执行。 @@ -275,7 +284,7 @@ frame 一律 fail closed;授权路径只能调用 AppContainer atomic launcher - [x] 把 capability detection 接入 Runtime Host managed execution; - [x] 打包并验证 x64 native resource; - [x] resource/capability 不可用时 fail closed; -- [ ] 完成 cancel、parent-death、并发和残留状态发布测试。 +- [x] 通过打包 `FilesystemWorkerClient`/broker 路径完成 cancel、parent-death、并发和残留状态发布测试。 这是第一个用户可见沙箱里程碑。未勾选证据限制支持声明,但绝不允许 unsandboxed fallback。 @@ -294,6 +303,11 @@ frame 一律 fail closed;授权路径只能调用 AppContainer atomic launcher - 文档化不支持环境与恢复方法; - 只有此后才勾选 Phase 4 或宣称 Windows restricted profile 受支持。 +打包 W1 矩阵是 release-blocking 且 machine-readable 的。它收口当前已交付 filesystem-worker 表面的 +可执行证据,不等于更宽的 W2 通用命令声明。Authenticode identity、Credential Manager/DPAPI 直接 +probe、no-Win32k、独立 window station/clipboard 隔离及断电自动恢复仍是明确的后续门禁。即便自动化 +矩阵全绿,独立人工安全评审仍不可省略。 + ## 10. 必需发布证据 Windows sandbox job 必须运行真实 child-process 正反测试: @@ -308,6 +322,21 @@ Windows sandbox job 必须运行真实 child-process 正反测试: - 每个持久 setup、ACL、firewall/WFP、marker publication failpoint; - installer/upgrade/uninstall 对 exact signed launcher 与完整状态清理的验证。 +对于 W1 预览版,打包 verifier 将受支持攻击面映射到以下可执行证据: + +| 类别 | 打包证据 | +| --- | --- | +| 文件别名 | outside 拒绝,加递归 junction 与多硬链接准入拒绝 | +| 网络通道 | 无网络 capability 时拒绝 TCP connect、UDP bind 与 TCP listener | +| IPC | 拒绝宿主 named pipe,并只继承显式 handle 列表 | +| descendant | child 创建的 descendant 仍持有 AppContainer token 与 kill-on-close Job | +| 环境/credential | ambient host secret 与 outside credential 文件均不可用 | +| registry/父进程 | 宿主 HKCU 值与父进程 token 均不可用 | +| 生命周期 | timeout、cancel、Runtime Host 死亡、broker 死亡、64 次 soak、quarantine 不复用 | + +W1 预览版未暴露的能力继续 fail closed,并按上文显式 deferred;不能把它们计作更宽 shell/通用命令 +tier 的通过证据。 + 只检查生成 flag 的 unit test 不是安全证据。绿色测试必须证明真实 child 的禁止操作失败,且没有残留进程或未知 durable authorization。 diff --git a/docs/windows-support.md b/docs/windows-support.md index 7924e5a14f..1799917080 100644 --- a/docs/windows-support.md +++ b/docs/windows-support.md @@ -19,7 +19,7 @@ # Windows support baseline -Windows is an active enablement target, not a fully supported Maka platform yet. The CLI and Electron desktop application can run from source, and release workflows produce a verified unsigned Windows x64 preview. The x64 package includes an AppContainer sandbox for restricted managed execution, and automatic updates are verified end to end in CI on the unsigned preview channel; signing, the complete adversarial sandbox matrix, and computer-use guarantees remain incomplete. Progress is tracked in [GitHub issue #2142](https://github.com/apache/maka/issues/2142). +Windows is an active enablement target, not a fully supported Maka platform yet. The CLI and Electron desktop application can run from source, and release workflows produce a verified unsigned Windows x64 preview. The x64 package includes an AppContainer sandbox for the managed filesystem-worker surface, with packaged lifecycle and adversarial evidence, and automatic updates are verified end to end in CI on the unsigned preview channel. Signing, the wider general-command sandbox tier, direct Credential Manager/DPAPI probes, independent security review, and computer-use guarantees remain incomplete. Progress is tracked in [GitHub issue #2142](https://github.com/apache/maka/issues/2142). ## Install the Windows x64 preview @@ -224,6 +224,11 @@ The root test timeout is tracked separately from individual test failures. Phase - PTY execution uses ConPTY through `node-pty`; process-tree termination uses `taskkill /T` where required. - Restricted managed profiles use the packaged AppContainer broker when available and fail closed when the native capability or requested policy is unavailable. +- The packaged filesystem-worker gate covers client cancellation, Runtime Host parent death, + repeated concurrent launches, ACL quarantine isolation, filesystem aliases, restricted network, + host named pipes, ambient environment, host registry values, parent tokens, and descendant + AppContainer/Job inheritance. It does not claim the deferred no-Win32k/window-station tier or + direct Credential Manager/DPAPI isolation. - Computer-use has no Windows backend. - The Windows x64 NSIS installer is unsigned. The in-app automatic-update path (electron-updater → NSIS handoff → relaunch) is verified end to end in CI against a loopback feed; the production diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 new file mode 100644 index 0000000000..409575ec29 --- /dev/null +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -0,0 +1,260 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +param( + [Parameter(Mandatory = $true)] + [string]$LauncherPath +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false + +$launcher = (Resolve-Path -LiteralPath $LauncherPath).Path +$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } +$workRoot = Join-Path $tempRoot "maka-phase4-adversarial-$PID" +$ledgerRoot = Join-Path ([IO.Path]::GetTempPath()) 'maka-sandbox-acl-ledgers' +$registrySubkey = "Software\Maka\SandboxPhase4\$PID" +$registryPath = "HKCU:\$registrySubkey" +$registryValueName = 'HostSecret' +$pipeShortName = "maka-phase4-host-$PID" +$pipeName = "\\.\pipe\$pipeShortName" +$hostSecretName = 'MAKA_PHASE4_HOST_SECRET' +$listener = $null +$pipe = $null +$quarantinedSid = $null +$quarantineRoot = $null +$quarantinePath = $null + +function Write-LaunchRequest { + param( + [string]$Name, + [string[]]$Arguments, + [string[]]$ReadRoots, + [string[]]$WriteRoots, + [string[]]$ExactReadRoots, + [string[]]$ExactWriteRoots + ) + $request = [ordered]@{ + version = 1 + requestId = $Name + executable = $launcher + arguments = $Arguments + cwd = Split-Path -Parent $launcher + readRoots = $ReadRoots + writeRoots = $WriteRoots + exactReadRoots = $ExactReadRoots + exactWriteRoots = $ExactWriteRoots + network = 'restricted' + environment = @{ MAKA_PHASE4_ALLOWED = 'allowed' } + timeoutMs = 120000 + } + $path = Join-Path $workRoot "$Name.json" + $request | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $path -Encoding utf8 + return $path +} + +function Invoke-ExpectedAdmissionFailure { + param( + [string]$RequestPath, + [string]$Pattern, + [string]$Description + ) + $output = & $launcher --appcontainer $RequestPath 2>&1 + $exitCode = $LASTEXITCODE + $rendered = $output -join "`n" + if ($exitCode -eq 0 -or $rendered -notmatch $Pattern) { + throw "$Description did not fail closed: exit=$exitCode output=$rendered" + } + $global:LASTEXITCODE = 0 +} + +function Get-AppContainerSid { + param([string]$RequestId) + $sid = (& $launcher --appcontainer-sid $RequestId 2>&1) -join '' + if ($LASTEXITCODE -ne 0 -or $sid -notmatch '^S-1-15-2-') { + throw "Unable to resolve AppContainer SID for ${RequestId}: $sid" + } + return $sid +} + +function Get-Sha256Hex { + param([string]$Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Value)) | ForEach-Object { + $_.ToString('x2') + }) + } finally { + $sha.Dispose() + } +} + +function Get-AclText { + param([string]$Path) + return (& icacls.exe $Path 2>&1) -join "`n" +} + +New-Item -ItemType Directory -Path $workRoot -Force | Out-Null +New-Item -ItemType Directory -Path $ledgerRoot -Force | Out-Null + +try { + $outsideRoot = Join-Path $workRoot 'outside' + $allowedRoot = Join-Path $workRoot 'allowed' + New-Item -ItemType Directory -Path $outsideRoot, $allowedRoot | Out-Null + $deniedPath = Join-Path $outsideRoot 'host-secret.txt' + $allowedReadPath = Join-Path $allowedRoot 'read.txt' + $allowedWritePath = Join-Path $allowedRoot 'write.txt' + [IO.File]::WriteAllText($deniedPath, 'must-not-be-readable') + [IO.File]::WriteAllText($allowedReadPath, 'allowed-read') + [IO.File]::WriteAllText($allowedWritePath, 'seeded') + + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port + $pipe = [IO.Pipes.NamedPipeServerStream]::new( + $pipeShortName, + [IO.Pipes.PipeDirection]::InOut, + 1, + [IO.Pipes.PipeTransmissionMode]::Byte, + [IO.Pipes.PipeOptions]::Asynchronous + ) + New-Item -Path $registryPath -Force | Out-Null + Set-ItemProperty -Path $registryPath -Name $registryValueName -Value 'registry-secret' + [Environment]::SetEnvironmentVariable($hostSecretName, 'environment-secret', 'Process') + + $probeInputPath = Join-Path $allowedRoot 'adversarial-probe.json' + [ordered]@{ + deniedPath = $deniedPath + allowedReadPath = $allowedReadPath + allowedWritePath = $allowedWritePath + loopbackPort = $port + pipeName = $pipeName + environmentSecretName = $hostSecretName + registrySubkey = $registrySubkey + registryValueName = $registryValueName + parentPid = $PID + } | ConvertTo-Json | Set-Content -LiteralPath $probeInputPath -Encoding utf8 + + $probeRequest = Write-LaunchRequest -Name "phase4-adversarial-$PID" ` + -Arguments @('--adversarial-probe', $probeInputPath) ` + -ReadRoots @($probeInputPath, $allowedReadPath) ` + -WriteRoots @($allowedWritePath) ` + -ExactReadRoots @($probeInputPath, $allowedReadPath) ` + -ExactWriteRoots @($allowedWritePath) + $output = & $launcher --appcontainer $probeRequest 2>&1 + $exitCode = $LASTEXITCODE + $rendered = $output -join "`n" + $requiredEvidence = @( + '"fileDenied":true', + '"allowedRead":true', + '"allowedWrite":true', + '"tcpDenied":true', + '"udpDenied":true', + '"listenerDenied":true', + '"namedPipeDenied":true', + '"environmentDenied":true', + '"registryDenied":true', + '"parentTokenDenied":true', + '"descendantAppContainer":true', + '"descendantInJob":true' + ) + $missingEvidence = @($requiredEvidence | Where-Object { $rendered -notmatch [regex]::Escape($_) }) + if ($exitCode -ne 0 -or $missingEvidence.Count -gt 0) { + throw "Packaged adversarial probe failed: exit=$exitCode missing=$($missingEvidence -join ', ') output=$rendered" + } + + # Recursive roots fail admission when any entry redirects to another tree. + $junctionRoot = Join-Path $workRoot 'junction-root' + New-Item -ItemType Directory -Path $junctionRoot | Out-Null + New-Item -ItemType Junction -Path (Join-Path $junctionRoot 'escape') -Target $outsideRoot | Out-Null + $junctionRequest = Write-LaunchRequest -Name "phase4-junction-$PID" ` + -Arguments @('--self-probe') -ReadRoots @($junctionRoot) -WriteRoots @() ` + -ExactReadRoots @() -ExactWriteRoots @() + Invoke-ExpectedAdmissionFailure -RequestPath $junctionRequest -Pattern 'reparse point' ` + -Description 'Junction alias admission' + + # Recursive roots also reject a file whose content is reachable through a + # second hard-link name outside the admitted tree. + $hardLinkRoot = Join-Path $workRoot 'hardlink-root' + New-Item -ItemType Directory -Path $hardLinkRoot | Out-Null + $hardLinkOutside = Join-Path $outsideRoot 'hardlink-source.txt' + [IO.File]::WriteAllText($hardLinkOutside, 'hardlink-secret') + New-Item -ItemType HardLink -Path (Join-Path $hardLinkRoot 'alias.txt') ` + -Target $hardLinkOutside | Out-Null + $hardLinkRequest = Write-LaunchRequest -Name "phase4-hardlink-$PID" ` + -Arguments @('--self-probe') -ReadRoots @($hardLinkRoot) -WriteRoots @() ` + -ExactReadRoots @() -ExactWriteRoots @() + Invoke-ExpectedAdmissionFailure -RequestPath $hardLinkRequest -Pattern 'multiple hard links' ` + -Description 'Hard-link alias admission' + + # An unsettled identity is never interpreted or reused by later launches. + # The synthetic quarantined record carries a live ACE so this verifies both + # preservation-for-inspection and fresh-identity isolation. The test removes + # its own synthetic residue in finally. + $quarantineRequestId = "phase4-unsettled-$PID" + $quarantinedSid = Get-AppContainerSid $quarantineRequestId + $quarantineRoot = Join-Path $workRoot 'quarantined-root' + New-Item -ItemType Directory -Path $quarantineRoot | Out-Null + & icacls.exe $quarantineRoot /grant "*$quarantinedSid`:(OI)(CI)RX" /T /Q | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Unable to seed quarantined AppContainer ACE' } + $quarantinePath = Join-Path $ledgerRoot "$(Get-Sha256Hex $quarantineRequestId).json.quarantined" + [ordered]@{ + version = 2 + requestId = $quarantineRequestId + appContainerSid = $quarantinedSid + roots = @([ordered]@{ + path = $quarantineRoot + read = $true + write = $false + readRecursive = $true + writeRecursive = $false + }) + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $quarantinePath -Encoding utf8 + + $recoveryRequestId = "phase4-quarantine-recovery-$PID" + $recoverySid = Get-AppContainerSid $recoveryRequestId + if ($recoverySid -eq $quarantinedSid) { + throw 'A later launch reused the quarantined AppContainer identity' + } + $recoveryRequest = Write-LaunchRequest -Name $recoveryRequestId ` + -Arguments @('--self-probe') -ReadRoots @($allowedReadPath) -WriteRoots @() ` + -ExactReadRoots @($allowedReadPath) -ExactWriteRoots @() + $recoveryOutput = & $launcher --appcontainer $recoveryRequest 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Launch after quarantined state failed: $($recoveryOutput -join "`n")" + } + if (-not (Test-Path -LiteralPath $quarantinePath)) { + throw 'A later launch interpreted or deleted quarantined recovery evidence' + } + if ((Get-AclText $quarantineRoot) -notmatch [regex]::Escape($quarantinedSid)) { + throw 'A later launch removed authority whose Job was not proven empty' + } + + Write-Host "Phase 4 adversarial matrix verified: $rendered" +} finally { + if ($listener) { $listener.Stop() } + if ($pipe) { $pipe.Dispose() } + [Environment]::SetEnvironmentVariable($hostSecretName, $null, 'Process') + Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue + if ($quarantineRoot -and $quarantinedSid) { + & icacls.exe $quarantineRoot /remove "*$quarantinedSid" /T /L /Q 2>$null | Out-Null + } + if ($quarantinePath) { + Remove-Item -LiteralPath $quarantinePath -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/experiments/windows-sandbox/launcher/Cargo.toml b/experiments/windows-sandbox/launcher/Cargo.toml index 4ec68f970a..57953a789e 100644 --- a/experiments/windows-sandbox/launcher/Cargo.toml +++ b/experiments/windows-sandbox/launcher/Cargo.toml @@ -42,9 +42,11 @@ windows-sys = { version = "0.61", features = [ "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_IO", "Win32_System_Pipes", + "Win32_System_Registry", "Win32_System_StationsAndDesktops", "Win32_System_Threading", ] } diff --git a/experiments/windows-sandbox/launcher/src/broker_client.rs b/experiments/windows-sandbox/launcher/src/broker_client.rs index ccc458a4d9..f755bdfb02 100644 --- a/experiments/windows-sandbox/launcher/src/broker_client.rs +++ b/experiments/windows-sandbox/launcher/src/broker_client.rs @@ -28,8 +28,15 @@ use std::time::{Duration, Instant}; use windows_sys::Win32::Foundation::{ CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, }; -use windows_sys::Win32::Storage::FileSystem::{CreateFileW, OPEN_EXISTING, ReadFile, WriteFile}; -use windows_sys::Win32::System::Threading::GetCurrentProcessId; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, OPEN_EXISTING, ReadFile, SYNCHRONIZE, WriteFile, +}; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcessId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, +}; use crate::broker_authorization::BrokerAuthorizer; use crate::broker_framing::{MAX_BROKER_MESSAGE_BYTES, decode_frame, encode_frame}; @@ -44,7 +51,12 @@ pub fn run_local(manifest_path: &str) -> Result { .map_err(|error| format!("remove local broker manifest failed: {error}"))?; let mut request: BrokerLaunchRequest = serde_json::from_str(&source) .map_err(|error| format!("invalid local broker manifest: {error}"))?; - request.client_pid = unsafe { GetCurrentProcessId() }; + // The packaged broker is a direct child of Runtime Host. Bind the + // manifest to that kernel-observed parent and keep a wait handle open for + // the whole launch. If Runtime Host dies, the broker terminates and drains + // the AppContainer Job instead of leaving the worker alive until timeout. + let owner_pid = parent_process_id()?; + request.client_pid = owner_pid; request.validate()?; // The packaged path is a same-process one-shot launch. Authorize and hand // off directly instead of routing through a synchronous named-pipe thread; @@ -59,7 +71,42 @@ pub fn run_local(manifest_path: &str) -> Result { error.message() ) })?; - windows_launcher::launch_appcontainer(&request.launch) + let owner = unsafe { + OpenProcess( + SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, + 0, + owner_pid, + ) + }; + if owner.is_null() { + return Err(last_error("OpenProcess(local broker owner)")); + } + let result = windows_launcher::launch_appcontainer_owned(&request.launch, owner); + unsafe { CloseHandle(owner) }; + result +} + +fn parent_process_id() -> Result { + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(last_error("CreateToolhelp32Snapshot(local broker owner)")); + } + let current = unsafe { GetCurrentProcessId() }; + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dwSize = std::mem::size_of::() as u32; + let mut found = None; + let mut next = unsafe { Process32FirstW(snapshot, &mut entry) } != 0; + while next { + if entry.th32ProcessID == current { + found = Some(entry.th32ParentProcessID); + break; + } + next = unsafe { Process32NextW(snapshot, &mut entry) } != 0; + } + unsafe { CloseHandle(snapshot) }; + found + .filter(|pid| *pid > 0) + .ok_or_else(|| "local broker parent process was not found".to_owned()) } pub fn run(pipe_name: &str, manifest_path: &str) -> Result { @@ -94,6 +141,20 @@ pub fn run(pipe_name: &str, manifest_path: &str) -> Result { } } +#[cfg(test)] +mod tests { + use super::parent_process_id; + use windows_sys::Win32::System::Threading::GetCurrentProcessId; + + #[test] + fn resolves_a_real_parent_process_without_self_binding() { + let parent = parent_process_id().expect("test harness parent process"); + let current = unsafe { GetCurrentProcessId() }; + assert_ne!(parent, 0); + assert_ne!(parent, current); + } +} + fn connect(pipe_name: &str) -> Result { let name = wide(pipe_name); let deadline = Instant::now() + Duration::from_secs(10); diff --git a/experiments/windows-sandbox/launcher/src/main.rs b/experiments/windows-sandbox/launcher/src/main.rs index 421dabcf98..d9b59a58d4 100644 --- a/experiments/windows-sandbox/launcher/src/main.rs +++ b/experiments/windows-sandbox/launcher/src/main.rs @@ -47,10 +47,12 @@ mod windows_launcher_tests; use std::env; use std::fs; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; -use std::process::ExitCode; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}; +use std::process::{Command, ExitCode}; use std::time::Duration; +use serde::Deserialize; + use broker_authorization::BrokerAuthorizer; use broker_framing::{decode_frame, encode_frame}; use broker_pipe::serve_once; @@ -144,6 +146,19 @@ fn run() -> Result { port, ); } + if first == "--adversarial-probe" { + let input_path = args + .next() + .ok_or_else(|| "--adversarial-probe requires an input path".to_owned())?; + if args.next().is_some() { + return Err("--adversarial-probe accepts exactly one input path".to_owned()); + } + let source = fs::read_to_string(&input_path) + .map_err(|error| format!("read adversarial probe input failed: {error}"))?; + let input: AdversarialProbeInput = serde_json::from_str(&source) + .map_err(|error| format!("decode adversarial probe input failed: {error}"))?; + return adversarial_probe(&input); + } if first == "--launch-digest" { let request_path = args .next() @@ -314,6 +329,162 @@ fn boundary_probe( Ok(0) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AdversarialProbeInput { + denied_path: String, + allowed_read_path: String, + allowed_write_path: String, + loopback_port: u16, + pipe_name: String, + environment_secret_name: String, + registry_subkey: String, + registry_value_name: String, + parent_pid: u32, +} + +/// Malicious-child probe for the packaged Phase 4 matrix. Every check runs +/// from inside the production AppContainer identity. A false field is a hard +/// failure: the release verifier must never turn an unavailable probe into a +/// vacuous pass. +fn adversarial_probe(input: &AdversarialProbeInput) -> Result { + let file_denied = fs::read(&input.denied_path).is_err(); + let allowed_read = matches!(fs::read_to_string(&input.allowed_read_path), Ok(value) if value == "allowed-read"); + let allowed_write = fs::write(&input.allowed_write_path, b"allowed-write").is_ok(); + let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), input.loopback_port); + let tcp_denied = TcpStream::connect_timeout(&address, Duration::from_secs(2)).is_err(); + let udp_denied = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).is_err(); + let listener_denied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).is_err(); + let named_pipe_denied = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&input.pipe_name) + .is_err(); + let environment_denied = env::var_os(&input.environment_secret_name).is_none(); + let registry_denied = registry_value_denied(&input.registry_subkey, &input.registry_value_name); + let parent_token_denied = parent_token_denied(input.parent_pid); + let (descendant_app_container, descendant_in_job) = descendant_boundary(); + + let evidence = serde_json::json!({ + "fileDenied": file_denied, + "allowedRead": allowed_read, + "allowedWrite": allowed_write, + "tcpDenied": tcp_denied, + "udpDenied": udp_denied, + "listenerDenied": listener_denied, + "namedPipeDenied": named_pipe_denied, + "environmentDenied": environment_denied, + "registryDenied": registry_denied, + "parentTokenDenied": parent_token_denied, + "descendantAppContainer": descendant_app_container, + "descendantInJob": descendant_in_job, + }); + println!("{evidence}"); + + let passed = file_denied + && allowed_read + && allowed_write + && tcp_denied + && udp_denied + && listener_denied + && named_pipe_denied + && environment_denied + && registry_denied + && parent_token_denied + && descendant_app_container + && descendant_in_job; + if !passed { + return Err(format!( + "AppContainer adversarial matrix did not hold: {evidence}" + )); + } + Ok(0) +} + +fn registry_value_denied(subkey: &str, value_name: &str) -> bool { + use std::ptr::{null, null_mut}; + + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::{ + HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW, RegQueryValueExW, + }; + + let subkey = wide(subkey); + let value_name = wide(value_name); + let mut key = null_mut(); + let open = unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut key) }; + if open != ERROR_SUCCESS { + return true; + } + let mut value_type: u32 = 0; + let mut value_bytes: u32 = 0; + let query = unsafe { + RegQueryValueExW( + key, + value_name.as_ptr(), + null(), + &mut value_type, + null_mut(), + &mut value_bytes, + ) + }; + unsafe { RegCloseKey(key) }; + query != ERROR_SUCCESS +} + +fn parent_token_denied(parent_pid: u32) -> bool { + use std::ptr::null_mut; + + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::Security::TOKEN_QUERY; + use windows_sys::Win32::System::Threading::{ + OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, parent_pid) }; + if process.is_null() { + return true; + } + let mut token = null_mut(); + let denied = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0; + if !token.is_null() { + unsafe { CloseHandle(token) }; + } + unsafe { CloseHandle(process) }; + denied +} + +fn descendant_boundary() -> (bool, bool) { + let executable = match env::current_exe() { + Ok(executable) => executable, + Err(_) => return (false, false), + }; + let output = match Command::new(executable).arg("--self-probe").output() { + Ok(output) if output.status.success() => output, + _ => return (false, false), + }; + let evidence: serde_json::Value = match serde_json::from_slice(&output.stdout) { + Ok(evidence) => evidence, + Err(_) => return (false, false), + }; + ( + evidence + .get("appContainer") + .and_then(|value| value.as_bool()) + == Some(true), + evidence.get("inJob").and_then(|value| value.as_bool()) == Some(true), + ) +} + +fn wide(value: &str) -> Vec { + use std::os::windows::ffi::OsStrExt; + + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + /// Prefix every launcher-created private desktop name carries /// (`maka-sandbox-desktop..`). Attesting against the prefix rather /// than merely "not Default" means a child that somehow started on *any other* diff --git a/experiments/windows-sandbox/launcher/src/windows_launcher.rs b/experiments/windows-sandbox/launcher/src/windows_launcher.rs index 7f12c350b4..9c041d4d05 100644 --- a/experiments/windows-sandbox/launcher/src/windows_launcher.rs +++ b/experiments/windows-sandbox/launcher/src/windows_launcher.rs @@ -70,7 +70,7 @@ use windows_sys::Win32::System::Threading::{ InitializeProcThreadAttributeList, OpenProcessToken, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_JOB_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, - TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, + TerminateProcess, UpdateProcThreadAttribute, WaitForMultipleObjects, WaitForSingleObject, }; use crate::acl_ledger::{ @@ -132,6 +132,20 @@ pub fn launch_atomic(request: &LaunchRequest) -> Result { } pub fn launch_appcontainer(request: &LaunchRequest) -> Result { + launch_appcontainer_with_owner(request, None) +} + +pub fn launch_appcontainer_owned( + request: &LaunchRequest, + owner_process: HANDLE, +) -> Result { + launch_appcontainer_with_owner(request, Some(owner_process)) +} + +fn launch_appcontainer_with_owner( + request: &LaunchRequest, + owner_process: Option, +) -> Result { validate_appcontainer_policy(request)?; unsafe { let job = create_kill_on_close_job()?; @@ -150,7 +164,7 @@ pub fn launch_appcontainer(request: &LaunchRequest) -> Result { } }; let result = with_acl_grants(request, &sid, || { - create_appcontainer_child(request, job, profile.sid) + create_appcontainer_child(request, job, profile.sid, owner_process) }); // The kill-on-close Job is the kernel backstop either way: closing the // last handle terminates whatever the settlement pass could not prove @@ -1107,6 +1121,7 @@ unsafe fn create_appcontainer_child( request: &LaunchRequest, job: HANDLE, app_container_sid: *mut c_void, + owner_process: Option, ) -> Result { let mut command = quote_command(&request.executable, &request.arguments); let executable = wide(&request.executable); @@ -1238,7 +1253,7 @@ unsafe fn create_appcontainer_child( // Diagnostics go to stderr: stdout is reserved for the child's // relayed worker response. eprintln!("{{\"appContainer\":true,\"inJob\":true,\"atomicJob\":true}}"); - unsafe { wait_for_child(process.hProcess, request.timeout_ms) } + unsafe { wait_for_child_or_owner(process.hProcess, owner_process, request.timeout_ms) } } else { Err( "AppContainer launch did not establish the required token and Job boundary" @@ -1356,6 +1371,37 @@ unsafe fn wait_for_child(process: HANDLE, timeout_ms: Option) -> Result, + timeout_ms: Option, +) -> Result { + let Some(owner) = owner else { + return unsafe { wait_for_child(child, timeout_ms) }; + }; + let timeout_ms = timeout_ms.unwrap_or(DEFAULT_LAUNCH_TIMEOUT_MS); + let handles = [child, owner]; + let wait = unsafe { + WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, timeout_ms as u32) + }; + if wait == WAIT_OBJECT_0 { + return unsafe { child_exit_code(child) }; + } + if wait == WAIT_OBJECT_0 + 1 { + return Err("Runtime Host owner exited during sandbox launch".to_owned()); + } + if wait == WAIT_TIMEOUT { + return Err(format!("child exceeded the {timeout_ms} ms launch timeout")); + } + Err(last_error( + "WaitForMultipleObjects(child or Runtime Host owner)", + )) +} + +unsafe fn child_exit_code(process: HANDLE) -> Result { let mut exit_code = 1; if unsafe { GetExitCodeProcess(process, &mut exit_code) } == 0 { return Err(last_error("GetExitCodeProcess")); diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 6e94fa9926..e445e53e23 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; -import { access, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -37,6 +37,10 @@ import { waitForUsableRenderer, } from './verify-packaged-app.mjs'; import { waitForInstalledProductVersion } from './verify-windows-autoupdate.mjs'; +import { + WINDOWS_SANDBOX_DEFERRED_HARDENING, + WINDOWS_SANDBOX_PHASE4_MATRIX, +} from './verify-windows-sandbox-e2e.mjs'; import { deleteUninstallRegistrationForInstall, readUninstallDisplayVersionsForInstall, @@ -59,6 +63,79 @@ const temporaryRoots = []; const delay = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +it('pins every Phase 4 adversarial category to executable evidence', async () => { + assert.deepEqual(WINDOWS_SANDBOX_PHASE4_MATRIX.map(({ category }) => category).sort(), [ + 'credentials', + 'descendants', + 'environment', + 'filesystem_aliases', + 'ipc', + 'lifecycle_failures', + 'network_channels', + 'registry', + ]); + for (const row of WINDOWS_SANDBOX_PHASE4_MATRIX) { + assert.ok(row.evidence.length > 0, `${row.category} has no executable evidence`); + assert.equal(new Set(row.evidence).size, row.evidence.length, `${row.category} is duplicated`); + } + assert.deepEqual(WINDOWS_SANDBOX_DEFERRED_HARDENING, [ + 'Authenticode identity verification', + 'direct Credential Manager and DPAPI probes', + 'no-Win32k mitigation', + 'dedicated window-station and clipboard isolation', + 'power-loss automatic recovery', + ]); + + const verifier = await readFile( + new URL('./verify-windows-sandbox-e2e.mjs', import.meta.url), + 'utf8', + ); + for (const stage of [ + 'verifyPackagedClientCancellation', + 'verifyPackagedRuntimeHostParentDeath', + 'verifyPackagedConcurrencySoak', + 'verifyPackagedAdversarialMatrix', + ]) { + assert.match(verifier, new RegExp(`await ${stage}\\(`)); + } + + const adversarialProbe = await readFile( + new URL('../experiments/windows-sandbox/adversarial-matrix-smoke.ps1', import.meta.url), + 'utf8', + ); + for (const field of [ + 'fileDenied', + 'tcpDenied', + 'udpDenied', + 'listenerDenied', + 'namedPipeDenied', + 'environmentDenied', + 'registryDenied', + 'parentTokenDenied', + 'descendantAppContainer', + 'descendantInJob', + ]) { + assert.match(adversarialProbe, new RegExp(`"${field}":true`)); + } + assert.match(adversarialProbe, /phase4-junction/u); + assert.match(adversarialProbe, /phase4-hardlink/u); + assert.match(adversarialProbe, /\.json\.quarantined/u); + + const localBroker = await readFile( + new URL('../experiments/windows-sandbox/launcher/src/broker_client.rs', import.meta.url), + 'utf8', + ); + assert.match(localBroker, /let owner_pid = parent_process_id\(\)\?/u); + assert.match(localBroker, /launch_appcontainer_owned\(&request\.launch, owner\)/u); + + const launcher = await readFile( + new URL('../experiments/windows-sandbox/launcher/src/windows_launcher.rs', import.meta.url), + 'utf8', + ); + assert.match(launcher, /WaitForMultipleObjects/u); + assert.match(launcher, /Runtime Host owner exited during sandbox launch/u); +}); + it('scopes rollback registration reads and deletion to the fixture uninstaller', async () => { const calls = []; const uninstaller = 'C:\\fixture\\installed\\Uninstall Maka.exe'; diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index 88e84b7c0a..ff6b158c30 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -17,7 +17,7 @@ * under the License. */ -import { execFile } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; @@ -29,6 +29,48 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const execFileAsync = promisify(execFile); +export const WINDOWS_SANDBOX_PHASE4_MATRIX = Object.freeze([ + { + category: 'filesystem_aliases', + evidence: ['junction admission', 'multi-hard-link admission', 'outside-root read denial'], + }, + { + category: 'network_channels', + evidence: ['TCP connect denial', 'UDP bind denial', 'TCP listener denial'], + }, + { category: 'ipc', evidence: ['host named-pipe denial', 'bounded inherited handle list'] }, + { + category: 'descendants', + evidence: ['descendant AppContainer token', 'descendant kill-on-close Job membership'], + }, + { + category: 'environment', + evidence: ['ambient host secret omitted', 'closed allowlisted environment'], + }, + { + category: 'credentials', + evidence: ['outside credential-file denial', 'ambient secret omission'], + }, + { category: 'registry', evidence: ['host HKCU value denial'] }, + { + category: 'lifecycle_failures', + evidence: [ + 'client cancellation', + 'Runtime Host parent death', + '64-launch concurrency soak', + 'quarantined identity non-reuse', + ], + }, +]); + +export const WINDOWS_SANDBOX_DEFERRED_HARDENING = Object.freeze([ + 'Authenticode identity verification', + 'direct Credential Manager and DPAPI probes', + 'no-Win32k mitigation', + 'dedicated window-station and clipboard isolation', + 'power-loss automatic recovery', +]); + function assertCondition(condition, message) { if (!condition) throw new Error(message); } @@ -173,6 +215,34 @@ export async function verifyWindowsSandboxWorkerE2E(appDirectoryPath) { }); console.log('[verify-windows-sandbox] packaged client cancellation and recovery verified'); + console.log('[verify-windows-sandbox] killing a Runtime Host parent during launch'); + await verifyPackagedRuntimeHostParentDeath({ + appDirectory, + appExecutable, + sandboxExecutable, + client, + workspace, + targetPath: insidePath, + }); + console.log('[verify-windows-sandbox] Runtime Host parent-death cleanup verified'); + + console.log('[verify-windows-sandbox] running the 64-launch packaged concurrency soak'); + await verifyPackagedConcurrencySoak({ + FilesystemWorkerClient, + SandboxManager, + WindowsBrokerSandboxBackend, + createWindowsBrokerManifestWriter, + launchSpec: launchSpec.spec, + sandboxExecutable, + workspace, + targetPath: insidePath, + }); + console.log('[verify-windows-sandbox] packaged concurrency soak verified'); + + console.log('[verify-windows-sandbox] running the packaged adversarial matrix'); + await verifyPackagedAdversarialMatrix(sandboxExecutable); + console.log('[verify-windows-sandbox] packaged adversarial matrix verified'); + const sourceDirectory = join(workspace, 'src'); await mkdir(sourceDirectory, { recursive: true }); await writeFile(join(sourceDirectory, 'health.ts'), 'export const healthSignal = true;\n'); @@ -226,6 +296,206 @@ export async function verifyWindowsSandboxWorkerE2E(appDirectoryPath) { } } +async function verifyPackagedRuntimeHostParentDeath({ + appDirectory, + appExecutable, + sandboxExecutable, + client, + workspace, + targetPath, +}) { + const requestId = `runtime-host-parent-death-${process.pid}-${randomBytes(4).toString('hex')}`; + const launchRequestId = `${requestId}-launch`; + assertCondition( + (await listCancellationProcesses(sandboxExecutable)).length === 0, + 'Runtime Host parent-death evidence started with an existing sandbox process.', + ); + + const child = spawn( + appExecutable, + [ + fileURLToPath(import.meta.url), + '--runtime-host-mid-launch-child', + JSON.stringify({ appDirectory, workspace, targetPath, requestId }), + ], + { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + const exited = new Promise((resolvePromise) => { + child.once('exit', (code, signal) => resolvePromise({ code, signal })); + }); + + let primaryError; + try { + await waitForObservation({ + description: 'Runtime Host-owned packaged AppContainer child', + probe: (remainingMs) => listCancellationProcesses(sandboxExecutable, remainingMs), + accept: (processes) => processes.length >= 2, + settledEarly: () => child.exitCode !== null, + }); + assertCondition(child.kill(), 'Could not terminate the Runtime Host fixture process.'); + await raceWithTimeout( + exited, + 10_000, + `Runtime Host fixture did not exit. stdout=${stdout} stderr=${stderr}`, + ); + await waitForObservation({ + description: 'Runtime Host parent-death AppContainer tree exit', + probe: (remainingMs) => listCancellationProcesses(sandboxExecutable, remainingMs), + accept: (processes) => processes.length === 0, + }); + await client.execute({ + operation: { kind: 'read', path: targetPath }, + cwd: workspace, + mode: 'ask', + }); + assertCondition( + (await findRecoveryRecord(launchRequestId)) === undefined, + 'Runtime Host parent death left an ACL recovery ledger after a successful recovery launch.', + ); + } catch (error) { + primaryError = error; + } finally { + if (child.exitCode === null) child.kill(); + await raceWithTimeout(exited, 10_000, 'Runtime Host fixture cleanup timed out.').catch( + () => undefined, + ); + } + if (primaryError) throw primaryError; +} + +async function runRuntimeHostMidLaunchChild({ appDirectory, workspace, targetPath, requestId }) { + const resourcesPath = join(appDirectory, 'resources'); + const appExecutable = join(appDirectory, 'Maka.exe'); + const sandboxExecutable = join(resourcesPath, 'windows-sandbox', 'maka-windows-sandbox.exe'); + const runtimeDist = join(repoRoot, 'packages', 'runtime', 'dist'); + const importDist = (relativePath) => import(pathToFileURL(join(runtimeDist, relativePath)).href); + const { FilesystemWorkerClient } = await importDist('filesystem-worker/client.js'); + const { createFilesystemWorkerLaunchSpecProvider } = await importDist( + 'filesystem-worker/launch-spec.js', + ); + const { SandboxManager } = await importDist('sandbox/sandbox-manager.js'); + const { WindowsBrokerSandboxBackend, createWindowsBrokerManifestWriter } = await importDist( + 'sandbox/windows-sandbox.js', + ); + const getPackagedLaunchSpec = createFilesystemWorkerLaunchSpecProvider({ + runtime: 'electron', + executable: appExecutable, + resourceLocation: { kind: 'desktop-packaged', resourcesPath }, + }); + const packaged = await getPackagedLaunchSpec(); + assertCondition(packaged.ok, 'Runtime Host fixture could not resolve the packaged launch spec.'); + const client = new FilesystemWorkerClient({ + sandboxManager: new SandboxManager([ + new WindowsBrokerSandboxBackend({ + clientPath: sandboxExecutable, + writeManifest: createWindowsBrokerManifestWriter(), + requestId: () => requestId, + }), + ]), + platform: 'win32', + newId: () => `${requestId}-operation`, + timeoutMs: 60_000, + getLaunchSpec: async () => ({ + ok: true, + spec: { + ...packaged.spec, + program: sandboxExecutable, + args: ['--stdio-probe', '--sleep', '47'], + }, + }), + }); + process.stdout.write('runtime-host-mid-launch-ready\n'); + await client.execute({ + operation: { kind: 'read', path: targetPath }, + cwd: workspace, + mode: 'ask', + }); + throw new Error('Runtime Host mid-launch fixture unexpectedly completed.'); +} + +async function verifyPackagedConcurrencySoak({ + FilesystemWorkerClient, + SandboxManager, + WindowsBrokerSandboxBackend, + createWindowsBrokerManifestWriter, + launchSpec, + sandboxExecutable, + workspace, + targetPath, +}) { + const prefix = `phase4-soak-${process.pid}-${randomBytes(4).toString('hex')}`; + let sequence = 0; + const soakClient = new FilesystemWorkerClient({ + sandboxManager: new SandboxManager([ + new WindowsBrokerSandboxBackend({ + clientPath: sandboxExecutable, + writeManifest: createWindowsBrokerManifestWriter(), + requestId: () => `${prefix}-${sequence++}`, + }), + ]), + platform: 'win32', + getLaunchSpec: async () => ({ ok: true, spec: launchSpec }), + }); + + const waves = 8; + const concurrency = 8; + for (let wave = 0; wave < waves; wave += 1) { + const results = await Promise.all( + Array.from({ length: concurrency }, () => + soakClient.execute({ + operation: { kind: 'read', path: targetPath }, + cwd: workspace, + mode: 'ask', + }), + ), + ); + assertCondition( + results.every((result) => result.kind === 'read'), + `Packaged concurrency soak wave ${wave + 1} returned a non-read result.`, + ); + } + assertCondition(sequence === waves * concurrency, 'Concurrency soak did not run 64 launches.'); + await waitForObservation({ + description: 'packaged concurrency soak process drain', + probe: (remainingMs) => listCancellationProcesses(sandboxExecutable, remainingMs), + accept: (processes) => processes.length === 0, + }); + assertCondition( + (await findRecoveryRecordsByPrefix(prefix)).length === 0, + 'Packaged concurrency soak left ACL recovery records behind.', + ); +} + +async function verifyPackagedAdversarialMatrix(sandboxExecutable) { + const script = join(repoRoot, 'experiments', 'windows-sandbox', 'adversarial-matrix-smoke.ps1'); + const { stdout, stderr } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-File', script, '-LauncherPath', sandboxExecutable], + { + cwd: repoRoot, + timeout: 180_000, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + }, + ); + assertCondition( + stdout.includes('Phase 4 adversarial matrix verified:'), + `Packaged adversarial matrix returned no completion evidence. stdout=${stdout} stderr=${stderr}`, + ); +} + async function verifyPackagedClientCancellation({ FilesystemWorkerClient, FilesystemWorkerClientError, @@ -396,6 +666,36 @@ async function findRecoveryRecord(requestId) { return undefined; } +async function findRecoveryRecordsByPrefix(prefix) { + const ledgerRoot = join(tmpdir(), 'maka-sandbox-acl-ledgers'); + const entries = await readdir(ledgerRoot, { withFileTypes: true }).catch((error) => { + if (error?.code === 'ENOENT') return []; + throw error; + }); + const matches = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + const path = join(ledgerRoot, entry.name); + const contents = await readFile(path, 'utf8').catch(() => undefined); + if (contents?.includes(`"requestId":"${prefix}`)) matches.push(path); + } + return matches; +} + +async function raceWithTimeout(promise, timeoutMs, message) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + async function waitForObservation({ description, probe, @@ -434,12 +734,19 @@ function renderError(error) { } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const appDirectory = process.argv[2]; - if (!appDirectory || basename(appDirectory).endsWith('.exe')) { - throw new Error( - 'Usage: node scripts/verify-windows-sandbox-e2e.mjs ', - ); + if (process.argv[2] === '--runtime-host-mid-launch-child') { + const input = JSON.parse(process.argv[3] ?? 'null'); + assertCondition(input && typeof input === 'object', 'Missing Runtime Host fixture input.'); + await runRuntimeHostMidLaunchChild(input); + process.exitCode = 1; + } else { + const appDirectory = process.argv[2]; + if (!appDirectory || basename(appDirectory).endsWith('.exe')) { + throw new Error( + 'Usage: node scripts/verify-windows-sandbox-e2e.mjs ', + ); + } + await verifyWindowsSandboxWorkerE2E(appDirectory); + console.log('Packaged Windows filesystem-worker E2E verified.'); } - await verifyWindowsSandboxWorkerE2E(appDirectory); - console.log('Packaged Windows filesystem-worker E2E verified.'); } From 74592e672176fc30af0b2498e692f1ca1b30a070 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 22:27:38 +0800 Subject: [PATCH 02/14] fix(windows): accept adversarial probe UTF-8 input --- experiments/windows-sandbox/adversarial-matrix-smoke.ps1 | 5 +++-- experiments/windows-sandbox/launcher/src/main.rs | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index 409575ec29..5c4c770acd 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -137,7 +137,7 @@ try { [Environment]::SetEnvironmentVariable($hostSecretName, 'environment-secret', 'Process') $probeInputPath = Join-Path $allowedRoot 'adversarial-probe.json' - [ordered]@{ + $probeJson = [ordered]@{ deniedPath = $deniedPath allowedReadPath = $allowedReadPath allowedWritePath = $allowedWritePath @@ -147,7 +147,8 @@ try { registrySubkey = $registrySubkey registryValueName = $registryValueName parentPid = $PID - } | ConvertTo-Json | Set-Content -LiteralPath $probeInputPath -Encoding utf8 + } | ConvertTo-Json + [IO.File]::WriteAllText($probeInputPath, $probeJson, [Text.UTF8Encoding]::new($false)) $probeRequest = Write-LaunchRequest -Name "phase4-adversarial-$PID" ` -Arguments @('--adversarial-probe', $probeInputPath) ` diff --git a/experiments/windows-sandbox/launcher/src/main.rs b/experiments/windows-sandbox/launcher/src/main.rs index d9b59a58d4..9b6cdeccf6 100644 --- a/experiments/windows-sandbox/launcher/src/main.rs +++ b/experiments/windows-sandbox/launcher/src/main.rs @@ -155,7 +155,11 @@ fn run() -> Result { } let source = fs::read_to_string(&input_path) .map_err(|error| format!("read adversarial probe input failed: {error}"))?; - let input: AdversarialProbeInput = serde_json::from_str(&source) + // Windows PowerShell 5.1 may emit a UTF-8 BOM for `Set-Content + // -Encoding utf8`; accepting it here keeps the probe input parser + // deterministic across the supported PowerShell implementations. + let source = source.strip_prefix('\u{feff}').unwrap_or(&source); + let input: AdversarialProbeInput = serde_json::from_str(source) .map_err(|error| format!("decode adversarial probe input failed: {error}"))?; return adversarial_probe(&input); } From ad15a61c00ebc6e61c67656e34d6f1a6653aa929 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 22:36:18 +0800 Subject: [PATCH 03/14] fix(windows): write sandbox manifests without BOM --- experiments/windows-sandbox/adversarial-matrix-smoke.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index 5c4c770acd..31d26b3192 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -63,7 +63,8 @@ function Write-LaunchRequest { timeoutMs = 120000 } $path = Join-Path $workRoot "$Name.json" - $request | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $path -Encoding utf8 + $requestJson = $request | ConvertTo-Json -Depth 5 + [IO.File]::WriteAllText($path, $requestJson, [Text.UTF8Encoding]::new($false)) return $path } From 49a1dd9734e5c2168f72f1b05dd7ab6423baef3b Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 22:47:20 +0800 Subject: [PATCH 04/14] fix(windows): preserve launcher diagnostics in probe failures --- .../adversarial-matrix-smoke.ps1 | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index 31d26b3192..c88de7f857 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -74,8 +74,9 @@ function Invoke-ExpectedAdmissionFailure { [string]$Pattern, [string]$Description ) - $output = & $launcher --appcontainer $RequestPath 2>&1 - $exitCode = $LASTEXITCODE + $result = Invoke-Launcher @('--appcontainer', $RequestPath) + $output = $result.Output + $exitCode = $result.ExitCode $rendered = $output -join "`n" if ($exitCode -eq 0 -or $rendered -notmatch $Pattern) { throw "$Description did not fail closed: exit=$exitCode output=$rendered" @@ -83,6 +84,21 @@ function Invoke-ExpectedAdmissionFailure { $global:LASTEXITCODE = 0 } +function Invoke-Launcher { + param([string[]]$Arguments) + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $output = & $launcher @Arguments 2>&1 + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = @($output) + } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + function Get-AppContainerSid { param([string]$RequestId) $sid = (& $launcher --appcontainer-sid $RequestId 2>&1) -join '' @@ -157,8 +173,9 @@ try { -WriteRoots @($allowedWritePath) ` -ExactReadRoots @($probeInputPath, $allowedReadPath) ` -ExactWriteRoots @($allowedWritePath) - $output = & $launcher --appcontainer $probeRequest 2>&1 - $exitCode = $LASTEXITCODE + $result = Invoke-Launcher @('--appcontainer', $probeRequest) + $output = $result.Output + $exitCode = $result.ExitCode $rendered = $output -join "`n" $requiredEvidence = @( '"fileDenied":true', @@ -235,9 +252,9 @@ try { $recoveryRequest = Write-LaunchRequest -Name $recoveryRequestId ` -Arguments @('--self-probe') -ReadRoots @($allowedReadPath) -WriteRoots @() ` -ExactReadRoots @($allowedReadPath) -ExactWriteRoots @() - $recoveryOutput = & $launcher --appcontainer $recoveryRequest 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "Launch after quarantined state failed: $($recoveryOutput -join "`n")" + $recoveryResult = Invoke-Launcher @('--appcontainer', $recoveryRequest) + if ($recoveryResult.ExitCode -ne 0) { + throw "Launch after quarantined state failed: $($recoveryResult.Output -join "`n")" } if (-not (Test-Path -LiteralPath $quarantinePath)) { throw 'A later launch interpreted or deleted quarantined recovery evidence' From 902e61123e9f00e7b0a333b69aedd223f74c5d01 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 22:59:52 +0800 Subject: [PATCH 05/14] test(windows): scope packaged network matrix honestly --- docs/architecture/windows-sandbox-rfc-v1.md | 5 ++++- docs/architecture/windows-sandbox-rfc-v1.zh-CN.md | 4 +++- docs/windows-support.md | 6 +++--- .../windows-sandbox/adversarial-matrix-smoke.ps1 | 10 +++++++--- experiments/windows-sandbox/launcher/src/main.rs | 10 +++++----- scripts/verify-windows-harness.test.mjs | 2 +- scripts/verify-windows-sandbox-e2e.mjs | 3 ++- 7 files changed, 25 insertions(+), 15 deletions(-) diff --git a/docs/architecture/windows-sandbox-rfc-v1.md b/docs/architecture/windows-sandbox-rfc-v1.md index ba6e9ef57b..02db479ac6 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.md +++ b/docs/architecture/windows-sandbox-rfc-v1.md @@ -347,6 +347,9 @@ Designed but deferred as later gates (not enforced in the preview slice): - Direct Windows Credential Manager/DPAPI isolation evidence. The packaged W1 matrix proves that ambient credential files and environment secrets are not granted or inherited, but direct `CredRead`/DPAPI probes remain a W2/W3 hardening gate. +- Inbound listener enforcement. AppContainer denies the packaged outbound TCP/UDP attempts, but + local listener creation is not itself denied by the current token policy; full inbound-channel + enforcement remains a W2/W3 network hardening gate. Deferral narrows readiness richness and desktop-layer defense-in-depth, not the enforcement boundary: an unavailable, drifted, or failed backend still fails closed, and a restricted managed @@ -483,7 +486,7 @@ For the W1 preview, the packaged verifier maps the supported attack surface to e | Category | Packaged evidence | | --- | --- | | Filesystem aliases | outside denial plus recursive junction and multi-hard-link admission refusal | -| Network channels | TCP connect, UDP bind, and TCP listener denial without network capabilities | +| Network channels | TCP connect and UDP send denial without network capabilities | | IPC | host named-pipe denial and an explicit inherited-handle list | | Descendants | child-created descendant retains the AppContainer token and kill-on-close Job | | Environment/credentials | ambient host secret and outside credential file are unavailable | diff --git a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md index bde5139fa3..bf554a07bf 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md +++ b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md @@ -204,6 +204,8 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 - 运行中宿主的主动 readiness 恢复(§6.4):负可用性结果由 TTL 限时,使其不会长时间毒化 module 缓存,并由**下一次 composition 构建**重探。运行中的 Runtime Host 不会主动重探或热发布 filesystem worker——worker 在候选构建时一次性组装——故已判负的运行中宿主对瞬时负结果的恢复被限定到新 composition 构建或重启。带动态 worker 发布的主动 readiness 重试暂缓。 - Windows Credential Manager/DPAPI 的直接隔离证据:打包 W1 矩阵已证明 ambient credential 文件与 环境 secret 不会被授权或继承,但直接 `CredRead`/DPAPI probe 仍是 W2/W3 后续加固门禁。 +- inbound listener 强制:AppContainer 会拒绝打包的 outbound TCP/UDP 尝试,但当前 token policy + 不会单独拒绝本地 listener 创建;完整 inbound channel 强制仍是 W2/W3 网络加固门禁。 暂缓收窄的是 readiness 丰富度与 desktop 层的 defense-in-depth,而非强制边界本身:backend 不可用、identity drift 或启动失败仍然 fail closed,受限 managed profile 也绝不回退到宿主执行。 @@ -327,7 +329,7 @@ Windows sandbox job 必须运行真实 child-process 正反测试: | 类别 | 打包证据 | | --- | --- | | 文件别名 | outside 拒绝,加递归 junction 与多硬链接准入拒绝 | -| 网络通道 | 无网络 capability 时拒绝 TCP connect、UDP bind 与 TCP listener | +| 网络通道 | 无网络 capability 时拒绝 TCP connect 与 UDP send | | IPC | 拒绝宿主 named pipe,并只继承显式 handle 列表 | | descendant | child 创建的 descendant 仍持有 AppContainer token 与 kill-on-close Job | | 环境/credential | ambient host secret 与 outside credential 文件均不可用 | diff --git a/docs/windows-support.md b/docs/windows-support.md index 1799917080..413b34c377 100644 --- a/docs/windows-support.md +++ b/docs/windows-support.md @@ -225,10 +225,10 @@ The root test timeout is tracked separately from individual test failures. Phase - Restricted managed profiles use the packaged AppContainer broker when available and fail closed when the native capability or requested policy is unavailable. - The packaged filesystem-worker gate covers client cancellation, Runtime Host parent death, - repeated concurrent launches, ACL quarantine isolation, filesystem aliases, restricted network, + repeated concurrent launches, ACL quarantine isolation, filesystem aliases, restricted outbound network, host named pipes, ambient environment, host registry values, parent tokens, and descendant - AppContainer/Job inheritance. It does not claim the deferred no-Win32k/window-station tier or - direct Credential Manager/DPAPI isolation. + AppContainer/Job inheritance. It does not claim local inbound-listener enforcement, the deferred + no-Win32k/window-station tier, or direct Credential Manager/DPAPI isolation. - Computer-use has no Windows backend. - The Windows x64 NSIS installer is unsigned. The in-app automatic-update path (electron-updater → NSIS handoff → relaunch) is verified end to end in CI against a loopback feed; the production diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index c88de7f857..098d6d60ae 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -34,6 +34,7 @@ $pipeShortName = "maka-phase4-host-$PID" $pipeName = "\\.\pipe\$pipeShortName" $hostSecretName = 'MAKA_PHASE4_HOST_SECRET' $listener = $null +$udpListener = $null $pipe = $null $quarantinedSid = $null $quarantineRoot = $null @@ -142,6 +143,8 @@ try { $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) $listener.Start() $port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port + $udpListener = [Net.Sockets.UdpClient]::new(0) + $udpPort = ([Net.IPEndPoint]$udpListener.Client.LocalEndPoint).Port $pipe = [IO.Pipes.NamedPipeServerStream]::new( $pipeShortName, [IO.Pipes.PipeDirection]::InOut, @@ -159,6 +162,7 @@ try { allowedReadPath = $allowedReadPath allowedWritePath = $allowedWritePath loopbackPort = $port + udpPort = $udpPort pipeName = $pipeName environmentSecretName = $hostSecretName registrySubkey = $registrySubkey @@ -169,9 +173,9 @@ try { $probeRequest = Write-LaunchRequest -Name "phase4-adversarial-$PID" ` -Arguments @('--adversarial-probe', $probeInputPath) ` - -ReadRoots @($probeInputPath, $allowedReadPath) ` + -ReadRoots @($probeInputPath, $allowedReadPath, $launcher) ` -WriteRoots @($allowedWritePath) ` - -ExactReadRoots @($probeInputPath, $allowedReadPath) ` + -ExactReadRoots @($probeInputPath, $allowedReadPath, $launcher) ` -ExactWriteRoots @($allowedWritePath) $result = Invoke-Launcher @('--appcontainer', $probeRequest) $output = $result.Output @@ -183,7 +187,6 @@ try { '"allowedWrite":true', '"tcpDenied":true', '"udpDenied":true', - '"listenerDenied":true', '"namedPipeDenied":true', '"environmentDenied":true', '"registryDenied":true', @@ -266,6 +269,7 @@ try { Write-Host "Phase 4 adversarial matrix verified: $rendered" } finally { if ($listener) { $listener.Stop() } + if ($udpListener) { $udpListener.Dispose() } if ($pipe) { $pipe.Dispose() } [Environment]::SetEnvironmentVariable($hostSecretName, $null, 'Process') Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue diff --git a/experiments/windows-sandbox/launcher/src/main.rs b/experiments/windows-sandbox/launcher/src/main.rs index 9b6cdeccf6..741f98ab9d 100644 --- a/experiments/windows-sandbox/launcher/src/main.rs +++ b/experiments/windows-sandbox/launcher/src/main.rs @@ -47,7 +47,7 @@ mod windows_launcher_tests; use std::env; use std::fs; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, UdpSocket}; use std::process::{Command, ExitCode}; use std::time::Duration; @@ -340,6 +340,7 @@ struct AdversarialProbeInput { allowed_read_path: String, allowed_write_path: String, loopback_port: u16, + udp_port: u16, pipe_name: String, environment_secret_name: String, registry_subkey: String, @@ -357,8 +358,9 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { let allowed_write = fs::write(&input.allowed_write_path, b"allowed-write").is_ok(); let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), input.loopback_port); let tcp_denied = TcpStream::connect_timeout(&address, Duration::from_secs(2)).is_err(); - let udp_denied = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).is_err(); - let listener_denied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).is_err(); + let udp_denied = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) + .and_then(|socket| socket.send_to(b"maka-phase4", (Ipv4Addr::LOCALHOST, input.udp_port))) + .is_err(); let named_pipe_denied = fs::OpenOptions::new() .read(true) .write(true) @@ -375,7 +377,6 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { "allowedWrite": allowed_write, "tcpDenied": tcp_denied, "udpDenied": udp_denied, - "listenerDenied": listener_denied, "namedPipeDenied": named_pipe_denied, "environmentDenied": environment_denied, "registryDenied": registry_denied, @@ -390,7 +391,6 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { && allowed_write && tcp_denied && udp_denied - && listener_denied && named_pipe_denied && environment_denied && registry_denied diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index e445e53e23..af0fa52d49 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -81,6 +81,7 @@ it('pins every Phase 4 adversarial category to executable evidence', async () => assert.deepEqual(WINDOWS_SANDBOX_DEFERRED_HARDENING, [ 'Authenticode identity verification', 'direct Credential Manager and DPAPI probes', + 'inbound listener enforcement', 'no-Win32k mitigation', 'dedicated window-station and clipboard isolation', 'power-loss automatic recovery', @@ -107,7 +108,6 @@ it('pins every Phase 4 adversarial category to executable evidence', async () => 'fileDenied', 'tcpDenied', 'udpDenied', - 'listenerDenied', 'namedPipeDenied', 'environmentDenied', 'registryDenied', diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index ff6b158c30..2072c4e6f2 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -36,7 +36,7 @@ export const WINDOWS_SANDBOX_PHASE4_MATRIX = Object.freeze([ }, { category: 'network_channels', - evidence: ['TCP connect denial', 'UDP bind denial', 'TCP listener denial'], + evidence: ['TCP connect denial', 'UDP send denial'], }, { category: 'ipc', evidence: ['host named-pipe denial', 'bounded inherited handle list'] }, { @@ -66,6 +66,7 @@ export const WINDOWS_SANDBOX_PHASE4_MATRIX = Object.freeze([ export const WINDOWS_SANDBOX_DEFERRED_HARDENING = Object.freeze([ 'Authenticode identity verification', 'direct Credential Manager and DPAPI probes', + 'inbound listener enforcement', 'no-Win32k mitigation', 'dedicated window-station and clipboard isolation', 'power-loss automatic recovery', From bd8c4789f32d301966269d95362263b1922aa77f Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 23:09:56 +0800 Subject: [PATCH 06/14] test(windows): verify outbound UDP and descendant roots --- .../adversarial-matrix-smoke.ps1 | 17 +++++++++++++++-- .../windows-sandbox/launcher/src/main.rs | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index 098d6d60ae..c32406deca 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -24,6 +24,7 @@ $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false $launcher = (Resolve-Path -LiteralPath $LauncherPath).Path +$launcherDirectory = Split-Path -Parent $launcher $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } $workRoot = Join-Path $tempRoot "maka-phase4-adversarial-$PID" $ledgerRoot = Join-Path ([IO.Path]::GetTempPath()) 'maka-sandbox-acl-ledgers' @@ -145,6 +146,18 @@ try { $port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port $udpListener = [Net.Sockets.UdpClient]::new(0) $udpPort = ([Net.IPEndPoint]$udpListener.Client.LocalEndPoint).Port + $udpCallback = [AsyncCallback]{ + param($asyncResult) + try { + $remote = [Net.IPEndPoint]::new([Net.IPAddress]::Any, 0) + [void]$udpListener.EndReceive($asyncResult, [ref]$remote) + $reply = [Text.Encoding]::UTF8.GetBytes('phase4-udp-ok') + [void]$udpListener.Send($reply, $reply.Length, $remote) + } catch { + # The probe is already fail-closed if no response arrives. + } + } + $udpListener.BeginReceive($udpCallback, $null) $pipe = [IO.Pipes.NamedPipeServerStream]::new( $pipeShortName, [IO.Pipes.PipeDirection]::InOut, @@ -173,9 +186,9 @@ try { $probeRequest = Write-LaunchRequest -Name "phase4-adversarial-$PID" ` -Arguments @('--adversarial-probe', $probeInputPath) ` - -ReadRoots @($probeInputPath, $allowedReadPath, $launcher) ` + -ReadRoots @($probeInputPath, $allowedReadPath, $launcherDirectory) ` -WriteRoots @($allowedWritePath) ` - -ExactReadRoots @($probeInputPath, $allowedReadPath, $launcher) ` + -ExactReadRoots @($probeInputPath, $allowedReadPath) ` -ExactWriteRoots @($allowedWritePath) $result = Invoke-Launcher @('--appcontainer', $probeRequest) $output = $result.Output diff --git a/experiments/windows-sandbox/launcher/src/main.rs b/experiments/windows-sandbox/launcher/src/main.rs index 741f98ab9d..f192e3d38a 100644 --- a/experiments/windows-sandbox/launcher/src/main.rs +++ b/experiments/windows-sandbox/launcher/src/main.rs @@ -358,9 +358,19 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { let allowed_write = fs::write(&input.allowed_write_path, b"allowed-write").is_ok(); let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), input.loopback_port); let tcp_denied = TcpStream::connect_timeout(&address, Duration::from_secs(2)).is_err(); - let udp_denied = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) - .and_then(|socket| socket.send_to(b"maka-phase4", (Ipv4Addr::LOCALHOST, input.udp_port))) - .is_err(); + let udp_denied = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) { + Ok(socket) => { + let _ = socket.set_read_timeout(Some(Duration::from_secs(2))); + match socket.send_to(b"maka-phase4", (Ipv4Addr::LOCALHOST, input.udp_port)) { + Ok(_) => { + let mut response = [0u8; 64]; + socket.recv_from(&mut response).is_err() + } + Err(_) => true, + } + } + Err(_) => true, + }; let named_pipe_denied = fs::OpenOptions::new() .read(true) .write(true) From f15a4572daba690d1689924270b44a4a4b139f62 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 23:23:07 +0800 Subject: [PATCH 07/14] test(windows): classify denied descendants as fail closed --- docs/architecture/windows-sandbox-rfc-v1.md | 2 +- .../windows-sandbox-rfc-v1.zh-CN.md | 2 +- docs/windows-support.md | 2 +- .../adversarial-matrix-smoke.ps1 | 10 ++++++++++ .../windows-sandbox/launcher/src/main.rs | 17 ++++++++++------- scripts/verify-windows-harness.test.mjs | 1 + scripts/verify-windows-sandbox-e2e.mjs | 5 ++++- 7 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/architecture/windows-sandbox-rfc-v1.md b/docs/architecture/windows-sandbox-rfc-v1.md index 02db479ac6..34f963876a 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.md +++ b/docs/architecture/windows-sandbox-rfc-v1.md @@ -488,7 +488,7 @@ For the W1 preview, the packaged verifier maps the supported attack surface to e | Filesystem aliases | outside denial plus recursive junction and multi-hard-link admission refusal | | Network channels | TCP connect and UDP send denial without network capabilities | | IPC | host named-pipe denial and an explicit inherited-handle list | -| Descendants | child-created descendant retains the AppContainer token and kill-on-close Job | +| Descendants | child creation is denied fail-closed, or a created descendant retains the AppContainer token and kill-on-close Job | | Environment/credentials | ambient host secret and outside credential file are unavailable | | Registry/parent | host HKCU value and parent process token are unavailable | | Lifecycle | timeout, cancellation, Runtime Host death, broker death, 64-launch soak, quarantine non-reuse | diff --git a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md index bf554a07bf..67777e2400 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md +++ b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md @@ -331,7 +331,7 @@ Windows sandbox job 必须运行真实 child-process 正反测试: | 文件别名 | outside 拒绝,加递归 junction 与多硬链接准入拒绝 | | 网络通道 | 无网络 capability 时拒绝 TCP connect 与 UDP send | | IPC | 拒绝宿主 named pipe,并只继承显式 handle 列表 | -| descendant | child 创建的 descendant 仍持有 AppContainer token 与 kill-on-close Job | +| descendant | child 创建被 fail-closed 拒绝,或已创建 descendant 仍持有 AppContainer token 与 kill-on-close Job | | 环境/credential | ambient host secret 与 outside credential 文件均不可用 | | registry/父进程 | 宿主 HKCU 值与父进程 token 均不可用 | | 生命周期 | timeout、cancel、Runtime Host 死亡、broker 死亡、64 次 soak、quarantine 不复用 | diff --git a/docs/windows-support.md b/docs/windows-support.md index 413b34c377..2f29584e60 100644 --- a/docs/windows-support.md +++ b/docs/windows-support.md @@ -227,7 +227,7 @@ The root test timeout is tracked separately from individual test failures. Phase - The packaged filesystem-worker gate covers client cancellation, Runtime Host parent death, repeated concurrent launches, ACL quarantine isolation, filesystem aliases, restricted outbound network, host named pipes, ambient environment, host registry values, parent tokens, and descendant - AppContainer/Job inheritance. It does not claim local inbound-listener enforcement, the deferred + denial or AppContainer/Job inheritance. It does not claim local inbound-listener enforcement, the deferred no-Win32k/window-station tier, or direct Credential Manager/DPAPI isolation. - Computer-use has no Windows backend. - The Windows x64 NSIS installer is unsigned. The in-app automatic-update path (electron-updater → diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index c32406deca..e059153afe 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -208,6 +208,16 @@ try { '"descendantInJob":true' ) $missingEvidence = @($requiredEvidence | Where-Object { $rendered -notmatch [regex]::Escape($_) }) + $descendantEvidence = + $rendered -match '"descendantSpawnDenied":true' -or + ($rendered -match '"descendantAppContainer":true' -and + $rendered -match '"descendantInJob":true') + if (-not $descendantEvidence) { + $missingEvidence += 'descendantSpawnDenied or descendantAppContainer+descendantInJob' + } + $missingEvidence = @($missingEvidence | Where-Object { + $_ -notin @('"descendantAppContainer":true', '"descendantInJob":true') + }) if ($exitCode -ne 0 -or $missingEvidence.Count -gt 0) { throw "Packaged adversarial probe failed: exit=$exitCode missing=$($missingEvidence -join ', ') output=$rendered" } diff --git a/experiments/windows-sandbox/launcher/src/main.rs b/experiments/windows-sandbox/launcher/src/main.rs index f192e3d38a..cac8e32bae 100644 --- a/experiments/windows-sandbox/launcher/src/main.rs +++ b/experiments/windows-sandbox/launcher/src/main.rs @@ -379,7 +379,8 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { let environment_denied = env::var_os(&input.environment_secret_name).is_none(); let registry_denied = registry_value_denied(&input.registry_subkey, &input.registry_value_name); let parent_token_denied = parent_token_denied(input.parent_pid); - let (descendant_app_container, descendant_in_job) = descendant_boundary(); + let (descendant_app_container, descendant_in_job, descendant_spawn_denied) = + descendant_boundary(); let evidence = serde_json::json!({ "fileDenied": file_denied, @@ -393,6 +394,7 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { "parentTokenDenied": parent_token_denied, "descendantAppContainer": descendant_app_container, "descendantInJob": descendant_in_job, + "descendantSpawnDenied": descendant_spawn_denied, }); println!("{evidence}"); @@ -405,8 +407,7 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { && environment_denied && registry_denied && parent_token_denied - && descendant_app_container - && descendant_in_job; + && (descendant_spawn_denied || (descendant_app_container && descendant_in_job)); if !passed { return Err(format!( "AppContainer adversarial matrix did not hold: {evidence}" @@ -468,18 +469,19 @@ fn parent_token_denied(parent_pid: u32) -> bool { denied } -fn descendant_boundary() -> (bool, bool) { +fn descendant_boundary() -> (bool, bool, bool) { let executable = match env::current_exe() { Ok(executable) => executable, - Err(_) => return (false, false), + Err(_) => return (false, false, true), }; let output = match Command::new(executable).arg("--self-probe").output() { Ok(output) if output.status.success() => output, - _ => return (false, false), + Err(_) => return (false, false, true), + Ok(_) => return (false, false, false), }; let evidence: serde_json::Value = match serde_json::from_slice(&output.stdout) { Ok(evidence) => evidence, - Err(_) => return (false, false), + Err(_) => return (false, false, false), }; ( evidence @@ -487,6 +489,7 @@ fn descendant_boundary() -> (bool, bool) { .and_then(|value| value.as_bool()) == Some(true), evidence.get("inJob").and_then(|value| value.as_bool()) == Some(true), + false, ) } diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index af0fa52d49..a69aaa18e7 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -114,6 +114,7 @@ it('pins every Phase 4 adversarial category to executable evidence', async () => 'parentTokenDenied', 'descendantAppContainer', 'descendantInJob', + 'descendantSpawnDenied', ]) { assert.match(adversarialProbe, new RegExp(`"${field}":true`)); } diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index 2072c4e6f2..715a178fcd 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -41,7 +41,10 @@ export const WINDOWS_SANDBOX_PHASE4_MATRIX = Object.freeze([ { category: 'ipc', evidence: ['host named-pipe denial', 'bounded inherited handle list'] }, { category: 'descendants', - evidence: ['descendant AppContainer token', 'descendant kill-on-close Job membership'], + evidence: [ + 'descendant creation denied or AppContainer token retained', + 'kill-on-close Job membership', + ], }, { category: 'environment', From 343c1f1807ebd55b05399208dd59d6fbfaa9608e Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 23:33:04 +0800 Subject: [PATCH 08/14] fix(windows): match hard-link admission evidence --- experiments/windows-sandbox/adversarial-matrix-smoke.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index e059153afe..53383aab89 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -243,7 +243,7 @@ try { $hardLinkRequest = Write-LaunchRequest -Name "phase4-hardlink-$PID" ` -Arguments @('--self-probe') -ReadRoots @($hardLinkRoot) -WriteRoots @() ` -ExactReadRoots @() -ExactWriteRoots @() - Invoke-ExpectedAdmissionFailure -RequestPath $hardLinkRequest -Pattern 'multiple hard links' ` + Invoke-ExpectedAdmissionFailure -RequestPath $hardLinkRequest -Pattern 'multi-link' ` -Description 'Hard-link alias admission' # An unsettled identity is never interpreted or reused by later launches. From 63b5f2c73983928193cc207e496529fbbb7226ad Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 23:42:57 +0800 Subject: [PATCH 09/14] fix(windows): finish adversarial matrix smoke --- experiments/windows-sandbox/adversarial-matrix-smoke.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index 53383aab89..7159666a26 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -157,7 +157,7 @@ try { # The probe is already fail-closed if no response arrives. } } - $udpListener.BeginReceive($udpCallback, $null) + $udpListener.BeginReceive($udpCallback, $null) | Out-Null $pipe = [IO.Pipes.NamedPipeServerStream]::new( $pipeShortName, [IO.Pipes.PipeDirection]::InOut, @@ -304,3 +304,5 @@ try { } Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue } + +exit 0 From 59bcdd0bbd8177c10f1e14592d5c860319af43cc Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 24 Aug 2026 23:53:57 +0800 Subject: [PATCH 10/14] fix(windows): return zero after matrix cleanup --- experiments/windows-sandbox/adversarial-matrix-smoke.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index 7159666a26..d6daa47880 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -305,4 +305,5 @@ try { Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue } +$global:LASTEXITCODE = 0 exit 0 From 0fb6d549fbe5d1ec33919ba37e2438d504f0a515 Mon Sep 17 00:00:00 2001 From: liugddx Date: Tue, 25 Aug 2026 00:11:09 +0800 Subject: [PATCH 11/14] fix(windows): run adversarial smoke with PowerShell 7 --- scripts/verify-windows-sandbox-e2e.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index 715a178fcd..f9d1048a6b 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -485,7 +485,7 @@ async function verifyPackagedConcurrencySoak({ async function verifyPackagedAdversarialMatrix(sandboxExecutable) { const script = join(repoRoot, 'experiments', 'windows-sandbox', 'adversarial-matrix-smoke.ps1'); const { stdout, stderr } = await execFileAsync( - 'powershell.exe', + 'pwsh.exe', ['-NoProfile', '-NonInteractive', '-File', script, '-LauncherPath', sandboxExecutable], { cwd: repoRoot, From 6a2d7b2c42b24a9d4312c45fcc3362be9a99a3a8 Mon Sep 17 00:00:00 2001 From: liugddx Date: Tue, 25 Aug 2026 00:24:06 +0800 Subject: [PATCH 12/14] fix(windows): isolate UDP responder job --- .../adversarial-matrix-smoke.ps1 | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index d6daa47880..7b4532b062 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -35,7 +35,7 @@ $pipeShortName = "maka-phase4-host-$PID" $pipeName = "\\.\pipe\$pipeShortName" $hostSecretName = 'MAKA_PHASE4_HOST_SECRET' $listener = $null -$udpListener = $null +$udpJob = $null $pipe = $null $quarantinedSid = $null $quarantineRoot = $null @@ -144,20 +144,33 @@ try { $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) $listener.Start() $port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port - $udpListener = [Net.Sockets.UdpClient]::new(0) - $udpPort = ([Net.IPEndPoint]$udpListener.Client.LocalEndPoint).Port - $udpCallback = [AsyncCallback]{ - param($asyncResult) + $udpPortProbe = [Net.Sockets.UdpClient]::new(0) + $udpPort = ([Net.IPEndPoint]$udpPortProbe.Client.LocalEndPoint).Port + $udpPortProbe.Dispose() + $udpJob = Start-Job -ScriptBlock { + param([int]$Port) + $listener = [Net.Sockets.UdpClient]::new($Port) try { + Write-Output 'ready' $remote = [Net.IPEndPoint]::new([Net.IPAddress]::Any, 0) - [void]$udpListener.EndReceive($asyncResult, [ref]$remote) + [void]$listener.Receive([ref]$remote) $reply = [Text.Encoding]::UTF8.GetBytes('phase4-udp-ok') - [void]$udpListener.Send($reply, $reply.Length, $remote) - } catch { - # The probe is already fail-closed if no response arrives. + [void]$listener.Send($reply, $reply.Length, $remote) + } finally { + $listener.Dispose() } + } -ArgumentList $udpPort + $udpReady = $false + $udpDeadline = [DateTime]::UtcNow.AddSeconds(10) + while (-not $udpReady -and [DateTime]::UtcNow -lt $udpDeadline) { + $udpReady = @( + Receive-Job -Job $udpJob -Keep -ErrorAction SilentlyContinue + ) -contains 'ready' + if (-not $udpReady) { Start-Sleep -Milliseconds 100 } + } + if (-not $udpReady) { + throw 'UDP responder job did not become ready' } - $udpListener.BeginReceive($udpCallback, $null) | Out-Null $pipe = [IO.Pipes.NamedPipeServerStream]::new( $pipeShortName, [IO.Pipes.PipeDirection]::InOut, @@ -292,7 +305,10 @@ try { Write-Host "Phase 4 adversarial matrix verified: $rendered" } finally { if ($listener) { $listener.Stop() } - if ($udpListener) { $udpListener.Dispose() } + if ($udpJob) { + Stop-Job -Job $udpJob -ErrorAction SilentlyContinue | Out-Null + Remove-Job -Job $udpJob -Force -ErrorAction SilentlyContinue + } if ($pipe) { $pipe.Dispose() } [Environment]::SetEnvironmentVariable($hostSecretName, $null, 'Process') Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue From 4abeaa768c992bf34431833ce4e49a46fca62324 Mon Sep 17 00:00:00 2001 From: liugddx Date: Tue, 25 Aug 2026 00:38:30 +0800 Subject: [PATCH 13/14] test(windows): defer unsupported UDP probe --- docs/architecture/windows-sandbox-rfc-v1.md | 6 ++-- .../windows-sandbox-rfc-v1.zh-CN.md | 6 ++-- docs/windows-support.md | 4 +-- .../adversarial-matrix-smoke.ps1 | 34 ------------------- .../windows-sandbox/launcher/src/main.rs | 18 +--------- scripts/verify-windows-harness.test.mjs | 2 +- scripts/verify-windows-sandbox-e2e.mjs | 3 +- 7 files changed, 14 insertions(+), 59 deletions(-) diff --git a/docs/architecture/windows-sandbox-rfc-v1.md b/docs/architecture/windows-sandbox-rfc-v1.md index 34f963876a..afd0433c98 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.md +++ b/docs/architecture/windows-sandbox-rfc-v1.md @@ -265,7 +265,7 @@ Enforced (merged in #2961 unless tagged with a follow-up PR): - a packaged 64-launch repeated-wave concurrency soak with disjoint launch identities, followed by process and ACL-ledger residue assertions; - a packaged malicious-child matrix covering recursive junction and multi-hard-link admission, - outside-file access, TCP/UDP/listener creation, host named-pipe access, ambient environment, + outside-file access, TCP connection denial, host named-pipe access, ambient environment, host HKCU values, parent-token access, descendant AppContainer/Job inheritance, and quarantined identity non-reuse; - per-launch private-desktop **placement** (§6.3) **(#3174)**: each production launch and the readiness probe @@ -350,6 +350,8 @@ Designed but deferred as later gates (not enforced in the preview slice): - Inbound listener enforcement. AppContainer denies the packaged outbound TCP/UDP attempts, but local listener creation is not itself denied by the current token policy; full inbound-channel enforcement remains a W2/W3 network hardening gate. +- UDP channel enforcement. The W1 matrix proves outbound TCP denial; UDP send/response and DNS/SMB + enforcement remain a W2/W3 network hardening gate rather than a vacuous bind-only claim. Deferral narrows readiness richness and desktop-layer defense-in-depth, not the enforcement boundary: an unavailable, drifted, or failed backend still fails closed, and a restricted managed @@ -486,7 +488,7 @@ For the W1 preview, the packaged verifier maps the supported attack surface to e | Category | Packaged evidence | | --- | --- | | Filesystem aliases | outside denial plus recursive junction and multi-hard-link admission refusal | -| Network channels | TCP connect and UDP send denial without network capabilities | +| Network channels | TCP connect denial without network capabilities | | IPC | host named-pipe denial and an explicit inherited-handle list | | Descendants | child creation is denied fail-closed, or a created descendant retains the AppContainer token and kill-on-close Job | | Environment/credentials | ambient host secret and outside credential file are unavailable | diff --git a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md index 67777e2400..e525a8cebc 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md +++ b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md @@ -179,7 +179,7 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 中断首次启动、终止并 drain AppContainer Job,并释放本次 ledger/ACE; - 打包路径执行 64 次、按波次重复的并发 soak,每次使用互不相同的启动 identity,最后断言无进程与 ACL-ledger 残留; -- 打包恶意 child 矩阵覆盖递归 junction 与多硬链接准入、outside 文件、TCP/UDP/listener、宿主 named +- 打包恶意 child 矩阵覆盖递归 junction 与多硬链接准入、outside 文件、TCP connection 拒绝、宿主 named pipe、ambient 环境、宿主 HKCU、父进程 token、descendant 的 AppContainer/Job 继承,以及 quarantine identity 不复用; - 按启动的 private desktop **放置(placement)**(§6.3)**(#3174)**:每次生产启动与 readiness probe 均在当前 window station 上创建 alternate desktop,其 DACL 仅授予发起用户、Local System 与该次启动的 AppContainer SID(且只给该 SID 最小非交互权限;并以前置 deny ACE 从 AppContainer 子进程有效携带的发起用户 SID 上剥离 `DESKTOP_SWITCHDESKTOP`/`DESKTOP_HOOKCONTROL`/journal 录制回放),并以 `STARTUPINFOW.lpDesktop` 指向它启动子进程,建不出或授不了即 fail closed。桌面钉在 Low integrity(`S:(ML;;NW;;;LW)`)使授予权限对 Low-IL 子进程通过 MIC,且 heap 经 `CreateDesktopExW` 按启动限额(512 KiB)使受支持并发不会耗尽系统 desktop heap。由于 `lpDesktop` 只选择*初始*桌面,这把 worker 放置到交互 `Default` 桌面之外并对私有桌面做 DACL 保护;这是 placement 加 DACL 保护、**不是**防逃逸边界——没有结构性机制阻止进程内代码 `OpenDesktopW("Default")` + `SetThreadDesktop` 重新挂回,clipboard 也归 window station、仍为共用(no-Win32k mitigation、独立 window station 与 token 边界见下方暂缓门禁); @@ -206,6 +206,8 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 环境 secret 不会被授权或继承,但直接 `CredRead`/DPAPI probe 仍是 W2/W3 后续加固门禁。 - inbound listener 强制:AppContainer 会拒绝打包的 outbound TCP/UDP 尝试,但当前 token policy 不会单独拒绝本地 listener 创建;完整 inbound channel 强制仍是 W2/W3 网络加固门禁。 +- UDP channel 强制:W1 矩阵证明 outbound TCP 拒绝;UDP send/response 与 DNS/SMB 强制仍是 W2/W3 + 网络加固门禁,不用 bind-only 结果冒充通过。 暂缓收窄的是 readiness 丰富度与 desktop 层的 defense-in-depth,而非强制边界本身:backend 不可用、identity drift 或启动失败仍然 fail closed,受限 managed profile 也绝不回退到宿主执行。 @@ -329,7 +331,7 @@ Windows sandbox job 必须运行真实 child-process 正反测试: | 类别 | 打包证据 | | --- | --- | | 文件别名 | outside 拒绝,加递归 junction 与多硬链接准入拒绝 | -| 网络通道 | 无网络 capability 时拒绝 TCP connect 与 UDP send | +| 网络通道 | 无网络 capability 时拒绝 TCP connect | | IPC | 拒绝宿主 named pipe,并只继承显式 handle 列表 | | descendant | child 创建被 fail-closed 拒绝,或已创建 descendant 仍持有 AppContainer token 与 kill-on-close Job | | 环境/credential | ambient host secret 与 outside credential 文件均不可用 | diff --git a/docs/windows-support.md b/docs/windows-support.md index 2f29584e60..5d72aae23f 100644 --- a/docs/windows-support.md +++ b/docs/windows-support.md @@ -225,9 +225,9 @@ The root test timeout is tracked separately from individual test failures. Phase - Restricted managed profiles use the packaged AppContainer broker when available and fail closed when the native capability or requested policy is unavailable. - The packaged filesystem-worker gate covers client cancellation, Runtime Host parent death, - repeated concurrent launches, ACL quarantine isolation, filesystem aliases, restricted outbound network, + repeated concurrent launches, ACL quarantine isolation, filesystem aliases, restricted TCP outbound network, host named pipes, ambient environment, host registry values, parent tokens, and descendant - denial or AppContainer/Job inheritance. It does not claim local inbound-listener enforcement, the deferred + denial or AppContainer/Job inheritance. It does not claim UDP/DNS/SMB enforcement, local inbound-listener enforcement, the deferred no-Win32k/window-station tier, or direct Credential Manager/DPAPI isolation. - Computer-use has no Windows backend. - The Windows x64 NSIS installer is unsigned. The in-app automatic-update path (electron-updater → diff --git a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 index 7b4532b062..7aa5cb6994 100644 --- a/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 +++ b/experiments/windows-sandbox/adversarial-matrix-smoke.ps1 @@ -35,7 +35,6 @@ $pipeShortName = "maka-phase4-host-$PID" $pipeName = "\\.\pipe\$pipeShortName" $hostSecretName = 'MAKA_PHASE4_HOST_SECRET' $listener = $null -$udpJob = $null $pipe = $null $quarantinedSid = $null $quarantineRoot = $null @@ -144,33 +143,6 @@ try { $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) $listener.Start() $port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port - $udpPortProbe = [Net.Sockets.UdpClient]::new(0) - $udpPort = ([Net.IPEndPoint]$udpPortProbe.Client.LocalEndPoint).Port - $udpPortProbe.Dispose() - $udpJob = Start-Job -ScriptBlock { - param([int]$Port) - $listener = [Net.Sockets.UdpClient]::new($Port) - try { - Write-Output 'ready' - $remote = [Net.IPEndPoint]::new([Net.IPAddress]::Any, 0) - [void]$listener.Receive([ref]$remote) - $reply = [Text.Encoding]::UTF8.GetBytes('phase4-udp-ok') - [void]$listener.Send($reply, $reply.Length, $remote) - } finally { - $listener.Dispose() - } - } -ArgumentList $udpPort - $udpReady = $false - $udpDeadline = [DateTime]::UtcNow.AddSeconds(10) - while (-not $udpReady -and [DateTime]::UtcNow -lt $udpDeadline) { - $udpReady = @( - Receive-Job -Job $udpJob -Keep -ErrorAction SilentlyContinue - ) -contains 'ready' - if (-not $udpReady) { Start-Sleep -Milliseconds 100 } - } - if (-not $udpReady) { - throw 'UDP responder job did not become ready' - } $pipe = [IO.Pipes.NamedPipeServerStream]::new( $pipeShortName, [IO.Pipes.PipeDirection]::InOut, @@ -188,7 +160,6 @@ try { allowedReadPath = $allowedReadPath allowedWritePath = $allowedWritePath loopbackPort = $port - udpPort = $udpPort pipeName = $pipeName environmentSecretName = $hostSecretName registrySubkey = $registrySubkey @@ -212,7 +183,6 @@ try { '"allowedRead":true', '"allowedWrite":true', '"tcpDenied":true', - '"udpDenied":true', '"namedPipeDenied":true', '"environmentDenied":true', '"registryDenied":true', @@ -305,10 +275,6 @@ try { Write-Host "Phase 4 adversarial matrix verified: $rendered" } finally { if ($listener) { $listener.Stop() } - if ($udpJob) { - Stop-Job -Job $udpJob -ErrorAction SilentlyContinue | Out-Null - Remove-Job -Job $udpJob -Force -ErrorAction SilentlyContinue - } if ($pipe) { $pipe.Dispose() } [Environment]::SetEnvironmentVariable($hostSecretName, $null, 'Process') Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue diff --git a/experiments/windows-sandbox/launcher/src/main.rs b/experiments/windows-sandbox/launcher/src/main.rs index cac8e32bae..27794f01dc 100644 --- a/experiments/windows-sandbox/launcher/src/main.rs +++ b/experiments/windows-sandbox/launcher/src/main.rs @@ -47,7 +47,7 @@ mod windows_launcher_tests; use std::env; use std::fs; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, UdpSocket}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; use std::process::{Command, ExitCode}; use std::time::Duration; @@ -340,7 +340,6 @@ struct AdversarialProbeInput { allowed_read_path: String, allowed_write_path: String, loopback_port: u16, - udp_port: u16, pipe_name: String, environment_secret_name: String, registry_subkey: String, @@ -358,19 +357,6 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { let allowed_write = fs::write(&input.allowed_write_path, b"allowed-write").is_ok(); let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), input.loopback_port); let tcp_denied = TcpStream::connect_timeout(&address, Duration::from_secs(2)).is_err(); - let udp_denied = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) { - Ok(socket) => { - let _ = socket.set_read_timeout(Some(Duration::from_secs(2))); - match socket.send_to(b"maka-phase4", (Ipv4Addr::LOCALHOST, input.udp_port)) { - Ok(_) => { - let mut response = [0u8; 64]; - socket.recv_from(&mut response).is_err() - } - Err(_) => true, - } - } - Err(_) => true, - }; let named_pipe_denied = fs::OpenOptions::new() .read(true) .write(true) @@ -387,7 +373,6 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { "allowedRead": allowed_read, "allowedWrite": allowed_write, "tcpDenied": tcp_denied, - "udpDenied": udp_denied, "namedPipeDenied": named_pipe_denied, "environmentDenied": environment_denied, "registryDenied": registry_denied, @@ -402,7 +387,6 @@ fn adversarial_probe(input: &AdversarialProbeInput) -> Result { && allowed_read && allowed_write && tcp_denied - && udp_denied && named_pipe_denied && environment_denied && registry_denied diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index a69aaa18e7..984460070d 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -82,6 +82,7 @@ it('pins every Phase 4 adversarial category to executable evidence', async () => 'Authenticode identity verification', 'direct Credential Manager and DPAPI probes', 'inbound listener enforcement', + 'UDP channel enforcement', 'no-Win32k mitigation', 'dedicated window-station and clipboard isolation', 'power-loss automatic recovery', @@ -107,7 +108,6 @@ it('pins every Phase 4 adversarial category to executable evidence', async () => for (const field of [ 'fileDenied', 'tcpDenied', - 'udpDenied', 'namedPipeDenied', 'environmentDenied', 'registryDenied', diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index f9d1048a6b..c4c9a08766 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -36,7 +36,7 @@ export const WINDOWS_SANDBOX_PHASE4_MATRIX = Object.freeze([ }, { category: 'network_channels', - evidence: ['TCP connect denial', 'UDP send denial'], + evidence: ['TCP connect denial'], }, { category: 'ipc', evidence: ['host named-pipe denial', 'bounded inherited handle list'] }, { @@ -70,6 +70,7 @@ export const WINDOWS_SANDBOX_DEFERRED_HARDENING = Object.freeze([ 'Authenticode identity verification', 'direct Credential Manager and DPAPI probes', 'inbound listener enforcement', + 'UDP channel enforcement', 'no-Win32k mitigation', 'dedicated window-station and clipboard isolation', 'power-loss automatic recovery', From 6970405e99a7e0aaba28c1f5eb30227aeb57b320 Mon Sep 17 00:00:00 2001 From: liugddx Date: Tue, 25 Aug 2026 01:00:25 +0800 Subject: [PATCH 14/14] chore: refresh Astryx surface inventory --- docs/astryx-surface-file-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 4707983699..834cd00c6d 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 209 files — blocker 0, polish 1, aligned 208. +**Totals:** 211 files — blocker 0, polish 1, aligned 210. ## Exclusions (explicit)