Durable record: bound to the owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root) — a fork's copied parent record no longer provisions the child, and a tampered record fails loud. Private temp dir: random unguessable name persisted in the record, created exclusively (pre-existing entries and reparse points fail EEXIST). Persistence: a fresh provision kicks an immediate flush (no write-behind debounce), narrowing the crash window to the flush latency — documented as the one self-healing gap. Runner-failure rules: exit-gated on 127 so a confined command that prints the signature on a non-127 exit is never misclassified. Spawn: AssignProcessToJobObject failure terminates the suspended child (no hanging orphans). SandboxExecutionPolicy.sessionId is the branded SessionId. Boundary docs: qualifying clause on the absolutist sentences, NULL-DACL Known Limitation, 'full' scoped to the supported NTFS surface, CLM gate comment.
11 KiB
Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer
Status: implemented
English | 中文
Problem
The sandbox decision leaves PLATFORM_CHAINS.win32 empty, so shipped Windows profiles degrade to danger-full-access because no confining executor exists. The win32 rung must confine the two file-effect modes the sandbox vocabulary promises — read-only (zero writes) and workspace-write (writes under the workspace root plus a backend-defined temp area) — while leaving reads, network, and process visibility alone, because every mode permits reading.
Decision
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a WRITE_RESTRICTED token (CreateRestrictedToken with WRITE_RESTRICTED + DISABLE_MAX_PRIVILEGE + LUA_TOKEN) whose restricting SIDs include an orphan SID (S-1-4-x-y); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because WRITE_RESTRICTED intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (10e4dfb) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only sandbox/acl-session event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution — a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency; a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap — and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The record is BOUND to its owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a /permission downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:-root tree-creation escape (standing AU:(AD) + AU:(OI)(CI)(IO)(M) ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (<temp>\dsh-<16 random hex> — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap --tmpfs /tmp semantics). It ships as @deepseek-ai/dsh-sandbox-windows-acl (backend plus the ./runner argv-prefix entry), the win32 chain rung of dsh-sandbox-local, and @deepseek-ai/dsh-pwsh-sandbox as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack.
How the restriction works (why no new identity)
The identity routes restrict by who runs the child; this rung restricts by token derivation. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: CreateRestrictedToken derives a token that adds the restricting SIDs and the WRITE_RESTRICTED flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the orphan-SID check, which only the workspace and temp ACEs satisfy. DISABLE_MAX_PRIVILEGE | LUA_TOKEN synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (SidsToDisable turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
Alternatives considered
Why not mxc (Microsoft xContainer)?
Two disqualifiers. First, the OS floor is too new: the mxc OS-version policy sets the product floor at Windows 11 24H2 (build 26100), and the BaseContainer tier (T1, Experimental_CreateProcessInSandbox) exists only on 25H2+ (build 26600+) with the OS feature enabled — on every supported release at or below 25H2 the filesystem policy falls back to T3, AppContainer plus host-side DACL ACE augmentation. Second, supporting arbitrary-path reads under either tier means granting read access by writing ACLs over every path the child may read: a model that reads the whole workspace and arbitrary files would require wholesale host DACL mutation — a standing side effect and a cost a write-only restriction does not need.
Why not AppContainer?
An AppContainer token carries no ambient read access: every readable path must be pre-granted through capabilities or explicit ACEs, so arbitrary-path reads — the harness's read model — are unsupported without the same wholesale grants. The restricted token needs no read grants at all: it intersects write access only.
Why not landstrip?
The landstrip evaluation was rejected before implementation (not battle-tested; the in-house launcher plan won), and its Windows backend is AppContainer-shaped, inheriting the same arbitrary-read problem.
Consequences
Bought: write-only confinement with no new OS floor (CreateRestrictedToken predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with STATUS_DLL_INIT_FAILED; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (SetNamedSecurityInfoW walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and Get-ComputerInfo silently returns incomplete results) as the price of closing the C:-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); whoami and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run pwsh in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so Add-Type, non-core .NET statics ([System.IO.*]::, [math]::), COM objects, and reflection fail with "only core types" errors while -f formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations.
Testing
The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs (base.spec.ts, windows-shell.spec.ts) plus the win32 real-runner suites (packages/sandbox/sandbox-windows-acl/tests/, packages/bash/pwsh-sandbox/tests/) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by packages/sandbox/sandbox-local/tests/acl-session.spec.ts (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the write SID and temp path, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — with the Win32 surface mocked) and on win32 by grant.spec.ts (real-DACL materialization), the acl.spec.ts idempotent-grant fast-path, the failure-paths.spec.ts suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the runner.spec.ts --write-sid contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the windows-acl-run: signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite).
Related
The pwsh executor decision owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes.