Skip to content

feat(sandbox): Windows sandbox principals (foundation for #662, does not close it) - #808

Open
Vasanthdev2004 wants to merge 100 commits into
mainfrom
feat/windows-sandbox-identity
Open

feat(sandbox): Windows sandbox principals (foundation for #662, does not close it)#808
Vasanthdev2004 wants to merge 100 commits into
mainfrom
feat/windows-sandbox-identity

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Opt-in behind ZERO_WINDOWS_SANDBOX_IDENTITY=1. The provisioning half has now been run on a real elevated session; the logon half has not, and that is called out below.

What this does NOT do yet

Two corrections to how an earlier version of this description read, both raised in review.

This does not close #662 for a default install. The principal backend is deliberately disabled whenever the network mode is deny (windowsSandboxPrincipalEligible), because WFP block filters key on the offline-marker SID that only a restricted token can carry. Default policy IS network-deny. So with nothing but ZERO_WINDOWS_SANDBOX_IDENTITY=1 set, commands keep using the restricted same-user token and credentialDenyReadPaths remains a no-op on Windows. Principal read confinement needs elevated setup AND a network-allow command profile, until the filters are also keyed to the principal SID. This PR is the foundation for #662, not its fix.

One change here is not gated by the opt-in. WindowsACLAllowWrite now includes DELETE. FILE_DELETE_CHILD is deliberately NOT granted: it would let a sandboxed command delete a protected carveout such as .git/config through its parent directory and recreate it without the deny ACE. That mask is shared with the capability-SID plans, so it applies on every elevated setup re-run whether or not the env var is set. It is a fix rather than a regression (without it a sandboxed command could create files it could never delete or rename), but it is a real behaviour change for installs that never opt in, and it belongs in the release notes rather than buried in a principal PR.

Why

credentialDenyReadPaths opens with if runtime.GOOS == "windows" { return nil }, so on Windows no credential path is protected (#662, and the Windows half of #675). That is not an oversight and not a one-line fix.

Every Windows backend derives its token from the CALLING user via CreateRestrictedToken. A deny-read ACE that would stop the sandboxed child reading ~/.aws names the same account Zero itself runs as, so it would lock Zero out too. The one existing escape hatch is costly: the runner drops WRITE_RESTRICTED whenever any DenyRead path is configured, because the kernel skips restricted-SID deny ACEs for reads under that flag, and a fully restricted token then cannot open executables. That is the same wall #640 hit.

What this does

Gives the sandbox an identity of its own: a separate local account per workspace, in one managed group.

The inversion is the point. A separate account has no access to the caller's profile at all, so credential stores are unreachable by construction rather than by enumerating deny rules. The interesting direction becomes what to GRANT, and the same SID is what a write grant or a firewall rule keys to.

  • Provisioning: managed group, stable per-workspace account name inside the 20-char limit, crypto/rand password meeting complexity policy, SID resolution. Idempotent, so setup re-runs converge instead of accumulating accounts.
  • Logon rights: grants only SeBatchLogonRight, and explicitly denies interactive, network, remote-interactive and service logon, so the account cannot be signed into even if its password leaked. LogonUser is pinned to "." so a same-named domain account is never picked up.
  • ACLs: denies emitted before allows so carve-outs survive Windows DACL evaluation; workspace granted read+write; read roots granted read (a principal has none by default); protected metadata denied write and materialized so the ACE exists before the directory does.
  • Secret storage: the password is stored with an explicit, inheritance-PROTECTED DACL naming only the invoking user and SYSTEM. The sandbox principal is deliberately absent, because a principal that could read it could mint its own token and the boundary would be decorative. The ACL is applied to an empty file before the password is written, so the bytes never exist under the config directory's inherited permissions. The password is additionally encrypted to the invoking user with CryptProtectData, since an ACL only binds while the filesystem is the one being asked and a backup or a mounted image would otherwise give it up in the clear. The principal name is the entropy, so a blob copied onto another principal's path fails to decrypt rather than authenticating the wrong account.
  • Runner: asks for a principal token first and uses it instead of the restricted token. Fail-soft by design, opt-out, no provisioned account or no stored secret all report "not available" and the existing path runs unchanged; only a provisioned-but-unusable identity surfaces an error, since that means the sandbox is broken rather than absent, and that error names the opt-out variable so there is a way back.
  • Removal: revocation keyed to the trustee, so retiring a principal drops every ACE naming it without needing a record of what was granted. This is the cleanup path the capability-SID model lacks, and the "no removal path" gap I raised on fix(sandbox): keep Windows restricted-token SIDs narrow (no Users broaden) #640.

Gated behind ZERO_WINDOWS_SANDBOX_IDENTITY=1, so no existing install changes behaviour.

Verification, and what is not verified

gofmt, go vet, go build ./... clean; builds for linux, darwin and windows. 29 tests, all passing when I ran them, covering name derivation and truncation, password complexity, "already exists" handling, the raw Win32 struct layouts, LSA byte-vs-rune lengths, deny-before-allow ordering, trustee scoping, root grants, metadata materialization, revocation, secret round-trip and overwrite, path traversal, and idempotent removal.

Two of those matter most and do real work rather than asserting intent: one reads the stored secret's DACL back and fails if any trustee other than the owner and SYSTEM appears, and another asserts SE_DACL_PROTECTED so an inherited ACE cannot reach it.

One deliberate restriction. Network denial is enforced by WFP filters keyed to the offline-marker SID. The restricted token carries that SID; a token from LogonUser cannot, because it names the account rather than a synthetic capability SID. A principal would therefore have left those block filters matching nothing, and deny is the default mode. So the principal stands down whenever the network is denied and the restricted-token path runs instead, which means this backend currently engages only for network-allowed commands. Trading network denial for read confinement would have been the wrong way round. Keying the filters to the principal's own SID is the follow-up that lifts the restriction.

Honest caveats:

  1. Not all privileged syscalls have executed. NetUserAdd, LsaAddAccountRights, NetUserDel and LogonUser all need administrator rights. They compile and are layout-checked, but nobody has run them. The provisioning round-trip test is gated behind ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1 plus an elevation check. Account and group creation have since been confirmed on a real elevated session; the logon path has not.
  2. The logon half is still unproven. TestGrantLogonRightsAndMintPrincipalToken has not run to completion: Smart App Control on this machine blocks freshly built unsigned binaries, so it needs a clean elevated box. Everything that does not require elevation runs here, including the secret round-trip, which asserts the password does not appear verbatim in the stored bytes.

Worth deciding before this leaves draft

Creating real local accounts is user-visible in a way the current sandbox is not: AV and EDR commonly flag NetUserAdd, enterprise policy often blocks local account creation, and the accounts appear in net user and Settings. None of that blocks the design, but it should be a deliberate call rather than a surprise in a merged PR.

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features
    • Added sandbox exec for running commands through the configured sandbox.
    • Added optional Windows sandbox accounts with network-aware isolation and protected credentials.
    • Improved Windows sandbox support for read access, runtime directories, and Git metadata.
  • Bug Fixes
    • Strengthened protection against redirected paths, junctions, unsafe cleanup, and stale permissions.
    • Improved setup diagnostics, rollback safety, and deterministic runtime behavior.
  • Tests
    • Expanded Windows coverage for sandbox execution, ACLs, identity management, secrets, networking, and rollback.

@Vasanthdev2004
Vasanthdev2004 marked this pull request as ready for review July 26, 2026 17:45
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Windows sandbox principal provisioning, protected secret storage, handle-relative ACL enforcement, runtime token selection, deterministic runtime roots, network coverage checks, and the sandbox exec command.

Changes

Windows sandbox principal

Layer / File(s) Summary
Provision and protect principals
internal/sandbox/windows_identity_windows.go, internal/sandbox/windows_identity_logon_windows.go, internal/sandbox/windows_identity_secret_*.go
Creates role-specific accounts and groups, grants batch logon rights, stores DPAPI-protected secrets, validates privileges, and supports lookup, retirement, and rollback.
Plan and apply protected ACLs
internal/sandbox/windows_identity_acl.go, internal/sandbox/windows_acl*.go
Builds ordered deny and allow entries, supports file materialization, rejects reparse redirection, records object identities, and performs handle-relative cleanup and restoration.
Integrate setup and runtime execution
internal/sandbox/windows_identity_runtime_windows.go, internal/sandbox/windows_setup*.go, internal/sandbox/windows_command_runner_windows.go, internal/sandbox/windows_runner.go
Propagates principal opt-in and caller identity, provisions both roles, tracks ACL ledgers, validates network coverage, selects principal tokens, and retains restricted-token fallback behavior.
Expose command execution and diagnostics
internal/cli/sandbox*.go, internal/doctor/hardening.go, internal/sandbox/profile.go, internal/sandbox/runtime_state.go
Adds sandbox exec, Git file-form carveouts, deterministic runtime roots, setup validation, and principal status reporting.

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: 🔴 Critical · up to d42dc

The Windows sandbox changes currently have concrete security and environment-integrity risks: degraded launches may expose scrubbed credentials, fallback directories may be attacker-controlled, protected .git metadata may not be handled correctly, and failed setup or rollback may leave secret or ACL state behind; one test can also modify System32 without cleanup. The PR is not merge-ready until these issues are fixed.

Possibly related PRs

  • Gitlawb/zero#640: Both changes modify Windows restricted-token SID construction and read-capability handling.

Suggested reviewers: gnanam1990, anandh8, kevincodex1

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR provides foundation work for #662 but does not enable default Windows credential read denial, so the linked issue remains unresolved. Complete default principal enforcement or retarget network filtering to support principal SIDs before treating #662 as resolved.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the Windows sandbox principal foundation and correctly states that issue #662 is not closed.
Out of Scope Changes check ✅ Passed The implementation and tests support Windows principal provisioning, ACL enforcement, runtime integration, rollback, cleanup, and related safety requirements.
Docstring Coverage ✅ Passed Docstring coverage is 86.79% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 280 functions across 50 files. (35 skipped: 35 over the file limit.)
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/windows-sandbox-identity

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jul 26, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/sandbox/windows_identity_logon_windows.go (2)

48-54: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consolidate the five separate advapi32.dll lazy loads.

Five independent windows.NewLazySystemDLL("advapi32.dll") calls where windows_identity_windows.go uses a single shared netapi32 var for its DLL and derives procs from it. Mirroring that pattern here is cheap and keeps the two files consistent.

♻️ Proposed refactor
-var (
-	procLogonUserW          = windows.NewLazySystemDLL("advapi32.dll").NewProc("LogonUserW")
-	procLsaOpenPolicy       = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaOpenPolicy")
-	procLsaClose            = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaClose")
-	procLsaAddAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaAddAccountRights")
-	procLsaNtStatusToWinErr = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaNtStatusToWinError")
-)
+var (
+	advapi32                = windows.NewLazySystemDLL("advapi32.dll")
+	procLogonUserW          = advapi32.NewProc("LogonUserW")
+	procLsaOpenPolicy       = advapi32.NewProc("LsaOpenPolicy")
+	procLsaClose            = advapi32.NewProc("LsaClose")
+	procLsaAddAccountRights = advapi32.NewProc("LsaAddAccountRights")
+	procLsaNtStatusToWinErr = advapi32.NewProc("LsaNtStatusToWinError")
+)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_identity_logon_windows.go` around lines 48 - 54,
Consolidate the five independent advapi32.dll lazy loads in the proc
declarations around procLogonUserW, procLsaOpenPolicy, procLsaClose,
procLsaAddAccountRights, and procLsaNtStatusToWinErr by defining one shared lazy
DLL variable and deriving each procedure from it, matching the shared-DLL
pattern used by the neighboring Windows identity implementation.

195-203: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Redundant/fragile "keep alive" idiom repeated across both files.

Both files independently reinvent a "keep the buffer alive after the syscall" step, but the object is already retained through the call by the compiler's special-case handling of uintptr(unsafe.Pointer(x)) appearing in the .Call() argument list (per unsafe package docs, this also applies to LazyProc.Call on Windows), and pointer fields nested inside that object are reachable transitively via normal GC tracing. None of these five sites add real protection, and if protection were ever genuinely needed, _ = buffer[0] / _ = info is not the guaranteed primitive for it — runtime.KeepAlive is.

  • internal/sandbox/windows_identity_logon_windows.go#L195-L203: replace the runtimeKeepAliveUint16 helper with a direct runtime.KeepAlive(buffer) call at each use (or drop it, since the buffer is already protected via entry in the .Call() argument).
  • internal/sandbox/windows_identity_logon_windows.go#L150-L152: swap runtimeKeepAliveUint16(buffer) for runtime.KeepAlive(buffer), or remove the line.
  • internal/sandbox/windows_identity_windows.go#L202-L204: drop defer func(){_=info}() in ensureWindowsSandboxGroup, or replace with defer runtime.KeepAlive(&info) if you want to keep the intent explicit.
  • internal/sandbox/windows_identity_windows.go#L239: same for the info defer in ensureWindowsSandboxUser.
  • internal/sandbox/windows_identity_windows.go#L262: same for the entry defer in addWindowsSandboxUserToGroup.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_identity_logon_windows.go` around lines 195 - 203,
Remove the redundant fragile keep-alive idioms and rely on the syscall argument
retention; in internal/sandbox/windows_identity_logon_windows.go:150-152 and
:195-203, remove runtimeKeepAliveUint16 and its uses (or replace each with
runtime.KeepAlive(buffer) if explicit intent is retained). In
internal/sandbox/windows_identity_windows.go:202-204, :239, and :262, remove the
defer closures referencing info or entry, or replace them with defer
runtime.KeepAlive(&info) / defer runtime.KeepAlive(&entry) respectively.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/windows_identity_acl.go`:
- Around line 85-91: Validate each value in ProtectedMetadataNames before
constructing the WindowsACLEntry, accepting only a single non-empty path
component and rejecting empty values, "."/"..", and any value containing path
separators. Do not call filepath.Join for rejected names; add tests covering
traversal and separator-containing inputs while preserving valid-name
materialization.

---

Nitpick comments:
In `@internal/sandbox/windows_identity_logon_windows.go`:
- Around line 48-54: Consolidate the five independent advapi32.dll lazy loads in
the proc declarations around procLogonUserW, procLsaOpenPolicy, procLsaClose,
procLsaAddAccountRights, and procLsaNtStatusToWinErr by defining one shared lazy
DLL variable and deriving each procedure from it, matching the shared-DLL
pattern used by the neighboring Windows identity implementation.
- Around line 195-203: Remove the redundant fragile keep-alive idioms and rely
on the syscall argument retention; in
internal/sandbox/windows_identity_logon_windows.go:150-152 and :195-203, remove
runtimeKeepAliveUint16 and its uses (or replace each with
runtime.KeepAlive(buffer) if explicit intent is retained). In
internal/sandbox/windows_identity_windows.go:202-204, :239, and :262, remove the
defer closures referencing info or entry, or replace them with defer
runtime.KeepAlive(&info) / defer runtime.KeepAlive(&entry) respectively.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2343104-e3d2-400c-8739-a6f655821fe1

📥 Commits

Reviewing files that changed from the base of the PR and between ac50a5a and 0da98d0.

📒 Files selected for processing (6)
  • internal/sandbox/windows_acl_apply_windows.go
  • internal/sandbox/windows_identity_acl.go
  • internal/sandbox/windows_identity_acl_test.go
  • internal/sandbox/windows_identity_logon_windows.go
  • internal/sandbox/windows_identity_windows.go
  • internal/sandbox/windows_identity_windows_test.go

Comment thread internal/sandbox/windows_identity_acl.go
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 09813349cd7f
Changed files (123): internal/cli/sandbox.go, internal/cli/sandbox_exec.go, internal/cli/sandbox_exec_cancel_test.go, internal/cli/sandbox_exec_env_test.go, internal/cli/sandbox_exec_grace_unix_test.go, internal/cli/sandbox_exec_signal_other.go, internal/cli/sandbox_exec_signal_other_test.go, internal/cli/sandbox_exec_signal_windows.go, internal/cli/sandbox_exec_test.go, internal/doctor/hardening.go, internal/doctor/hardening_principal_windows_test.go, internal/peermsg/private_dir_other.go, and 111 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jul 26, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🧹 Nitpick comments (3)
internal/sandbox/windows_command_runner_windows.go (2)

84-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Give the operator an exit when the principal backend breaks.

This is the one path that hard-fails instead of falling back, and the message is a bare wrapped error. Since the whole feature is opt-in, tell the user how to opt back out — the ensureWindowsUnelevatedSetup message at Line 136 is a good model for actionable runner errors.

♻️ Suggested wording
 	principalToken, ok, err := windowsSandboxPrincipalToken(config)
 	if err != nil {
-		fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
+		fmt.Fprintf(stderr, "%s: sandbox principal is provisioned but unusable: %v — "+
+			"re-run `zero sandbox setup` from an elevated terminal, or unset %s to fall back to the restricted-token sandbox\n",
+			WindowsSandboxCommandRunnerName, err, windowsSandboxIdentityEnv)
 		return 1
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_command_runner_windows.go` around lines 84 - 88,
Update the error handling around windowsSandboxPrincipalToken so the stderr
message explains that the Windows sandbox principal backend failed and gives the
operator an actionable way to disable or opt out of the opt-in feature,
following the guidance style used by ensureWindowsUnelevatedSetup. Preserve the
existing immediate exit with status 1.

89-97: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the principal lookup above the restricted-token SID computation.

capabilitySIDs, offlineSID, tokenSIDs, and writeRestricted are all computed unconditionally and discarded on the principal path. Moving the windowsSandboxPrincipalToken call to just after the network-policy validation makes the two backends read as a clean either/or and avoids the wasted SID resolution. (Only do this if the network-enforcement question above resolves in favor of keeping the principal path independent of those SIDs.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_command_runner_windows.go` around lines 89 - 97,
Move the windowsSandboxPrincipalToken lookup and its success-path handling to
immediately after network-policy validation, before computing capabilitySIDs,
offlineSID, tokenSIDs, or writeRestricted. Keep the principal-token execution
via runWindowsCommandAsUser unchanged, and ensure the restricted-token SID
calculations run only on the fallback path.
internal/sandbox/windows_identity_secret_windows.go (1)

139-166: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider DPAPI for the on-disk secret. The ACL blocks other users, but the password is still stored in plaintext. If you want defense in depth against offline inspection or backup exposure, encrypt it with DPAPI before writing it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_identity_secret_windows.go` around lines 139 - 166,
Update writeWindowsSandboxSecret to protect the password with Windows DPAPI
before persisting it, writing the encrypted bytes instead of plaintext while
preserving the existing owner ACL and cleanup behavior. Reuse the repository’s
existing DPAPI encryption helper if available; otherwise add the minimal
Windows-specific encryption step and report encryption failures without writing
the secret.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/windows_command_runner_windows.go`:
- Around line 78-97: Update the principal execution branch in the Windows
command runner so deny-mode commands cannot bypass network isolation: either
make the WFP filter use the provisioned principal SID, or bypass the principal
path and continue through the restricted-token backend when NetworkDeny is
enabled. Ensure the existing windowsRuntimeTokenSIDs-based deny behavior remains
enforced.

In `@internal/sandbox/windows_identity_runtime_windows.go`:
- Around line 106-127: Update provisionWindowsSandboxPrincipalForSetup to reset
the password for existing principals before writeWindowsSandboxSecret persists
the credential. Reuse ensureWindowsSandboxUser’s existing account-handling
behavior or adjust the provisioning flow so nerrUserExists accounts receive the
newly generated password, while preserving fresh-account provisioning and
subsequent logon-rights setup.

In `@internal/sandbox/windows_identity_secret_windows_test.go`:
- Around line 181-196: Update windowsSecretACEList to inspect the generic
ACE_HEADER returned by GetAce before interpreting it as ACCESS_ALLOWED_ACE.
Accept only the supported allow-ACE type, and return a clear error for deny,
object, or any other unsupported ACE type so invalid SID offsets cannot be
decoded as trustees.

---

Nitpick comments:
In `@internal/sandbox/windows_command_runner_windows.go`:
- Around line 84-88: Update the error handling around
windowsSandboxPrincipalToken so the stderr message explains that the Windows
sandbox principal backend failed and gives the operator an actionable way to
disable or opt out of the opt-in feature, following the guidance style used by
ensureWindowsUnelevatedSetup. Preserve the existing immediate exit with status
1.
- Around line 89-97: Move the windowsSandboxPrincipalToken lookup and its
success-path handling to immediately after network-policy validation, before
computing capabilitySIDs, offlineSID, tokenSIDs, or writeRestricted. Keep the
principal-token execution via runWindowsCommandAsUser unchanged, and ensure the
restricted-token SID calculations run only on the fallback path.

In `@internal/sandbox/windows_identity_secret_windows.go`:
- Around line 139-166: Update writeWindowsSandboxSecret to protect the password
with Windows DPAPI before persisting it, writing the encrypted bytes instead of
plaintext while preserving the existing owner ACL and cleanup behavior. Reuse
the repository’s existing DPAPI encryption helper if available; otherwise add
the minimal Windows-specific encryption step and report encryption failures
without writing the secret.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 90fab087-5f05-4a9a-ae92-73e983828792

📥 Commits

Reviewing files that changed from the base of the PR and between 0da98d0 and 9734058.

📒 Files selected for processing (4)
  • internal/sandbox/windows_command_runner_windows.go
  • internal/sandbox/windows_identity_runtime_windows.go
  • internal/sandbox/windows_identity_secret_windows.go
  • internal/sandbox/windows_identity_secret_windows_test.go

Comment thread internal/sandbox/windows_command_runner_windows.go
Comment thread internal/sandbox/windows_identity_runtime_windows.go Outdated
Comment thread internal/sandbox/windows_identity_secret_windows_test.go
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Validation update: the provisioning chain has now been run for real, elevated, on Windows 11.

=== RUN   TestProvisionWindowsSandboxIdentityRoundTrip
--- PASS: TestProvisionWindowsSandboxIdentityRoundTrip (0.05s)

and the objects it created were really there, confirmed independently afterwards:

net user zero-sbx-ziptest01 /delete      -> The command completed successfully.
net localgroup ZeroSandboxUsers /delete  -> The command completed successfully.

Verified end to end: NetLocalGroupAdd, NetUserAdd, NetLocalGroupAddMembers and the SID lookup all succeed against the real APIs; a second provision returns the same username and SID, so the idempotent "already exists" handling is correct; and lookup finds what provisioning created. Notably there was no ERROR_PASSWORD_RESTRICTION, so the generated password satisfies the default complexity policy. That also means the hand-rolled USER_INFO_1, LOCALGROUP_INFO_1 and LOCALGROUP_MEMBERS_INFO_3 layouts marshal correctly, which matters because they are passed as raw buffers where a wrong field order fails or corrupts memory rather than erroring cleanly.

Still not verified: that test exercises provisionWindowsSandboxIdentity only. LsaAddAccountRights (the batch-logon grant and the deny-interactive hardening) and LogonUser (minting the token) have still never executed, so the identity is proven to exist but not yet proven usable. CI cannot cover either, since it runs unelevated.

Also still open: the provisioning entry points have no non-test callers yet. zero sandbox setup does not create a principal, so the feature is inert end to end and the runner seam always falls back. Wiring setup, the ACL plan application and teardown is the remaining work, and I deliberately held it until the primitives were known good.

Keeping this a draft until the logon half is exercised too.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Setup is wired now, so the feature is reachable end to end rather than inert.

zero sandbox setup, elevated and opted in, provisions this workspace's principal, grants it the batch logon right, stores the password locked to the invoking user, and applies the ACL plan that grants read+write on the workspace and read on the declared read roots. Those grants are what let a sandboxed command run at all, since a separate account has no inherent access to the caller's tree, and their absence everywhere else is what puts credential stores out of reach. At command time the runner logs on as that principal instead of building a restricted token.

Provisioning is folded into setup's existing rollback rather than each later failure path having to remember it, and the rollback revokes ACEs before deleting the account. Doing it the other way round would leave ACEs naming a SID that no longer resolves, which is the orphaned residue this model exists to avoid.

Everything stays behind ZERO_WINDOWS_SANDBOX_IDENTITY=1. Without it setup creates no account and the capability-SID backend is unchanged, which is deliberate: account creation shows up in net user and is exactly what endpoint protection and enterprise policy tend to object to.

How to exercise it, on a machine where creating local accounts is acceptable:

$env:ZERO_WINDOWS_SANDBOX_IDENTITY = "1"
zero sandbox setup          # elevated
zero sandbox policy
net user                    # a zero-sbx-... principal should now exist

Validation status: provisioning (group, account, membership, SID, idempotency) is confirmed working elevated on Windows 11. The logon half now has a test, TestGrantLogonRightsAndMintPrincipalToken, which exercises LsaAddAccountRights and LogonUser and asserts the minted token's user SID is the principal rather than the caller. It has not been run yet; Smart App Control blocks freshly built unsigned binaries on the machine available to me, so it needs a box without that restriction. That is the last unproven primitive and the reason this is still a draft.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jul 26, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/windows_identity_runtime_windows_test.go`:
- Around line 11-29: Make TestWindowsSandboxIdentityGating hermetic by clearing
windowsSandboxIdentityEnv from the process environment before running the table,
so the "absent" case cannot fall back to an externally set value. Restore the
original environment after the test using the standard test cleanup mechanism.

In `@internal/sandbox/windows_setup_windows.go`:
- Around line 38-64: Add coverage in the Windows sandbox setup tests for the
flow around runWindowsSandboxSetup: verify opt-out does not call
setupWindowsSandboxPrincipal, and verify an opt-in principal-setup failure still
invokes the existing ACL rollback. Use the test’s existing configuration and
rollback helpers, preserving current success and error behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bb64b652-8bb9-4259-8b0e-53533dd380cf

📥 Commits

Reviewing files that changed from the base of the PR and between 0b52129 and 69c56ad.

📒 Files selected for processing (3)
  • internal/sandbox/windows_identity_runtime_windows.go
  • internal/sandbox/windows_identity_runtime_windows_test.go
  • internal/sandbox/windows_setup_windows.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/sandbox/windows_identity_runtime_windows.go

Comment thread internal/sandbox/windows_identity_runtime_windows_test.go
Comment thread internal/sandbox/windows_setup_windows.go
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Thanks, this was a useful pass. Went through all three.

Network enforcement (the hedge on the second point) turned out to be the real finding. Chasing it down: windowsRuntimeTokenSIDs adds the offline-marker SID to the restricted token on NetworkDeny, and the WFP block filters installed by setup are keyed to that SID (IdentitySIDs: []string{offlineSID}). A token from LogonUser names the account, so it cannot carry a synthetic capability SID. That means a denied-network command routed through a principal left those filters matching nothing, and deny is the default mode. So opting into this backend silently swapped network enforcement for read confinement, which is not a trade anyone asked for.

Fixed in fb8e39b: the principal stands down whenever the network is denied and the restricted-token path runs instead. Keying the filters to the principal's own SID is the follow-up that lifts the restriction, and I would rather do that with the privileged paths validated on a clean box than bolt it on here.

Worth flagging that my first regression test for this was worthless. It called windowsSandboxPrincipalToken and asserted it declined, but on a machine with nothing provisioned the lookup declines anyway, so it passed with the guard deleted. Pulled the decision out into windowsSandboxPrincipalEligible and asserted that instead. Mutation check now behaves: guard removed gives a fail, restored gives a pass. It also asserts the guard is specific to denial rather than a blanket disable, which would have made the whole backend dead code while still going green.

Actionable error: taken. The message now names ZERO_WINDOWS_SANDBOX_IDENTITY and points at re-running setup elevated.

DPAPI: also taken, in deb3a98. The ACL is still the primary control and the thing that keeps the principal from reading its own credential, but you are right that it only binds while the filesystem is the one being asked, so a backup or a mounted image gives up the password in the clear. CryptProtectData with the principal name as entropy, which additionally means a blob copied onto another principal's path fails to decrypt instead of authenticating the wrong account. Older plaintext secrets read as unavailable and fall back; the next elevated setup rewrites them.

Hoisting the lookup above the SID computation: leaving it. Now that the principal path is gated on network mode, it is no longer independent of those SIDs, so the ordering earns its keep.

Still unproven and called out in the description: TestGrantLogonRightsAndMintPrincipalToken has not run to completion here. Smart App Control on this machine blocks freshly built unsigned binaries, so the logon half needs a clean elevated box before I would call it verified.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Superseded by my full review below, which carries the verdict (changes requested). Leaving this note in place rather than deleting it so the thread order still makes sense.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Changes requested.

Two things drive that. The lookup path below discards a check you deliberately wrote, and it should be fixed regardless of what else happens. Separately, the privileged half of this change has never been executed by anyone, and account provisioning, logon-rights assignment and credential storage are not things I am willing to approve unrun, however sound the design reasoning is. Neither point is a criticism of the direction, which I think is right.

The design reasoning here is unusually clear, and the honesty about what has and has not been run is appreciated.

One practical note before anything else: the description opens by calling this a draft, but the pull request is not marked as a draft on GitHub, so it currently sits open for review and merge. Converting it would match your stated intent. Related, the Smoke jobs for macOS, Ubuntu and Windows, along with Zero Review, were still pending when I looked, so the CI signal you describe as the check for the wiring commit has not yet reported.

What I was able to verify. On macOS, make fmt-check, go build ./... and go vet ./... are clean, and the full suite passes at 82 packages with no failures. More usefully for a change of this shape, GOOS=windows go vet ./internal/sandbox/... exits cleanly and GOOS=windows go test -c compiles the test binary, which type-checks the roughly 1,500 lines of _windows.go that never compile on a non-Windows host. That is not execution, but it does confirm the Win32 call sites, struct definitions and build tags hold together across the whole addition.

I also mutated the ACL ordering to check the test does real work: reversing the entry order returned by buildWindowsPrincipalACLPlan fails TestPrincipalACLPlanEmitsDeniesBeforeAllows. The deny-before-allow invariant is genuinely asserted rather than only documented.

Two further things came back clean and are worth recording. Password generation draws 24 bytes from crypto/rand and encodes them with unpadded base32, giving roughly 120 bits with no modulo bias, and the fixed prefix covering the complexity classes is a reasonable approach. Account naming leaves 11 hex characters of the SHA-256 digest after the nine-character prefix, so 44 bits, which puts a birthday collision far beyond any plausible number of workspaces on one machine.

One substantive finding. lookupWindowsSandboxIdentity (internal/sandbox/windows_identity_windows.go:338-345) collapses every error from resolveWindowsSandboxSID into errWindowsSandboxIdentityUnavailable, which discards the deliberate check you wrote at lines 274-276 refusing a name that resolves to a non-user account.

The effect is that if zero-sbx-<hash> is squatted by a pre-existing local group or alias, resolveWindowsSandboxSID correctly refuses it, but the caller reads that refusal as "not provisioned" and windowsSandboxPrincipalToken (lines 73-76 of windows_identity_runtime_windows.go) falls back quietly to the restricted token. Your own description draws the line in the right place, that only a provisioned-but-unusable identity should surface an error, and this is precisely that case reaching the operator as silence. Distinguishing ERROR_NONE_MAPPED from other lookup failures would preserve the fallback for the common "setup has not run" case while surfacing the rest.

A smaller one: the comment at windows_identity_windows.go:122 refers the reader to sandboxRuntimeKey for how the workspace key is hashed, but no such symbol exists. The function is windowsSandboxWorkspaceKey in windows_identity_runtime_windows.go:44.

On the question you raised for decision. Creating real local accounts being visible to endpoint protection, enterprise policy and net user seems worth settling before this leaves draft, and I agree it is a product call rather than a design flaw. The inversion argument is persuasive on its merits: unreachable by construction is a stronger boundary than an enumerated deny list, and the trustee-keyed revocation answers a real gap.

Limitations of this review. I have no Windows host and no elevated session, so NetUserAdd, LsaAddAccountRights, NetUserDel and LogonUser are unexecuted by me as well. I did not check the raw Win32 struct layouts against the SDK, and I did not review the LSA byte-versus-rune length handling beyond confirming it compiles. Everything above rests on reading the code and on cross-compilation.

Worth flagging for coordination: this addresses the same credentialDenyReadPaths weakness on Windows that I raised on #801, where removing the sandbox HOME and XDG_CONFIG_HOME overrides makes real credential locations the resolution target. The two changes point at the same boundary from opposite sides and would benefit from being sequenced deliberately.

Merge is kevin's call per the program gate.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

CI is green now. The Windows smoke failure was not from this branch, and it is worth saying what it actually was rather than just re-running until it passed.

Three tests failed, all in internal/cli and internal/config, neither of which this branch touches. I reproduced both of the internal/config ones locally under CPU contention, with the exact CI messages, on a tree with none of this branch's changes. They are long-standing Windows flakes: #800 and #802 each relaxed an assertion, which is why neither held.

Fixes are up separately rather than folded in here, since they have nothing to do with the sandbox work and one of them touches product code:

I also opened #811 for something that fell out of the reproduction and is a genuine user-facing bug rather than a test problem: the provider-command timeout is a floor, not a bound. Process creation happens before the timer is armed and the drain after Terminate() is unbounded, so I measured LoadProviderCommand taking 19.7s and then 106s against a 5s timeout. Not fixed in either PR on purpose, since changing what that timeout bounds deserves its own review.

Nothing on this branch changed for any of that. Once #809 and #810 land I will rebase this one.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jul 27, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

♻️ Duplicate comments (3)
internal/sandbox/windows_identity_runtime_windows_test.go (1)

11-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Table is still not hermetic.

The "absent" case falls through to os.Getenv, so this test fails on any machine that actually has ZERO_WINDOWS_SANDBOX_IDENTITY=1 exported — precisely the machines doing the elevated validation runs for this PR. Add t.Setenv(windowsSandboxIdentityEnv, "") before the table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_identity_runtime_windows_test.go` around lines 11 -
22, Make TestWindowsSandboxIdentityGating hermetic by setting
windowsSandboxIdentityEnv to an empty value with t.Setenv before iterating over
the test cases, ensuring the "absent" case cannot inherit the host environment.
internal/sandbox/windows_identity_secret_windows_test.go (1)

183-198: 🎯 Functional Correctness | 🟡 Minor | 💤 Low value

Still assumes every ACE is an ACCESS_ALLOWED_ACE.

GetAce returns a generic ACE_HEADER; a deny or object ACE would put the SID at a different offset and this helper would decode garbage, making the "unexpected trustee" assertion misleading rather than failing cleanly. Gate on ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE and return an error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_identity_secret_windows_test.go` around lines 183 -
198, The windowsSecretACEList helper must validate each ACE type before
interpreting its SID layout. After GetAce returns, check ace.Header.AceType and
return an error for any type other than windows.ACCESS_ALLOWED_ACE_TYPE; only
then cast to ACCESS_ALLOWED_ACE and copy the SID.
internal/sandbox/windows_identity_acl.go (1)

85-92: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Path traversal via ProtectedMetadataNames still unaddressed.

filepath.Join(cleaned, name) accepts ../separator-bearing values, so a malformed ProtectedMetadataNames entry can materialize a deny ACE outside root.Root. This was flagged in a prior review and is still present with no validation added.

🔒 Proposed fix
 		for _, name := range root.ProtectedMetadataNames {
+			if name == "" || name == "." || name == ".." || filepath.Base(name) != name {
+				return WindowsACLPlan{}, fmt.Errorf(
+					"windows principal ACL plan: invalid protected metadata name %q", name,
+				)
+			}
 			entries = append(entries, WindowsACLEntry{
 				Action:      WindowsACLDenyWrite,
 				Path:        filepath.Join(cleaned, name),

Add a regression test in windows_identity_acl_test.go covering a traversal/separator-bearing name once this validation lands. As per coding guidelines, **/*_test.go: "add regression tests for behavior changes."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_identity_acl.go` around lines 85 - 92, Validate each
entry from root.ProtectedMetadataNames before constructing the WindowsACLEntry,
rejecting traversal or separator-bearing names that could escape
cleaned/root.Root; only append entries for safe metadata names. Add a regression
test in windows_identity_acl_test.go covering both traversal and
separator-bearing input.

Source: Coding guidelines

🧹 Nitpick comments (1)
internal/sandbox/windows_identity_windows.go (1)

196-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use runtime.KeepAlive instead of a deferred no-op.

defer func() { _ = info }() does keep info alive (the closure captures it), but it reads as dead code and a future cleanup will delete it, silently reintroducing a use-after-free window. The same pattern repeats at Lines 239 and 262.

♻️ Proposed change
 	status, _, _ := procNetLocalGroupAdd.Call(
 		0, // local machine
 		1, // level: LOCALGROUP_INFO_1
 		uintptr(unsafe.Pointer(&info)),
 		0,
 	)
-	// Keep info alive across the call: the struct holds pointers into Go memory
-	// that the syscall dereferences.
-	defer func() { _ = info }()
+	// Keep info (and the Go strings it points at) alive across the call.
+	runtime.KeepAlive(info)
 	return netAPIStatus("NetLocalGroupAdd", status, nerrGroupExists, errorAliasExists)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/windows_identity_windows.go` around lines 196 - 205, Replace
the deferred no-op keeping info alive in the NetLocalGroupAdd call with
runtime.KeepAlive(info) after the syscall returns. Apply the same change to the
corresponding patterns around the related calls at Lines 239 and 262, and add
the runtime import if needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/windows_identity_logon_windows.go`:
- Around line 108-154: The native Windows calls need explicit GC liveness
guarantees for all borrowed arguments. In grantWindowsSandboxLogonRights, add
runtime.KeepAlive for attributes after procLsaOpenPolicy.Call and for entry
after procLsaAddAccountRights.Call, while retaining the buffer keep-alive; also
update the LogonUserW call site to keep the user, domain, and secret pointers
alive after the call returns.

In `@internal/sandbox/windows_identity_runtime_windows.go`:
- Around line 139-145: Update the Windows sandbox identity flow around
ensureWindowsSandboxUser and writeWindowsSandboxSecret so a pre-existing
account’s password is actually synchronized before writing the secret. Remove
the inaccurate claim that the caller resets the password, and ensure the stored
secret matches the account password for both new and existing users.

In `@internal/sandbox/windows_identity_secret_windows.go`:
- Around line 186-196: Update readWindowsSandboxSecret to map permission-denied
errors, including Windows ERROR_ACCESS_DENIED, to
errWindowsSandboxIdentityUnavailable alongside missing-file errors so callers
fall back to the restricted token. Update removeWindowsSandboxSecret to treat
the same unreadable or inaccessible-secret condition as non-fatal, allowing
principal cleanup to continue while preserving other error propagation.

In `@internal/sandbox/windows_identity_windows.go`:
- Around line 213-241: The existing-user path in ensureWindowsSandboxUser must
reset the account password via NetUserSetInfo at level 1003 using USER_INFO_1003
before returning success; update internal/sandbox/windows_identity_windows.go
lines 213-241 accordingly while preserving normal creation behavior. In
internal/sandbox/windows_identity_runtime_windows.go lines 139-145, revise the
related comment to accurately describe that ensureWindowsSandboxUser performs
the password reset.

---

Duplicate comments:
In `@internal/sandbox/windows_identity_acl.go`:
- Around line 85-92: Validate each entry from root.ProtectedMetadataNames before
constructing the WindowsACLEntry, rejecting traversal or separator-bearing names
that could escape cleaned/root.Root; only append entries for safe metadata
names. Add a regression test in windows_identity_acl_test.go covering both
traversal and separator-bearing input.

In `@internal/sandbox/windows_identity_runtime_windows_test.go`:
- Around line 11-22: Make TestWindowsSandboxIdentityGating hermetic by setting
windowsSandboxIdentityEnv to an empty value with t.Setenv before iterating over
the test cases, ensuring the "absent" case cannot inherit the host environment.

In `@internal/sandbox/windows_identity_secret_windows_test.go`:
- Around line 183-198: The windowsSecretACEList helper must validate each ACE
type before interpreting its SID layout. After GetAce returns, check
ace.Header.AceType and return an error for any type other than
windows.ACCESS_ALLOWED_ACE_TYPE; only then cast to ACCESS_ALLOWED_ACE and copy
the SID.

---

Nitpick comments:
In `@internal/sandbox/windows_identity_windows.go`:
- Around line 196-205: Replace the deferred no-op keeping info alive in the
NetLocalGroupAdd call with runtime.KeepAlive(info) after the syscall returns.
Apply the same change to the corresponding patterns around the related calls at
Lines 239 and 262, and add the runtime import if needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4be32672-966b-47b1-955b-a7e02d7e5891

📥 Commits

Reviewing files that changed from the base of the PR and between ac50a5a and deb3a98.

📒 Files selected for processing (13)
  • internal/sandbox/windows_acl_apply_windows.go
  • internal/sandbox/windows_command_runner_windows.go
  • internal/sandbox/windows_identity_acl.go
  • internal/sandbox/windows_identity_acl_test.go
  • internal/sandbox/windows_identity_dpapi_windows.go
  • internal/sandbox/windows_identity_logon_windows.go
  • internal/sandbox/windows_identity_runtime_windows.go
  • internal/sandbox/windows_identity_runtime_windows_test.go
  • internal/sandbox/windows_identity_secret_windows.go
  • internal/sandbox/windows_identity_secret_windows_test.go
  • internal/sandbox/windows_identity_windows.go
  • internal/sandbox/windows_identity_windows_test.go
  • internal/sandbox/windows_setup_windows.go

Comment thread internal/sandbox/windows_identity_logon_windows.go
Comment thread internal/sandbox/windows_identity_runtime_windows.go Outdated
Comment thread internal/sandbox/windows_identity_secret_windows.go
Comment thread internal/sandbox/windows_identity_windows.go Outdated
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Thanks, this is a good review, and the lookup finding is right.

The squatted-name case. Fixed in 9e1e651. You are right that it lands exactly where the description says the line should sit, and I had written the check and then thrown it away one call later. It was worse than the one site you found: windowsSandboxPrincipalToken also swallowed every error from the lookup, so even once the lookup stopped collapsing them the runtime path would still have gone quiet. Both are fixed. Only ERROR_NONE_MAPPED now means setup has not run; anything else propagates.

The decision sits in its own function rather than inline, because the lookup derives its account name from a workspace key, so a test cannot hand it a name that resolves to a group. The test drives that classifier with a real error from a well-known local group, needs no privilege, and I checked it fails if the old collapse-everything behaviour is restored:

non-user account "Administrators" classified as unprovisioned, which would
silently downgrade to the restricted token

The stale comment. Fixed, it is windowsSandboxWorkspaceKey.

The draft framing. That was stale and I have rewritten the opening. This is not a draft: it is opt-in behind an environment variable and I would rather it be reviewed than sit hidden. The provisioning half has since been run on a real elevated session, so account and group creation are no longer unexecuted. LogonUser and the LSA rights still are, because Smart App Control on this machine blocks freshly built unsigned test binaries and that is the one path I cannot exercise here. I would rather that stay an explicit caveat than get quietly waved through, so I am not asking you to approve it unrun.

CI. It has reported since, and is green on all nine checks. Three Windows tests did fail on the first run, none of them in code this branch touches. I reproduced two of them locally under CPU contention on a clean tree, so they were pre-existing flakes rather than anything here; they are fixed in #810 and #809, and #811 covers a genuine product bug that fell out of the reproduction.

On sequencing with #801. Agreed, and worth being concrete: these do point at the same boundary from opposite sides. #801 removes the sandbox HOME and XDG_CONFIG_HOME overrides so real credential locations become the resolution target, and this makes those locations unreachable by construction for the sandboxed principal. If #801 lands first there is a window where the target moves before the boundary exists. That ordering is worth kevin's attention rather than ours.

Also worth flagging for the same reason: this backend currently stands down whenever the network is denied, which is the default. WFP filters key on the offline-marker SID and a LogonUser token cannot carry a synthetic capability SID, so a principal would have left them matching nothing. I would rather lose the read confinement than silently lose network denial. Keying the filters to the principal's own SID is the follow-up.

The two things you verified that I could not, the cross-compiled vet and go test -c over the roughly 1,500 lines of _windows.go, plus the ACL ordering mutation, are the checks I most wanted from a non-Windows reviewer. Thank you for doing them.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both taken, and the first one was a real bug rather than a documentation slip.

The pre-existing account. You are right, and the effect is worse than the comment being wrong. NetUserAdd leaves an existing account entirely alone, ensureWindowsSandboxUser treated that status as success, and provisioning then handed back a freshly generated password that was never applied to anything. The caller stored it as the secret. So a second zero sandbox setup on the same workspace left the account authenticating with its old password and the secret on disk holding one that never worked, and every later command failed to log on with a principal that looked correctly provisioned. Setup was not idempotent in the way I claimed anywhere it mattered.

Fixed in e33dce0. ensureWindowsSandboxUser now reports whether the account already existed, and provisioning resets the password via NetUserSetInfo with USER_INFO_1003 when it did, so the returned value is always the account's real password. I removed both comments that asserted a reset already happened.

The gated provisioning test now provisions twice and logs on with the password from the second run. That is the only assertion worth having here: a stale password is indistinguishable from a correct one until something actually authenticates with it, so checking that the two runs return the same identity would have passed straight through this bug.

The keep-alives. Also taken. attributes, entry and the three LogonUser string pointers now have explicit runtime.KeepAlive calls. Worth noting a second problem in the same place: the existing runtimeKeepAliveUint16(buffer) sat after the error check, so on the failure path the function returned with the buffer already collectable. The keep-alives now run immediately after each call and before the check. I also converted the two netapi32 sites that used a deferred no-op closure, so the file uses one idiom throughout.

gofmt, go vet, and builds for linux, darwin and windows are clean, and the unprivileged suite passes.

On the uint32(unsafe.Sizeof(attributes)) narrowing that ast-grep flagged: leaving it. Sizeof on a fixed-layout struct is a compile-time constant well under 2^32, and LSA_OBJECT_ATTRIBUTES.Length is a ULONG, so the conversion is required by the API rather than incidental.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jul 27, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/windows_identity_windows_test.go`:
- Around line 239-246: After provisioning the test principal in the gated
identity test, register a t.Cleanup callback that revokes SeBatchLogonRight and
removes the test principal, ensuring cleanup runs on every subsequent failure
path. Keep the existing grantWindowsSandboxLogonRights and
logonWindowsSandboxPrincipal flow unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d03dfa6a-7671-40c4-b4c8-5d77781ed16c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e1e651 and e33dce0.

📒 Files selected for processing (4)
  • internal/sandbox/windows_identity_logon_windows.go
  • internal/sandbox/windows_identity_runtime_windows.go
  • internal/sandbox/windows_identity_windows.go
  • internal/sandbox/windows_identity_windows_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/sandbox/windows_identity_logon_windows.go
  • internal/sandbox/windows_identity_runtime_windows.go
  • internal/sandbox/windows_identity_windows.go

Comment thread internal/sandbox/windows_identity_windows_test.go Outdated
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Taken, and it was pointing at more than the test.

You are right that the round trip left residue: it granted a real batch logon right to a real local account and had no cleanup at all, so anyone running the gated suite kept both. That is on me, and it got worse when I added the logon step in the last commit.

The part worth flagging is that the same hole was in the production teardown. removeWindowsSandboxPrincipalForSetup deleted the account and never touched its LSA account rights, so the rights stayed behind keyed to a SID that no longer resolves. That is precisely the orphaned residue this design claims to avoid, and the reason ACE revocation here is keyed to the trustee instead of to a record of what was granted. The logon-rights half of that argument was simply not implemented.

Fixed in fbe340b:

  • revokeWindowsSandboxLogonRights drops every right the principal holds and removes its LSA entry. All rights rather than a named list, deliberately: a principal being retired should not keep rights granted by an older setup that this one no longer knows about.
  • Teardown calls it before deleting the account, while the SID still resolves. Reversing that order is what strands the entry.
  • Both gated tests now revoke and then remove, in that order.

One thing I did not want to take on trust. Treating "this account holds no rights" as success depends on STATUS_OBJECT_NAME_NOT_FOUND surviving LsaNtStatusToWinError as an error errors.Is still matches, and Windows errno assumptions of that shape have been wrong on me before in this repo. There is now an unprivileged test asserting it, and asserting that the tolerance does not also swallow access-denied, which would have let teardown report success having done nothing.

gofmt, go vet, GOOS=windows go vet, and builds for linux, darwin and windows are clean; the unprivileged suite passes.

gnanam1990
gnanam1990 previously approved these changes Jul 27, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Approve.

Reviewed at fbe340b3995c, base ac50a5a840d2, re-confirmed against the live head before posting.

I withdraw both findings from my previous review. Each is fixed, and the first is fixed in the way I hoped rather than the cheapest way.

lookupWindowsSandboxIdentity no longer collapses every lookup failure into "not provisioned". classifyWindowsSandboxLookupError (internal/sandbox/windows_identity_windows.go) maps ERROR_NONE_MAPPED to errWindowsSandboxIdentityUnavailable and returns everything else unchanged, so the deliberate refusal in resolveWindowsSandboxSID for a name resolving to a non-user account now reaches the operator instead of degrading quietly to the restricted token. TestLookupWindowsSandboxIdentityRejectsNonUserAccount covers exactly that case. The sandboxRuntimeKey comment now names windowsSandboxWorkspaceKey, which exists.

On the execution question, which was my other reason for requesting changes. The position has changed materially. Account and group provisioning have now been run on a real elevated session, the description says so precisely, and all three Smoke jobs plus Zero Review are passing, including windows-latest. The logon half — LsaAddAccountRights and LogonUser — remains unexecuted, and the description says that too, in those words.

I am approving with that gap open rather than in spite of it, for two reasons. The whole surface is behind ZERO_WINDOWS_SANDBOX_IDENTITY=1 and off by default, so no existing install changes behaviour. And the disclosure is accurate and specific rather than implied, which is the standard the review protocol asks for. An unrun privileged path that nobody reaches without opting in, declared plainly, is a reasonable posture for foundation work.

On the new material in this delta. The DPAPI wrapping is well-judged. CRYPTPROTECT_UI_FORBIDDEN is the right flag for a path that may run without an interactive desktop, the LocalFree of the DPAPI-allocated output is correctly deferred, and the ciphertext is copied out rather than aliased. I checked the one thing that looked like a documentation mismatch and it was not: the comment says the principal name is the entropy, and windowsSandboxSecretEntropy derives it from the secret's own filename, which is the principal name — so read and write agree by construction, as the comment claims.

Resetting the password when the account already exists is a real bug fix rather than a refinement. NetUserAdd leaves an existing account untouched, so without NetUserSetInfo the stored secret would not have been the account's password, and the failure would have surfaced much later as an unexplained logon failure. Revoking logon rights before deleting the principal, and keeping the restricted token when the network is denied, are both correct orderings.

Two smaller things came back clean and are worth recording. Replacing defer func() { _ = info }() with runtime.KeepAlive is the correct idiom — the deferred closure did not reliably keep the pointed-to Go memory alive across the syscall, and KeepAlive does. And the KeepAlive calls were added for name and comment as well, not only the struct.

Verification. On macOS, go build ./..., go vet ./... and gofmt -l are clean and the suite passes. More usefully for this change, GOOS=windows go vet ./internal/sandbox/... exits 0 and GOOS=windows go test -c compiles, which type-checks the entire Windows surface including the new DPAPI file. That is not execution, but it confirms the Win32 call sites, struct definitions and build tags hold together across the whole addition.

Limitations. I have no Windows host and no elevated session. LsaAddAccountRights, LogonUser, CryptProtectData and NetUserSetInfo are unexecuted by me. I did not check the raw struct layouts against the SDK beyond confirming the existing layout tests still pass.

This does not clear CodeRabbit's outstanding review, and #812 is stacked on this branch, so landing order matters.

Merge is kevin's call per the program gate.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both findings are correct. I checked each against the head before agreeing, and neither is a misreading. Fixed in 6ccf4cf.

1, the account takeover. Confirmed. ensureWindowsSandboxUser reported "already exists", and provisioning went straight to resetWindowsSandboxUserPassword with nothing between. The only thing separating Zero's account from a stranger's was the name matching a pattern Zero generates itself. resolveWindowsSandboxSID refuses a non-user account, so a group could not be adopted, but another user could, and that is the case that matters.

Ownership is now read back from the comment provisioning stamps before anything is touched, and a name held by an account Zero did not create fails with a typed errWindowsSandboxNameCollision rather than being adopted. Your framing of the alternatives was the right one and I took the second: refuse, do not try to be clever about it.

The irony is not lost on me. I added exactly this guard to the deletion path in the follow-up PR after CodeRabbit raised deleting-by-derived-name, and did not think to look at the adoption path, which is the more dangerous of the two. Deleting the wrong account is loud. Resetting its password and quietly running as it is not.

2, the partial-failure residue. Also confirmed, and your description of why is precise: the rollback is only constructed after provisionWindowsSandboxPrincipalForSetup returns, so nothing could repair a failure inside it. A failure between account creation and secret storage left the account, and possibly its granted logon rights, behind with no caller able to remove them.

Provisioning now unwinds what the run actually did, in reverse, on every failure path, tracking the four things you listed.

One deliberate difference from your list, worth stating because it is a judgement rather than an oversight. Cleanup is scoped to what THIS run created. An account that already existed and belongs to Zero is a working principal from an earlier setup, so deleting it because a later run failed would turn a partial failure into a total one. For the pre-existing case the repair is dropping the stored secret instead: this run reset the password, so the secret no longer matches, and absent beats stale because the command path treats a missing secret as "not provisioned" and falls back to the restricted token rather than failing. If you think that is the wrong call I will change it.

3, the unexecuted LogonUser path. Agreed, and I have said so in the description since the start rather than being talked into it. It is the central runtime path and it has not run end to end on an elevated machine. Smart App Control on my box blocks freshly built unsigned binaries, which is exactly the class of binary the gated provisioning test produces. I am not going to claim that as verified, and I do not think opt-in gating substitutes for running it.

You also asked for a test with an unrelated existing account on the derived name. Added, driven against Administrator, Guest and DefaultAccount, which need no privilege because the assertion is only that they are not classified as ours. Neutering the ownership check makes it fail, so it is load bearing rather than decorative.

gofmt, go vet, GOOS=windows go vet, and builds for linux, darwin and windows are clean; the unprivileged suite passes. The elevated run is still outstanding and remains the thing I would want before this merges.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jul 27, 2026

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/windows_identity_runtime_windows.go`:
- Around line 146-190: Update the provisioning cleanup flow around the undo
closure and grantWindowsSandboxLogonRights call: compute secretPath immediately
after identity provisioning succeeds, before granting logon rights, and remove
the secretWritten condition so undo removes any resolved secret path on
subsequent failure. Preserve the existing no-op behavior when secretPath is
empty and keep successful secret writing unchanged.

In `@internal/sandbox/windows_identity_windows.go`:
- Around line 389-432: Update setupWindowsSandboxPrincipal to remove the
existing Windows sandbox secret when provisioning succeeds in changing or
reusing an account but setup fails before writeWindowsSandboxSecret. Ensure the
rollback error path deletes the stale .secret file, while preserving the normal
secret write and unrelated provisioning error behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 24ed1d1c-eaa0-4d51-843c-1e1a7a825854

📥 Commits

Reviewing files that changed from the base of the PR and between fbe340b and 6ccf4cf.

📒 Files selected for processing (3)
  • internal/sandbox/windows_identity_runtime_windows.go
  • internal/sandbox/windows_identity_windows.go
  • internal/sandbox/windows_identity_windows_test.go

Comment thread internal/sandbox/windows_identity_runtime_windows.go Outdated
Comment thread internal/sandbox/windows_identity_windows.go Outdated
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both findings are the same hole seen from two angles, and you are right: the cleanup I added missed the one window it was written for.

Provisioning always sets the account's password, including resetting a pre-existing owned account's, so from the moment it returns the stored secret is already stale. My undo() only removed the secret when this run had written one, and it derived the path after the rights grant, so a failure in between had nothing to remove and left a stale secret against a password that had just changed. The next command would then fail the logon and report a broken sandbox, which is precisely the "absent beats stale" outcome I claimed the cleanup produced.

Fixed in 832f53a: the path is resolved from the account name before anything can fail, and removal is unconditional rather than gated on secretWritten.

Worth naming the pattern, since this is twice now on this PR. The takeover fix and this one are both cases where I reasoned correctly about what should happen and then wrote a condition that did not cover the case I was reasoning about. Reading the comment I had written would have told you the intended behaviour; only reading the code shows it did not happen.

gofmt, go vet, builds for linux, darwin and windows clean, sandbox suite passes.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Approve.

Reviewed at 832f53a98d74, base 5d1869e, re-confirmed against the live head before posting. My earlier approval at fbe340b was dismissed by the push; this replaces it, and the new work is strictly better.

The two commits since then are both real improvements, not polish.

windowsSandboxUserIsManaged closes a hazard that was live in the version I approved. The account name is derived from a workspace hash rather than discovered, so it can be occupied by an account with nothing to do with Zero — and provisioning would previously have adopted it and reset its password. Reading back the comment stamp before adopting, and refusing with a named error otherwise, is the right shape, and the same predicate is reused on the delete path in #812. Dropping the stored secret when provisioning fails closes the matching half: a secret file that no longer corresponds to any account is worse than none, because it looks provisioned.

One substantive finding, non-blocking, on the adoption gate.

provisionWindowsSandboxIdentity proves ownership using the comment field alone. It does not inspect the adopted account's group memberships. An account named zero-sbx-<hash>, carrying Zero's comment, and also a member of Administrators would pass the gate: Zero resets its password, adds it to the sandbox group, and mints principal tokens for it. The sandboxed child then runs as an administrator, which inverts the property this whole design rests on — your description's argument is that a separate account has no access to the caller's profile by construction, and an adopted account with extra memberships is precisely the case where that stops being true by construction.

I want to be fair about reachability: planting such an account requires administrator rights already, so this is not fresh escalation. It is a persistence and laundering path — something that had admin once leaves a stamped account behind, and Zero thereafter grants it sandbox duty on every run — and it is also the shape a botched or partial earlier provisioning could leave behind on its own. Given that the model's selling point is a boundary that holds by construction, asserting the adopted account's memberships (at minimum, that it is not in Administrators) rather than only its comment would make the claim true rather than nearly true. A comment is a stamp, not a capability check.

What I verified. On macOS: gofmt, go build ./..., go vet ./... clean, suite passing. GOOS=windows go vet ./internal/sandbox/... exits 0 and GOOS=windows go test -c compiles, which type-checks the whole Windows surface including the two new netapi32 procs and the USER_INFO_1 read-back. That is type-checking, not execution.

Limitations, unchanged and still the main thing a reader should weigh. I have no Windows host and no elevated session. NetUserGetInfo, NetApiBufferFree, NetUserSetInfo, LsaAddAccountRights and LogonUser are unexecuted by me. Your description remains accurate about which halves you have run, and that accuracy is why I am comfortable approving with the logon path still unrun: the feature is behind ZERO_WINDOWS_SANDBOX_IDENTITY=1 and off by default, so nothing changes for an existing install.

CodeRabbit's changes-requested from 08:17 is still outstanding and is separate from this.

Merge is kevin's call per the program gate.

@Vasanthdev2004
Vasanthdev2004 force-pushed the feat/windows-sandbox-identity branch from 832f53a to 99fefdc Compare July 27, 2026 09:47
anandh8x
anandh8x previously approved these changes Jul 27, 2026

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review at 99fefdc

PR #808 — Windows sandbox principals (foundation for #662). 14 files, +2559, 12 commits, all new *_windows.go files (build-constrained) except windows_identity_acl.go which is pure-Go ACL-plan logic that compiles on all platforms. Opt-in behind ZERO_WINDOWS_SANDBOX_IDENTITY=1.

Verdict: approve. The design is sound, the fail-soft contract is right, and the honest caveats are the right ones.

What this does

Gives the sandbox its own identity on Windows: a separate local account per workspace in one managed group. This inverts the read-confinement problem — instead of trying to deny the caller's own account (which locks Zero out too), a separate account has no access to the caller's profile by construction, so credential stores are unreachable without enumerating deny rules.

What's good

  • The inversion is the right design. Every other Windows backend derives its token from the calling user via CreateRestrictedToken, which is why credentialDenyReadPaths is a no-op on Windows. A separate account makes "what to GRANT" the interesting question instead of "what to DENY," and the same SID keys write grants and firewall rules.
  • Fail-soft contract is correct. No provisioned account, no stored secret, or opt-in off → ok=false, nil error, restricted-token backend runs unchanged. Only a provisioned-but-unusable identity surfaces an error (broken sandbox, not absent sandbox). The runner integration (windows_command_runner_windows.go) is a clean 25-line addition that tries the principal first and falls back.
  • Network-denial tradeoff is honest. A principal token from LogonUser can't carry the offline-marker SID that WFP filters key on, so the principal stands down when the network is denied and the restricted-token path runs instead. The PR explicitly says "trading network denial for read confinement would have been the wrong way round." Keying filters to the principal's own SID is the named follow-up.
  • Provisioning is idempotent. "Already exists" statuses are success. Re-running zero sandbox setup converges instead of accumulating accounts. Password is reset on re-provisioning so the stored secret stays in step with the account.
  • Squat protection. windowsSandboxUserIsManaged reads back the comment stamp before adopting an existing account. Refuses with a named error (errWindowsSandboxNameCollision) if the name is taken by a non-Zero account. This closes the "reset a stranger's password" hazard.
  • Secret storage is layered. DACL naming only the invoking user + SYSTEM, applied to an empty file before the password is written (bytes never exist under inherited permissions), SE_DACL_PROTECTED so inherited ACEs can't reach it, plus DPAPI (CryptProtectData) encryption with the principal name as entropy so a blob copied to another path fails to decrypt. The test TestStoredSecretDACLNamesOnlyOwnerAndSystem reads the DACL back and fails if any other trustee appears; another asserts SE_DACL_PROTECTED.
  • ACL plan is deny-before-allow. Carve-outs survive Windows DACL evaluation order. Trustee-keyed revocation drops every ACE naming the principal without needing a record of what was granted — the cleanup path the capability-SID model lacks.
  • Rollback is thorough. provisionWindowsSandboxPrincipalForSetup computes secretPath early (before anything can fail), the undo closure removes the secret unconditionally ("provisioning has already replaced the account's password by the time any of this can fail, so whatever is on disk cannot authenticate"), and setupWindowsSandboxPrincipal calls removePrincipal() on ACL-plan failure, which removes secret → logon rights → account in that order.
  • Logon rights are least-privilege. Only SeBatchLogonRight granted; interactive, network, remote-interactive, and service logon explicitly denied. LogonUser pinned to "." so a same-named domain account is never picked up.
  • Platform separation is clean. windows_identity_acl.go (plan logic, no build tag, compiles everywhere, testable on Linux) vs *_windows.go (syscall execution, build-constrained). Cross-compile for GOOS=windows clean; GOOS=windows go test -c type-checks the full Windows surface including netapi32 procs and USER_INFO_1 layout.

Verification performed

  • GOOS=windows go vet ./internal/sandbox/... — clean
  • GOOS=windows go test -c — compiles (type-checks all Windows-specific code)
  • go build ./internal/sandbox/... (Linux) — clean
  • go test ./internal/sandbox/ (Linux, from non-/tmp path) — pass, all 14 tests green
  • go vet ./internal/sandbox/... — clean

CodeRabbit's findings are addressed

CodeRabbit's latest CHANGES_REQUESTED (08:17Z) asked for (1) computing secretPath before granting logon rights and removing the secretWritten condition, and (2) removing the stale .secret file when provisioning succeeds but setup fails before writeWindowsSandboxSecret. Both are addressed by commits 99fefdc and 52f843a (pushed 09:46Z, after the review). The undo closure now computes secretPath early and removes it unconditionally; setupWindowsSandboxPrincipal's rollback calls removePrincipal() which removes the secret first.

gnanam's non-blocking finding (acknowledged, not blocking)

gnanam's APPROVED review notes that the adoption gate (windowsSandboxUserIsManaged) checks the comment field alone, not the account's group memberships. An account named zero-sbx-<hash> with Zero's comment but also in Administrators would pass the gate. gnanam correctly frames this as a persistence/laundering path (not fresh escalation, since planting requires admin already). The fix — asserting the adopted account is not in Administrators — is a reasonable follow-up but not a blocker given the opt-in gate and the admin prerequisite for exploitation.

Honest caveats (from the PR description, still accurate)

  1. The logon half is unproven. NetUserAdd, LsaAddAccountRights, LogonUser need elevation; they compile and are layout-checked but haven't run to completion (Smart App Control blocked the test binary). The provisioning round-trip test is gated behind ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1 plus an elevation check.
  2. Creating real local accounts is user-visible. AV/EDR commonly flag NetUserAdd; enterprise policy often blocks local account creation; accounts appear in net user and Settings. The opt-in gate makes this a deliberate call.

These are the right caveats for a foundation PR. The feature is off by default; nothing changes for an existing install.

Verdict

Approve. The design inverts the Windows read-confinement problem correctly, the fail-soft contract is sound, the rollback paths are thorough, and the honest caveats are the right ones. gnanam's non-blocking finding (membership check on adoption) is worth a follow-up. CodeRabbit's two actionable findings are addressed by the latest commits. Ready for kevin to merge.

…exists

The gate asked whether the CALLING process held SeAssignPrimaryTokenPrivilege
and SeIncreaseQuotaPrivilege. That is the wrong lifetime, and it got the answer
backwards where it mattered.

The process that needs those privileges is the later, ordinary command process.
Setup does not run it, and nothing carries launch authority across the UAC
boundary. So the caller-token check refused unelevated setup, which was never
the dangerous case, and PASSED elevated setup, which is precisely the case that
lands a local account, its password, its logon-right assignments, workspace
ACEs, the recovery ledger and network filter state for a backend no later
command can use. The check existed to prevent durable machine state serving
something that cannot run, and in the one configuration that creates that state
it allowed it.

Requiring every sandboxed command to run elevated would line the privileges up
and is deliberately not done: it is a worse boundary than the restricted token
it would replace, and it is not the lifecycle this is for. Until a reviewed
bootstrap or broker exists, provisioning is closed rather than half-working.

windowsPrincipalLaunchAvailable replaces the preflight var and takes no
argument, because the answer is about which mechanisms exist rather than about
the current process. It is the one place that learns to say yes when a broker
lands, and everything below it -- the plans, the ledger, the ACL and filter
work -- still builds and is still tested against it.

The runner keeps its own privilege check at launch time, which is the correct
lifetime for one; only the setup wiring is removed.

The regression test asserts the property the old shape could not express: the
refusal is stable regardless of what the calling process holds, and it does not
tell the operator to re-run from an elevated terminal, which is the advice that
produced the unusable state. Restoring the caller-passes behaviour fails both
new tests.

Refs #662, which this is foundation for and does not close.
…rage

runtimeRootTestConfig put the workspace and sandbox home under t.TempDir, but
windowsSandboxRuntimeCandidates still derived a candidate through the
production sandboxUserCacheDir seam, and the test creates every candidate and
registers os.RemoveAll cleanup for each.

So the test reached into the real user cache: it fails outright on a read-only
home, and on an ordinary developer or CI account it creates and then deletes a
path outside its own storage. The workspace hash makes a collision unlikely; it
does not make somebody else's directory test-owned.

Redirected before any candidate is derived, and restored with t.Cleanup.
Production derivation is unchanged, and the assertion that setup materializes
every granted write root still stands.

A before/after count of the real cache cannot observe this, since the test
creates and removes in the same run. The mechanism is the evidence:
prepareSandboxRuntime reads sandboxUserCacheDir, and the test RemoveAlls every
candidate that derivation produces.
…oken

TestPrincipalLaunchPreflightExplainsAMissingPrivilege ran the real preflight and
then asserted SeAssignPrimaryTokenPrivilege appeared in the message. Which
privilege is missing is a property of the account running the test, not of the
code: an unelevated user often holds neither, but this box holds
SeAssignPrimaryTokenPrivilege and not SeIncreaseQuotaPrivilege, so only the
other name appeared and the test failed for a reason that was never a defect.

Its own comment said it asserted "the shape of the answer rather than a fixed
verdict, because the verdict legitimately differs by machine", and then it
hardcoded one of the two names. The comment was right and the assertion did not
match it.

principalLaunchPrivilegeError splits the rendering out from the token work, so
each combination is driven directly: neither held, only assign-primary-token,
only increase-quota, and nothing missing. Each asserts the message names what IS
missing and does NOT name the one that is held, since sending an operator after
a privilege they already have is its own failure. Making the message always name
both fails the two single-privilege cases.

The real preflight keeps a test, reduced to what does not vary: if it refuses,
the refusal names at least one of the two rather than something the operator
cannot act on. Stability across calls is unchanged.
…ate anchor

The fallback runtime root moved from a fresh os.MkdirTemp parent to the
predictable <temp>/zero/runtime/v1/<hash> so that elevated setup and the later
command process agree on it. That agreement is required and stays. But a
predictable path directly under the shared system temp is not a private
allocation. On an ordinary shared /tmp another local user can create /tmp/zero
with mode 0700 first, and every affected user then fails before the sandbox
command starts; a pre-existing redirected component makes host-side
preparation lease, create, chmod and clean somewhere other than the derived
tree. Determinism was being used as if it were ownership.

The tree now lives beneath <temp>/zero-runtime-<user>, and that anchor is
created and validated by peermsg.EnsurePrivateDir before anything is built
under it: handle-relative descent, no-follow, refused if any component is a
link or the leaf is not owned by the current user, mode 0700. That primitive
already existed with exactly the semantics jatmn described, so it is exported
and reused rather than written a fourth time. On Unix the uid is in the name
because /tmp is shared; on Windows os.TempDir is already per-user and the
private descriptor plus owner check do the rest.

Validation runs on both paths that can hand back a fallback root:
sandboxRuntimeRootFor returns one itself when the cache lands inside the
workspace, and prepareSandboxRuntime asks for one when the cache root cannot be
leased. Both are covered, each with a junction planted at the anchor, and both
refuse with the junction target untouched; a control shows a clean fallback
still prepares beneath the anchor.

Two things went wrong on the way and are worth recording. The first draft
called pathWithinRoot with its arguments reversed, which asked whether the
anchor was beneath the root, answered false every time, and skipped the
validation entirely while every dependent test reported success through the
junction. And the first lease-failure trigger planted its file at the derived
leaf, so preparation failed on that mkdir before ever reaching the fallback
branch. Both fixed; with validation disabled the two refusal tests now die on
"reported success" while the control stays green.
…back anchor

peermsg.EnsurePrivateDir walks every component from the root and refuses
any link. That is the right rule for the directory Zero owns and the wrong
rule for the operator's temp location above it: on macOS os.TempDir sits
under /var, a symlink to /private/var, so 8af0599 refused every fallback
on every Mac with "refusing non-directory or symlink runtime path component
var".

Anchor the fallback under the physical temp dir instead, so only the anchor
itself is a new component. EvalSymlinks off Windows; GetFinalPathNameByHandle
on Windows, where EvalSymlinks does not traverse a junction and a redirected
TEMP would trip the same way. The fixture compares against the resolved
temp dir rather than its spelling, since t.TempDir can come back as an 8.3
short name.
…e tier boundary

The .git rename guard belonged to one planner rather than to the grant. The
allow-write mask both backends share includes DELETE, and it inherits from each
write root onto .git; the carveouts that actually protect git are attached to
.git/config and .git/hooks as objects, so renaming .git aside and recreating it
discards them and hands back credential.helper and core.hooksPath. The principal
planner denied DELETE there and the capability planner did not, and the
capability backend is the default. Both now derive that object from one place, so
a change to the shared mask cannot update one consumer and miss the other. The
regression reads the ACE back off the applied object rather than asserting plan
shape, because the plan is what was wrong.

The unelevated tier now states its boundary before it mutates anything. A profile
carrying denyRead runs on a strict token, which applies the restricted-SID check
to reads, so the read capability must be granted at the volume root that
permissionProfileReadRoots seeds. Elevated setup can write that DACL; an ordinary
user cannot, and the common opener asks for WRITE_DAC on every entry, so this
tier failed on that one root on every command. Dropping the root ACE instead is
not available, because the strict token would then fail its own read check for
the executable. So it is refused up front, naming the root, the denyRead cause
and elevated setup as the remedy. That last part matters: the previous
after-the-fact diagnostic told the reader that running setup elevated would NOT
fix it, which is the opposite of the truth for this case.

Separately, `sandbox exec` no longer reports a signaled child as exit 255. On
Unix ExitCode() answers -1 for signal termination and os.Exit truncates that, so
a child killed by SIGTERM was indistinguishable from one that chose to exit 255.
It now returns the conventional 128+signal, read from the ProcessState, with
Windows keeping the exit code it already reports.
… not after

ensureWindowsSandboxRuntimeCandidates ran as Administrator and used os.MkdirAll,
which follows links. Both candidate ancestries are prepared by the invoking user,
so a junction planted at a not-yet-created component below the cache directory,
or at the fallback anchor, made elevated setup build the runtime tree beneath a
redirected, Administrator-writable target. The ACL applier's no-follow check runs
afterwards and does spot the redirection, but the privileged create has already
happened by then and sits outside its rollback boundary. A leaf check after
MkdirAll would leave the same gap.

Candidates are now created by peermsg.EnsurePrivateDir, which descends
handle-relative with no-follow, refuses any component that is a link, and refuses
a leaf this user does not own. It is handed a physically resolved base first,
because it walks from the volume root and a redirected cache or TEMP above the
owned tail is the operator's business: the same pairing the fallback anchor
already uses, and the same mistake that refused every fallback on macOS when the
parent was left unresolved. physicalTempDir is generalised to physicalDir for
that, keeping GetFinalPathNameByHandle on Windows because EvalSymlinks does not
traverse a junction.

The regression plants a junction at the first owned component and asserts the
redirected target stays empty; letting the creator follow links again fails it on
that assertion.
…t its pathname

cleanupSandboxRuntimeRoots reclaims inactive sibling workspace roots on an age
and count policy, treating them as disposable cache state. Setup and its marker
treat their DACL as durable provisioned state. When the reclaimed workspace runs
again, command-side preparation recreates the same deterministic pathname as the
ordinary caller, and the new directory inherits from its parent without the
capability SID ACE elevated setup applied to the object that used to be there.

ValidateWindowsSandboxSetupMarker still passed, because it fingerprints pathnames
and actions rather than the identity of the ACL-bearing object. So the restricted
child launched and then failed its cache, temp and package-cache writes with a
bare access-denied and nothing pointing at setup.

The command now asks the object, not the pathname: before the token is minted it
confirms the runtime root carries an allow ACE for its capability SID, and
refuses with the setup remedy when it does not. That reconciles the two owners
without making cleanup preserve trees it is meant to reclaim.

The regression applies a real plan, reclaims the directory the way cleanup does,
recreates the pathname the way an ordinary run does, and asserts the refusal
names the root and the remedy; accepting a missing grant fails it on that. Not
covered, and said plainly: a write performed by a real capability token, which
needs a provisioned sandbox an unelevated box cannot create. The ACE presence is
the fact the command consumes, and that is what is pinned.
The unelevated marker fingerprints pathnames and actions, and cleanup may
reclaim the deterministic runtime directory that plan was realized on. An
ordinary later run recreates the same pathname with the caller-private DACL and
no capability ACE, so the serialized plan is unchanged and the cached marker
returned before anything looked at the directory. The restricted child still
carried the capability SID; the new object did not grant it, and every temp,
package-cache and build-cache write failed after launch with a bare access
denial and nothing pointing at setup.

The restricted-token tier got this check in the previous commit. This tier
reaches the same lifecycle without elevation, and its answer differs: it owns
its plan, so it reapplies rather than refusing. Refusing would print advice to
run elevated setup, which is unnecessary here and unfollowable for a user with
no Administrator account.

The regression drives ensureWindowsUnelevatedSetup through provision, reclaim,
recreate, and asserts the grant is restored, with a setup guard proving the
recreated directory really lost it first. A companion pins that an intact root
still takes the cached fast path.
…ts git later

The capability planner emitted the deny-delete on .git and the deny-writes on
.git\config and .git\hooks without Materialize. On a workspace that had no
.git when setup ran, the first apply pass skipped all three as missing and the
deferred pass skipped them again, because nothing else in the capability plan
created them. Setup recorded success anyway.

A later git init then created .git, config and hooks beneath the already
granted workspace. They inherit the workspace allow, DELETE included, with no
object-specific deny of their own, so a sandboxed command could rename .git
aside, recreate it, and get credential.helper and core.hooksPath back. This is
the default backend, so the weaker of the two planners' rules was the one
almost every Windows user got.

The principal planner has materialized its carveouts from the start, which is
also what lets the applier's deferred pass land the deny-delete: creating
config and hooks creates .git as their parent. Sharing that shape rather than
only the guard's pathname is the fix.

The plan-shape test pinned materialize=false on these three entries, which
encoded exactly the behaviour that was wrong, so it is updated rather than
worked around. The new regression applies the real plan to a workspace with no
.git, runs a real git init, and reads the deny mask back off the object.
…root

A profile that configures denyRead selects a fully restricted token, and that
token applies its restricted-SID check to reads, so the plan granted the read
capability at every read root. Production profiles seed ReadRoots with the bare
filesystem root.

The applier marks allow entries on a directory inheritable and calls
SetSecurityInfo, and Windows propagates inheritable ACEs onto existing
children. So applying that one entry was never a change to a sandbox-owned
object: it walks and rewrites DACL inheritance across unrelated system,
application and user trees on the drive, and a locked or exclusively opened
descendant leaves the result dependent on ambient filesystem state.

Paying that price does not even buy a working sandbox. A bare root resolves on
one volume, while the executables and libraries a command needs can sit on
another without being read roots of their own, so the strict token can still
fail before its executable starts. Measured separately: with the read
capability granted nowhere, the strict token cannot open cmd.exe.

So the profile is refused rather than half-served. The refusal moves out of the
unelevated tier, where it started for the narrower reason that an ordinary user
lacks the rights, and becomes the plan's own answer that both tiers consume.
Elevated setup refuses before its first mutation, which matters because that
tier CAN write the DACL. Profiles without denyRead are untouched and keep the
workspace write jail.

This leaves denyRead unavailable on Windows until there is a bounded
authorization model covering the real platform, runtime and executable
dependencies on every relevant volume, which is what #869 tracks. The
alternative on offer was a silently voided write jail or a volume-wide ACL
rewrite, and refusing is better than either.
…ume root

The refusal looked for a volume-root entry, which is a symptom of the
production profile rather than the thing that is unsafe.
permissionProfileReadRoots happens to seed the bare filesystem root, so that
check covered production by coincidence. A denyRead profile with a narrowed
read list carried no volume root, sailed through, and elevated setup would have
put an inheritable read ACE on C:\Windows: the same defect one level down.

The grant itself is what cannot be applied to a directory Zero does not own,
and it exists only for a denyRead profile, so that is what is refused now. Read
grants that land on the plan's own write roots are excluded, because those are
Zero's directories and were never the objection; the diagnostic names the
broadest path outside them, preferring a volume root so it reports the worst
one.

Found by probing the neighbouring case rather than by rerunning the one the
first fix was written against.
A missing .git meant "this directory may become a repository", so the Windows
plan materialized .git/config and .git/hooks to get the deny ACE in place
before git first ran. That is right for a standalone directory. It is wrong
when the workspace is a subdirectory of an existing repository: the created
directory is a control directory competing with the ancestor's for git's
discovery walk, synthesized inside a repository Zero does not own.

The carveouts are now skipped when an ancestor carries git metadata, which is
git's own discovery rule. That is the same argument the linked-worktree branch
already makes: the metadata governing this workspace lives outside the write
root, the sandboxed principal has no inherited access to it, so it needs no
carveout here. The non-materialized deny-delete on <root>/.git is emitted
separately and still guards the name if a repository is ever created.

Ancestor detection accepts .git of either shape, since a linked worktree or
submodule ancestor carries it as a pointer file and still owns the directory.

Reported by jatmn. Worth recording that the stated consequence did not
reproduce here: a .git holding only config and hooks is not a valid repository,
so this git version walks past it and status, log and rev-parse still resolve
to the ancestor. The fix stands on the narrower ground that creating
repository metadata Zero does not own is wrong regardless, and anything later
adding a HEAD there would break discovery for real.
exec.Cmd reads a nil Env as "inherit this process's entire environment", which
is a different statement from "run with no variables". The plan owns its
environment: directCommandEnv and scrubSensitiveEnv return a slice they built,
and that slice is non-nil with length zero when every entry was sensitive.
Testing its length collapsed those two states and turned the strictest possible
answer into the loosest one.

Reported by jatmn as a credential leak. Being precise about reachability: it is
not reachable through `zero sandbox exec` today. The child environment is
os.Environ(), so an environment holding only sensitive keys also has no
%AppData%, and config resolution fails at sandbox_exec.go before the planner
runs. Two independent checks reached that conclusion and I confirmed it at the
call site.

Fixed regardless. The guard is one assignment, and the next caller that hands
the plan a deliberately narrow environment would inherit everything instead,
silently. Both directions are pinned by a child that prints its own
environment, on every platform rather than behind a Unix-only skip.
The test applied the real ACL plan to C:\Windows\System32 and used the result
as its privilege probe: on success it skipped, having already mutated. Both
return values were discarded at the call site, so the snapshot holding the
original descriptor was gone and nothing could put it back. A process that does
hold WRITE_DAC there would leave an inheritable allow ACE for a synthetic SID
on System32 and on everything later created beneath it, permanently.

Reachability is narrower than reported, and worth writing down. System32 grants
WRITE_DAC only to TrustedInstaller; Administrators and SYSTEM get 0x1301bf on
the object, which does not include it, and their GA aces are inherit-only. So
an ordinary elevated run takes the denial branch. The branch that mutates needs
a token whose backup or restore privilege is enabled, since the applier opens
with FILE_FLAG_BACKUP_SEMANTICS, which is the normal shape of a LocalSystem
service token and therefore of a self-hosted runner installed as a service.
That could not be driven here, so it rests on documented behaviour rather than
on output. The trigger is narrow; the residue is unbounded and silent.

The coupling this test exists to pin, openWindowsACLTarget's wording against
windowsACLPlanDeniedPath's marker, is now pinned against a disposable directory
rigged to refuse WRITE_DAC. A plain deny ace is not enough there, because the
owner keeps an implicit WRITE_DAC that defeats it and the apply succeeds;
OWNER_RIGHTS is what makes the DACL the whole story. That was measured, not
assumed, and a naive deny-only fix would have relocated the same bug.

No run can now leave residue outside t.TempDir(), and the skip that used to
follow a completed mutation is a SETUP INVALID failure instead.
…lanning

gitMetadataWriteCarveoutSpecs types a linked worktree's or submodule's .git as
a pointer FILE. That typed result is flattened to a path before ACL planning,
and gitMetadataCarveoutIsFile tried to recover the shape by deriving suffixes
under a sentinel root whose .git never exists. The derivation therefore always
took the directory branch and produced exactly one file-shaped suffix,
.git\config, which a bare <root>\.git can never match. Both planners emitted
MaterializeFile:false for a path stage one had already typed correctly.

The window that turns a wrong plan into damage is the caller-to-elevated-helper
gap. The profile is built in the user's shell and the plan is applied in a
separately launched helper, across the UAC prompt, so a concurrent
`git worktree remove`, `git submodule deinit`, or hostile local process can
delete the pointer in between. The applier then creates a DIRECTORY at the
pointer path and the worktree is broken, persistently, because the runner
discards the rollback closure.

The carveout SET already carries the answer, so no type change is needed.
gitMetadataWriteCarveoutSpecs is the only producer of ReadOnlySubpaths, and it
emits a bare .git in exactly one case: the pointer. The directory case emits
.git\hooks and .git\config and never their parent. So the shape is read off
which carveout this is rather than guessed from a pathname.

The regression drives BuildWindowsACLPlan and applyWindowsACLPlan with the
pointer removed after planning, which is the only place the loss is visible; a
stage-one assertion on specs[0].IsFile passed for the whole time the bug was
live. A control with the pointer present keeps a green result from meaning an
apply that did nothing.
The runtime root is deterministic so setup and later runners agree on one path,
and the sandboxed command is granted write access to cache, data and tmp. So it
can replace one of them with a symlink or a Windows junction on its way out.
Preparation then ran os.MkdirAll and os.Chmod on raw pathnames, which follow,
and the HOST Zero process, the ordinary user rather than the confined
principal, created package-cache directories inside a target the previous
command chose.

ensureFallbackRuntimeAnchor proves the per-user anchor and says nothing about
the reusable root or anything beneath it, so it never covered this.

Preparation now descends from the operator-owned base through retained
no-follow handles: NtCreateFile with OBJ_DONT_REPARSE and
FILE_OPEN_REPARSE_POINT on Windows, openat and mkdirat with O_NOFOLLOW plus
fchmod on the descriptor elsewhere. fchmod rather than chmod is the half that
matters off Windows, since chmod follows symlinks. The deterministic naming
contract is untouched.

Deliberately NOT peermsg.EnsurePrivateDir, which is the obvious patch and the
wrong one: it ends in a protected-DACL write that strips the sandbox
principal's grant elevated setup installed on the runtime tree, bringing back
the bare access denials from npm and go that the grant exists to prevent. That
would have passed every unit test on an unprovisioned box. This descent
validates and creates; it does not re-secure.

Two precisions worth recording. The Windows chmod is dropped rather than ported,
because it only toggles READONLY and was measured landing on the junction rather
than its target, so it bought nothing. And the exposure on the cache-derived
root predates this PR, byte-identical to main; what this PR added was making the
fallback root deterministic and persistent too, extending the same shape to it.
The helper tests call ensureRuntimeTreeDirs directly, so reverting
prepareSandboxRuntime to the old pathname loop left every one of them green.
The defect was in what the entry point called, so it needs a test that goes
through the entry point.
rollbackWindowsACLSnapshots branched on createdAnything(), which is an OR
across the whole materialization record. When this run creates the parent chain
and a racer wins the leaf, that predicate is true while the ACL-bearing file
belongs to somebody else: Chain carries Made:true and FileMade is false.

The rollback then could not remove the parent, because it was no longer empty,
and the unconditional skip meant the existing no-follow, identity-validated
restore never ran either. A file that existed before setup started was left
wearing an aborted setup's DACL, and the failure was loud about the directory
it could not remove while silent about the descriptor it never put back.

rollbackWindowsACLMaterialization now reports whether the ACL-BEARING TARGET is
among what it removed: the file leaf when FileMade is set, the deepest chain
entry otherwise. Anything it cannot prove it removed reports false, which
routes the caller to the restore that is already no-follow and TargetID-guarded
and refuses on a mismatch rather than forcing.

All three conservative properties are unchanged and none needed new code:
nothing new is ever deleted, so a raced leaf is still never removed; there is
still no pathname fallback; and a rollback that could not remove the parent
still says so while now also restoring the leaf.

The regression is the mixed-ownership case rather than a wholly created
directory becoming non-empty, driven through applyWindowsACLPlan with the leaf
created inside the existing swap hook so the race is deterministic.
…y happen

The first version of this regression used the anchor-stage swap hook, which
fires before the directory chain exists. Creating the parent there made the
chain the RACER's, not this run's, so createdAnything() was false, the ordinary
restore path ran, and the test passed with the fix reverted.

The mixed ownership the finding is about needs the chain to be ours and only
the leaf to be theirs, which is reachable at exactly one instant: after
makeWindowsACLDirChainNoFollow returns and before createWindowsACLChildFile
runs. windowsACLRacedLeafHook makes that instant addressable.

Reverting the disposition check now fails it with the deny ACE still on the
racer's file.
runSandboxPlannedCommand started the backend wrapper with a bare
exec.Command().Run(): no context, no signal forwarding, no shutdown path. A
terminal masks that, because terminals signal the whole foreground process
group, but a supervisor or task runner sending SIGTERM to the `zero sandbox
exec` PID killed only Zero. The wrapper and everything under it kept running,
doing filesystem and network work after the caller considered the task
cancelled, and the deferred plan cleanup never ran because the process was
gone.

The CLI's shared shutdown context is threaded in, Cancel kills the tree, and
WaitDelay bounds how long a child that ignores the first signal can hold the
wrapper open and skip cleanup.

DELIBERATELY WITHOUT execution.ConfigureProcessGroup, which the obvious fix
would add. Putting the child in its own process group closes this hole and
opens a worse one: it severs every kernel-delivered group signal, so a
supervisor escalating to a group kill, and a terminal delivering Ctrl+C to its
foreground group, would stop reaching the child. That was measured, not
assumed: with the group change a group-directed kill left both the child and
its grandchild alive, where today it reaps them. Keeping the child in Zero's
group leaves group delivery exactly as it is, while the directed-signal case,
which reached nothing at all before, now reaches the child.

The remaining gap is a grandchild on the directed-signal path, which the group
path still covers. Closing that too needs a job object on Windows and a group
Zero owns on Unix, which is the trade above and a separate decision.

The existing 128+signal mapping for a child that exits by signal is untouched;
that is a different direction of signalling.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

All seven are in at 659140f6, rebased onto current main first so this is evidence against what would actually merge. Each fix was falsified.

Three of them I am reporting back differently than stated, because the mechanism reproduced and the consequence did not. Details below rather than buried.

System32. Fixed, and the trigger is narrower than described. System32 grants WRITE_DAC only to TrustedInstaller; Administrators and SYSTEM get 0x1301bf on the object, which excludes it, and their GA aces are inherit-only. An elevated administrator therefore takes the denial branch. The mutating branch needs a token whose backup or restore privilege is enabled, since the applier opens with FILE_FLAG_BACKUP_SEMANTICS, which is the normal shape of a LocalSystem service token and so of a self-hosted runner installed as a service. That could not be driven here, so it rests on documented behaviour rather than output. Narrow trigger, unbounded residue, and the test is in go test ./... on the windows-latest leg, so it is fixed. The coupling now pins against a disposable directory rigged to refuse WRITE_DAC. A plain deny ace is not enough there, because the owner keeps an implicit WRITE_DAC that defeats it; OWNER_RIGHTS is what makes the DACL the whole story. Measured, and a deny-only fix would have relocated the same bug.

Empty scrubbed environment. Fixed, and not reachable through zero sandbox exec today. The child environment is os.Environ(), so an environment holding only sensitive keys also has no %AppData%, and config resolution fails before the planner runs. Two independent checks reached that and I confirmed it at the call site. Fixed regardless, because the guard is one assignment and the next caller handing the plan a deliberately narrow environment would inherit everything silently. Both directions are pinned by a child that prints its own environment.

Nested .git. Fixed. The mechanism is real, the stated consequence is not, at least on this git version: a .git holding only config and hooks is not a valid repository, so discovery walks past it and status, log and rev-parse all still resolved to the ancestor before the fix. It stands on the narrower ground that synthesizing repository metadata Zero does not own is wrong regardless, and anything later adding a HEAD there breaks discovery for real. The guard uses git's own rule, nearest ancestor with a .git, and accepts both shapes since a worktree or submodule ancestor carries a pointer file.

Linked-worktree shape. Fixed without the type change. The carveout SET already carries the answer: gitMetadataWriteCarveoutSpecs is the only producer of ReadOnlySubpaths and emits a bare .git in exactly one case, the pointer; the directory case emits .git\hooks and .git\config and never their parent. So the shape is read off which carveout this is rather than guessed from a pathname, and no wire-format or planner signature changes. The regression drives plan then apply with the pointer removed in between, which is the only place the loss is visible.

Raced leaf. Fixed. rollbackWindowsACLMaterialization now reports whether the ACL-bearing target is among what it removed, and the caller only skips the restore when it is. All three conservative properties are unchanged and none needed new code. Worth flagging: my first regression used the anchor-stage hook, which fires before the chain exists, so the parent became the racer's too and the test passed with the fix reverted. There is now a seam at the one instant the mixed case is reachable, after the chain and before the leaf.

Runtime descendants. Fixed with a handle-relative no-follow descent, NtCreateFile with OBJ_DONT_REPARSE on Windows and openat/mkdirat with O_NOFOLLOW plus fchmod elsewhere. Deliberately not peermsg.EnsurePrivateDir, which is the obvious patch and wrong: it ends in a protected-DACL write that would strip the principal grant elevated setup installs on the runtime tree, and it would pass every unit test on an unprovisioned box. The Windows chmod is dropped rather than ported, since it only toggles READONLY and was measured landing on the junction rather than its target. One attribution correction: the exposure on the cache-derived root is byte-identical to main and predates this PR; what this PR added was making the fallback deterministic and persistent too, extending the same shape to it.

Cancellation. Fixed, and I did not take the shape you might expect. Threading the context, killing the tree on cancel, and bounding with WaitDelay closes it. I deliberately did NOT add execution.ConfigureProcessGroup: putting the child in its own group closes this hole and opens a worse one, severing kernel-delivered group signals so a supervisor escalating to a group kill and a terminal delivering Ctrl+C to its foreground group both stop reaching the child. That was measured, with the group change a group-directed kill left child and grandchild alive where today it reaps them. Keeping the child in Zero's group leaves group delivery unchanged while the directed-signal case, which reached nothing before, now reaches the child. The residue is a grandchild on the directed path, which the group path still covers; closing that too needs a job object on Windows and a group Zero owns on Unix, and that is the trade above rather than a free win. Happy to take it if you would rather have the grandchild than the group delivery, but I did not want to make that call silently.

On scope: I am keeping this as one PR. Vasanth's call.

Six tests fail on my box for every branch including pristine main, because the worktree lives under %TEMP% and "outside the workspace" lands inside an allowed temp root. Not from this branch.

The Windows Smoke leg failed on my own new tests with "has no operator-owned
base to descend from" for a perfectly ordinary tree.

runtimeCandidateBase decides ownership with a containment test, and containment
runs on spellings. A runner's temp is C:\Users\RUNNER~1\..., an 8.3 short name
that compares unequal to the long form of the same directory the cache root
resolves to. The same class of mismatch prepareSandboxRuntime already documents
for its own comparison.

ensureRuntimeTreeDirs now canonicalizes before asking, the same way the rest of
the runtime state does.

The tests were the other half of it: a bare t.TempDir() only lands under the
user cache directory on a machine where temp happens to sit there, which is
true on my box and false on the runner. They now pin the cache root
themselves, so they are about the descent rather than about where temp lives.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Overall guidance

The two findings below have the same underlying shape: the implementation proves an intermediate state but not the complete lifecycle that consumes it. The cancellation test proves that runSandboxPlannedCommand returns, but not that a graceful phase occurred before escalation. The nested-workspace test proves that setup does not create .git, but not what happens if the sandboxed command creates it later. In both cases the local assertion passes while the downstream contract is still broken.

That pattern is also why review on this branch has produced repeated follow-ups. This is a large security-sensitive change—116 files, roughly 18,000 added lines, and 96 commits—covering planners, persistent markers, privileged setup, runtime consumption, cleanup, and three different platform backends. The size is not itself a finding, but fixing one highlighted branch at a time makes it easy to validate the producer without validating the later consumer, or to cover the object that exists during setup without covering the object that replaces or appears after it.

For the next revision, please treat these as two bounded root-cause closure passes rather than two line edits:

  1. For command shutdown, trace signal receipt → cancellation initiation → graceful wait → forced escalation → status mapping → plan cleanup on every platform path.
  2. For Git metadata, trace absent metadata → profile/plan construction → setup/marker persistence → later repository creation → config/hooks writes and .git replacement on every backend.

The regression tests should drive those complete sequences through the production entry points and assert the final security/process effect, not only plan shape or helper return. A short result matrix for Unix/macOS/Windows and for absent/existing/later-created Git metadata would make it possible to verify the next push as one coherent closure rather than discover another adjacent state afterward.

This guidance intentionally does not reopen the accepted principal-launch sequencing, require a process-group redesign, request that setup pre-create nested .git, or pull pre-existing WFP/runtime issues into this PR.

Findings

  • [P2] Apply the shutdown grace before force-killing the command
    internal/cli/sandbox_exec.go:148
    The shutdown sequence currently has no graceful phase. signalContext converts SIGINT/SIGTERM into context cancellation, exec.CommandContext synchronously invokes the custom Cancel, and that callback immediately calls KillProcessTree. On Unix that primitive sends SIGKILL; on Windows it invokes taskkill /T /F. Only after cancellation does WaitDelay start its timer, so it cannot postpone a force-kill that already happened. The comments at lines 122-124 and 149-151 describe a five-second grace followed by escalation if the child ignores the first signal, but no first graceful signal is sent. A directed Unix SIGTERM consequently terminates the child as SIGKILL (137 rather than 143), and the child has no opportunity to flush output, remove lock files, or run its shutdown handlers. The signal-status tests do not cover this path: they signal a standalone exec.Command and call signaledExitStatus directly.

    The root cause is that the cancellation callback is being used for both phases of a two-phase policy: it should initiate shutdown, while the timeout path should perform escalation. Please separate those responsibilities so cancellation first requests graceful termination using the platform-appropriate mechanism, waits for the existing bound, and only then force-kills a target that remains alive. Preserve the process-group tradeoff already discussed in the thread; this finding does not require putting the child in a new group or changing terminal/supervisor group delivery.

    Please add a regression through runSandboxPlannedCommand in which a child traps the graceful signal and records that its cleanup handler ran, plus a stubborn-child case proving force termination occurs after—not before—the bound. Cover the returned status there, and add a command-level assertion that plan cleanup happens only after termination completes, so a passing test demonstrates the actual lifecycle rather than only the configured duration.

  • [P1] Keep Git control files protected when a nested repository is created later
    internal/sandbox/profile.go:174
    Returning no carveouts for a workspace beneath an ancestor .git correctly avoids having setup synthesize a competing repository, but it turns a fact observed during planning into a permanent policy decision. The failure sequence is:

    1. The workspace is initially governed by an ancestor repository and has no local .git, so gitMetadataWriteCarveoutSpecs returns nil.
    2. The resulting permission profile contains no .git/config or .git/hooks denies. On Windows, BuildWindowsACLPlan still emits a non-materialized deny-delete entry for the missing .git, but the applier skips it; because there are no carveouts to materialize, the deferred pass also has no object to revisit. Setup then persists a valid marker for that plan.
    3. A sandboxed command later runs git init in the workspace or otherwise creates a valid nested .git. The new directory, config, and hooks inherit the workspace-wide write grant, including DELETE, with no object-specific deny. The persisted Windows setup remains current because the serialized plan has not changed. On the pathname-based backends, the profile likewise contains no rule for those future paths.
    4. The command can set credential.helper or core.hooksPath, install a hook, or rename and replace the nested .git. The author’s latest explanation confirms that adding HEAD makes Git discovery select this nested repository; that is precisely the later-created state the current test never reaches.

    The root cause is conflating “this workspace must not own Git metadata at setup time” with “this workspace can never acquire Git metadata during the command.” The first statement is valid; the second is not stable over the lifetime of a writable workspace. Please model that state transition explicitly. The acceptable outcome is that setup does not pre-create a competing .git, while a repository created later is either refused with an actionable diagnostic, causes enforcement to be safely re-established before it becomes usable, or is handled by another mechanism that preserves the same config/hooks and non-replaceability invariants. The review does not require one particular implementation.

    Please replace the plan-shape-only oracle with an end-to-end lifecycle regression: create an ancestor repository and a nested workspace without .git, build/apply the real sandbox policy, attempt git init from the sandboxed command, and then prove either that creation was refused or that writes to config/hooks and replacement of .git are denied. On Windows, include marker reuse after creation so the test cannot pass only because setup was rerun; on Linux/macOS, exercise the actual backend rule/monitor rather than inspecting ReadOnlySubpaths. Keep a control proving ordinary ancestor Git discovery is unchanged before nested creation and that linked-worktree gitfiles still work.

The shutdown comments described a graceful signal followed by escalation while
the code did neither. Cancel called KillProcessTree, which is SIGKILL on Unix
and taskkill /T /F on Windows, and WaitDelay only starts counting AFTER Cancel
returns, so it could never postpone a kill that had already happened. A
directed SIGTERM reached the child as 137 rather than 143, with no chance to
flush output, drop a lock file, or run a shutdown handler. The comment
described a policy the code did not implement.

Cancel now calls execution.TerminateProcessTree, the two-phase policy the
background package already uses: SIGTERM, poll for the grace, then SIGKILL what
is still alive. On Windows that resolves to the single-phase kill, which is
correct rather than lazy, since there is no signal to send a child owning no
console. WaitDelay becomes a backstop for the case where the tree is gone but
Wait is still blocked on a pipe a grandchild holds open.

The process-group tradeoff from the previous round is untouched: the child
stays in Zero's group, so terminal and supervisor group delivery is unchanged.

Regressions run a child that traps the signal and records that its handler ran,
and a stubborn child that ignores it and must still die after the bound rather
than before it. Verified on real Linux rather than only cross-compiled, since
the graceful phase does not exist on Windows and a Windows-only run would prove
nothing about it.
firstSubcommand skips words beginning with "-" but not the value that follows
one, so the network classifier read the wrong word:

    git clone https://x                  network=true
    git -C sub clone https://x           network=FALSE
    git -c user.name=x clone https://x   network=FALSE

Both spellings are ones an ordinary user types by habit, and both walked
straight through the network gate. "--opt=value" happened to survive because it
is a single token.

git now gets a subcommand parser that knows which of its global options consume
the next word. The keys are lowercase because the analyzer lowercases every
word before this point, which also means -C and -c arrive identically; that is
fine, since both consume a value and nothing here needs to tell them apart.

Found while building the nested-repository check, which needs to recognise
"git init" through the same options. Reported separately rather than folded in,
because it is a live gate bypass rather than part of that finding.

The regression drives AnalyzeCommand rather than the helper, since the gate
reads the analysis and a helper-level assertion would not have caught this. It
keeps local-only spellings classified local, so the fix cannot be "call
everything network".
A workspace governed by an ancestor repository gets no git carveouts at all.
gitMetadataWriteCarveoutSpecs returns nothing for it, on every backend, and that
is deliberate: naming <root>/.git/config and <root>/.git/hooks makes the Windows
plan create, and the bubblewrap helper mount, a control directory that competes
with the ancestor's for git's discovery walk inside a repository Zero does not
own.

So there is no protection here to extend to a repository created during the
command. It lands under the plain workspace write grant with nothing denying
credential.helper or core.hooksPath, and on Windows nothing denying DELETE on
.git either. The serialized plan never changed, so the cached setup marker stays
valid and the next run does not notice.

Refuse it instead. The protection would have to be established at a moment the
sandbox is no longer at, and reporting success while the guarantee is absent is
worse than refusing.

The condition is the workspace, not the directory the command names. Resolving
that would mean tracking -C and cwd through the script, which is the same
option-parsing surface that let "git -C sub clone" past the network gate.
Refusing a git init aimed elsewhere too is the conservative side of that trade,
and the reason text says so. The detection shares gitSubcommand with the network
gate, so those global options cannot bypass this gate either, and it covers
init-db.

The refusal sits ahead of the persistent grant, session grant and allow-permission
returns in Evaluate: a broad approval to run shell commands is not an approval to
create an unprotectable repository.
The grace tests proved a handler ran and that escalation waited out the bound,
but never looked at the status, which is the only part of this a caller can see
and the part the report was about: 137 rather than 143.

An untrapped child now has to come back as 128+SIGTERM. That is the assertion the
other two cannot make between them, since one traps the signal and the other
ignores it, so a first phase that was still SIGKILL would leave both passing. The
graceful child exits 42 from its handler and the status has to be 42, and the
stubborn child's has to be 128+SIGKILL so "ask nicely and give up" cannot pass
either.

Plan cleanup is ordered after termination by a defer in runSandboxExec wrapped
around this call, which holds only while nothing survives the return. The
stubborn child records its pid and the run has to leave it reaped, so a refactor
that lets Wait come back with the tree still up fails here rather than deleting
the policy-report file underneath a live command.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both closed at 0981334.

P1, nested .git

I went with refusal. The other two options in your list do not survive contact:
re-establishing enforcement mid-command means noticing the create and re-running
setup while the command holds the tree, and "another mechanism" would have to be
a .git we own, which is the competing control directory the previous fix
removed.

You are right that the carveout gap is not Windows-only. gitMetadataWriteCarveoutSpecs
returns nothing for a nested workspace on every backend, so macOS gets no
seatbelt rule for those paths and the bubblewrap helper mounts nothing there
either. The refusal therefore sits in Evaluate, not in the Windows plan.

git init and init-db in a workspace governed by an ancestor repository now
come back as a deny with nested_git_init and a diagnostic that names the
remedy. The detection shares gitSubcommand with the network gate, so
git -C sub init and git -c k=v init cannot walk past it. Worth flagging: I
found that same option handling missing on the network side while building this,
so git -C sub clone https://x and git -c k=v clone https://x were both
classified network=false. That is fixed in 6bd7032 with its own test.

The condition is the workspace, not the directory the command names. Resolving
that means tracking -C and cwd through the script, which is exactly the surface
that produced the bypass above, so a git init aimed outside the workspace is
refused too. The reason text says so rather than leaving the operator guessing.

Placement matters as much as the rule: the refusal runs ahead of the persistent
grant, the session grant and the allow-permission return. A standing approval to
run shell commands is not an approval to create a repository the sandbox cannot
protect.

internal/sandbox/git_nested_init_refusal_test.go drives it through the real
engine. It opens with a setup guard that the nested workspace really has no
carveouts, so if that ever comes back the premise fails there instead of the
refusal quietly guarding nothing. Controls: git status, add, commit, log,
worktree list and submodule update --init all still run in the nested
workspace, a standalone workspace still creates repositories, and a linked
worktree keeps its pointer carveout and is not refused, which is the shape Zero's
own dev checkouts have.

On marker reuse: nothing is created, so there is no post-creation state for the
marker to be stale about. The plan is unchanged and stays correct.

P2, shutdown grace

Cancel now calls execution.TerminateProcessTree with the same two-phase
policy the background package uses: SIGTERM, poll the grace, SIGKILL what is
left. On Windows that resolves to the single-phase kill, which is right rather
than lazy given there is no signal to send a child that owns no console.
WaitDelay drops to a backstop for a tree that is gone while Wait is still
blocked on a pipe a grandchild holds open.

Four regressions through runSandboxPlannedCommand, verified on real Linux:

  • a child that traps TERM records that its handler ran and the status is the 42
    it chose
  • an untrapped child comes back as 143, which is the assertion the other cases
    cannot make between them since one traps and one ignores
  • a stubborn child dies after the grace, not before it, and the status is 137
  • the stubborn child's pid is reaped by the time the run returns

That last one is the cleanup ordering you asked for. runSandboxExec defers
plan.Cleanup() around this call, so ordering holds exactly as long as nothing
outlives the return. Reverting to process.Start() plus a detached wait fails it
with the pid still alive.

Every claim above was checked by reverting the change and confirming the test
dies naming the missing thing. The graceful signal, the escalation bound, the
status, the reap, the engine refusal, the ancestor condition, the ordering ahead
of the allow paths, and the option handling each have a mutation that kills the
matching test and nothing else.

One local note that is not a finding: six internal/sandbox tests and three in
internal/cli fail on my box only because my checkout lives under %TEMP%, so
AllowTemp makes the "outside the workspace" fixtures writable. From a checkout
on another volume both packages are green apart from
TestBuildServeScopeKeepsLexicalPaths, which needs the symlink privilege.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

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.

Windows sandbox does not deny reads of cloud credential stores

6 participants