Skip to content

sandbox(windows): dedicated-account + WFP backend for agent-sandbox - #561

Open
colinhacks wants to merge 7 commits into
sandbox-win-net-parityfrom
sandbox-win-account
Open

sandbox(windows): dedicated-account + WFP backend for agent-sandbox#561
colinhacks wants to merge 7 commits into
sandbox-win-net-parityfrom
sandbox-win-account

Conversation

@colinhacks

Copy link
Copy Markdown
Contributor

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.

  • Generous-read-minus-secrets is inexpressible. An allowlist cannot say "read everything except these", so the policy degrades to the explicit allow-set.
  • Deny-inside-allow does not hold. A secret under a directory carrying an inherited ALL APPLICATION PACKAGES grant 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 PACKAGES grant 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.

nub run --sandbox-admin setup      # elevated, once per machine
nub run --sandbox <policy> <cmd>   # unelevated, every time after

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

Policy shape Backend Privilege
Default-deny allowlist, coarse or absent net (build-jail) per-run AppContainer none
Generous read, deny inside a grant, or per-host egress (agent-sandbox) dedicated account + WFP one-time elevated setup

Selection is needs_account_backend(). Unit tests pin build-jail's shape to the AppContainer path specifically, because a regression there would make nub install demand elevation.

Mechanism

Egress — four persistent filters in one nub-owned sublayer. A BLOCK on ALE_AUTH_CONNECT_V4/V6 conditioned on FWPM_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_DAC and WRITE_OWNER; each exclusion is a compile-time assertion naming the bypass it prevents. Removal hand-builds the ACL rather than calling SetEntriesInAclW(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.md rather than hidden:

  • Single-hop launch. SRT and Codex add a second hop through a runner holding a restricted token; confinement here comes from the account's ACL reach plus SID-keyed WFP, neither of which needs that token.
  • The child shares the caller's window station and desktop rather than getting a private one. It is granted access to both, which on an interactive WinSta0 gives the sandbox account input injection, keystroke and screen capture, and clipboard read against the user's own session for the run's duration. lpDesktop stays 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.
  • Job-Object whole-tree kill is best-effort — seclogon's own job refuses cross-session nesting.
  • Concurrent runs share one account, so one run's teardown can revoke a grant another still needs.
  • Child-created files are owned by the sandbox account. Access is preserved; ownership changes.
  • Egress coverage is ALE_AUTH_CONNECT only — inbound and bind are unfiltered. DNS still resolves through the Dnscache service 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 an
elevated setup:

  CHILD USERNAME=nub-sandbox USERPROFILE=C:\Users\nub-sandbox
PASS the child runs AS the dedicated sandbox account, not the invoking user (exit 0)
PASS KEY: the denied secret inside the granted tree is UNREADABLE (exit 5)
PASS NC same run, same tree: the granted file still reads (the deny is surgical) (exit 0)
PASS NC unconfined: the secret is readable absent the sandbox (exit 0)
PASS write inside the granted tree succeeds (exit 0)
PASS write to the UNGRANTED sibling dir is denied (exit 5)
PASS NC unconfined: the sibling dir is writable absent the sandbox (exit 0)
PASS egress to a loopback endpoint OUTSIDE the permitted window is blocked (WSAEACCES) (exit 5)
PASS NC unconfined: the same endpoint is reachable (exit 0)
PASS a GRANDCHILD the sandboxed child spawns is fenced too (the filter keys on the SID) (exit 5)
PASS NC unconfined: the same grandchild reaches the endpoint (exit 0)
PASS a listener INSIDE the permitted window IS reachable from the sandboxed child (exit 0)
ALL WINDOWS ACCOUNT ENFORCEMENT PROBES PASSED

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_DENIED is a distinct exit code and fails the assertion, so
the 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; bare
cargo test on a windows-latest runner will not build or run it.

scripts/rust-build.sh test -p nub-sandbox --no-run --profile fast \
  --target x86_64-pc-windows-gnu --features windows-sandbox-probe

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.

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_ace would replace a
NULL 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 NetPolicy literal that never grew the MITM tier's fields, and an expect_err requiring a Debug bound Prepared does not have. CI builds only three named integration targets on windows-latest, which is why neither surfaced.

LIMITATIONS.md still 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

…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
…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.
Copilot AI review requested due to automatic review settings July 25, 2026 08:11
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview, Comment Jul 25, 2026 8:20am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 dedicated nub-sandbox local 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 setup per machine (account + 4 persistent WFP filters over a loopback port window + %PROGRAMDATA%\nub\sandbox lock); every run after is unelevated via CreateProcessWithLogonW through 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 of FILE_DELETE_CHILD/WRITE_DAC/WRITE_OWNER; strip hand-rebuilds the DACL because SetEntriesInAclW(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 on ALE_USER_ID (defeats surrogate-spawn), loopback permit out-weighs the account block (const-asserted).
  • Backend selectionneeds_account_backend && is_provisioned gates the new route in apply; build-jail's shape is unit-tested to stay on the admin-free AppContainer path, and an un-provisioned machine falls through, so nub install never demands elevation.
  • Enforcement probe — opt-in --features windows-sandbox-probe real-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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-sandbox/src/backend/windows_account/acl.rs Outdated
Comment thread crates/nub-sandbox/src/backend/windows_account/mod.rs Outdated
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants