sandbox(windows): dedicated-account + WFP backend for agent-sandbox - #561
sandbox(windows): dedicated-account + WFP backend for agent-sandbox#561colinhacks wants to merge 7 commits into
Conversation
…dbox) Adds a second Windows backend alongside the per-run AppContainer one. The AppContainer path is a pure allowlist and stays exactly as it was — it is build-jail's mechanism and remains admin-free. Agent-sandbox routes instead to a dedicated local account fenced by SID-keyed WFP filters, which is the only shape on Windows that expresses a generous-read base, a deny carved inside a grant, and per-host egress. The privilege split is the point: one elevated setup per machine installs the account and four persistent WFP filters over a pre-authorized loopback port window; every run after that is unelevated, because the egress proxy binds into that window rather than a filter chasing an ephemeral port. This commit lands the WFP fence, the plan derivation and mode selection, the durable marker/ledger, the CreateProcessWithLogonW launcher, and the setup/teardown/status/clean surface. The account and ACL modules land next. Refs .fray/sandbox-decisions-current.md
…nt setup/teardown/status/clean
…rcement probe Completes the dedicated-account backend: - account.rs — NetUserAdd/NetUserSetInfo lifecycle (UF_SCRIPT is mandatory on workstation SKUs), BCryptGenRandom credential over a shell-safe alphabet with a retry on the local password policy, localized BUILTIN\Users membership resolved from S-1-5-32-545 and added by PSID, DPAPI machine-scope credential store, Winlogon user-picker hide, SID-before-NetUserDel teardown. - acl.rs — inheritable grant/deny aces whose masks deliberately exclude FILE_DELETE_CHILD, WRITE_DAC and WRITE_OWNER (each exclusion is a compile-time assertion naming the bypass it prevents), plus a hand-built ACL rebuild for removal because SetEntriesInAclW(REVOKE_ACCESS) does not remove explicit deny aces on Windows 11 25H2. - tests/windows_account_enforcement.rs — the real behavioral probe: secret denied inside a granted tree, writes jailed, egress blocked on raw WSAEACCES, surrogate-spawn fenced, proxy reachable inside the window. Every assertion carries a negative control, and an un-provisioned machine exits non-zero rather than passing hollow. Two pre-existing Windows-target breaks are fixed alongside, because the lib's unit tests and the enforcement suite could not compile without them: a NetPolicy literal that never grew the MITM tier's fields, and an expect_err that requires a Debug bound Prepared does not have. CI builds only three named integration targets on windows-latest, which is why neither surfaced. Gates: host clippy --all-targets --all-features -D warnings clean, host tests green, and the same clippy clean for x86_64-pc-windows-gnu.
…mt --all `cargo fmt --all` reflows the whole workspace, vendor/aube included. Those 16 files are pure rustfmt churn, unrelated to this branch and owned by the aube fork-discipline flow, so they are restored to the branch point. The net diff now touches no vendor/aube file.
…backend Two fresh-context reviews (correctness/security, impact-analysis) plus a real VM run turned up defects the local gates structurally cannot catch — both Windows `apply`s are cfg-gated, so the host never executes them and the cross-target clippy only type-checks. Security: - Never log on as the marker's `account` field. It was a free-form string from a file a standard user can own (ProgramData grants Users add-subdirectory and CREATOR OWNER full control), while only the hardcoded account's SID was validated — so swapping `account` while keeping the real `sid` launched every "sandboxed" run as an attacker-chosen account with no WFP fence, reported as fully enforced. The field is gone and the marker version is bumped. - Lock the credential store to Administrators, SYSTEM and the creating user with a protected DACL, and reset its owner. An additive deny left ProgramData's inherited Users read in place, so any local account could read the DPAPI blob and machine-scope-decrypt it; without the owner reset a pre-creating user kept implicit WRITE_DAC and could undo the lock. The directory is now locked before the credential is written into it. - Apply the same live-SID check to the sweep that the launch already had, so a writable marker is not an ACE-removal primitive aimed at arbitrary paths. Correctness: - A deny target's parent carve stamped the volume root: `Path::parent()` of a canonicalized `\\?\C:\x.env` is the drive, not `None`. - A grant or deny naming a path that does not exist no longer aborts the run. The flagship policy denies `~/.ssh`, `~/.aws` and similar, most absent on a real machine, so the backend died on its own headline shape. - The parent carve is recorded and stripped, instead of leaving a permanent deny ACE that survived teardown. - Grant the sandbox account on the caller's window station and desktop. A non-interactive caller runs on a per-logon service station, not WinSta0, and seclogon's auto-grant does not cover it — the child died in loader init with STATUS_DLL_INIT_FAILED. Diagnosed on the VM with a minimal probe and no nub code. The grant fails forward, since it is a no-op where it is unnecessary. - Check `ResumeThread` and `GetExitCodeProcess`; a failed resume hung forever on an INFINITE wait and a failed query reported success. - Guard the empty-DACL case in the ACL rebuild, which denies everyone. Routing: - The account route now also requires a provisioned machine. Without that it subsumed the strict-Windows tier entirely — that tier's condition is byte-identical to the per-host arm — leaving it dead and failing its own test. Falling through keeps it live and degrading honestly. - Gate the enforcement probe behind an opt-in feature. Ungated, bare `cargo test` on a windows-latest runner either turned the leg red or silently provisioned the runner with a local account and WFP filters. - Every error naming `--sandbox-setup` now names the flag that exists, and `--sandbox-admin` joins the run subcommand's value-flag list per its own documented invariant. Gates: host and x86_64-pc-windows-gnu clippy --all-targets --all-features -D warnings clean, 113 lib tests plus every suite green, fmt clean.
Found by the post-fix VM run. Windows reads a NULL DACL as UNRESTRICTED access, not as an empty allow-set, so merging an ace into it via SetEntriesInAclW yields a DACL containing only that ace. Writing that converts "everyone allowed" into "the sandbox account and nobody else" and permanently locks out the object's own owner — observed on a C:\Windows\Temp child, where a grant reduced a seven-ace DACL to one and the owner lost traverse on a directory it owned. `strip` already guarded the mirror case; `add_ace` did not. It now refuses: a grant is skipped, because a NULL DACL already admits the account, and a deny fails closed with a message naming the path rather than silently leaving a hole or destroying the descriptor. Also from that run: teardown removes the state directory it created, which carries a protected DACL an unelevated user cannot clear, and a marker that exists but is unreadable no longer borrows the "re-run setup" instruction — re-running setup does not fix a permission problem.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
ℹ️ No critical issues — two user-facing error strings lost their line-continuation. Everything else is a deliberate, documented spike bound.
Reviewed changes — the new Windows dedicated-account + WFP backend for the agent-sandbox policy grammar, added alongside (not replacing) the existing per-run AppContainer backend.
- Second Windows backend under
windows_account/— runs the child as a dedicatednub-sandboxlocal account fenced by SID-keyed WFP egress filters and DACL grant/deny ACEs, expressing generous-read-minus-secrets, deny-inside-a-grant, and per-host egress — the three shapes the AppContainer allowlist cannot. - Privilege split — one elevated
nub run --sandbox-admin setupper machine (account + 4 persistent WFP filters over a loopback port window +%PROGRAMDATA%\nub\sandboxlock); every run after is unelevated viaCreateProcessWithLogonWthrough seclogon. - DACL surgery in
acl.rs— canonical ACE ordering (explicit-deny before inherited-allow) is the whole fs mechanism; grant masks compile-assert the exclusion ofFILE_DELETE_CHILD/WRITE_DAC/WRITE_OWNER;striphand-rebuilds the DACL becauseSetEntriesInAclW(REVOKE_ACCESS)fails to remove deny ACEs on Win11 25H2; NULL-DACL writes fail closed. - Credential + state — DPAPI machine-scope credential gated solely by a PROTECTED DACL + owner reset on the state dir; v2 marker drops the account-name field (v1 name-trust was a fixed vuln) and carries only the SID + port window, re-validated against the live account on every launch and sweep.
- WFP fence in
wfp.rs— 4 transactional persistent filters keyed onALE_USER_ID(defeats surrogate-spawn), loopback permit out-weighs the account block (const-asserted). - Backend selection —
needs_account_backend && is_provisionedgates the new route inapply; build-jail's shape is unit-tested to stay on the admin-free AppContainer path, and an un-provisioned machine falls through, sonub installnever demands elevation. - Enforcement probe — opt-in
--features windows-sandbox-probereal-box test with a negative control paired to every confinement assertion; plus incidental fixes to two pre-existing Windows-target compile breaks.
This is a spike to the sandbox-win-net-parity feature branch, not main, and it is exceptionally documented and self-reviewed (two fresh-context reviews plus VM runs already applied, with three security-outcome fixes called out in the body). The security-critical invariants I checked — canonical DACL order, mask exclusions, NULL-DACL handling, credential lockdown, marker identity, SID-keyed WFP, and build-jail routing — are all backed in-code by compile-time assertions, unit tests, and the enforcement probe. The spike bounds (single-hop launch, shared account, broad window-station grant on an interactive session, DNS still resolving, inbound unfiltered, best-effort job reap) are deliberate and enumerated in LIMITATIONS.md; none is a defect to fix here.
Claude Opus | 𝕏
Both strings were written through a Python heredoc, which consumed the trailing backslashes, so the fragments joined with their source indentation intact and the messages rendered with long runs of literal spaces. Both are user-facing: the NULL-DACL deny refusal and the unreadable-marker degradation reason.

