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 110 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, deterministic runtime roots, network coverage checks, Git protections, and the ChangesWindows sandbox security and execution
Priority: ➖ Normal Estimated code review effort: 5 (Critical) | ~120 minutes Severity of issue fixed: Medium Merge Risk: 🟠 High · up to The Windows sandbox changes still carry material security, filesystem, and command-availability risks. In particular, elevated operations may alter unintended paths, ACL planning can create the wrong object type, and valid sandbox commands may be refused. These issues should be resolved before merge. 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Full details: Linked Issues checkExplanation The PR provides substantial provisioning, ACL, secret-storage, and runtime foundations for Resolution Implement and validate a supported command-launch mechanism for the sandbox principal, or another default Windows confinement mechanism. Ensure sandboxed commands cannot read the credential stores listed in Full details: Out of Scope Changes checkExplanation The PR includes changes beyond the linked issue and principal foundation, including the new sandbox CLI command, nested Git initialization policy, signal-handling behavior, and the unrelated exported peermsg directory helper. ✨ 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.
The runtime root is deterministic so setup and later runners agree on one path, and the sandboxed command is granted write access to cache, data and tmp. So it can replace one of them with a symlink or a Windows junction on its way out. Preparation then ran os.MkdirAll and os.Chmod on raw pathnames, which follow, and the HOST Zero process, the ordinary user rather than the confined principal, created package-cache directories inside a target the previous command chose. ensureFallbackRuntimeAnchor proves the per-user anchor and says nothing about the reusable root or anything beneath it, so it never covered this. Preparation now descends from the operator-owned base through retained no-follow handles: NtCreateFile with OBJ_DONT_REPARSE and FILE_OPEN_REPARSE_POINT on Windows, openat and mkdirat with O_NOFOLLOW plus fchmod on the descriptor elsewhere. fchmod rather than chmod is the half that matters off Windows, since chmod follows symlinks. The deterministic naming contract is untouched. Deliberately NOT peermsg.EnsurePrivateDir, which is the obvious patch and the wrong one: it ends in a protected-DACL write that strips the sandbox principal's grant elevated setup installed on the runtime tree, bringing back the bare access denials from npm and go that the grant exists to prevent. That would have passed every unit test on an unprovisioned box. This descent validates and creates; it does not re-secure. Two precisions worth recording. The Windows chmod is dropped rather than ported, because it only toggles READONLY and was measured landing on the junction rather than its target, so it bought nothing. And the exposure on the cache-derived root predates this PR, byte-identical to main; what this PR added was making the fallback root deterministic and persistent too, extending the same shape to it.
The helper tests call ensureRuntimeTreeDirs directly, so reverting prepareSandboxRuntime to the old pathname loop left every one of them green. The defect was in what the entry point called, so it needs a test that goes through the entry point.
rollbackWindowsACLSnapshots branched on createdAnything(), which is an OR across the whole materialization record. When this run creates the parent chain and a racer wins the leaf, that predicate is true while the ACL-bearing file belongs to somebody else: Chain carries Made:true and FileMade is false. The rollback then could not remove the parent, because it was no longer empty, and the unconditional skip meant the existing no-follow, identity-validated restore never ran either. A file that existed before setup started was left wearing an aborted setup's DACL, and the failure was loud about the directory it could not remove while silent about the descriptor it never put back. rollbackWindowsACLMaterialization now reports whether the ACL-BEARING TARGET is among what it removed: the file leaf when FileMade is set, the deepest chain entry otherwise. Anything it cannot prove it removed reports false, which routes the caller to the restore that is already no-follow and TargetID-guarded and refuses on a mismatch rather than forcing. All three conservative properties are unchanged and none needed new code: nothing new is ever deleted, so a raced leaf is still never removed; there is still no pathname fallback; and a rollback that could not remove the parent still says so while now also restoring the leaf. The regression is the mixed-ownership case rather than a wholly created directory becoming non-empty, driven through applyWindowsACLPlan with the leaf created inside the existing swap hook so the race is deterministic.
…y happen The first version of this regression used the anchor-stage swap hook, which fires before the directory chain exists. Creating the parent there made the chain the RACER's, not this run's, so createdAnything() was false, the ordinary restore path ran, and the test passed with the fix reverted. The mixed ownership the finding is about needs the chain to be ours and only the leaf to be theirs, which is reachable at exactly one instant: after makeWindowsACLDirChainNoFollow returns and before createWindowsACLChildFile runs. windowsACLRacedLeafHook makes that instant addressable. Reverting the disposition check now fails it with the deny ACE still on the racer's file.
runSandboxPlannedCommand started the backend wrapper with a bare exec.Command().Run(): no context, no signal forwarding, no shutdown path. A terminal masks that, because terminals signal the whole foreground process group, but a supervisor or task runner sending SIGTERM to the `zero sandbox exec` PID killed only Zero. The wrapper and everything under it kept running, doing filesystem and network work after the caller considered the task cancelled, and the deferred plan cleanup never ran because the process was gone. The CLI's shared shutdown context is threaded in, Cancel kills the tree, and WaitDelay bounds how long a child that ignores the first signal can hold the wrapper open and skip cleanup. DELIBERATELY WITHOUT execution.ConfigureProcessGroup, which the obvious fix would add. Putting the child in its own process group closes this hole and opens a worse one: it severs every kernel-delivered group signal, so a supervisor escalating to a group kill, and a terminal delivering Ctrl+C to its foreground group, would stop reaching the child. That was measured, not assumed: with the group change a group-directed kill left both the child and its grandchild alive, where today it reaps them. Keeping the child in Zero's group leaves group delivery exactly as it is, while the directed-signal case, which reached nothing at all before, now reaches the child. The remaining gap is a grandchild on the directed-signal path, which the group path still covers. Closing that too needs a job object on Windows and a group Zero owns on Unix, which is the trade above and a separate decision. The existing 128+signal mapping for a child that exits by signal is untouched; that is a different direction of signalling.
The Windows Smoke leg failed on my own new tests with "has no operator-owned base to descend from" for a perfectly ordinary tree. runtimeCandidateBase decides ownership with a containment test, and containment runs on spellings. A runner's temp is C:\Users\RUNNER~1\..., an 8.3 short name that compares unequal to the long form of the same directory the cache root resolves to. The same class of mismatch prepareSandboxRuntime already documents for its own comparison. ensureRuntimeTreeDirs now canonicalizes before asking, the same way the rest of the runtime state does. The tests were the other half of it: a bare t.TempDir() only lands under the user cache directory on a machine where temp happens to sit there, which is true on my box and false on the runner. They now pin the cache root themselves, so they are about the descent rather than about where temp lives.
The shutdown comments described a graceful signal followed by escalation while the code did neither. Cancel called KillProcessTree, which is SIGKILL on Unix and taskkill /T /F on Windows, and WaitDelay only starts counting AFTER Cancel returns, so it could never postpone a kill that had already happened. A directed SIGTERM reached the child as 137 rather than 143, with no chance to flush output, drop a lock file, or run a shutdown handler. The comment described a policy the code did not implement. Cancel now calls execution.TerminateProcessTree, the two-phase policy the background package already uses: SIGTERM, poll for the grace, then SIGKILL what is still alive. On Windows that resolves to the single-phase kill, which is correct rather than lazy, since there is no signal to send a child owning no console. WaitDelay becomes a backstop for the case where the tree is gone but Wait is still blocked on a pipe a grandchild holds open. The process-group tradeoff from the previous round is untouched: the child stays in Zero's group, so terminal and supervisor group delivery is unchanged. Regressions run a child that traps the signal and records that its handler ran, and a stubborn child that ignores it and must still die after the bound rather than before it. Verified on real Linux rather than only cross-compiled, since the graceful phase does not exist on Windows and a Windows-only run would prove nothing about it.
firstSubcommand skips words beginning with "-" but not the value that follows
one, so the network classifier read the wrong word:
git clone https://x network=true
git -C sub clone https://x network=FALSE
git -c user.name=x clone https://x network=FALSE
Both spellings are ones an ordinary user types by habit, and both walked
straight through the network gate. "--opt=value" happened to survive because it
is a single token.
git now gets a subcommand parser that knows which of its global options consume
the next word. The keys are lowercase because the analyzer lowercases every
word before this point, which also means -C and -c arrive identically; that is
fine, since both consume a value and nothing here needs to tell them apart.
Found while building the nested-repository check, which needs to recognise
"git init" through the same options. Reported separately rather than folded in,
because it is a live gate bypass rather than part of that finding.
The regression drives AnalyzeCommand rather than the helper, since the gate
reads the analysis and a helper-level assertion would not have caught this. It
keeps local-only spellings classified local, so the fix cannot be "call
everything network".
A workspace governed by an ancestor repository gets no git carveouts at all. gitMetadataWriteCarveoutSpecs returns nothing for it, on every backend, and that is deliberate: naming <root>/.git/config and <root>/.git/hooks makes the Windows plan create, and the bubblewrap helper mount, a control directory that competes with the ancestor's for git's discovery walk inside a repository Zero does not own. So there is no protection here to extend to a repository created during the command. It lands under the plain workspace write grant with nothing denying credential.helper or core.hooksPath, and on Windows nothing denying DELETE on .git either. The serialized plan never changed, so the cached setup marker stays valid and the next run does not notice. Refuse it instead. The protection would have to be established at a moment the sandbox is no longer at, and reporting success while the guarantee is absent is worse than refusing. The condition is the workspace, not the directory the command names. Resolving that would mean tracking -C and cwd through the script, which is the same option-parsing surface that let "git -C sub clone" past the network gate. Refusing a git init aimed elsewhere too is the conservative side of that trade, and the reason text says so. The detection shares gitSubcommand with the network gate, so those global options cannot bypass this gate either, and it covers init-db. The refusal sits ahead of the persistent grant, session grant and allow-permission returns in Evaluate: a broad approval to run shell commands is not an approval to create an unprotectable repository.
The grace tests proved a handler ran and that escalation waited out the bound, but never looked at the status, which is the only part of this a caller can see and the part the report was about: 137 rather than 143. An untrapped child now has to come back as 128+SIGTERM. That is the assertion the other two cannot make between them, since one traps the signal and the other ignores it, so a first phase that was still SIGKILL would leave both passing. The graceful child exits 42 from its handler and the status has to be 42, and the stubborn child's has to be 128+SIGKILL so "ask nicely and give up" cannot pass either. Plan cleanup is ordered after termination by a defer in runSandboxExec wrapped around this call, which holds only while nothing survives the return. The stubborn child records its pid and the run has to leave it reaped, so a refactor that lets Wait come back with the tree still up fails here rather than deleting the policy-report file underneath a live command.
…llow The secret file was created relative to a pinned parent handle, but the parent CHAIN was still made with os.MkdirAll. That walks a pathname, so a junction planted above a component that does not exist yet sent an elevated create to the other side of it, and the no-follow open that followed then found an ordinary directory at the requested name because the redirection had already happened one level up. The runtime-root candidates had the same create, under the invoking user's own cache tree. Both now use makeWindowsACLDirChainNoFollow, which anchors on the deepest existing ancestor and creates each component relative to the handle above it, and both unwind exactly what they created when a later step fails. The secret leaf is created relative to the chain's own deepest handle rather than reopening its parent by name, so the whole operation resolves the path once.
Main and this branch fixed the linked-worktree .git pointer independently. The branch version carries the shape each carveout needs, which the Windows ACL plan uses to materialize .git/config as a file, so it is the one that survives the rebase. What it lacked was the seam main's worktree tests drive: an lstat that fails is a real state with its own required answer and no filesystem it can be staged on. Threaded through the spec function so both entry points share one implementation.
The rebase kept this branch version of the carveout set, which took the pointer branch for anything that was not a directory. Main pins the narrower rule and its tests caught the difference on every platform: a symlink named .git, dangling or not, and a special entry such as a fifo are not the gitdir pointer git writes for a linked worktree, so they keep the legacy child carveouts rather than being handed single-object protection for not being a directory. Also picks the runtime-root candidate by excluding the shared fallback rather than by path prefix. A CI runner spells one directory two ways, so os.UserCacheDir and t.TempDir disagreed on a path they both meant and the new junction test failed its own setup check there while passing here.
Everything between creating the secret leaf and writing it is handle-relative and no-follow. The failure path was not: it called os.Remove on the pathname, which is the one resolution the rest of the function exists to avoid, and it did not even work. The leaf is created with FILE_SHARE_READ alone, so a delete by name while that handle is open is a sharing violation. The error was discarded and the directory unwind did not cover the file, so a failed provisioning left an empty secret behind that a later run would read as a stale password. The handle is closed first and the leaf is then deleted relative to the pinned parent, the way rollbackWindowsACLMaterialization already removes what it created. Closing first rather than widening the share mask keeps the secret unshareable for delete while it exists, which is the narrower of the two ways to make this work. A cleanup that still cannot finish is joined into the returned error rather than dropped. Two seams for the failure paths, since neither is reachable otherwise: the DACL step, and the delete. My first attempt at the second held a real second handle on the leaf to block the delete, and it blocked nothing, so the test passed while proving the opposite of what it claimed.
|
Fixed at a2e9558, rebased onto current The P3. You were right, and the mechanism was worse than a missing flag: I reverted the fix to check, and with Took your first option. The leaf handle is closed and the file is then deleted relative to the pinned Two seams, since neither failure is otherwise reachable: the DACL step, and the delete. Worth recording how the second one went, because the first version was a false negative. I tried to block the delete for real by holding a second handle on the leaf without sharing delete. It blocked nothing, the cleanup succeeded, and the test passed while asserting the opposite of what had happened. Arranging a Windows sharing conflict is not something a test can stage reliably, so the delete is injected and the test says what the code does with a failure, which is the part under test. On the two decisions. The launch gate. Merging as the foundation, which is what #662 scoped it as. What made that comfortable rather than a shrug is the refusal itself: with the opt-in set, provisioning stops with a message naming the mechanism, saying no bootstrap or broker exists yet to carry launch authority, and telling the operator to unset the variable to use the restricted-token sandbox. That is a documented dead end rather than a broken feature, and it deliberately does not invite a re-run from an elevated terminal, which would pass a token check and still not run a command. I would rather land the foundation with an honest gate than carry 100 commits alongside a moving LogonUser on a clean elevated host. Still unverified, and I am not going to claim otherwise. My box is unelevated and Smart App Control blocks the unsigned test binary, so Thanks for the review, and for separating the decisions from the defects rather than filing them all as blockers. |
|
@gnanam1990 whenever you have time, this is ready for another look. Your review is on jatmn reviewed the head on 9 September and found no merge-blocking defects, with one P3 on the secret failure cleanup that is also fixed now: the leaf is deleted relative to the pinned parent handle instead of by pathname, and a cleanup that cannot finish is reported rather than discarded. |
|
Merged main in at Four conflicts, all with #1006, all unions: Two things worth a look rather than a skim, both from main deleting
Sandbox package green on Windows here, linux and darwin cross-builds pass. @jatmn your findings at |
Brings in #1006 (narrow restricted-token SIDs, fail closed on DenyRead) and the rest of main since the last rebase. Four files conflicted, all unions with #1006: - windows_acl.go: WindowsACLEntry carries NoInherit (main) beside MaterializeFile (branch); both new actions kept. - windows_acl_apply_windows.go: DenyWrite mask takes the SYNCHRONIZE strip from main; DenyDelete and RevokeCapability cases both kept. - windows_acl_test.go: branch assertions re-applied inside ForWorkspaceWriteProfile; the branch test #1006 rewrote under a new name comes back whole beside it. - windows_setup.go: marker schema stays at 7, which is above main. Main deleted windowsExplicitAccessEntries in favour of prepareWindowsACLPathGroupEntries, which the branch used for the pre-materialization validity check and in the rename-guard test. Both now call the new builder with a nil DACL, and the rule that a DenyDelete ACE never inherits moves into that builder so no plan can make it inherit. TestElevatedSetupRefusesAVolumeRootReadGrant now expects the #1006 gate's message, since that gate refuses the same denyRead profile before setup reaches the volume-root refusal; the outcome assertions (refused, never applied) are unchanged, and the SETUP INVALID guard still proves the plan carries the volume-root grant.
|
Force-pushed to drop a trailer from the merge commit message; content identical. Tip is now 76d607e. |
gnanam1990
left a comment
There was a problem hiding this comment.
Reviewed current head 76d607ecdcb22c5271827fab7666deca71c68827 against main 6937a309cf00825572210a7610a1f3ea8b74c2f9. CI is green, but two bounded blockers remain.
Findings
-
[P1] Include
git clonein the nested-repository creation guard
internal/sandbox/analyzer.go:332-340commandCreatesGitRepositorysetsGitInitonly forinit/init-db, whilerisk.go:162makes that flag the route tonested_git_init. I reproduced this throughEngine.Evaluatein an ancestor-governed workspace with no root.git:Policy.Network = NetworkAllow,PermissionAllow,PermissionGranted = true, commandgit clone https://example.invalid/repo.git .. The current head returnsActionAllowwith onlynetwork,shell, rather thanActionDeny/BlockNestedGitInit. The clone would create root.git/configand hooks after the plan omitted their carveouts, leaving them under the plain workspace write grant. This guard is on the ordinary cross-platform policy path; it does not depend on enabling the unavailable principal launch broker.Treat
cloneas repository creation in the same guard and add this consumer-level regression with the root-target spelling. Preserve global-option handling, ordinary Git operations, standalone-workspace behavior, and the allowedgit submodule update --initcontrol. Network approval is explicitly granted in the reproduction; the missing protection is the separate repository-creation refusal, not the network prompt. -
[P2] Keep the dual-role rollback fixtures inside test-owned roots
internal/sandbox/windows_dualrole_rollback_windows_test.go:76
internal/sandbox/windows_dualrole_rollback_windows_test.go:133-144Both tests call real setup with
C:\sandboxhome/C:\ws.setupWindowsSandboxPrincipalmaterializes runtime candidates derived from the real user cache/shared temp (windows_identity_runtime_windows.go:395), andapplyWindowsPrincipalACLscreates or rewrites ledgers belowC:\sandboxhome(:732,:769;windows_principal_ledger.go:93-117). The principal/ACL seams do not intercept those writes. Rollback writes the ledger again and does not remove the runtime roots, so an otherwise ordinary test run can fail on drive-root permissions or leave/collide with host state.Make
windowsSandboxTestConfig(t)uset.TempDir()for home/workspace, redirectsandboxUserCacheDirandTMP/TEMPto test-owned roots, and update the offline-membership caller. Keep the production setup path under test, but assert it touches no non-test paths.
Scope and validation
The earlier secret-failure cleanup now closes the leaf, deletes relative to the pinned parent, and joins cleanup errors; I am not reopening that finding. The unavailable opt-in launch broker and clean-host logon validation remain the previously documented foundation/product boundary, not new implementation requests in this review.
The disposable root-target clone decision regression fails on this head; existing init/global-option/ordinary-Git controls pass. Windows sandbox tests cross-compile and diff hygiene passes. No external clone was executed, and Windows-only tests were not run natively during this local review. The test-isolation finding is supported by the current production call path. No dependency or new third-party integration is introduced. The author branch was unchanged.
Verdict: Changes requested. Re-review scope is these two findings and their regressions.
…ts stay on test-owned roots commandCreatesGitRepository knew only init and init-db, while risk.go routes the nested-repository refusal through that flag alone. git clone <url> . into a workspace governed by an ancestor repository lands a root .git whose config and hooks setup never planned carveouts for, the same state init produces, and with network allowed and the shell grant given it came back as an ordinary allow. clone now sets the flag; the network prompt is a separate gate and granting it does not answer this one. The regression drives Engine.Evaluate in a nested workspace with network allowed and the grant given, for the root-target spelling, a global-option spelling and a subdirectory target, with ordinary git operations, git submodule update --init and a standalone workspace as controls. The dual-role rollback tests handed real setup C:\sandboxhome and C:\ws. The principal and ACL seams intercept the identity work, not the filesystem work: setup materializes runtime candidates under the user cache and TEMP and writes ledgers below the sandbox home, so an ordinary run wrote to the live cache, failed on drive-root permissions, or left host state behind; this box still has a C:\sandboxhome from the old form. windowsSandboxTestConfig now takes the test, uses t.TempDir for home and workspace, redirects sandboxUserCacheDir and TMP/TEMP, and asserts afterwards that the fixed paths were neither created nor changed by the run and that the redirected cache is not the live one. The offline-membership caller follows. Reported by @gnanam1990.
|
Both fixed at 4d3f5dc. clone is repository creation now. commandCreatesGitRepository sets GitInit for clone alongside init and init-db, so risk.go routes it to the same nested-repository refusal; the network prompt stays a separate gate. The regression is your reproduction through Engine.Evaluate in a nested workspace with network allowed and the shell grant given, for the root-target spelling, a global-option spelling and a subdirectory target, with git status, git fetch, git submodule update --init and a standalone workspace as controls. Dropping clone from the guard fails it with the exact decision you saw: allow, not denied. The dual-role tests are on test-owned roots. windowsSandboxTestConfig takes the test, builds home and workspace under t.TempDir, redirects sandboxUserCacheDir and TMP/TEMP, and asserts afterwards that C:\sandboxhome and C:\ws were neither created nor changed by the run and that the redirected cache is not the live one. The assertion is unchanged-by-this-run rather than absent, and the reason is your finding demonstrated on my own box: it still has a C:\sandboxhome with a capability-SID file from August and a principal-ACL directory my earlier run today touched. The offline-membership caller follows the same config. Both files pass natively here on the redirected roots; whole sandbox package green on Windows, linux and darwin cross-builds and vet pass. @gnanam1990 these two are the only changes since your review. @jatmn your findings at 1056e28 were addressed at a2e9558; the merge, the trailer strip and this are on top of that. |
|
Two standing reviews here, so one note for both of you. @gnanam1990 your clone finding is fixed. @anandh8x your review is at Head is |
|
@jatmn your P3 from The failure cleanup now acts on the object the create made: the leaf handle is closed and the leaf is deleted relative to the pinned parent through On your two decision items I think you are right that they are not yours to resolve from the diff, and I am not asking you to re-litigate them. The opt-in path genuinely cannot finish setup while Head is |
The runtime-root tests all iterate whatever windowsSandboxRuntimeCandidates returns, so they hold for any self-consistent candidate set, including one missing an entry. Dropping the temp-derived fallback from that set left all three green while leaving the root prepareSandboxRuntime falls back to ungranted, which is the third part of anandh8x's review finding: setup grants only the initially derived root, so the fallback is not provisioned either. The new test drives sandboxRuntimeRootFor for both outcomes, the ordinary cache-derived selection and the one a machine makes when the cache root is unusable, and asserts each is in the set setup grants. Driven through the selector rather than by re-deriving the fallback, so it stays true if the selection rule changes. Removing either candidate now fails it with the path it would have refused.
|
@anandh8x correcting myself on the note above. I said your P1 looked closed and asked you to check it rather than take my word; I went and checked it properly instead, and two of the three parts were closed while the third was implemented but unpinned. Parts one and two hold. Part three, the fallback root, was the gap. The candidate set does include it, so the behaviour is right, but every test iterated whatever the candidate set returned, which means they all held for a set missing an entry. I removed the temp-derived fallback from the set and all three stayed green, leaving exactly the state you described: setup grants only the initially derived root and a machine that falls back is refused the write. Pushed Your two P2s I have not closed and am not claiming to. The elevated end-to-end transcript still does not exist; I cannot produce it from this machine, and I would not want opt-in called production-ready without it. The base is two commits behind main, conflict-free.
|
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready. The requests below focus on correcting the underlying ownership, planning, and test-isolation contracts, with regression cases to show those contracts hold.
Merge readiness
The branch is two commits behind main at c1937dfa (#949 and #914). GitHub reports it mergeable and all 13 head checks pass, with no changed-file overlap with those commits. Please refresh against current main and rerun the applicable checks to meet AGENTS.md's fresh-base requirement. This is a merge-readiness requirement; the comparison does not establish a conflicting change or rollback of upstream behavior.
Findings
1. [P1] Preserve Git carveouts when the workspace owns a nested repository
internal/sandbox/profile.go:194
With both outer/.git/ and outer/project/.git/ present, using outer/project as the workspace causes gitMetadataWriteCarveoutSpecs to return no hooks/config carveouts. The local .git directory does not take the early pointer-file branch, and the later ancestor check suppresses its protections. Meanwhile, workspaceGovernedByAncestorRepository returns false whenever the workspace has its own .git, so the initialization refusal does not compensate. Commands receive workspace write access without the previous protections on that repository's .git/config and .git/hooks, including configuration capable of changing what Git executes.
The root cause is inconsistent ownership classification between profile construction and the refusal guard. An ancestor having a repository does not make it the owner of this workspace's metadata when the workspace already has its own repository. The same nested-own-directory case returns both carveouts on the merge base and current target, and none on this head.
Please make those classifications agree so an existing local repository retains its config/hooks protection. Preserve the file-shaped protection for linked-worktree pointers and the behavior that avoids manufacturing a competing .git in a workspace genuinely governed by an ancestor. A focused regression should create both repositories and inspect the resulting protections, alongside controls for a pointer file and an ancestor-governed workspace with no local .git. The existing own-Git guard assertion alone cannot catch a profile that has already lost its carveouts.
2. [P1] Make the unelevated runtime ACL plan applicable on first use
internal/sandbox/windows_runner.go:365
The command profile now includes both runtime candidates for the unelevated tier, while prepareSandboxRuntime creates only the selected candidate. Start with a usable cache and no previously provisioned fallback: the cache runtime exists, but the fallback is serialized as another required allow-write root. ensureWindowsUnelevatedSetup passes that plan to applyWindowsACLPlan, whose missing-target branch returns “windows ACL target does not exist.” The command fails before launch, and the unsuccessful setup records no marker that could change the next attempt.
The mismatch is between the plan's required objects and the objects provisioned by this execution route. Elevated setup ensures the candidate trees exist; the unelevated route does not perform that step. The actual command-argument and ACL-plan construction reproduces the absent required root on this head, while the corresponding base and target plans require only the prepared root. The native Windows rejection follows from the applier's required-target check; I have not executed that native failure here.
Please ensure every required runtime target in an unelevated plan is ready when that plan is applied. Keep the candidate agreement needed for elevated setup and marker validation, and retain the parent-derived paths: deriving candidates again inside the helper would use its redirected TEMP. A useful regression starts with fresh owned cache and fallback locations, takes the real unelevated route, and verifies that setup reaches command launch. Include the alternate runtime selection and a retry so the fix addresses provisioning rather than relying on a tree left by a previous test or elevated setup.
3. [P2] Close the final directory descriptor during fallback validation
internal/sandbox/runtime_state.go:287; internal/peermsg/private_dir_unix.go:25
The new per-command call to peermsg.EnsurePrivateDir exposes a descriptor-lifetime defect in that helper. defer unix.Close(parentFD) captures the initial descriptor value, but the traversal closes that descriptor and repeatedly assigns a new one to parentFD. The deferred operation therefore does not reliably close the directory held at the end of traversal. With an odd-depth fallback anchor, twenty runtime prepare/release cycles leave twenty additional descriptors open. The corresponding base and target fallback paths leave none.
The helper's bug predates this PR; its repeated use during fallback preparation is the causal change here. Closing the runtime lease does not release the helper's leaked descriptor, so a long-running process using this fallback can eventually exhaust its descriptors.
Please make descriptor ownership explicit throughout the traversal: release superseded descriptors and close the currently owned descriptor exactly once on success and error exits. Keep the no-follow traversal, owner validation, and private permissions. A regression should exercise repeated calls at different path depths, plus a failure partway through traversal, and verify that descriptors do not accumulate. Testing only one path depth can hide the defect through descriptor-number reuse.
4. [P2] Verify materialized child identity before rollback deletes it
internal/sandbox/windows_acl_apply_windows.go:967
Rollback verifies the anchor's identity, but the materialization record identifies descendants by name and whether they were created. If setup creates .git/config, that file is renamed aside, and another ordinary file is moved into the same name before a later ACL failure, rollback reopens the replacement and deletes it. The relative open protects against escaping through an ancestor path; it does not prove that the child is the object this setup created. The snapshot's TargetID guard is reached only afterward, and successful removal skips it entirely.
This deletion path becomes relevant to default restricted-token setup through the new protected-metadata materialization. The older rollback machinery already had unsafe pathname cleanup, but the default protected-write entries did not previously materialize these targets. This finding concerns ownership of a replaced descendant under an unchanged anchor. It is supported by the creation, close, reopen, and deletion code path; a native Windows replacement-race reproduction has not been run here.
Please make rollback remove only the actual objects created by this attempt. Any identity comparison needs to apply to the object being deleted, without reopening a mutable name between the check and deletion. Preserve handle-relative, no-follow traversal and conservative refusal when ownership cannot be established. Exercise an ordinary same-shape replacement after materialization, force a later failure, and assert that the replacement survives and the mismatch is surfaced. Also retain a control showing that unchanged objects created by the failed attempt are removed. Check both file leaves and created directory components, since the ownership record covers both.
5. [P2] Cover the inline Git alias that bypasses nested-repository protection
internal/sandbox/analyzer.go:341
In an ancestor-governed workspace with no local .git, this supported Git command creates a repository:
git -c alias.bootstrap=init bootstrapcommandCreatesGitRepository sees bootstrap, falls through its exact-subcommand switch, and does not set GitInit. A shell request with permission granted passes Evaluate, while the profile supplies no local hooks/config carveouts. Actual Git execution creates the repository. On macOS, the previous profile supplied Seatbelt write-denial rules for those future paths; this PR removes them, leaving the new repository's execution configuration writable.
The evaluator also allowed this alias on the base and target. The regression is that the passive protection is removed and the replacement refusal misses this command. It is separate from finding 1: this workspace starts without its own repository, so recognizing an existing local .git directory will not close the gap.
Please ensure this inline alias-to-init command obeys the same protection/refusal contract as direct git init. Keep normal Git operations and ancestor discovery working. The necessary outcome is bounded to this demonstrated gap; it does not require a general shell interpreter or a blanket ban on Git aliases. A regression should pass this spelling through Engine.Evaluate with the shell grant and network permission already present, assert the refusal or equivalent effective protection, and retain a harmless inline-alias control so the fix cannot pass by rejecting every alias.
6. [P2] Isolate runtime-producing Windows tests from the user's cache and temp
internal/sandbox/windows_identity_policy_windows_test.go:319; internal/sandbox/windows_workspace_canonical_windows_test.go:108
TestSetupGrantsTheRuntimeRootCommandsActuallyUse calls real runtime setup and preparation with only the workspace redirected. TestSetupAndPrepareRuntimeAgreeOnANonCanonicalRoot does the same. The resolved runtime roots therefore reach the user's actual cache and fallback temp. These calls create persistent trees there, and preparation calls the real cache reclaimer, which can remove older inactive user runtime trees. Releasing the lease closes the lease; it does not undo those filesystem effects.
The fixture owns the workspace but not every root resolved by the code under test. AGENTS.md explicitly requires isolation of real config/cache/state and applies that requirement to every test reaching the same storage. Setup stubs that redirect only the cache also leave their fallback temp candidate outside the fixture.
Please establish owned cache and temp roots before these production calls and keep their cleanup within the fixture. Apply the fixture to the sibling runtime-producing tests and stubs, including fallback selection. Preserve the real setup/preparation assertions so isolation does not hide the behavior being tested. Verify the actual resolved roots belong to the fixture and exercise both cache and fallback selection; redirecting only the preferred candidate would leave the same root cause on the alternate path.
7. [P3] Check resolver purity before creating the runtime
internal/sandbox/windows_workspace_canonical_windows_test.go:230
TestTeardownPathDerivationCreatesNothing snapshots temp before resolving setup paths, then calls prepareSandboxRuntime before its final assertCreatedNothing. With a fresh temp directory, preparation legitimately creates the fallback anchor. The assertion reports that new entry as a side effect of path derivation even though it was produced by the later preparation call. A pre-existing anchor hides the failure.
The observed interval contains both the pure operation being tested and an intentionally effectful operation. Isolating temp as requested above makes that ordering defect easier to expose; it does not fix it.
Please assert the resolver's lack of side effects immediately after resolution, then prepare the runtime and retain the setup/command root-membership check. Run this case with an initially absent fallback anchor. Both contracts matter: resolving paths creates nothing, and subsequent preparation creates a usable runtime at one of those paths.
8. [P3] Pin the principal mode in caller-identity transport tests
internal/sandbox/windows_setup_test.go:526
TestWindowsSandboxSetupArgsCarryTheCallerIdentity and TestWindowsSandboxSetupArgsOmitAnUnknownCallerIdentity leave PrincipalOptIn unset. With ZERO_WINDOWS_SANDBOX_IDENTITY=1, both fail in BuildWindowsSandboxSetupArgs at the principal-provisioning refusal before testing identity transport. Both failures reproduce with that environment value.
An unset option deliberately consults ambient configuration, so these transport fixtures have an uncontrolled input unrelated to their assertions. This leaves the earlier environment-isolation request unresolved for these tests.
Please explicitly select opt-out in these transport fixtures, through the option or a scoped environment setting. Preserve the production behavior for an unset option and the dedicated tests of ambient opt-in. Running these two tests with the outer environment both unset and set to 1 should exercise the same caller-identity assertions, without requiring principal provisioning to become available.
gitMetadataWriteCarveoutSpecs asked the ancestor question unconditionally, so a workspace nested inside another repository lost its hooks and config carveouts even when it owned its own .git. That metadata is the workspace's, not the ancestor's, and nothing compensated: workspaceGovernedByAncestorRepository answers the same question by testing for a local .git, so it reported such a workspace as not governed and the initialization refusal never fired. Commands received plain workspace write access over a live .git/config and .git/hooks, which is the configuration that decides what git executes. The ancestor branch is now gated on the lstat error, which makes the condition literally the test workspaceGovernedByAncestorRepository applies and the shared doc comment already claimed. It cannot reintroduce the competing-control-directory problem it was written for, because the carveouts only materialize where .git already exists and git's discovery walk therefore already stops at this workspace. A permission failure on the lstat keeps today's behaviour. Reported by @jatmn.
…icalization PermissionProfileFromPolicy stores normalizeProfilePath(root), which resolves symlinks, so comparing against a raw t.TempDir() path matched locally and failed on both CI runners: macOS reaches the temp directory through /var -> /private/var, and the Windows runner's profile directory has a short-name expansion. The carveouts were present in both failures; only the lookup missed them.
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 is not a fix for issue 662 on 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 issue 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
sandbox execfor running commands through the configured sandbox.