feat(sandbox): Windows sandbox principals (foundation for #662, does not close it) - #808
feat(sandbox): Windows sandbox principals (foundation for #662, does not close it)#808Vasanthdev2004 wants to merge 74 commits into
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds Windows sandbox principal provisioning, protected secret storage, handle-relative ACL enforcement, runtime token selection, deterministic runtime roots, network coverage checks, and the ChangesWindows sandbox principal
Estimated code review effort: 5 (Critical) | ~120 minutes Merge Risk: 🔴 Critical · up to 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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
internal/sandbox/windows_identity_logon_windows.go (2)
48-54: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsolidate the five separate
advapi32.dlllazy loads.Five independent
windows.NewLazySystemDLL("advapi32.dll")calls wherewindows_identity_windows.gouses a single sharednetapi32var 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 winRedundant/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 (perunsafepackage docs, this also applies toLazyProc.Callon 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]/_ = infois not the guaranteed primitive for it —runtime.KeepAliveis.
internal/sandbox/windows_identity_logon_windows.go#L195-L203: replace theruntimeKeepAliveUint16helper with a directruntime.KeepAlive(buffer)call at each use (or drop it, since the buffer is already protected viaentryin the.Call()argument).internal/sandbox/windows_identity_logon_windows.go#L150-L152: swapruntimeKeepAliveUint16(buffer)forruntime.KeepAlive(buffer), or remove the line.internal/sandbox/windows_identity_windows.go#L202-L204: dropdefer func(){_=info}()inensureWindowsSandboxGroup, or replace withdefer runtime.KeepAlive(&info)if you want to keep the intent explicit.internal/sandbox/windows_identity_windows.go#L239: same for theinfodefer inensureWindowsSandboxUser.internal/sandbox/windows_identity_windows.go#L262: same for theentrydefer inaddWindowsSandboxUserToGroup.🤖 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
📒 Files selected for processing (6)
internal/sandbox/windows_acl_apply_windows.gointernal/sandbox/windows_identity_acl.gointernal/sandbox/windows_identity_acl_test.gointernal/sandbox/windows_identity_logon_windows.gointernal/sandbox/windows_identity_windows.gointernal/sandbox/windows_identity_windows_test.go
Zero automated PR reviewVerdict: No blockers found Blockers
Validation
ScopeHead: This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
internal/sandbox/windows_command_runner_windows.go (2)
84-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winGive 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
ensureWindowsUnelevatedSetupmessage 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 valueHoist the principal lookup above the restricted-token SID computation.
capabilitySIDs,offlineSID,tokenSIDs, andwriteRestrictedare all computed unconditionally and discarded on the principal path. Moving thewindowsSandboxPrincipalTokencall 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 valueConsider 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
📒 Files selected for processing (4)
internal/sandbox/windows_command_runner_windows.gointernal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_secret_windows.gointernal/sandbox/windows_identity_secret_windows_test.go
|
Validation update: the provisioning chain has now been run for real, elevated, on Windows 11. and the objects it created were really there, confirmed independently afterwards: 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. Keeping this a draft until the logon half is exercised too. |
|
Setup is wired now, so the feature is reachable end to end rather than inert.
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 How to exercise it, on a machine where creating local accounts is acceptable: 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. |
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
internal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_runtime_windows_test.gointernal/sandbox/windows_setup_windows.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/sandbox/windows_identity_runtime_windows.go
|
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: 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 Actionable error: taken. The message now names 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. 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: |
gnanam1990
left a comment
There was a problem hiding this comment.
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.
|
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 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 Nothing on this branch changed for any of that. Once #809 and #810 land I will rebase this one. |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (3)
internal/sandbox/windows_identity_runtime_windows_test.go (1)
11-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winTable is still not hermetic.
The
"absent"case falls through toos.Getenv, so this test fails on any machine that actually hasZERO_WINDOWS_SANDBOX_IDENTITY=1exported — precisely the machines doing the elevated validation runs for this PR. Addt.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 valueStill assumes every ACE is an
ACCESS_ALLOWED_ACE.
GetAcereturns a genericACE_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 onace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPEand 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 winPath traversal via
ProtectedMetadataNamesstill unaddressed.
filepath.Join(cleaned, name)accepts../separator-bearing values, so a malformedProtectedMetadataNamesentry can materialize a deny ACE outsideroot.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.gocovering 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 winUse
runtime.KeepAliveinstead of a deferred no-op.
defer func() { _ = info }()does keepinfoalive (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
📒 Files selected for processing (13)
internal/sandbox/windows_acl_apply_windows.gointernal/sandbox/windows_command_runner_windows.gointernal/sandbox/windows_identity_acl.gointernal/sandbox/windows_identity_acl_test.gointernal/sandbox/windows_identity_dpapi_windows.gointernal/sandbox/windows_identity_logon_windows.gointernal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_runtime_windows_test.gointernal/sandbox/windows_identity_secret_windows.gointernal/sandbox/windows_identity_secret_windows_test.gointernal/sandbox/windows_identity_windows.gointernal/sandbox/windows_identity_windows_test.gointernal/sandbox/windows_setup_windows.go
|
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: 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: The stale comment. Fixed, it is 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. 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 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 The two things you verified that I could not, the cross-compiled vet and |
|
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. Fixed in e33dce0. 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.
On the |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
internal/sandbox/windows_identity_logon_windows.gointernal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_windows.gointernal/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
|
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. Fixed in fbe340b:
One thing I did not want to take on trust. Treating "this account holds no rights" as success depends on
|
gnanam1990
left a comment
There was a problem hiding this comment.
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.
|
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. 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 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 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
|
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
internal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_windows.gointernal/sandbox/windows_identity_windows_test.go
|
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 Fixed in 832f53a: the path is resolved from the account name before anything can fail, and removal is unconditional rather than gated on 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.
|
gnanam1990
left a comment
There was a problem hiding this comment.
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.
832f53a to
99fefdc
Compare
anandh8x
left a comment
There was a problem hiding this comment.
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 whycredentialDenyReadPathsis 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
LogonUsercan'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 setupconverges instead of accumulating accounts. Password is reset on re-provisioning so the stored secret stays in step with the account. - Squat protection.
windowsSandboxUserIsManagedreads 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_PROTECTEDso 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 testTestStoredSecretDACLNamesOnlyOwnerAndSystemreads the DACL back and fails if any other trustee appears; another assertsSE_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.
provisionWindowsSandboxPrincipalForSetupcomputessecretPathearly (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"), andsetupWindowsSandboxPrincipalcallsremovePrincipal()on ACL-plan failure, which removes secret → logon rights → account in that order. - Logon rights are least-privilege. Only
SeBatchLogonRightgranted; interactive, network, remote-interactive, and service logon explicitly denied.LogonUserpinned 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 forGOOS=windowsclean;GOOS=windows go test -ctype-checks the full Windows surface includingnetapi32procs andUSER_INFO_1layout.
Verification performed
GOOS=windows go vet ./internal/sandbox/...— cleanGOOS=windows go test -c— compiles (type-checks all Windows-specific code)go build ./internal/sandbox/...(Linux) — cleango test ./internal/sandbox/(Linux, from non-/tmppath) — pass, all 14 tests greengo 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)
- The logon half is unproven.
NetUserAdd,LsaAddAccountRights,LogonUserneed 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 behindZERO_WINDOWS_IDENTITY_PROVISION_TEST=1plus an elevation check. - Creating real local accounts is user-visible. AV/EDR commonly flag
NetUserAdd; enterprise policy often blocks local account creation; accounts appear innet userand 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.
…dbox exec Four of the five findings were real. Taking them in order of what they cost. Cross-user principal collision (P1). The account name was keyed on the workspace alone while its DPAPI secret and ACL ledger both live under the invoking user's sandbox home, so two Windows users sharing one workspace derived the same account with private bookkeeping. The second user found no ledger of their own and either retired the first user's working principal or adopted it and rotated its password while storing only their own secret; either way the first user's marker still validated and their next command could not log on. windowsSandboxPrincipalKey now folds the invoking user's SID into the key. The setup LOCK deliberately keeps the workspace-only key: two users setting up one shared workspace still write DACLs on the same paths, so they must keep serializing even though they now provision separate accounts. Splitting the two keys is the point, and there is a test asserting they differ. Ledger not restored on rollback (P1). The narrowed ledger is written only after everything succeeds, but the rollback closure restored the old ACEs and left it narrowed. A later setup or teardown reads the ledger to decide what to revoke, so those restored paths would never be revisited: access outside the current policy held by an account nothing knows to clean up. The closure now writes back the union recorded before any DACL changed. Over-recording is the safe direction. sandbox exec skipped the production planning path (P2). It built a SandboxManager directly, so it never reached prepareSandboxRuntime and ran without the runtime write root that a real sandboxed command gets. It now goes through Engine.BuildCommandPlan. This matters beyond tidiness: the command exists to prove enforcement, and it was proving a path no tool call takes. Plan resources leaked (P2). No plan.Cleanup(), so every invocation left a /tmp/zero-sandbox-report-* behind on Linux. Deferred immediately after the build. The `--` separator (P2). The reported case, `exec -- cmd --help`, already worked: the loop hits the separator first and returns. The real break was the tolerated separator-less form, where `exec mycmd --help` printed Zero's help and never ran mycmd. The separator is now located before any help flag is interpreted, and help is only read from the wrapper's own arguments. Two existing ledger tests seeded state under a username derived from the workspace key and had to move to the principal key. Worth noting for anyone running this branch: an account provisioned before this commit will not be found by the new key, and setup will retire it as unrecorded. That is correct behaviour for an unmerged branch with no released accounts, but it is not a no-op locally.
Two failures on the elevated setup path, both reported by anandh8x. The first is caller identity. windowsSandboxPrincipalKey sampled the CURRENT process token, and elevated setup is not the caller's process: under over-the-shoulder UAC or `runas` it belongs to whichever administrator typed the credentials. Setup therefore provisioned an account, a secret, an ACL ledger and a set of grants named after that administrator, while every later command derived the ordinary user's key, found nothing, and fell back to the restricted token. The admin-keyed leftovers were unreachable and nothing would ever reclaim them. The caller's SID now crosses the UAC boundary in the setup args, exactly as the principal opt-in already does and for exactly the same reason, and the key is derived from it. That fixes everything merely NAMED after the invoking user. It cannot fix the secret. CryptProtectData derives its key from the user that stores the blob, so a password sealed by another administrator could not be unsealed by the caller no matter how the account were named. Provisioning is refused up front in that case, before an account or a grant exists, with a message that says how to clear it. The refusal is scoped to the principal opt-in: the default restricted-token sandbox stores no secret and works fine across that boundary. The second is the opt-out path. Retiring an existing principal could fail, and setup printed the error and carried on to write an opted-out marker and exit 0. Teardown being idempotent was the argument for continuing, but it is the reason this must fail instead: a machine with nothing to retire passes straight through, so an error here means a real account, secret, logon right, ACE set and ledger are still installed, and the marker now claims none of them exist. Setup returns non-zero and rolls back its ACL work. runWindowsSandboxSetup had no coverage at all, since every step wants an elevated token, a machine-global mutex, real DACLs, the WFP engine or a local account. The external effects are now seams in the idiom the package already used for two of them, and the tests drive the step order and each failure path, including the marker never being written.
Three corrections, the first to my own previous commit. Making the opt-out retirement failure fatal was too broad. removeWindowsSandboxPrincipalForSetup deliberately continues past a secret file owned by another administrator's setup, reporting it at the end through errors.Join, so that one unlinkable file cannot strand the account, its logon rights and its ACEs. The account is already gone when that error is returned. Treating it as fatal made `zero sandbox setup` fail permanently on such a machine and rolled back its ACL work, taking the DEFAULT restricted-token sandbox down over inert residue. The rule is now the narrower one that was always meant: fatal when the ACCOUNT survived, because that is exactly what makes an opted-out marker a lie. Asked of the account rather than inferred from the error, since the error is a join whose parts are not separable at the call site, and the lookup fails closed so an undeterminable state counts as still installed. A retirement that left something inert behind still completes, and still names what it left. Second, assertWindowsSetupRunsAsCaller cannot fire on any path Zero takes, and said otherwise. Zero never elevates the helper: runSandboxSetupHelper is a plain exec.Command and runWindowsSandboxSetup refuses unless already elevated, so the operator supplies elevation by opening an elevated terminal and BOTH halves run there. The caller SID is resolved from the process that becomes the helper, so the two are equal by construction. The guard is kept, because it is a correctness assertion that becomes load-bearing the moment a ShellExecute "runas" path is added and already covers a helper invoked directly, but its doc now says plainly what it does not do. The residual hazard has a different shape and is loud: elevating as another administrator provisions into that account's sandbox home, and the operator's own session finds no marker and is told to run setup. Third, parseSandboxExecArgs claimed to scan for the separator up front. Every branch of its loop body returned, so it only ever examined index 0 and the scan did not exist. Rewritten as the straight-line decision it actually was, which is also the rule we want: only the first token is the wrapper's. sandbox exec also built its engine without SensitiveEnvKeys, so a key named by apiKeyEnv in the user's config was scrubbed for every sandboxed tool call and handed to the one command whose purpose is to reproduce that environment. Tests: the opt-out branch now covers both sides of the rule, and parseSandboxExecArgs gets the coverage it never had, including the reported `-- cmd --help` case and an invariant that the returned command is always a trailing slice of the input.
…reate Reported by anandh8x as P2 #3. ensureWindowsSandboxGroup treated NERR_GroupExists and ERROR_ALIAS_EXISTS as plain success, so any local group that happened to carry our name was adopted. Its members, and every grant already keyed to it, silently became part of the sandbox's identity. A name is not proof of provenance, which is the same reasoning windowsSandboxUserIsManaged already applies to an ACCOUNT of our name. The group half was missing. An unprivileged user cannot create a local group, but an administrator, an installer or an earlier build can, and the principal would then inherit whatever it grants. An existing group is now adopted only when it carries the managed comment, read back with NetLocalGroupGetInfo. Anything else is refused by name rather than adopted, renamed around or deleted: removing somebody else's group would be destructive, and provisioning into it is the hole being closed. The decision is split from the syscall into resolveWindowsSandboxGroupAdd so it can be tested without Administrator and without leaving a real local group on the machine running the suite. Tests cover both "already exists" statuses, refusal for a foreign group, adoption of our own so re-running setup still converges, no ownership probe when we just created it ourselves, an unreadable probe surfacing rather than being guessed either way, and a real API failure still failing. Also rebased onto main, 17 commits behind, under the fresh-base rule. All 51 commits replayed with no conflicts.
Reported by jatmn. The principal path appended the account's own SID to the restricting-SID list before building a WRITE_RESTRICTED token. That token allows a write only when BOTH the normal token and the restricting list allow it. The account SID is already enabled in the normal token, so listing it as a restricting SID makes the second check a formality for anything granted to that account: every path carrying a direct principal ACE passes both halves and is writable wherever it sits. The principal's own profile directory, which Windows creates on first logon with exactly such an ACE, is outside every configured write root and was writable for that reason. It is the same defect as the World SID in the restricted list (#865), with the account SID in place of Everyone. A SID already carried by the normal token cannot also restrict it. The previous reasoning, recorded in the comment this replaces, was that the ACL plan grants the workspace to the principal SID so removing it would jail the principal out of its own tree. That is not so, because setup applies BuildWindowsACLPlan on every path, not only the restricted-token one, so each configured write root already carries a capability ACE as well as the principal ACE. Confining to the capability SIDs therefore restores the intersection the jail is supposed to be: a write root satisfies the normal token through its principal ACE and the restriction through its capability ACE, while a path holding only a principal ACE now fails the restricted check. The account SID is passed to windowsPrincipalJailSIDs and excluded there rather than simply not passed. Naming it makes the exclusion the function's contract instead of an omission a later edit could undo silently, and it also strips the SID should it ever arrive through the capability list, which is the route the World SID took. On the test jatmn asked for: the existing jail test grants WinBuiltinGuestsSid, a GROUP, so it exercised a configuration the product never ships and could not have caught this. The new test plants the account SID INSIDE the capability list and asserts it is gone, which is deliberately falsifiable: a list that never contained it would pass against any implementation, including one that appends the SID straight back. Verified by mutation, disabling the filter fails it with the reported symptom. A second test pins that the capability SIDs survive, so the fix cannot degenerate into jailing the principal out of its workspace. Still outstanding from the same review and not addressed here: the runtime-root fallback that hands setup and each command runner a different directory, and the elevated end-to-end evidence.
… per process Reported by jatmn. sandboxRuntimeRootFor falls back to a private tree when the cache-derived runtime root would land inside the workspace, and that fallback called os.MkdirTemp and cached the answer in a process-global map. Separate processes therefore got separate answers. Elevated setup granted the sandbox principal write access to the directory it created, and every later __windows-command-runner process created a different one and pointed TMP, GOCACHE, npm and the rest at it. Those directories are made by the calling user and carry no ACE for the principal, so ordinary cache and temp writes failed with a bare ACCESS_DENIED and nothing naming the sandbox. sandboxRuntimeRootFor already documented that both callers must agree exactly; the fallback was the branch where that could not hold. It now hashes the workspace under os.TempDir, the same shape as the cache-derived root, so every process reaches the same path with no shared state. When even that lands inside the workspace it returns an error rather than picking somewhere arbitrary: a runtime tree governed by the workspace's own policy makes the sandbox's cache writes indistinguishable from the work it is confining. Two consequences worth naming. It creates nothing now, so the split between deterministicSandboxRuntimeRoot and the resolver is no longer about avoiding a side effect. The comments that justified that split on those grounds were rewritten rather than left describing behaviour the code no longer has. Teardown can name the fallback tree for the first time. windowsPrincipalTeardown Paths used the deterministic resolver precisely because the fallback was random and unnameable, which meant an opted-out machine kept principal ACEs on whatever tree the fallback had produced. It now goes through the shared resolver and revokes what commands actually used. Tests pin the property the defect turned on: a repeated call, which is what a second process looks like with no shared state, must return the same root. The old implementation could not have passed that, since only the in-process map made repeat calls agree. Also pinned: per-workspace separation, that naming the tree creates nothing so teardown leaves no directory behind, and that the root stays outside the workspace.
probe-inside.txt is a leftover from the elevated end-to-end probe and was never meant to be committed. A `git add -A` in the write-jail commit swept it in. It is what the automated review's diff-hygiene check was failing on: the file carries trailing whitespace, so `git diff --check` reported a blocker while tests, build and smoke all passed.
A fresh setup marker rejected the very command it had just been written for. Engine.run augments the profile with the selected runtime root before the Windows runner sees it, but setup fingerprinted the bare profile, so the extra write root changed the ACL plan hash and every command on a restricted filesystem failed with "permission roots or deny lists changed". The runtime root also reached the principal ACL plan only. A principal command runs on a WRITE_RESTRICTED token, where a write needs the normal check and the restricting-SID check to both pass, so a root carrying just the account ACE was still unwritable once the marker agreed. Derive the runtime candidates once and present them on both sides of the setup protocol. Setup covers the cache-derived root and the temp-derived fallback rather than whichever one it happened to select, since selection is per process and a command that fell back would otherwise land on an unprovisioned tree. Both are pure functions of the workspace root, so setup can cover the set and a later process can select from it. Regressions fail without this: the marker one reproduces the exact rejection, the capability-plan one shows the missing restricting-SID grant.
The aliasing test appended a sentinel to the returned jail and then ranged over the caller's slice looking for it. That can never fail: append returns a new header, so the caller's length never grows and the range never reaches the slot the write landed in. It passed against an implementation that returns the caller's slice verbatim, which is the exact thing it claims to rule out. Lint noticed the symptom as an ineffectual assignment. Assert through the backing array instead: element storage must not be shared, and an append must not write into the caller's spare capacity. Both fail against an aliasing implementation. Also drop windowsSandboxDeterministicRuntimeRootPath, which lost its last caller to the shared runtime-candidate helper and duplicated its derivation.
Elevated setup stopped working entirely: zero-windows-sandbox-setup.exe: windows ACL target does not exist: ...\AppData\Local\Temp\zero\runtime\v1\67b1b01412f588b9 Putting both runtime candidates into the capability plan added a write root that nothing creates. The capability plan deliberately refuses to materialize a write root, since an absent path is a typo or a stale config and inventing the tree would grant write on a directory nobody asked for, so the whole run fails on a path that is merely missing. Only the selected root was ever created, and only on the principal path. Create every candidate on the setup side, before the plan that grants them is built. The regression walks the plan and fails on any granted write root that does not exist, so the two halves cannot drift apart again: today one function chooses the candidates and another creates them, and nothing else couples them. Found by the elevated end-to-end run, which is the only thing that executes this path. No unit test reached it and CI does not run it.
"permission roots or deny lists changed" told an operator that every sandboxed command would now refuse to run, and nothing else. Not which side is stale, not by how much, not even whether the marker belongs to this workspace. Debugging it meant reading the source and guessing, which is what happened. Report the marker path, both entry counts and both hashes. The counts separate the two shapes this takes: equal counts mean the same roots spelled differently, unequal counts mean one side has roots the other has never heard of.
Every sandboxed command failed marker validation with two plans of the same size and different hashes: marker ...\windows-setup.json has 12 entries, hash 76fda2032e66; this command expects 12 entries, hash 4be1dbf8b642 The temp-derived runtime candidate reads os.TempDir(), and the sandbox points TMPDIR, TMP and TEMP at its own runtime temp for everything it launches. The command runner inherits that env, so when it derived the candidate set it produced a root under the runtime tree while setup, whose TEMP is untouched, produced one under the real temp. Same count, different path, and no command could run. A fingerprint both halves compare cannot be a function of the caller's environment. Derive it only where TEMP is still the operator's: the setup args builder, the Windows command plan, and doctor. The runner now takes the profile it is handed, and commandConfig no longer re-derives on behalf of whoever happens to call it. The regression settles the profile first and redirects TEMP afterwards, in that order, so it reproduces the mismatch against the old behaviour.
A profile with DenyRead drops WRITE_RESTRICTED, so Windows runs the restricted-SID check over reads as well as writes. Read roots were granted only to the principal's account SID, and the write jail removes that SID from the restricting set on purpose, so reads passed the normal check and matched nothing on the restricted one. The result was not a narrower sandbox but an unusable one: no read root readable, including the executable the command was trying to start. Mint a read capability SID, grant it on every read root in the capability plan, and carry it in the strict token's restricting set, so the read allow-list and the restriction come from one value instead of two that can drift. Deny it on every DenyRead path too. The read roots begin at the filesystem root, so without that the carveouts stay readable through the new grant and the deny list stops meaning anything, which is the whole reason the strict token is chosen. The no-write-roots case keeps its ReadOnly deny as well: both SIDs the token can carry must be denied, not only the newest. Gated on DenyRead, which is what selects the strict token. Elsewhere reads never reach the restricted check and the grant would be ACEs on the filesystem root that buy nothing. Both halves decide from the profile alone, so they cannot disagree about whether the entries exist.
A bulk edit dropped the separator, leaving the drive-relative `C:workspace` where `C:\workspace` was meant. Windows resolves the first well enough that the two halves still agreed; on Linux a backslash is an ordinary character, so the command half derived no runtime candidates at all and the marker carried two the command never saw. The setup half no longer pre-augments either: BuildWindowsSandboxSetupArgs folds the runtime roots in itself, and passing them in hid that from the one test that covers it.
A linked worktree or submodule has .git as a FILE holding a `gitdir:` pointer, not a directory. The principal plan names .git/config and .git/hooks and materializes both, and the Windows materializer gets there by descending through .git as a directory. A regular file cannot have children, so opted-in elevated setup aborted and the sandbox could not be used in a worktree at all. Zero's own development worktrees are this shape, which is how it went unnoticed. Deny the pointer file itself there instead. That is the stronger protection rather than a fallback: a principal able to rewrite `gitdir:` repoints the repository at a control directory of its choosing, which subsumes editing config or planting a hook. The real control directory sits outside the write root, so nothing is inherited there and no carveout is needed. Decided by an Lstat rather than a lexical guess, since the layout is a property of the checkout. An absent .git keeps the directory-shaped carveouts so they are still created before git first runs.
The elevated secret write resolved its path four separate times: MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then WriteFile by name. The sandbox home belongs to the invoking user, who is the party this sandbox contains, so each resolution was a place to swap a component. A symlink leaf lets an Administrator truncate a file of the caller's choosing and then rewrite its DACL; a junction alone is enough to plant the deterministic secret somewhere the caller controls. Create the leaf relative to a pinned no-follow parent handle, refuse it if it is a reparse point, and apply both the DACL and the bytes to that handle. The name is never resolved again after the create. The payload is now sealed before the file exists, so a failure there leaves nothing on disk rather than an empty file for someone to race. Cleanup on failure stays by name, which is safe in the direction that matters: at worst it misses and leaves a locked-down file, never deletes something it did not create.
CreateProcessAsUser exempts only a restricted version of the caller's own primary token from SE_ASSIGNPRIMARYTOKEN_NAME, which is precisely why the ordinary restricted-token path works while holding nothing special. A principal token comes from LogonUser against a separate local account, so the exemption does not apply and both that privilege and SE_INCREASE_QUOTA_NAME are required. Nothing enabled or checked either, and a token measured on an ordinary unelevated process holds neither, so the failure arrived as a bare "Access is denied" from inside process creation, before the command's executable was ever opened, and read as the command being rejected. Enable them where they are held, since present-but-disabled still fails the access check and that is where an elevated administrator lands, and refuse with the specific names and a way out where they are not. Detected by ENUMERATING the token. AdjustTokenPrivileges reports an unheld privilege by returning success with ERROR_NOT_ALL_ASSIGNED, which this binding does not surface: it returns nil for SeTcbPrivilege on an ordinary process. A check built on its error passed everywhere, which is worse than no check, since it would call the sandbox ready in exactly the case it cannot run. This does not make the principal launchable. It makes the reason legible while the launch mechanism itself is settled.
The child environment starts as the invoking user's, and the deliberate sandbox redirects only replace HOME, the temp variables and the per-tool cache dirs. Everything identifying the account survived, so a command running as the principal read USERPROFILE, APPDATA, LOCALAPPDATA, HOMEDRIVE, HOMEPATH, USERNAME and USERDOMAIN describing the CALLER, whose profile the principal deliberately cannot open. Native tools resolve per-user state through exactly those, so they fail during startup or quietly look somewhere they have no business reading. Point them into the sandbox runtime tree, which is already granted to the principal and already holds its caches, so the paths are writable by construction. Naming the real Windows profile would need LoadUserProfile to have run, and a variable pointing at a directory that does not exist yet is a worse answer than one pointing somewhere usable. Layered under the deliberate redirects rather than over them: sandboxRuntimeEnvironment stays the single owner of HOME, TMPDIR, TMP and TEMP, and a regression pins that so the two cannot drift into setting the same variables from two places. This is the environment half of the finding. Loading the principal's profile and known folders is left until the launch mechanism is settled, since LOGON_WITH_PROFILE would do it as a side effect.
Both unelevated ACL failures told the reader to re-run with `--sandbox forbid`. There is no such option: SandboxPreferenceForbid is an internal engine state with no flag behind it, so acting on it produced an unknown option and left them stuck on the failure they had just been told how to clear. Advice that does not work costs more than none, because finding that out takes the reader's time. Name the real way out instead, the user config key, which is honored from global config only so a cloned repo cannot set it. The elevated-setup remedy beside it was already correct and stays. Reported by jatmn against the same string on #640. It predates this branch, having arrived with the unelevated fallback tier in #427, and the copy on #886 is fixed separately in 1b304e1. Also covers the secret write with the junction regression it was owed: the caller owns the sandbox home, so they can put a reparse point where the secret directory is expected, and the pathname version followed it in an elevated process. The test asserts the refusal names the reparse point and that nothing survives on the far side, since refusing while still creating the file would leave the caller holding it.
verifyWindowsACLTargetNotRedirected asks GetFinalPathNameByHandle where the handle landed, then compared that against canonicalSandboxWorkspaceRoot(path), which runs filepath.EvalSymlinks. That is the same resolution the kernel had just performed, so for a directory symlink the two sides agreed precisely BECAUSE the redirect happened: elevated setup went on to rewrite the DACL of an object outside the workspace while the check reported success. Junctions were rejected, but by accident rather than by design. Go reports a junction as ModeIrregular rather than ModeSymlink, so EvalSymlinks refuses it, the canonicalization falls back to the lexical path, and the mismatch surfaces. That is a property of the standard library's mode bits, not of this guard, and a Go release that resolved mount points would silently disarm the one case it was known to catch. The expected side is now normalized without resolving anything. GetLongPathName expands an 8.3 short name by reading directory entries and does not follow a link to its target, and EqualFold still covers casing, so the two spellings that legitimately name the same object still compare equal. Every failure degrades to the lexically cleaned path, which can only produce a spurious refusal, never a spurious match. A target deliberately spelled through a symlink is now refused. That is the intended direction: the question here is whether the object the handle landed on is the object that was named. On the tests, plainly: the directory-symlink regression is the one that separates the old basis from the new, and it needs Developer Mode or SeCreateSymbolicLinkPrivilege, so it skips on a machine without either and skipped on mine. The junction test beside it passes against both bases and says so in its own comment; it is an invariant test guarding the Go behaviour the old code accidentally depended on, not a regression for this change.
TestACLComparablePathDoesNotResolveAReparsePoint failed on Windows CI while passing locally. The test was wrong, not the code. It asserted that windowsACLComparablePath returns a string equal to the path passed in. GetLongPathName legitimately rewrites that string: a CI runner's temp directory is an 8.3 short name, so expanding RUNNER~1 to runneradmin produces a different string naming exactly the same object, and the assertion failed on the one property the function is supposed to have. Both sides are now normalized before comparison, which states the real property: a path THROUGH the reparse point must not normalize to the target's normalization, or the guard would compare a redirected handle against a redirected expectation and match itself. A second assertion keeps the link component present so a resolution to something else entirely still fails. The test's documented limit is unchanged and still honest: a junction cannot separate the old basis from the new one, because EvalSymlinks does not resolve junctions either. It remains an invariant test.
…#812) * feat(sandbox): give each workspace an offline and an online principal The principal backend stood down whenever the network was denied, which is the default, so opting into it left the restricted-token path doing all the work in normal use. The reason was that network denial is enforced by block filters keyed to the offline-marker SID, and a principal token cannot carry it: LogonUser builds a token from an account's real group memberships, and the marker is a synthetic capability SID. A real local group closes that gap. ZeroSandboxOffline is created by setup, the block filters name its SID alongside the marker, and a principal is denied the network by being a member. Each workspace therefore gets two accounts that differ only in that membership, and the command's network mode selects between them. A group rather than each principal's own SID because principals are per workspace: one filter set covers every offline principal on the machine instead of needing a filter per workspace. Both principals are provisioned together even though a given setup run sees one profile, because setup needs elevation and commands do not. Provisioning lazily would mean an unelevated command discovering it needs an account it cannot create. They also get identical filesystem access, so an approved network command sees the same filesystem as an ordinary one. Two orderings are load bearing. The network plan is now built AFTER provisioning, because the group it keys to is created there; planning first installed filters naming only the marker and left every offline principal with an open network while looking correctly set up. And the role tag sits before the workspace hash in the account name, so truncation at the 20 character limit eats hash characters rather than the tag, which would otherwise collide the two roles onto one account on exactly the workspaces most likely to truncate. Anything that is not an explicit allow maps to the offline principal, so an unrecognised mode loses the network rather than keeping it. The filter identity set is resolved rather than assumed, and stays absent until the group exists, so a machine that never provisions principals computes the same plan as before. That matters because the plan is hashed into the setup marker and re-derived on every command; an identity set that differed between setup and the command path would fail every command as out of date. Cost worth stating: this doubles the sandbox accounts on a machine, to two per workspace. * fix(sandbox): prove ownership before deleting a sandbox account removeWindowsSandboxIdentity is called with a DERIVED name, so it could be pointed at a name that happens to belong to somebody else's local account. Deleting a user is not a recoverable mistake, and the only thing standing between the two cases was the name matching a pattern we generate ourselves. Raised by CodeRabbit against the test fixtures, but the production teardown path had the same hazard, so the guard belongs there rather than in the tests. The ownership check itself now lives on the base branch, which grew the same helper to stop provisioning ADOPTING a squatted account. This applies it to the other end: an account that is not ours is left alone rather than deleted. The gated tests get the protection for free, since their pre-clean goes through the same helper. Also appends the trimmed offline-group SID rather than the raw one. Worth noting the reported consequence does not hold: newWindowsWFPUserCondition canonicalises before converting, so a padded value would have been trimmed before reaching StringToSid. The resolver returns SID.String(), which never carries whitespace, so this is defensive tidying rather than a fix. * fix(sandbox): assert the filters cover principals, and report a retained account Two follow-ups from review, both on the same theme: a control that quietly does nothing looks identical to one that works. The network plan must be built AFTER provisioning, because provisioning creates the group the block filters name. Built first, the filters name only the offline marker and every offline principal has an open network while setup reports success. That ordering is invisible at the call site, so setup now checks the plan actually names the offline group before installing anything and refuses if it does not. A later refactor that moves the plan build back fails loudly instead of producing a security control that enforces nothing. The predicate is separate so it can be asserted directly: a plan carrying only the marker must read as uncovered, one carrying the group as covered, case differences must not read as missing, and a host with no group provisioned has no principal to miss and must not be refused. Making coverage always report true fails that test. Removal also reported plain success when it declined to delete an account Zero did not create. Leaving it alone is right, but telling an operator cleanup completed when a name they may care about was deliberately retained is not. That case is now a distinguishable sentinel, and teardown treats it as success, since "no principal of ours under this name" is the goal state either way. * fix(sandbox): spare adopted principals when dual-role setup rolls back Provisioning already declined to delete an account it had adopted, and the outer setup rollback then appended an unconditional removal for every role that got that far. With two roles that is the common case rather than an unlucky one: the offline role usually succeeds, so a failure in the online role or in ACL application destroyed a principal that was working before the run started. The removal closure is now only appended for a principal this run created. Threads the workspace key into the delete path as well. Ownership was proven from the account comment alone, which on a name collision belongs to a DIFFERENT workspace, so deleting it would have been the same unrecoverable mistake the check exists to prevent. Policy DenyWrite now reaches the principal ACL plan here too, matching the single-principal path. Fixes the mode-independence test, which required exactly one identity SID and so failed on any Windows host that already had ZeroSandboxOffline, where the plan legitimately carries two. CI never saw it because the Linux and macOS jobs leave the hook nil and a fresh Windows runner has no group. The hook is now pinned, and the test additionally asserts the property it is named for in the group-present case, including that the infra hash changes when the group appears, which is the cross-workspace coupling raised for a maintainer decision. * test(sandbox): stub the password reset and pin the resolved group SIDs Two review points on the tests added in the previous commit. The provisioning stub left resetWindowsSandboxUserPassword as a real call. Nothing under test reaches it any more, because rotation moved to the caller, but a test that resets a real managed account's password if the code ever moves back is not a risk worth carrying. It is now stubbed to fail the test instead, which also states the contract. The group-present assertion checked only that two identity SIDs were present. A duplicated offline marker or an unrelated SID would satisfy that while meaning something quite different, so it now pins both positions. Also drops a duplicated stub assignment left by the rebase. * fix(sandbox): refuse an offline group zero does not own ensureWindowsLocalGroup accepted NERR_GroupExists and ERROR_ALIAS_EXISTS as success without inspecting the group it was about to reuse. Setup then resolved that group's SID and installed it on the persistent WFP deny filters, and made the sandbox principal a member of it. If anything else on the machine already owns a group named ZeroSandboxOffline — another tool, a policy, a prior unrelated convention — that is not a no-op. Every existing member abruptly loses outbound access, because the filters now name their group. In the other direction the sandbox principal inherits whatever permissions that group carries, which is the opposite of what an offline principal is for. The add now reports its raw status and the already-exists branch verifies the group carries this setup's managed marker before adopting it, failing with an actionable message otherwise. A lookup error fails closed rather than adopting. NetLocalGroupAdd and the ownership lookup sit behind seams so the branch is reachable in tests without an elevated machine; the marker compared is the group's own, so the principals group and the offline group cannot be confused. Reported by jatmn on #812. * fix(sandbox): recheck offline group membership before minting a token Network denial does not follow from picking the offline account. The WFP block filters match the offline GROUP'S SID, and LogonUser builds a token from the account's real memberships — so membership is the whole enforcement, and the command path never revalidated it. An account that drifts out of ZeroSandboxOffline through local policy, an administrator, or a re-setup that could not re-add it still resolves, still has its stored secret, and still logs on. Its token no longer satisfies the filter condition, so a NetworkDeny command gets full egress under a profile that asked for none. The stale setup marker keeps the whole path looking healthy. The offline role now confirms the membership its mode depends on before the secret is read, and falls back to the restricted token when it is absent. That direction is deliberate: the restricted token carries the offline marker the same filters match, so egress stays blocked, and only read confinement is lost. A failed lookup surfaces rather than downgrading silently. The online role is not checked, since it is not in that group by design. Reported by jatmn on #812. * fix(sandbox): derive setup's runtime root deterministically or not at all windowsSandboxRuntimeRootPath resolved through sandboxRuntimeRootFor, which falls back to os.MkdirTemp when the user cache lives inside the workspace and memoizes that only in-process. Elevated setup is its own process. It granted the principals an ACE on temp root A; the next command, being a new process, derived temp root B, where the principal has no ACE, and failed ordinary cache writes with a bare ACCESS_DENIED and nothing pointing at the sandbox. Teardown, a third process, cleaned a third directory. The three callers that have to agree exactly could not agree at all. Setup now uses the same side-effect-free derivation teardown already used, and reports no runtime root when that derivation is unusable rather than inventing one. A root only the granting process can name is worse than no root: the principal loses the runtime tree, which is a degraded sandbox, instead of the sandbox appearing provisioned while every command fails. TestTeardownPathDerivationCreatesNothing asserted the opposite — that setup "should still fall back to a usable tree" — so it is inverted here, with the reasoning recorded in the test. That assertion encoded the assumption this finding overturns: a per-process temp tree is not usable. Restoring the fallback fails it with the invented path in the message, and a new TestSetupAndTeardownDeriveTheSameRuntimeRoot pins the ordinary case, so "report none" cannot quietly become the answer everywhere. Reported by jatmn on #812. * style(sandbox): separate the two doc paragraphs the rebase ran together Adapting the ACL-record test to dual roles left #808's fail-open rationale and #812's per-role rationale as one unbroken block. Both are worth keeping; they are two points, not one. * fix(sandbox): fail closed when offline-group coverage cannot be verified The post-provisioning assertion ran inside `if groupErr == nil`, so a failed lookup skipped it and setup carried on to install filters and write a success marker. The comment directly above it says what that costs: a machine reporting a successful setup while every offline principal has an open network. An empty SID was the same hole by a different route. Resolving to ("", nil) means the group does not exist, which is the ordinary state before provisioning and an impossible one after it, and WindowsNetworkPlanCoversPrincipals answers true for an empty SID (correctly, for the pre-provisioning callers that ask it). So the assertion passed vacuously in exactly the case where the group setup had just created was missing. Move the check into assertWindowsNetworkPlanCoversOfflineGroup, which takes the resolver as a parameter and fails closed on every answer that is not a definite yes: lookup error (wrapped, so the Win32 reason still reaches the operator), empty SID, plan omitting the group, and a nil resolver. Setup rolls back and exits 1 on each. Taking the resolver as a parameter is what makes the error paths testable, which is the regression the review asked for. Reported by @anandh8x on #812. * fix(sandbox): scope the offline-group assert to provisioned runs c404ebd made the coverage assert reject an empty group SID, closing the vacuous pass where a missing group counted as covered. It ran the assert unconditionally, and the offline group is only created inside provisionWindowsSandboxIdentity, which runs only under the ZERO_WINDOWS_SANDBOX_IDENTITY opt-in. So on a default machine with principals opted out, the resolver reports ("", nil) exactly as it should, and setup died with "the sandbox offline group does not exist after provisioning" on a path that worked before c404ebd. The empty-SID rejection is correct after provisioning and wrong before it. Pass provisioned to the assert and return early when it is false, gated at the call site on the same windowsSandboxIdentityEnabled check that decides whether principals are provisioned at all. The fail-closed behaviour anandh8x asked for is unchanged whenever provisioning ran. Reported by @jatmn on #812. * fix(sandbox): keep opted-out setup markers valid, and refuse a foreign offline group Two of jatmn's findings on this PR. Existing markers stay compatible (maintainer decision). The offline group is machine-global, and the plan included its SID whenever the group existed. So the first workspace to opt in changed the computed NetworkInfraHash for every OTHER sandbox home on the machine, and those homes rejected their own stored markers until each was re-run from an elevated terminal, having opted into nothing. The inclusion is now gated on THIS home's opt-in rather than on the group existing, so an opted-out home computes exactly the plan it computed before any of this existed. Setup and the command path read the flag from the same environment, so they agree. Opting in after setup does invalidate that home's marker, which is correct: it has no principals yet. Do not install filters for an unowned offline group (P1). The ownership check only ran through principal provisioning, so an opt-out setup reached the resolver and adopted any local alias carrying the name. applyWindowsNetworkPlan turns every SID in the plan into an allowed-to-match WFP descriptor, so a foreign group meant global deny filters against every one of ITS members: anyone with a local group by that name loses the network for those accounts because we ran setup. The resolver now requires the managed comment, and refuses rather than skipping, because a plan whose filters cover no principal while setup reports success is the failure this backend exists to prevent. Three existing tests exercised the group path without the opt-in and now set it. The new test asserts the other direction, that an opted-out home's hash is unchanged when another workspace creates the group, since that is the property the decision turns on. Verified both ways: disabling the gate fails the existing tests, making it unconditional fails the new one. * fix(doctor): report the principal that dual-role setup actually uses jatmn's P2. This branch made the offline principal work under NetworkDeny, but the doctor helper still described the old restricted-token standdown, so `zero doctor` reported active:false and told operators reads were unconfined for a correctly provisioned offline principal, recommending they enable network or drop the opt-in to fix something that was not broken. WindowsSandboxPrincipalInactiveReason is removed rather than reworded. Its only condition was the deny-mode standdown, so after this branch it could never return anything, and a check that cannot fire is worse than no check. What replaced it matters more than what it said. That helper existed to be the SINGLE rule doctor and the runtime both read, precisely so they could not drift, and drift is what happened anyway when dual-role changed the behaviour under one of them. WindowsSandboxPrincipalRoleForNetwork is now that shared rule: windowsSandboxRoleForNetwork delegates to it and doctor calls it, so the reported account and the used account cannot disagree. Doctor now names which principal a command runs as instead of asserting a standdown. One thing the existing tests caught. Routing the shared rule through NormalizeNetworkMode case-folds, so "ALLOW" selected the ONLINE principal where the runtime required an exact match and failed closed to offline. Sharing a rule is only an improvement if it shares the stricter one, so the comparison is exact and a test pins the casing. * fix(sandbox): converge the dual-role branch with the rebased identity work Rebasing #812 onto the new #808 needed real resolution rather than taking a side, and this records what each conflict actually decided. The account key. #808 made the principal key caller-scoped so elevated setup provisions the account the caller will later look for. #812 derived usernames from the workspace key alone. Every username derivation now uses the caller scoped key, including the two inline call sites a blanket substitution missed: the identity lookup in the unrecorded-retire path and the ledger read in windowsPrincipalRevocationPaths. That second one is why teardown could not find a recorded root the current policy no longer named. The setup LOCK stays keyed to the workspace on purpose, because two users setting up one shared workspace still write DACLs on the same paths and must serialize against each other. The network plan stays where #812 put it, after provisioning, because the block filters are keyed to the offline group that provisioning creates. Building it earlier, as #808 does, would install filters naming only the marker and leave every offline principal with an open network while looking correctly set up. Group ownership converged on #812's implementation, not mine. #808 grew a check hardcoded to the users group; #812 already had the general ensureWindowsLocalGroup plus windowsLocalGroupOwnedByZero, which covers both managed groups. The narrower version was removed and its test rewritten against the general seams, so the users group keeps the coverage anandh8x asked for while the offline group keeps its own. Two functions the resolution dropped and the compiler caught: windowsSandboxPrincipalKey and windowsCurrentUserSID. Worth naming because the previous attempt at this convergence lost the same first function silently. Also closes jatmn's remaining findings on this branch. The opt-out installed check now asks about BOTH role accounts rather than one, since retiring one while the other survives is exactly the half-done teardown an opted-out marker must not report as success. And the post-provisioning filter-coverage assert now resolves the offline group through the existing hook rather than the concrete function, so it is stubbable like everything else around it. The SensitiveEnvKeys omission jatmn reported on sandbox_exec.go arrives with the rebase; it was fixed on #808. * fix(sandbox): keep offline coverage and grant the fallback runtime root Two ways the dual-role split left a workspace worse off than it looked. The block filters are machine-global and every setup installs them by deleting and recreating one fixed set, but the plan a home builds names the offline group only when THAT home opted in. So an ordinary opted-out setup for a second workspace replaced the filters without the group SID, and the first workspace's offline principal, still in the group, still passing the runtime membership check and still holding a valid marker, was no longer matched by any filter. A NetworkDeny command there gained egress silently, because the second setup did exactly what it was asked. The gate itself is left alone, because it is load bearing for a different reason: the plan is hashed into each home's marker, and keying it on the group's existence made the first workspace to opt in invalidate every other home's marker on the machine. What a home RECORDS is about its own configuration; what setup INSTALLS is about the machine. Answering both from one plan is what forced a choice between stale markers and a silent hole, so WindowsNetworkPlanForApply answers the second question only, at the apply call site, leaving the fingerprinted plan untouched. Setup also granted the principals an ACE on the cache-derived runtime root alone, and none at all when the cache sat inside the workspace. That was correct when the other branch minted a random per-process directory through MkdirTemp, but fallbackSandboxRuntimeRoot now derives its path by hashing the workspace and creates nothing, so every process agrees on it. Commands in that layout therefore DO select it and redirect TMP, GOCACHE and the package caches into it, against a tree neither principal could write. Setup now grants the same candidate set the capability plan already covers, and creates each one, since applyWindowsACLPlan fails on a target that does not exist. Reverting either fix fails its regression: the opted-out plan installs filters naming only the marker SID, and setup grants nothing while commands write to Temp\zero\runtime\v1\<hash>. Two existing assertions had to be inverted rather than adapted, and both were asserting the old bug. One required setup to report NO runtime root in the cache-inside-workspace layout; the other compared setup's single root for equality against the command's choice. Setup covers the whole candidate set now precisely because that choice is made per process, so the contract is membership. * fix(sandbox): treat an unreadable offline group as a failure, and retire the pre-split principal Three findings from review. A resolution failure in WindowsNetworkPlanForApply returned a marker-only plan, and the machine's WFP filters are replaced wholesale from that plan. So an opted-out setup whose group lookup failed transiently removed the offline group SID another workspace's principals depend on, and that workspace's NetworkDeny commands silently regained egress while its marker and its direct membership check both still passed. The old reasoning was that refusing to install would trade a partial denial for no denial; that is wrong, because the alternative to installing is leaving the existing filters alone. It is fatal now, which is only reachable from an opted-out home since assertWindowsNetworkPlanCoversOfflineGroup is gated on provisioned. Splitting the single principal into offline and online roles changed both account names without changing the marker schema, so an installation made by the previous version kept a valid marker, setup was never re-run, and the runner found neither zero-sbx-d<key> nor zero-sbx-n<key> and fell back to the restricted-token backend with no read confinement. The schema version is bumped so that installation reports as out of date, and a legacy role derives the old untagged name so the ordered retirement can remove the account, its secret, its logon rights, its ACEs and its ledger. It is retired, never provisioned, and there is a test for that because the two lists are one line apart. Doctor reported that commands run as the selected principal whenever the marker validated, but marker validation compares serialized plans and hashes and names no account: deleting the account or its secret, or dropping the offline account out of its group, leaves the marker valid while the runtime falls back or fails. Verifying liveness needs Windows-only queries internal/doctor cannot make, so the claim is narrowed to what the marker actually proves. The role is still reported, since that part is derived rather than assumed.
jatmn's P1 asked for creation AND cleanup to be handle-bound rather than resolved from a pathname. Creation and the materialization unwind both are. The DACL restore was not, and its comment read as though it were. It re-opened the target with a no-follow open, which rules out a reparse point swapped in since apply and nothing else. The other substitution passes it untouched: rename the target aside and put an ordinary directory of the same name in its place. Nothing there is a link, so the open succeeds, the pre-apply DACL lands on the decoy, and the real object keeps the ACEs from the setup that just aborted. The snapshot now records the volume serial and file index of the object it read the DACL from, the restore proves it is writing back to that same object, and a mismatch is refused rather than forced through. Leaving the real object with the aborted setup's ACEs is the safe direction, since the caller is failing anyway. Three things nothing was pinning, all of which could be deleted with a green suite. This repository has already had a fix silently reverted by a later change, so these are worth more than their size. rollbackWindowsACLSnapshots documented its reverse iteration as pinned by TestRollbackUnwindsDescendantsBeforeAncestors. That test did not exist anywhere in the repo; the only match for the name was the sentence claiming it. The ordering is load-bearing twice over, because a materialized directory must be empty before its own removal and SetSecurityInfo propagates inheritable ACEs downward, so the ancestor has to go last. It exists now. The principal ACL rollback restores the ledger alongside the DACLs, and the neighbouring test asserted only the order of the two ACL reverts and never read the ledger. Deleting the restore left the suite green while the paths it put back were unnamed, so cleanup could not find them. windowsSandboxUserIsManaged promises an account carrying the legacy bare comment gets the workspace key stamped on. The probe for the legacy comment was seamed and the upgrade itself was not, so nothing could observe the call. It is seamed now, with the negative case covered too so the assertion cannot be satisfied by an unconditional rewrite.
…e plan Three follow-ups from going back over the review findings. None was raised directly; two are the same shape as things that were. windowsPrincipalPlanFingerprint was the one of three buildWindowsPrincipalACLPlan call sites that did not pass DenyWrite. Apply and teardown both did, so the hash the marker carries described a different plan than the one that actually gets applied, and a change to the policy's deny-write paths moved the applied plan while leaving the marker where it was. It is not a live hole, because the capability ACLPlanHash covers the same paths and moves the marker anyway. That is also exactly what would have kept it invisible until somebody changed the capability plan's shape. The role list was spelled out in three places and two of them are meant to differ, which is why writing them out by hand kept going wrong. windowsSandboxPrincipalIsInstalled asked only the offline and online roles while teardown retires the legacy account too, so a machine still holding the untagged pre-split account was reported clean and the opted-out marker claimed a teardown that had not happened. Provisioning has the opposite constraint: legacy must never appear there or setup would recreate that account on every run of an already-upgraded machine. Both are named now, windowsSandboxLiveRoles and windowsSandboxRetirableRoles, with a test pinning the legacy role into exactly one of them. And the opt-out error said "retire the principal" when a workspace has two plus the legacy one, all of which that re-run retires.
… launch Measured on an ordinary unelevated session: the token holds neither SeAssignPrimaryTokenPrivilege nor SeIncreaseQuotaPrivilege, so the principal launch path can never engage for the process the runner is designed to be called from. Elevated setup does not change that, because the command runs later from the caller rather than from setup. Until now setup succeeded completely in that situation. It created a local account, its password, its logon-right assignments, the workspace ACEs, the recovery ledger and the network filter state, and then every principal-mode command refused before opening its executable. The operator was left with durable machine state serving a backend that cannot run, and nothing said so at the point they could still act on it. The check runs in the caller's own process, which is the one whose privileges decide the answer, and before anything crosses the UAC boundary. It is wired to the same function the launch path uses, so a change to what a launch requires cannot leave setup provisioning for a capability that no longer exists. Three existing tests asserted argument plumbing with the opt-in on and passed only because nothing checked; they stub the preflight now, so they no longer depend on the privileges of whoever runs the suite. This does not give the principal backend a working launch path. That needs a different architecture and is not in this change.
TestTeardownPathDerivationCreatesNothing compared the number of entries in the SHARED temp directory before and after deriving the teardown paths. The assertion it wants is that deriving a path creates nothing, and a count cannot tell creation from removal: that root is also used by every other test binary running at the same time and by the OS, so a concurrent cleanup made the count fall and the failure read "temp directory gained -1 entries". It compares the entry names now and reports anything that APPEARED, which is the question actually being asked and is indifferent to whatever else disappears. The failure also names the entry rather than a delta, so the next person sees what was created instead of a number.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
- [P1] Put secret-parent creation behind the no-follow containment boundary
internal/sandbox/windows_identity_secret_windows.go:138
The new principal-secret path callsos.MkdirAll(filepath.Dir(path), 0o700)beforecreateWindowsSecretFileNoFollowopens and pins that parent.SandboxHomecan be selected through the caller-controlledZERO_WINDOWS_SANDBOX_HOMEoverride. If an untrusted ancestor is a junction/reparse point (or is swapped after setup begins) andwindows-sandboxdoes not yet exist, elevated setup follows the redirect while creating that child directory. The later no-follow open rejects the redirected parent and prevents the secret leaf write, but it happens after the privileged, attacker-directed directory creation and cannot roll back the side effect.
Guidance to stop the repeated class of issues
These repeated issues have the same structural cause: privileged setup operations are split between pathname-based helpers and handle-bound operations. Handle-bound operations can prove which object they mutate; pathname-based operations can follow a user-controlled redirect before the check obtains a handle. A no-follow guard only at the leaf or final mutation is therefore not a complete containment property: earlier parent creation, restore, ledger, or cleanup steps can remain outside the boundary.
Rather than patching this call site in isolation, please consolidate the privileged state-store paths (creation, read/write, restore, and cleanup for secrets, ledgers, markers, and capability state) behind a shared no-follow, handle-relative parent-chain primitive. It should:
- validate each existing component without following a reparse point,
- create each missing component relative to a retained, verified parent handle,
- record identity and which components this attempt actually crated, and
- on later failure, unwind only those proven creations through the same handle-bound basis.
Add a regression test that exercises the full production sequence: an existing junction and a swap at an ancestor where the next subdirectory is missing. It should assert that no object is created beyond the intended root, not only that the final file is not written. This is guidance on the root cause, not a request to redesign the principal model or remove the sandbox-home override.
Preserve the current owner-and-SYSTEM-only secret ACL, DPAPI encryption/entropy, existing-secret replacement behavior, and normal non-reparse sandbox-home setup.
gnanam1990
left a comment
There was a problem hiding this comment.
Verdict: changes requested
Reviewed head a40b50fe1f6dc688137d6975a3484b43dc6f10d6 against base 27b319ca88a3180bed5183f0c599e9307f3ece12.
The privileged Windows secret-store path still performs pathname-based parent creation before entering the no-follow containment boundary. At internal/sandbox/windows_identity_secret_windows.go:138, os.MkdirAll(filepath.Dir(path), ...) can follow a caller-controlled junction/reparse ancestor and create a missing directory outside the intended sandbox home before the later handle-based open rejects it. This is a P1 sandbox-boundary blocker.
Fix prompt
Verify this finding against the current PR head before editing. Fix the complete
privileged state-store path class, not only the final secret-file open.
In internal/sandbox/windows_identity_secret_windows.go and the shared Windows
state-store helpers, remove pathname-based creation, restore, and cleanup before
containment is established. Starting from a trusted root, open every existing
path component without following reparse points, create every missing component
relative to a retained verified parent handle, and retain sufficient object
identity plus a creation record to unwind only objects created by this setup
attempt. Do not use a post-create path check as the security boundary.
Preserve the owner-and-SYSTEM-only secret ACL, DPAPI encryption and entropy,
existing-secret replacement behavior, sandbox-home override support, and normal
non-reparse setup behavior.
Add Windows regression tests for both an existing junction and an ancestor swap
where the next child directory does not yet exist. Assert that no directory or
file is created beyond the intended sandbox root and that failure cleanup cannot
remove a substituted object. Run the focused Windows sandbox tests and Windows
cross-compilation before requesting re-review.
CI is green and Windows cross-compilation succeeds, but the privileged Windows runtime path was not executable on the macOS review host; the blocker is supported by the complete reachable source path and the missing production-sequence tests.
|
These reviews were written against The shared blocker: the
|
| # | finding | where |
|---|---|---|
| 2 | drive-root principal read ACE | 37629a2f skips a volume root before emitting the ACE; the regression builds from the production profile and fails if the guard is removed |
| 3 | setup not serialized | ff077553 adds a Global\ZeroSandboxSetup-<key> mutex around the whole transaction, thread-pinned, Administrators-only DACL, 60s bounded wait |
| 4 | marker does not fingerprint the principal plan | PrincipalPlanHash is a separate marker field, set at windows_setup.go:414 and refused on mismatch at :560 |
| 5 | rollback leaves the narrowed ledger | 09cc534e writes the pre-change ledger back alongside the DACL restore |
| 6 | opt-out does not retire | 84709257, extended by d9b72ba9 to cover both roles |
| 7 | doctor does not surface inactive principal execution | d9b72ba9; dual-role provisioning means eligibility no longer stands down under network-deny |
| 8 | cross-admin cleanup strands the principal | 84709257 classifies a denied unlink as errWindowsSandboxSecretNotOurs and continues |
| 9 | teardown discards revocation errors | revokeErr at windows_identity_runtime_windows.go:560; when set it returns the joined error and skips removeWindowsPrincipalACLLedger, so the ledger survives as the only record of which paths still carry ACEs |
| 10 | legacy comment upgrade unimplemented | upgradeWindowsSandboxUserComment at windows_identity_windows.go:671, wired into adoption at :899 |
| 11 | windowsACLPlanPaths unused |
gone |
| 12 | needs rebasing | merge base moved 8e266797 to 27b319ca; #865 is now an ancestor. One commit behind main, eeea3308, an unrelated TUI change |
@gnanam1990's #865 note, which I cannot close
You flagged that pulling in #865 breaks exec_command on Windows, PowerShell failing crypto init with BCrypt.dll ... 0x8007045A, and that the rebase would turn a clean run into 6/7 through no change of mine. The rebase has happened, so this branch now carries #865.
I cannot verify or refute that here: this box is unelevated and that path needs elevated setup. I am not going to claim it is fine on the strength of a green unit suite, since the tests that would exercise it skip without elevation. What I can say is that it is not introduced by this PR and affects main identically, and that the branch carries follow-ups in the same area (53fb2e8d, c914d762, 51976966) which may or may not change the symptom. If you still have the machine set up, a run against a40b50fe would settle whether this PR makes it better, worse, or neither, and I would rather know before it merges.
All ten checks are green on a40b50fe.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Provide a launch path that works from the later unelevated process
internal/sandbox/windows_setup.go:191Failure path:
BuildWindowsSandboxSetupArgsruns the new privilege preflight in the process invoking setup. Zero does not elevate its helper, andrunWindowsSandboxSetuprejects a process that is not already elevated, so an operator has to invoke setup from an elevated terminal. That process may pass the preflight and provision the accounts, secrets, logon rights, ACEs, ledger, filters, and marker. The command that consumes that state is a separate, ordinary Zero invocation.runWindowsSandboxCommandthen requiresSeAssignPrimaryTokenPrivilegeandSeIncreaseQuotaPrivilegeagain beforeCreateProcessAsUser; an ordinary token does not hold them, so it exits before opening the requested executable. Conversely, invoking setup from the ordinary process fails the preflight before provisioning. The check prevents unusable durable state, but it does not make the advertised setup-once/elevated then command/unelevated lifecycle executable.Root cause and required outcome: launch authority is being treated as a property validated during provisioning even though it belongs to a different future process lifetime. Please either provide a supported mechanism through which the ordinary command can launch the principal—such as a separately reviewed bootstrap or authenticated broker—or explicitly keep the runner/provisioning path unavailable until that mechanism exists. The outcome should preserve the separate-account read boundary and should not require users to run every sandboxed command from an elevated Zero process.
-
[P1] Publish rotated secrets as one fail-closed state transition
internal/sandbox/windows_identity_secret_handle_windows.go:70Failure path: for an adopted account, setup resets the account password at
windows_identity_runtime_windows.go:340-347and then opens the live secret withFILE_OVERWRITE_IF. That disposition truncates the only published credential before the protected replacement bytes are complete. Command readers do not share the setup mutex and callos.ReadFileon the live path. A reader in this interval can observe zero or partial bytes; a crash, ACL failure, disk-full error, or short write can leave the published file unusable after setup exits.readWindowsSandboxSecretdeliberately maps empty and undecryptable blobs toerrWindowsSandboxIdentityUnavailable, and the runner then falls back to the same-user restricted token, whose own warning says it does not confine reads. An explicitly opted-in command can therefore cross from the principal boundary to the weaker backend because setup was interrupted while rotating state.Root cause and required outcome: password rotation, encrypted-secret publication, and command-time backend selection are one logical transaction but are implemented as independent mutations with “invalid” interpreted the same as “never provisioned.” Seal and lock a separate replacement object, publish it atomically only after it is complete, and make a provisioned account with a corrupt/in-progress secret fail closed rather than look absent. Preserve the intentional fallback for a backend that genuinely was never provisioned; the important distinction is durable absence versus corruption or an incomplete transition.
-
[P1] Put every elevated state-store operation behind one containment boundary
internal/sandbox/windows_identity_secret_windows.go:138Failure path: the secret leaf is eventually created relative to a verified parent handle, but setup first calls pathname-based
os.MkdirAllfor that parent.SandboxHomeis caller-selectable, and an unprivileged user can create junctions. If an ancestor is redirected while a component is missing,MkdirAllcan create that component outside the intended state root before the later no-follow open detects and rejects the redirect. After the leaf handle is pinned, the error paths call pathname-basedos.Remove(path); because the parent handle is opened with delete sharing, an ancestor can be renamed and replaced so cleanup resolves to a different same-named file rather than the object setup created. The new principal ledger repeats the pathnameMkdirAll/temp-file/rename/remove protocol, so fixing only the secret leaf does not establish the state-store invariant.Root cause and required outcome: privileged state mutation is split between handle-bound operations that prove object identity and pathname helpers that re-resolve attacker-controlled ancestors. Please centralize secret and ledger creation, atomic replacement, rollback, and deletion behind a shared primitive that opens a trusted anchor without following reparses, walks or creates each component relative to retained handles, records the identities and components created by this attempt, and unwinds only those objects through the same handle-bound basis. Apply the same primitive to companion elevated marker/capability-state operations where they cross this boundary. Preserve the existing DPAPI entropy, owner-and-SYSTEM-only DACL, file formats, and normal non-reparse setup behavior.
-
[P1] Enforce the
.gitreplacement invariant in the capability plan too
internal/sandbox/windows_acl.go:103Failure path: this PR adds
DELETEto the inheritableWindowsACLAllowWritemask so sandbox principals can perform ordinary rename/delete operations. The principal plan also emits a non-inheritedWindowsACLDenyDeleteon.git, because moving that directory aside removes the object-level deny ACEs attached to.git/configand.git/hooks. The defaultBuildWindowsACLPlan, however, emits only the write-root allow and the config/hooks deny entries. Its restricted token therefore satisfies the newly added capability-SID check forDELETEon.git; the caller's normal token supplies the other half. The command can rename.git, create a replacement directory, and create fresh config/hooks that inherit the root allow without the old deny ACEs, restoring control ofcredential.helperandcore.hooksPath.Root cause and required outcome: the security invariant is encoded in one of two parallel ACL-plan producers even though the changed allow mask is shared by both. Please make
.gitreplacement protection a common write-root invariant consumed by both principal and capability plans, or add an equivalent capability-plan guard. Add plan and real-DACL coverage for both backends starting from absent and existing.gitlayouts. PreserveDELETEfor ordinary writable objects and keep Git able to update index, objects, refs, and lock files; the required deny applies only to replacement/takeover of the.gitcontrol directory. -
[P1] Match setup serialization to the scope of the state being mutated
internal/sandbox/windows_setup_lock_windows.go:80Failure path: the new mutex is keyed by workspace and intentionally allows different workspaces to run setup concurrently. Setup nevertheless replaces one fixed machine-global WFP filter set. An opted-out setup can resolve the offline group as absent and produce a marker-only apply plan, then pause. A concurrent opted-in setup for another workspace can create the offline group and install filters covering it. When the first setup resumes,
applyWindowsNetworkPlandeletes the fixed filter keys and recreates them from its stale marker-only plan. The opted-in workspace's marker still validates and its principal still passes the runtime membership check, but no filter matches that principal, so aNetworkDenycommand silently regains egress. Different-workspace setup also performs read-modify-write updates to the sandbox-home-widewindows-cap-sids.json, allowing one setup to overwrite mappings created by the other.Root cause and required outcome: the transaction lock is scoped to the initiating workspace rather than to each shared resource it protects. Keep the per-workspace lock for account/secret/ledger work, but add the appropriate machine-wide critical section around offline-group resolution plus WFP replacement, and a per-sandbox-home atomic merge/lock for the capability registry—or provide equivalent transactional revalidation immediately before commit. The filter plan must not be computed from state that can change before wholesale replacement, and registry updates must preserve mappings added by concurrent workspaces.
-
[P2] Establish an owner-private namespace before using deterministic temp roots
internal/sandbox/runtime_state.go:225Failure path: the fallback changed from an unpredictable, owner-private
os.MkdirTempdirectory to<os.TempDir()>/zero/runtime/v1/<workspace-hash>. On Unix,os.TempDir()is commonly a cross-user namespace.prepareSandboxRuntimeLeaseand runtime creation callMkdirAll,OpenFile, andChmodwithout verifying the owner, mode, file type, or symlink state of the existing hierarchy. The first local user to create/tmp/zerowith mode0700prevents other users from traversing it, so every affected fallback run for those users fails. A precreated same-user redirect can also make cache/temp writes and chmod act on a different tree. Cleanup enumerates and removes sibling roots without first establishing that the parent and candidate are owned runtime objects.Root cause and required outcome: cross-process determinism was solved by making the name predictable, but stable naming does not establish ownership in a shared namespace. Derive or create a per-user private parent, acquire it without following links, verify its owner/mode/type on reuse, and perform leasing and cleanup relative to that trusted parent. Preserve the deterministic final mapping needed for elevated setup and later command processes to agree; do not regress to a process-local random answer without another durable rendezvous mechanism.
-
[P2] Treat a linked worktree's gitdir as required mutable state, or reject it
internal/sandbox/profile.go:150Failure path: when
.gitis a file,gitMetadataWriteCarveoutSpecsprotects only the pointer and assumes the external target needs no access because it is outside the write root. That reasoning holds for preventing inherited writes, but not for running Git as a separate account. The principal receives access only through the explicit write/read roots. A linked worktree's pointer resolves to its per-worktree gitdir outside the workspace, and that directory can in turn reference the shared common Git directory. Git needs those locations for the worktree index/HEAD and shared objects, refs, config, and lock-file updates. With no bounded grant for them, ordinary Git operations fail after setup even though the profile reports the workspace as supported.Root cause and required outcome: the profile models
.gitonly as a protected pathname, while a gitfile is a dependency edge to control state required by the command. Either parse and canonicalize the gitfile/commondir chain and grant the minimum read/write subsets needed for this workspace, with the pointer itself remaining protected, or detect the layout and fail before provisioning/running with an actionable unsupported-layout error. Do not solve this by broadly granting the principal access to the caller's profile or entire parent repository tree. -
[P2] Key failed-revocation recovery to the immutable trustee SID
internal/sandbox/windows_principal_ledger.go:34Failure path: schema v1 stores only the paths on which a principal was granted ACEs. During teardown, ACE revocation can fail; the code deliberately continues through account deletion and retains the ledger so the paths supposedly remain recoverable. Windows does not reuse the deleted account's SID. A later setup recreates the deterministic username with a new SID, reads the old paths, and calls the revoke plan using that new SID. The old raw-SID ACEs do not match and remain on disk; after successful application, the ledger can be rewritten for the new trustee, losing the only record that those paths belong to the retired SID.
Root cause and required outcome: recovery state is keyed by a reusable display name while the ACL authority is the immutable SID. Version the ledger to persist the trustee SID alongside its path set, distinguish a stored retiring SID from the current account SID, and revoke each set using the SID that actually owns those ACEs. Keep failed sets until revocation succeeds. Preserve per-role separation and the current choice to finish deleting a broken account rather than strand it solely to keep its SID resolvable.
-
[P2] Keep the runtime-root test inside test-owned storage
internal/sandbox/windows_setup_runtime_root_test.go:159Failure path:
runtimeRootTestConfigputs the workspace and sandbox home undert.TempDir, butwindowsSandboxRuntimeCandidatesstill calls the productionsandboxUserCacheDirseam.TestWindowsSandboxSetupProvisionsEveryGrantedWriteRootthen creates every candidate and registersos.RemoveAllcleanup for them. A focused run with only test temp/build caches redirected failed at line 175 while trying to create/home/pi/.cache/zero/runtime/v1/...on a read-only home. On a writable developer or CI account, the same test mutates and removes a path in the real user cache. The workspace hash makes collision unlikely, but it does not make external user state test-owned.Root cause and required outcome: the test isolates the obvious inputs but not the ambient cache producer used by the function under test. Override
sandboxUserCacheDirto return at.TempDirbefore deriving any candidates, restore it witht.Cleanup, and keep all candidate creation/removal below that directory. This should not change production derivation or weaken the assertion that setup materializes every granted write root.
Overall guidance: close the boundary classes before requesting another review
The number of findings is not because the individual fixes are careless. This PR crosses several security and lifecycle boundaries at once: an ordinary caller, an elevated setup process, two future sandbox accounts, persistent secrets and ledgers, ACLs on user-controlled paths, machine-global WFP state, sandbox-home-wide capability state, and a later unelevated command process. Many fixes correctly harden one local operation while a sibling producer, consumer, rollback path, or resource scope continues to use the older contract. That is why the review keeps finding adjacent variants after the cited line is repaired.
Before another review, I recommend one end-to-end boundary audit organized around invariants rather than files or previous comments:
-
Write down the authority timeline. For caller argument construction, elevated setup, later command execution, and the sandbox principal, record which token/SID/privileges each phase actually has. Every transition must either carry a durable capability to the next phase or refuse before creating state. A privilege observed during setup is not evidence that the later command can use it.
-
Inventory state by ownership scope. Label every mutable object as per-command, per-workspace, per-sandbox-home/user, or machine-global: accounts, roles, secrets, ledgers, capability mappings, setup markers, groups, WFP filters, and runtime namespaces. Each lock, read-modify-write, rollback, and cleanup must be scoped at least as broadly as the object. If one setup replaces global state, a workspace-keyed lock alone cannot make that replacement current.
-
Define commit and recovery states for the principal transaction. Treat account password, encrypted secret, logon rights, ACL grants, ledger, group membership, filters, and marker as a state machine. Enumerate a crash or injected error after every mutation and define what a concurrent command may do in each state. “Provisioned but incomplete/corrupt” must not be collapsed into “never provisioned” when that selects a weaker security boundary. Persist the immutable identities needed to recover after account deletion.
-
Use one privileged filesystem primitive. All elevated state creation, replacement, restoration, and deletion beneath caller-influenced paths should start from a verified no-follow anchor and stay handle-relative. The primitive should record object identity and which components this transaction created so rollback cannot follow a new pathname or remove somebody else's object. A later validation can detect an earlier redirected side effect, but it cannot make that side effect safe.
-
Make policy invariants backend-independent. The capability and principal plans are parallel encodings of the same filesystem policy. Express rules such as “
.gitcannot be replaced while its protected children remain denied” once, or enforce parity tests that run both plan producers and real DACL application over the same layouts. Any shared access-mask change should trigger those tests for both backends. -
Model indirections and ambient namespaces explicitly. A gitfile is not just a file-shaped carveout; it points to required state. A deterministic path in a shared temp directory is not owned merely because Zero can predict it. Resolve or reject dependency edges, and establish ownership before trusting stable names.
-
Add lifecycle and adversarial integration cases, not only local unit assertions. At minimum, exercise: elevated setup followed by a fresh unelevated process; a reader during password rotation and a crash after each credential step; concurrent opted-in/opted-out setup for different workspaces sharing a home; global-filter replacement while group state changes; pre-existing and swapped junctions during create and cleanup; fresh, normal, and linked-worktree
.gitlayouts under both backends; failed revocation followed by account recreation; two OS users contending for the fallback temp namespace; and the complete test suite with a read-only real home/cache. Verify the externally visible boundary—actual launch, DACL, egress, cleanup, and absence of off-root mutation—not only the intermediate plan.
A useful completion criterion is that the audit produces one table mapping each invariant to its producer, persisted representation, consumer, lock/transaction scope, rollback/teardown path, and an adversarial test. Resolving that table as a whole should prevent another cycle where a correct local fix exposes the same missing invariant in the next phase.
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 butZERO_WINDOWS_SANDBOX_IDENTITY=1set, commands keep using the restricted same-user token andcredentialDenyReadPathsremains 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.
WindowsACLAllowWritenow includesDELETE.FILE_DELETE_CHILDis deliberately NOT granted: it would let a sandboxed command delete a protected carveout such as.git/configthrough 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
credentialDenyReadPathsopens withif 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~/.awsnames the same account Zero itself runs as, so it would lock Zero out too. The one existing escape hatch is costly: the runner dropsWRITE_RESTRICTEDwhenever 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.
SeBatchLogonRight, and explicitly denies interactive, network, remote-interactive and service logon, so the account cannot be signed into even if its password leaked.LogonUseris pinned to"."so a same-named domain account is never picked up.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.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_PROTECTEDso 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
LogonUsercannot, because it names the account rather than a synthetic capability SID. A principal would therefore have left those block filters matching nothing, anddenyis 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:
NetUserAdd,LsaAddAccountRights,NetUserDelandLogonUserall need administrator rights. They compile and are layout-checked, but nobody has run them. The provisioning round-trip test is gated behindZERO_WINDOWS_IDENTITY_PROVISION_TEST=1plus an elevation check. Account and group creation have since been confirmed on a real elevated session; the logon path has not.TestGrantLogonRightsAndMintPrincipalTokenhas 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 innet userand 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
sandbox execfor running commands through the configured sandbox.