Windows agent-sandbox needs three things the AppContainer backend cannot express: a generous-read base, a deny carved inside a granted directory, and per-host egress. This spike adds a second Windows path that expresses all three — a dedicated local account fenced by Windows Filtering Platform filters keyed on that account's SID.
The existing per-run AppContainer path is untouched. It is build-jail's mechanism and stays administrator-free end to end.
Why a second backend
The AppContainer path is a pure allowlist: reachable only where an object's ACL names the per-run AppContainer SID. Two things defeat agent-sandbox there.
ALL APPLICATION PACKAGESgrant is readable regardless of the allow-set — that grant satisfies the LowBox check before default-deny is reached.Both dissolve when the child runs as a separate local principal. The invoking user's own profile is denied by default with no ACE authored at all, no
ALL APPLICATION PACKAGESgrant ever covers a user SID, and an explicit deny ACE on a secret inside a granted tree wins on canonical DACL order.Per-host egress was never administrator-free on Windows regardless — there is no unprivileged per-host mechanism, so the full grammar always implied WFP, which always implied elevation. Given elevation is required anyway, the dedicated account buys the whole grammar rather than a subset. That is what collapsed the deny-strip's reason to exist, and it is dropped rather than finished: no
SE_DACL_PROTECTED, no DACL journal, no copy-on-deny.The privilege split
This is the product decision the design turns on. Every WFP write requires administrator, so a filter carrying the run's ephemeral proxy port would mean a UAC prompt on every run. Instead the one-time elevated setup pre-authorizes a narrow loopback port window, and the egress proxy binds into that window at launch.
The per-run launch uses
CreateProcessWithLogonW, which hands the credential to the Secondary Logon service and therefore needs no privilege in the caller — the unelevated broker never holds a foreign primary token. SRT and Codex converged on the same shape independently.The honest cost is that the permit covers a ten-port loopback window rather than one exact port, and it is not user-scoped.
What routes where
Selection is
needs_account_backend(). Unit tests pin build-jail's shape to the AppContainer path specifically, because a regression there would makenub installdemand elevation.Mechanism
Egress — four persistent filters in one nub-owned sublayer. A BLOCK on
ALE_AUTH_CONNECT_V4/V6conditioned onFWPM_CONDITION_ALE_USER_ID, and a higher-weight PERMIT for loopback inside the port window. Keying on the token's user is what defeats surrogate-spawn: a process-keyed filter loses the moment the child launches a helper.Filesystem — inheritable ALLOW aces for the account on granted subtrees, explicit DENY aces on secrets inside them. The grant masks deliberately exclude
FILE_DELETE_CHILD,WRITE_DACandWRITE_OWNER; each exclusion is a compile-time assertion naming the bypass it prevents. Removal hand-builds the ACL rather than callingSetEntriesInAclW(REVOKE_ACCESS), which does not remove explicit deny aces on Windows 11 25H2.State — a versioned marker records the account SID and port window so an unelevated run can read what it may not enumerate (WFP gates even reading on administrator). An append-only ledger records every ACL'd path, written before the ace is applied, so crash residue can be swept.
Spike bounds
Carried deliberately and documented in
LIMITATIONS.mdrather than hidden:WinSta0gives the sandbox account input injection, keystroke and screen capture, and clipboard read against the user's own session for the run's duration.lpDesktopstays NULL, since setting it was verified not to help. The masks are the ones the VM diagnosis confirmed working; narrowing them needs a bisect on a real box, because that surface fails by hanging in loader init rather than erroring.ALE_AUTH_CONNECTonly — inbound and bind are unfiltered. DNS still resolves through theDnscacheservice under a different token.Verification
Driven end to end on a real Windows Server 2022 box, from an unelevated session
(
IsInRole(Administrator)= False, Medium integrity), against a machine provisioned once by anelevated setup:
Every block is paired with a negative control that passed in the same run, and the identity
guard passed — so "no breakout" is not vacuous. Egress cases assert raw
WSAEACCES(10013)specifically; a plain
ERROR_ACCESS_DENIEDis a distinct exit code and fails the assertion, sothe WFP fence is confirmed as the mechanism rather than inferred. On an un-provisioned machine
the probe refuses and exits non-zero rather than passing hollow.
The probe is opt-in (
--features windows-sandbox-probe) because it provisions a machine; barecargo teston awindows-latestrunner will not build or run it.Gates: host and
x86_64-pc-windows-gnuclippy--all-targets --all-features -D warningsclean, 113 lib tests plus every suite green, fmt clean.
Review
Two fresh-context reviews (correctness/security, impact-analysis) and two VM runs. Everything
they confirmed is fixed here, including three that changed the security outcome: the marker's
account name was trusted as the logon identity while only the hardcoded account's SID was
validated; the credential store's lock was additive, so ProgramData's inherited read survived,
and without an owner reset a pre-creating user could undo it; and
add_acewould replace aNULL DACL — which Windows reads as unrestricted — with one containing only its own ace,
locking out the object's owner.
Incidental fixes
Two pre-existing breaks are fixed here because the crate's lib unit tests and the Windows enforcement suite could not compile for the Windows target without them: a
NetPolicyliteral that never grew the MITM tier's fields, and anexpect_errrequiring aDebugboundPrepareddoes not have. CI builds only three named integration targets onwindows-latest, which is why neither surfaced.LIMITATIONS.mdstill carries a stale claim in its untrusted-tier section (an omitted axis is described as relaxed; it floors). That belongs to the config-grammar effort and is left alone here to avoid conflicting with the in-flight branch.Verification