feat(sandbox): give each workspace an offline and an online principal - #812
feat(sandbox): give each workspace an offline and an online principal#812Vasanthdev2004 wants to merge 8 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:
WalkthroughWindows sandbox identity handling now provisions separate offline and online principals, selects them by network mode, applies offline-group network enforcement, and builds and validates network infrastructure after principal provisioning. ChangesWindows identity and network enforcement
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant runWindowsSandboxSetup
participant setupWindowsSandboxPrincipal
participant BuildWindowsNetworkInfraPlan
participant WindowsNetworkPlanCoversPrincipals
runWindowsSandboxSetup->>setupWindowsSandboxPrincipal: provision offline and online principals
setupWindowsSandboxPrincipal-->>runWindowsSandboxSetup: return role-specific identities
runWindowsSandboxSetup->>BuildWindowsNetworkInfraPlan: build network plan
BuildWindowsNetworkInfraPlan-->>runWindowsSandboxSetup: return identity SIDs and filters
runWindowsSandboxSetup->>WindowsNetworkPlanCoversPrincipals: verify principal coverage
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/sandbox/windows_identity_windows.go (1)
179-198: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winNo migration path for accounts provisioned before role tags existed. Adding the role tag to
windowsSandboxUserNamemakes every previously provisioned account name unreachable: nothing deletes it, and nothing finds it. On an existing opt-in machine that means a stale local account plus its secret and LSA logon rights linger forever, while commands silently drop to the restricted-token backend with no indication that setup needs re-running.
internal/sandbox/windows_identity_windows.go#L179-L198: delete or adopt the legacy untagged name (windowsSandboxUserPrefix + cleaned) during provisioning, so re-running setup reclaims the old account instead of abandoning it.internal/sandbox/windows_identity_runtime_windows.go#L96-L136: when the role lookup misses but a legacy untagged account exists, emit a one-line stderr hint pointing atzero sandbox setuprather than falling back mutely.🤖 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 179 - 198, The new role-tagged naming in windowsSandboxUserName needs a migration path for legacy accounts. In internal/sandbox/windows_identity_windows.go lines 179-198, during provisioning detect and delete or adopt the legacy untagged name formed from windowsSandboxUserPrefix and cleaned before using the role-tagged name; in internal/sandbox/windows_identity_runtime_windows.go lines 96-136, when role lookup misses but that legacy account exists, emit a one-line stderr hint directing the user to zero sandbox setup instead of silently falling back.
🧹 Nitpick comments (3)
internal/sandbox/windows_identity_windows_test.go (1)
396-425: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftExercise the online role through provisioning, not only name generation.
This regression test proves that offline and online names differ, but the privileged provisioning, logon-rights, token, and lookup tests cover only
windowsSandboxRoleOffline. Add an online-role round-trip case (or parameterize the existing integration test) so a role-specific provisioning or lookup regression cannot pass unnoticed.As per coding guidelines: “
**/*_test.go: Keep tests beside their source files, add regression tests for behavior changes, and run affected concurrent code under the race detector.”🤖 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_test.go` around lines 396 - 425, Add an integration round-trip test for windowsSandboxRoleOnline, covering provisioning, logon-right assignment, token creation, and account lookup alongside the existing offline-role coverage. Prefer parameterizing the existing role-specific integration test so both roles execute the same assertions, while preserving the current offline case and running the affected concurrent test with the race detector.Source: Coding guidelines
internal/sandbox/windows_network_test.go (1)
128-129: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueGlobal hook mutation is safe only while no test in this package is parallel.
Save/restore via
t.Cleanupis right, butresolveWindowsSandboxOfflineGroupSIDHookis package-global. The moment someone addst.Parallel()to any test that callsBuildWindowsNetworkInfraPlan, this becomes a data race that-racewill flag. A short comment stating these tests must stay serial would save the next person the debug.As per coding guidelines, "run affected concurrent code under the race detector".
🤖 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_network_test.go` around lines 128 - 129, Document near the save/restore of resolveWindowsSandboxOfflineGroupSIDHook that tests using this package-global hook, including BuildWindowsNetworkInfraPlan callers, must remain serial and must not use t.Parallel; preserve the existing t.Cleanup restoration.Source: Coding guidelines
internal/sandbox/windows_setup_windows.go (1)
57-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOrdering fix is correct. The offline group only exists after provisioning, so building the plan here is what makes the filters actually name it.
Optional: this block, the
applyWindowsNetworkPlanblock, and the marker-write block are now three byte-identical rollback-and-report branches. A smallfailSetup(stderr, err, rollback) inthelper would collapse ~24 lines and guarantee the next failure path can't forget the rollback.🤖 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_setup_windows.go` around lines 57 - 73, The setup failure handling is duplicated across the plan-building, applyWindowsNetworkPlan, and marker-write branches. Add a failSetup helper that accepts stderr, the setup error, and rollback, performs the existing rollback-and-report behavior, and returns the failure status; replace all three duplicated branches with calls to this helper while preserving their current error messages and rollback semantics.
🤖 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 214-216: Update the privileged tests around
provisionWindowsSandboxIdentity to stop unconditionally removing deterministic
accounts via removeWindowsSandboxIdentity. Use a unique per-test account key, or
track and remove only identities created by the current test, preserving
legitimate pre-existing accounts in both affected test cases.
In `@internal/sandbox/windows_network.go`:
- Around line 84-86: Update the groupSID handling in the identity SID collection
to append the trimmed value produced by strings.TrimSpace, while retaining the
non-empty guard. Ensure IdentitySIDs contains canonical SID strings so
applyWindowsNetworkPlan can consume them directly.
---
Outside diff comments:
In `@internal/sandbox/windows_identity_windows.go`:
- Around line 179-198: The new role-tagged naming in windowsSandboxUserName
needs a migration path for legacy accounts. In
internal/sandbox/windows_identity_windows.go lines 179-198, during provisioning
detect and delete or adopt the legacy untagged name formed from
windowsSandboxUserPrefix and cleaned before using the role-tagged name; in
internal/sandbox/windows_identity_runtime_windows.go lines 96-136, when role
lookup misses but that legacy account exists, emit a one-line stderr hint
directing the user to zero sandbox setup instead of silently falling back.
---
Nitpick comments:
In `@internal/sandbox/windows_identity_windows_test.go`:
- Around line 396-425: Add an integration round-trip test for
windowsSandboxRoleOnline, covering provisioning, logon-right assignment, token
creation, and account lookup alongside the existing offline-role coverage.
Prefer parameterizing the existing role-specific integration test so both roles
execute the same assertions, while preserving the current offline case and
running the affected concurrent test with the race detector.
In `@internal/sandbox/windows_network_test.go`:
- Around line 128-129: Document near the save/restore of
resolveWindowsSandboxOfflineGroupSIDHook that tests using this package-global
hook, including BuildWindowsNetworkInfraPlan callers, must remain serial and
must not use t.Parallel; preserve the existing t.Cleanup restoration.
In `@internal/sandbox/windows_setup_windows.go`:
- Around line 57-73: The setup failure handling is duplicated across the
plan-building, applyWindowsNetworkPlan, and marker-write branches. Add a
failSetup helper that accepts stderr, the setup error, and rollback, performs
the existing rollback-and-report behavior, and returns the failure status;
replace all three duplicated branches with calls to this helper while preserving
their current error messages and rollback semantics.
🪄 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: a1ae6364-8744-4d97-a882-357b313596ad
📒 Files selected for processing (7)
internal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_runtime_windows_test.gointernal/sandbox/windows_identity_windows.gointernal/sandbox/windows_identity_windows_test.gointernal/sandbox/windows_network.gointernal/sandbox/windows_network_test.gointernal/sandbox/windows_setup_windows.go
|
CI is green on all five checks, which closes the gap I flagged in the description. The three tests I could not run locally have now run: Still unexercised, unchanged from #808: the privileged calls. |
|
Both handled in f551a44, though the second one differently than suggested. Deleting accounts by derived name. Right, and the fix belongs deeper than the tests. Ownership is now read back from the comment provisioning stamps on the account, and an account that is not ours is left alone rather than deleted. The gated tests get it for free, since their pre-clean goes through the same helper, so they keep the deterministic key and still converge after a crashed run. The test asserts against The untrimmed append. Taken, but the stated consequence does not hold and I would rather say so than quietly accept it. Local runs are working again, so everything on this branch is now verified here rather than only in CI: the full |
f551a44 to
6c3037f
Compare
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. |
gnanam1990
left a comment
There was a problem hiding this comment.
Verdict
Changes requested — on branch state, not on the design.
Reviewed at 6c3037fe2c17, base feat/windows-sandbox-identity, re-confirmed live before posting. This is no longer marked draft, so I am reviewing it as a real pull request rather than leaving it alone as I did earlier.
The blocking point is small and mechanical: this branch is behind the branch it targets.
#808 is at 832f53a; this is at 6c3037f, and git merge-base --is-ancestor pr808 pr812 is false. The commit you are missing is 832f53a "fix(sandbox): drop the stored secret whenever provisioning fails", which is not incidental to this change — it touches provisionWindowsSandboxIdentity, the same function this branch edits, and #808 changed its signature to return an extra bool. A textual auto-merge that reports no conflict is not sufficient evidence here; this repository has already produced a clean merge that did not build. Rebase onto the current #808 head and confirm the merged tree still cross-compiles for Windows before this is ready.
I checked each branch independently: GOOS=windows go vet ./internal/sandbox/... and GOOS=windows go test -c both pass on #808 at 832f53a and on this branch at 6c3037f. I did not get a clean run of the two merged together, which is exactly the gap the rebase closes.
On the substance, which I think is right.
The diagnosis is the strongest part. #808 shipped with the principal backend standing down whenever the network was denied, deny is the default, so the feature was close to inert in ordinary use — and the reason is not visible from the code. LogonUser builds a token from real group memberships and the offline marker is a synthetic capability SID with no account behind it, so the two mechanisms could never meet. A real local group is the right answer, and keying one filter set to it rather than one per workspace is the right trade.
Building the network plan after provisioning is load bearing and I would keep it as prominent as you have it. Planning first installed filters naming only the marker and left every offline principal with an open network while the machine reported itself correctly configured — a security control that reports success while enforcing nothing. That deserves a test asserting the ordering directly rather than only the end state, because it is exactly the kind of thing a later refactor reverses silently.
Proving ownership before deleting is the same predicate #808 now uses before adopting, correctly applied to the other end. Good.
One non-blocking observation on that path. When the account is not ours, removal returns nil. Idempotence makes that defensible — there is no principal of ours, so the goal state holds — but it also means a stranger's account squatting the derived name produces a silent success, and the operator is told cleanup completed when a name they may care about was left in place. A diagnostic, or a distinguishable return, would cost little.
Worth settling alongside #808 rather than here. Two accounts per workspace instead of one doubles what an operator sees in net user, and endpoint protection and enterprise policy were already an open product question on the parent. That is a conversation for the stack, not a defect in this change.
Limitations. No Windows host, no elevated session. Everything above rests on reading and cross-compilation; none of the privileged paths this adds have been executed by me.
832f53a to
99fefdc
Compare
6c3037f to
4fc1bfa
Compare
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
internal/sandbox/windows_identity_windows_test.go (2)
95-96: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick winDo not print generated passwords.
A failed assertion writes the generated credential into test and CI logs. Redact it.
Proposed fix
- t.Fatalf("password %q lacks a required character class", first) + t.Fatal("generated password lacks a required character class")🤖 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_test.go` around lines 95 - 96, Update the failure message in the password character-class assertion to avoid interpolating the generated password variable first. Keep the assertion and required-character-class diagnostic, but report only a non-sensitive message so generated credentials never appear in test or CI logs.
217-274: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy liftAdd an offline-group membership assertion to the elevated provisioning test.
This round-trip covers provisioning and batch logon, but it still doesn’t check that the offline principal was added toZeroSandboxOffline, which is the part that actually drives network denial.🤖 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_test.go` around lines 217 - 274, Extend the elevated provisioning test after the second provision to verify that the identity from provisionWindowsSandboxIdentity belongs to the ZeroSandboxOffline group. Use the existing Windows group-membership lookup or assertion helper, and fail the test if the provisioned SID is not a member while preserving the current logon and cleanup checks.Source: Coding guidelines
🤖 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.
Outside diff comments:
In `@internal/sandbox/windows_identity_windows_test.go`:
- Around line 95-96: Update the failure message in the password character-class
assertion to avoid interpolating the generated password variable first. Keep the
assertion and required-character-class diagnostic, but report only a
non-sensitive message so generated credentials never appear in test or CI logs.
- Around line 217-274: Extend the elevated provisioning test after the second
provision to verify that the identity from provisionWindowsSandboxIdentity
belongs to the ZeroSandboxOffline group. Use the existing Windows
group-membership lookup or assertion helper, and fail the test if the
provisioned SID is not a member while preserving the current logon and cleanup
checks.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ae325643-a3d0-46ab-88da-b3072102d409
📒 Files selected for processing (7)
internal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_runtime_windows_test.gointernal/sandbox/windows_identity_windows.gointernal/sandbox/windows_identity_windows_test.gointernal/sandbox/windows_network.gointernal/sandbox/windows_network_test.gointernal/sandbox/windows_setup_windows.go
🚧 Files skipped from review as they are similar to previous changes (6)
- internal/sandbox/windows_network_test.go
- internal/sandbox/windows_identity_runtime_windows_test.go
- internal/sandbox/windows_network.go
- internal/sandbox/windows_setup_windows.go
- internal/sandbox/windows_identity_windows.go
- internal/sandbox/windows_identity_runtime_windows.go
|
Your blocking point is fixed, and your reason for insisting on it was correct in a way I would not have predicted. Rebased. This is now on And you were right to refuse a clean auto-merge as evidence. The rebase reported no conflict and did not build. The evidence you asked for, on the merged tree: Plus On the ordering, I took your suggestion and went a step further. You asked for a test asserting the ordering directly rather than the end state. A test alone would not have helped much, because the thing that breaks is the sequence inside The predicate is separate so it can be asserted on its own: marker-only reads as uncovered, marker-plus-group as covered, case differences do not read as missing, and a host with no group provisioned is not refused, since there is no principal for the filters to miss. Making coverage always return true fails that test. The silent success on removal is fixed too. It returns a distinguishable typed error now, and teardown treats it as success, since "no principal of ours under this name" is the goal state whether we deleted one or declined to. You were right that a bare On the account count, agreed it belongs on the stack rather than here. It is two per workspace now, and the local-account visibility question was already open on Your limitation note applies to me too, more so: the privileged paths in this change have not been executed anywhere, and the offline-group membership is the mechanism that carries the network denial, so it is the thing I would most want proven on real hardware before this ships. |
|
@gnanam1990 your changes-requested here is on Worth repeating the part you called: you refused to accept a clean auto-merge as evidence, and you were right. The rebase reported no conflict and did not build. Evidence on the merged tree, as you asked: On the ordering, I went further than a test. A unit test cannot easily observe a sequence inside Removal no longer reports plain success when it declines to delete an account Zero did not create; it returns a distinguishable typed error, and teardown treats it as success since the goal state holds either way. Full detail is in my earlier comment. Your limitation note applies to me more than to you: the privileged paths here have been executed by nobody, and offline-group membership is the mechanism that carries the network denial, so that is the thing most worth proving on real hardware before this ships. |
gnanam1990
left a comment
There was a problem hiding this comment.
Verdict
Approve.
Reviewed at 011b26d8ff3c, base feat/windows-sandbox-identity (99fefdc), re-confirmed live before posting. This withdraws my changes-requested.
The blocking point is resolved. This branch is no longer behind the one it targets: git merge-base --is-ancestor pr808 pr812 is now true and there are zero commits on #808 missing here, including 99fefdc, the one that changed provisionWindowsSandboxIdentity's signature and was my specific concern. I re-checked the merged state rather than relying on the ancestry alone — at this head go build ./..., go vet ./... and ./internal/sandbox all pass, and GOOS=windows go vet ./internal/sandbox/... plus GOOS=windows go test -c both succeed, so the stacked tree type-checks for Windows.
My non-blocking observation is also addressed, and better than I framed it. Removal no longer returns a bare nil when the account is not Zero's. errWindowsSandboxForeignAccountRetained is distinguishable, teardown paths treat it as success, and the comment explains the distinction precisely: leaving the account alone is correct, but reporting plain success would tell an operator cleanup completed when a name they may care about was deliberately left in place. That is exactly the right resolution — it keeps idempotence for callers who want the goal state while making the refusal visible to anyone who cares which.
The new assertion that the filters cover principals is worth having for the same reason the ordering is: it pins a property that is invisible from the outside when it breaks.
On the substance, which I said before and still think. The diagnosis is the strongest part of this change. #808 shipped with the principal backend standing down whenever the network was denied, deny is the default, so the feature was close to inert in ordinary use — and nothing in the code reveals why. LogonUser builds a token from real group memberships while the offline marker is a synthetic capability SID with no account behind it, so the two could never meet. A real local group is the right answer, and keying one filter set to it rather than one per workspace is the right trade.
Building the network plan after provisioning remains the load-bearing ordering, since planning first left every offline principal with an open network while the machine reported itself correctly configured. Proving ownership before deleting is the same predicate #808 uses before adopting, correctly applied to the other end.
One thing that belongs to the stack rather than to this PR. Two accounts per workspace instead of one doubles what an operator sees in net user, and endpoint protection and enterprise policy were already an open product question on the parent. Worth settling there before either lands.
Limitations. No Windows host, no elevated session. Everything above rests on reading and cross-compilation; none of the privileged paths this adds have been executed by me, and the same caveat that made #808 approvable applies here — the surface is behind an opt-in flag that is off by default.
CodeRabbit's changes-requested from earlier is separate from this and still outstanding.
Merge order is #808 then this, and merge itself is kevin's call per the program gate.
anandh8x
left a comment
There was a problem hiding this comment.
Review at 011b26d
PR #812 — give each workspace an offline and an online principal. Stacked on #808 (targets that branch). 7 files, +520/-97 incremental over #808's head. 3 commits.
Verdict: approve. The design closes the biggest gap in #808 (network denial was inert under the principal backend), and the load-bearing orderings are pinned by tests.
What this fixes
#808 shipped with the principal backend standing down whenever the network was denied. Deny is the default, so opting in left the restricted-token path doing all the work — the feature was close to inert. The cause: WFP block filters key to the offline-marker SID (a synthetic capability SID), and LogonUser builds a token from an account's real group memberships, so a principal token can't carry the marker.
How
A real local group (ZeroSandboxOffline). The block filters name its SID alongside the marker. A principal is denied the network by being a member of it. Each workspace gets two accounts that differ only in that membership; the command's network mode picks between them. A group rather than per-principal SIDs because one filter set covers every offline principal on the machine — no per-workspace filter and no setup re-run when a workspace appears.
What's good
- The diagnosis is the strongest part. The PR body explains why #808's principal was inert (LogonUser vs. synthetic SID) and why a group is the right fix (one filter set, no per-workspace filters). That's the kind of reasoning that makes the change safe to review without a Windows box.
- Fail-closed for unknown modes. Anything that isn't an explicit
NetworkAllowmaps to the offline principal. An unrecognized mode loses the network rather than keeps it.windowsSandboxRoleForNetworkis a clean 3-line function. - Role tag before the hash in the account name.
zero-sbx-d<hash>(offline) vszero-sbx-n<hash>(online). Truncation at the 20-char limit eats hash characters, never the tag.TestWindowsSandboxUserNameSeparatesRolesproves the two roles never collide, including under truncation — the dangerous collision is an ordinary command silently getting the online principal. - Both roles provisioned together. Setup needs elevation; commands don't. Provisioning lazily would mean an unelevated command discovering it needs an account it can't create. Both get identical filesystem access — an approved network command sees the same filesystem as an ordinary one.
- Network plan built AFTER provisioning. The group it keys to is created there. Planning first installed filters naming only the marker, leaving every offline principal with an open network while the machine reported correctly set up. The PR calls this out explicitly as "the same class of silent-downgrade bug this PR exists to remove."
WindowsNetworkPlanCoversPrincipalspins the ordering. This predicate is asserted in setup before installing anything. Moving the plan build back ahead of provisioning fails loudly.TestNetworkPlanCoverageDetectsPrincipalsLeftUncoveredproves a marker-only plan is detected as not covering principals, while a marker-plus-group plan passes. Case-insensitive SID comparison. Unprovisioned host (no group) is a no-op.- Setup-marker compatibility. The filter identity set is resolved, not assumed, and stays absent until the group exists. A machine that never provisions principals computes exactly the plan it computed before.
TestNetworkInfraPlanIncludesOfflineGroupIdentityproves the hash changes when the group appears (so setup and command path can't disagree silently) and the marker SID stays at position 0. - Lookup failure propagates.
TestNetworkInfraPlanPropagatesOfflineGroupLookupFailureproves a failed group lookup returns an error rather than silently degrading to "no group" (which would produce filters that cover no principal). - Ownership-before-delete.
removeWindowsSandboxIdentitynow callswindowsSandboxUserIsManagedbefore deleting. A foreign account returnserrWindowsSandboxForeignAccountRetained(distinguishable, not bare nil). Teardown paths treat it as success; operators see the refusal. This is gnanam's non-blocking finding from #808, addressed better than asked — the comment explains precisely why bare nil would be wrong. - Rollback is multi-step and ordered.
setupWindowsSandboxPrincipalprovisions both roles in a loop, appending removal and ACL-revert closures to anundoslice. Rollback unwinds in reverse, keeps going after a failure, reports the first error. ACEs reverted before accounts deleted (orphaned-residue prevention).
Verification
GOOS=windows go vet ./internal/sandbox/...— cleanGOOS=windows go test -c— compiles (type-checks all Windows surface)go build ./internal/sandbox/...(Linux) — cleango test ./internal/sandbox/(Linux, from non-/tmppath) — passgo vet ./internal/sandbox/...— clean- Stacked on #808:
git merge-base --is-ancestor pr808 pr812confirmed by gnanam; zero #808 commits missing.
Cost noted in the PR
Two accounts per workspace instead of one. Doubles what an operator sees in net user. The PR explicitly says this is a deliberate trade for making network denial work under a separate identity, and that the endpoint-protection/enterprise-policy product question belongs to the stack (both #808 and this) rather than to this PR alone.
What's not verified (same caveats as #808)
The privileged half is still unexercised: LogonUser, NetUserAdd, LsaAddAccountRights need an elevated Windows box. Three new tests haven't run locally (Smart App Control blocked the test binary). CI is the check. The feature is behind ZERO_WINDOWS_SANDBOX_IDENTITY=1, off by default.
Verdict
Approve. The design closes the network-denial gap in #808 correctly, the two load-bearing orderings (plan after provisioning, role tag before hash) are pinned by tests, and the ownership-before-delete check is the right resolution to gnanam's #808 finding. Merge order is #808 then this.
Stale — superseded by later APPROVED reviews on the latest head (011b26d). gnanam1990 APPROVED at 15:39:03Z; anandh8x APPROVED at 15:42:35Z. Merge state is CLEAN.
anandh8x
left a comment
There was a problem hiding this comment.
Reviewed the latest stacked head 011b26d against #808. The offline/online split and WFP group design are sound, but this branch adds a blocking rollback hole.
[P2] internal/sandbox/windows_identity_windows.go:537-540 — after a new offline account has been created and added to the managed group, failure while adding it to ZeroSandboxOffline returns created=true with an empty identity. provisionWindowsSandboxPrincipalForSetup then runs its cleanup with identity.Username empty, so the account this run just created is left behind. This is especially relevant here because group membership is the enforcement boundary and can fail under local policy. Return enough identity for rollback or clean up within provisionWindowsSandboxIdentity, and add a failure-path test. This should be resolved together with the analogous post-creation paths noted on #808.
011b26d to
5db0e6e
Compare
|
Taken, in The offline-group step now returns the account name with its error, same as the other post-creation failures. It is the one that matters most here for the reason you gave: membership IS the network denial, local policy can refuse a group join, and the rollback deleting Covered by an injected failure alongside the other two paths. Reverting just that return fails only the offline case: One thing the rebase turned up that is worth flagging, because it would not have been obvious in review. Also seamed |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/sandbox/windows_identity_rollback_windows_test.go (1)
145-164: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAssert that the read ACL remains a grant.
modeis discarded, soDENY_ACCESSor an empty mask would still pass as long as no write bits are present. Assertmode == windows.GRANT_ACCESSand at least one contractually required read bit.🤖 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_rollback_windows_test.go` around lines 145 - 164, Update TestWindowsACLAllowReadGrantsNoDelete to retain the mode returned by windowsACLAccess and assert it equals windows.GRANT_ACCESS. Also assert that the mask contains at least one contractually required read-access bit, while preserving the existing checks that forbid delete and write bits.
🤖 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_rollback_windows_test.go`:
- Around line 18-26: Update the test setup and cleanup around the
pre-existing-account regression test to save, stub, and restore
windowsSandboxUserIsManaged and resetWindowsSandboxUserPassword alongside the
existing seams. Configure the stubs so execution reaches the injected group
failure, then assert that injected error is returned, ensuring the test is
deterministic and does not modify real account state.
---
Outside diff comments:
In `@internal/sandbox/windows_identity_rollback_windows_test.go`:
- Around line 145-164: Update TestWindowsACLAllowReadGrantsNoDelete to retain
the mode returned by windowsACLAccess and assert it equals windows.GRANT_ACCESS.
Also assert that the mask contains at least one contractually required
read-access bit, while preserving the existing checks that forbid delete and
write bits.
🪄 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: bd266c61-0a6f-4795-88c7-7d55ece45da1
📒 Files selected for processing (8)
internal/sandbox/windows_identity_rollback_windows_test.gointernal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_runtime_windows_test.gointernal/sandbox/windows_identity_windows.gointernal/sandbox/windows_identity_windows_test.gointernal/sandbox/windows_network.gointernal/sandbox/windows_network_test.gointernal/sandbox/windows_setup_windows.go
🚧 Files skipped from review as they are similar to previous changes (7)
- internal/sandbox/windows_identity_runtime_windows.go
- internal/sandbox/windows_network.go
- internal/sandbox/windows_setup_windows.go
- internal/sandbox/windows_network_test.go
- internal/sandbox/windows_identity_runtime_windows_test.go
- internal/sandbox/windows_identity_windows.go
- internal/sandbox/windows_identity_windows_test.go
|
@coderabbitai review Rebased onto the updated #808 head. The offline-group failure path now returns the account name for rollback, and |
|
✅ Action performedReview finished.
|
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P2] Scope dual-role setup rollback to accounts this run created
internal/sandbox/windows_identity_runtime_windows.go:242-266
provisionWindowsSandboxPrincipalForSetupalready avoids deleting a pre-existing principal on failure (created=falsein its innerundo), butsetupWindowsSandboxPrincipalappends an unconditionalremoveWindowsSandboxPrincipalForSetupfor every role that completed before a later step fails. If offline provisioning succeeds on an account that already existed and online provisioning or ACL application then fails, the outer rollback deletes the working offline principal anyway. That turns a partial re-setup failure into loss of a principal that was fine before the run. The single-principal path on #808 had a similar outer-rollback shape; dual-role provisioning makes this bite more often. Please mirror thecreatedcontract in the outer rollback, or only append removal closures for principals this run actually created. -
[P3] Update the mode-independent infra-plan test for the offline group
internal/sandbox/windows_online_offline_test.go:59
TestBuildWindowsNetworkInfraPlanIsModeIndependentstill requires exactly one identity SID. On a Windows host whereZeroSandboxOfflinealready exists,BuildWindowsNetworkInfraPlannow legitimately returns two SIDs, so this test fails even though the plan is correct. CI stays green because Linux/macOS jobs leave the hook nil and fresh Windows runners usually have no group yet. Please stubresolveWindowsSandboxOfflineGroupSIDHookthe way the new network tests do, or relax the assertion to match the new contract.
Needs maintainer decision
- Machine-global offline group and per-workspace setup markers
internal/sandbox/windows_network.go:79-87andinternal/sandbox/windows_setup.go:182-265
ZeroSandboxOfflineis machine-global by design, andBuildWindowsNetworkInfraPlanfolds its SID intoNetworkInfraHashwhenever the group exists. That means identity-enabled setup in one workspace changes the expected infra hash for every other sandbox home on the machine, so their markers read as out of date until each re-runs elevated setup, even if they never opted into the principal backend. That is a predictable consequence of the chosen design, not an accidental bug. Please confirm this cross-workspace coupling is acceptable for the experimental flag, and document it if so; otherwise keep the infra hash independent of a group another workspace created.
jatmn
left a comment
There was a problem hiding this comment.
Re-reviewed at c4edd17 on feat/windows-sandbox-offline-online, stacked on feat/windows-sandbox-identity at 71ad0f1. Merge is clean and the branch now contains the base fixes I asked for in the earlier round (a1a0edc logon-rights guard, secret permission fail-soft, dual-role rollback scoped to created, host-independent infra-plan assertions). I still have one hardening item I would like addressed; the rest are compatibility or polish.
What I withdraw from my earlier review
Merge/rebase blocker. GitHub now reports mergeable: true / mergeable_state: clean, and 71ad0f1 is an ancestor of this head. The seam conflicts I called out against the older base are resolved here.
Outer dual-role rollback scoped to created. setupWindowsSandboxPrincipal only appends removal for principals this run created, and TestDualRoleSetupRollbackSparesAdoptedPrincipals exercises the adopted-offline / created-online failure path I cared about.
Mode-independent infra-plan test. The offline-group hook is pinned, group-present SIDs are asserted by value, and the cross-workspace hash coupling is documented in-test rather than hidden.
#808 rollback regressions. rightsAttempted && created is present in provisionWindowsSandboxPrincipalForSetup's undo(), and readWindowsSandboxSecret maps os.IsPermission to errWindowsSandboxIdentityUnavailable.
Coverage guard on groupErr as a P2. I rechecked this: BuildWindowsNetworkInfraPlan and the setup guard both call the same resolver, and provisioning creates the group immediately before the plan is built. A lookup failure at the guard after a successful plan build would need a transient error between two back-to-back calls. The groupErr == nil gate is a real defensive gap in the source, but not a practical failure mode on the happy path. I am keeping it as P3 below.
Findings
-
[P2] An adopted offline principal can survive provisioning without
ZeroSandboxOfflinemembership
internal/sandbox/windows_identity_windows.go:711-714
internal/sandbox/windows_identity_runtime_windows.go:344-346
If offline provisioning adopts an existing managed account andaddWindowsSandboxUserToOfflineGroupthen fails, provisioning returnscreated=false, so neither the inner nor outer rollback removes the account. That is the right data-loss tradeoff, but deny-mode commands can still log on as that principal when a readable secret remains from a prior run. The token is not in the offline group the WFP filters key to, so network denial may not apply. Membership is the enforcement boundary in this design.
This is a narrow edge case, not the common path: first-time create plus a failed offline join still deletes the account (created=true), and re-setup usually succeeds the group join becauseaddWindowsSandboxUserToOfflineGrouptreats "already a member" as success. The scenario that bites is re-setup adopting an existing offline account, a policy refusal on the offline-group join, and a secret that still authenticates. Please either refuse to leave an offline-role principal without offline-group membership after provisioning, or fail closed at command time when the offline principal is not a member ofZeroSandboxOffline. -
[P3] No migration path from
#808single-principal account names
internal/sandbox/windows_identity_windows.go:189-206
internal/sandbox/windows_identity_runtime_windows.go:107-112
This is an intentional rename, not an accidental regression:#808derivedzero-sbx-<hash>and this branch deriveszero-sbx-d<hash>/zero-sbx-n<hash>. There is no adopt/rename/cleanup of the legacy untagged name. Stack testers who upgrade without elevated re-setup will miss both role-tagged lookups and silently fall back to the restricted-token backend; re-setup then creates two new accounts while the old one and its secret remain orphaned. That is real compatibility debt for in-flight testers, but not a logic bug and not amainrelease concern yet. A migration path or a one-time stderr hint when a legacy account exists but role-tagged lookup misses would be enough if you want to support people already on the stacked branch. -
[P3] Setup skips the principal-coverage guard when offline-group lookup errors
internal/sandbox/windows_setup_windows.go:80-90
After principals are provisioned,resolveWindowsSandboxOfflineGroupSID()errors are ignored andWindowsNetworkPlanCoversPrincipalsis not run, yetapplyWindowsNetworkPlanstill proceeds. On the identity-enabled path the plan was just built with the same resolver, so this branch should not fire in normal operation. It is still worth failing setup whengroupErr != nilhere, or reusing the group SID already folded intonetworkPlan, rather than silently skipping the last assertion. -
[P3] Partial dual-role setup leaves allow-mode commands on the restricted-token path without warning
internal/sandbox/windows_identity_runtime_windows.go:329-334
internal/sandbox/windows_identity_runtime_windows.go:109-112
If setup fails after the offline role completes but before the online role is provisioned, allow-mode commands look up the online principal, geterrWindowsSandboxIdentityUnavailable, and fail-soft to the restricted-token backend with no stderr warning (unlike the missing-secret path). First-time setup failures usually leave no marker, so commands should not run anyway; this mostly matters on re-setup or other inconsistent machine state. Consider warning once when opt-in is set, the offline principal resolves, but the online principal does not. -
[P3] Teardown treats a name collision as a hard error after the secret is already removed
internal/sandbox/windows_identity_runtime_windows.go:399-404
internal/sandbox/windows_identity_runtime_windows.go:410-412
WhenlookupWindowsSandboxIdentityreturnserrWindowsSandboxNameCollision,removeWindowsSandboxPrincipalForSetupaborts beforeremoveWindowsSandboxIdentity, which would returnerrWindowsSandboxForeignAccountRetainedand is already treated as success downstream. The secret is gone but rollback/teardown reports failure for correctly declining to delete somebody else's account. Please fall through toremoveWindowsSandboxIdentity(or treat collision like foreign-retained) after secret removal.
Needs maintainer decision
- Machine-global
ZeroSandboxOfflinechanges every sandbox home'sNetworkInfraHash
internal/sandbox/windows_network.go:79-86
internal/sandbox/windows_setup.go:264-265
This is deliberate, documented behavior rather than drift: the test asserts the hash changes when the offline group appears. I am not asking you to resolve it in this PR.@kevincodex1still needs to decide whether cross-workspace marker invalidation is acceptable for the experimental flag and document it for operators, or whether the infra hash should stay independent of a group another workspace created. I do not think it should block the rest of this change.
windowsPrincipalRevokePlan was implemented and tested but had no production caller, so nothing ever used it. applyWindowsACLPlan merges into the existing DACL, which means a re-run after narrowing a write root or shortening a deny list left the previous, wider ACEs sitting beside the new ones: the principal kept access the current policy no longer granted, and the sandbox silently widened as a result of being tightened. Setup does get the chance to notice — marker validation already refuses commands with "permission roots or deny lists changed" until setup runs again — so the re-apply is exactly where this belongs. The ACL step is extracted into applyWindowsPrincipalACLs: build the plan, revoke every ACE naming this trustee on the paths it touches, then apply. Revocation is by trustee rather than by remembered path, so it also clears grants written by an older version of Zero. Its rollback is discarded on purpose — the only failure path from here removes the principal outright, and restoring stale ACEs for an account about to be deleted is the residue this exists to prevent. Extracting it also makes the ordering testable without new provisioning seams, which #812 already adds with a different signature; adding them here would have collided on its rebase. Three tests: revocation actually drops a grant on a root that left the policy while keeping the one that stayed (asserted against the real DACL, counting deny ACEs as well as allow, since trustee revocation drops both); revoking a path that was never created is a no-op rather than an error; and the production path revokes BEFORE it applies. That last one is the one that matters — the first two pass just as happily with the call site deleted, and deleting it kills only the third. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c4edd17 to
08ae591
Compare
|
Rebased onto the updated #808 ( Five hunks needed resolving and three were more than textual, so recording what I decided: The command-path lookup. #808 added a privileged-group re-check on the path that mints the token; this branch added the role parameter. Both survive — The ACL step inside the role loop. #808 replaced the bare A duplicate seam. Both branches independently added One test needed a real change, not a mechanical one. gofmt, |
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
internal/sandbox/windows_identity_runtime_windows.go (1)
396-417: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winTeardown still hard-fails on a name-collision lookup instead of falling through to the foreign-account path.
This exact code and line range were flagged in a prior review round and the comment here has no "resolved" marker — tracing the current code confirms it's still present:
if identity, err := lookupWindowsSandboxIdentity(key, role); err == nil { ... } else if !errors.Is(err, errWindowsSandboxIdentityUnavailable) { return err }
lookupWindowsSandboxIdentityreturnserrWindowsSandboxNameCollision(a distinct sentinel fromerrWindowsSandboxIdentityUnavailable) when the derived account name belongs to someone else. That's exactly the caseremoveWindowsSandboxIdentitya few lines below is designed to absorb gracefully viaerrWindowsSandboxForeignAccountRetained. As written, teardown returns the collision error immediately and never reaches that graceful path, contradicting this PR's own stated goal that teardown "leaves unmanaged or foreign accounts untouched" as a successful cleanup outcome.🐛 Proposed fix
- } else if !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + } else if !errors.Is(err, errWindowsSandboxIdentityUnavailable) && !errors.Is(err, errWindowsSandboxNameCollision) { return err }🤖 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.go` around lines 396 - 417, Update the teardown lookup error handling around lookupWindowsSandboxIdentity so errWindowsSandboxNameCollision follows the same fall-through path as errWindowsSandboxIdentityUnavailable instead of being returned. Preserve returning all other lookup errors, allowing removeWindowsSandboxIdentity to handle the foreign-account case through errWindowsSandboxForeignAccountRetained.internal/sandbox/windows_setup_windows.go (1)
80-90: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winOffline-group SID lookup error is still silently swallowed here.
This is the same code and line range flagged in a prior review round, and the comment carries no "resolved" marker — the current code confirms it's unchanged:
if groupSID, groupErr := resolveWindowsSandboxOfflineGroupSID(); groupErr == nil { if !WindowsNetworkPlanCoversPrincipals(networkPlan, groupSID) { ... } }Every other failure in this function (ACL plan build/apply, principal provisioning, plan build, marker write) reports the error and rolls back. Here, if
groupErr != nil, the coverage assertion is skipped entirely and setup proceeds straight toapplyWindowsNetworkPlanwithout ever checking that the just-installed filters actually cover the just-provisioned offline principal.BuildWindowsNetworkInfraPlanresolved this same SID successfully a few lines above via the same underlying call through the hook, so the window is narrow, but a transient failure on the second call should fail setup loudly — consistent with every other step here — rather than silently downgrading to "unchecked."🐛 Proposed fix
- if groupSID, groupErr := resolveWindowsSandboxOfflineGroupSID(); groupErr == nil { - if !WindowsNetworkPlanCoversPrincipals(networkPlan, groupSID) { - err := errors.New("network block filters do not name the sandbox offline group, so they would not apply to any sandbox principal; the network plan must be built after principals are provisioned") - if rollbackErr := rollback(); rollbackErr != nil { - fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) - return 1 - } - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) - return 1 - } - } + groupSID, err := resolveWindowsSandboxOfflineGroupSID() + if err != nil { + if rollbackErr := rollback(); rollbackErr != nil { + fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) + return 1 + } + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + return 1 + } + if !WindowsNetworkPlanCoversPrincipals(networkPlan, groupSID) { + err := errors.New("network block filters do not name the sandbox offline group, so they would not apply to any sandbox principal; the network plan must be built after principals are provisioned") + if rollbackErr := rollback(); rollbackErr != nil { + fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) + return 1 + } + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + 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_setup_windows.go` around lines 80 - 90, Handle errors returned by resolveWindowsSandboxOfflineGroupSID in the Windows sandbox setup flow instead of silently skipping coverage validation. When the lookup fails, report the error to stderr, invoke rollback, and return failure consistently with the other setup steps; only call WindowsNetworkPlanCoversPrincipals after a successful SID lookup.
🤖 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.go`:
- Around line 259-294: Update ensureWindowsSandboxOfflineGroup and the
underlying ensureWindowsLocalGroup flow to verify that an existing
ZeroSandboxOffline group has the expected windowsSandboxOfflineGroupComment
ownership marker before adopting it. Reject name collisions with unrelated
groups, while preserving creation and successful reuse of groups that carry the
matching ownership comment.
---
Duplicate comments:
In `@internal/sandbox/windows_identity_runtime_windows.go`:
- Around line 396-417: Update the teardown lookup error handling around
lookupWindowsSandboxIdentity so errWindowsSandboxNameCollision follows the same
fall-through path as errWindowsSandboxIdentityUnavailable instead of being
returned. Preserve returning all other lookup errors, allowing
removeWindowsSandboxIdentity to handle the foreign-account case through
errWindowsSandboxForeignAccountRetained.
In `@internal/sandbox/windows_setup_windows.go`:
- Around line 80-90: Handle errors returned by
resolveWindowsSandboxOfflineGroupSID in the Windows sandbox setup flow instead
of silently skipping coverage validation. When the lookup fails, report the
error to stderr, invoke rollback, and return failure consistently with the other
setup steps; only call WindowsNetworkPlanCoversPrincipals after a successful SID
lookup.
🪄 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: 0c448553-a210-41db-a54a-d1392dbf9b16
📒 Files selected for processing (12)
internal/sandbox/windows_dualrole_rollback_windows_test.gointernal/sandbox/windows_identity_policy_windows_test.gointernal/sandbox/windows_identity_privilege_recheck_windows_test.gointernal/sandbox/windows_identity_rollback_windows_test.gointernal/sandbox/windows_identity_runtime_windows.gointernal/sandbox/windows_identity_runtime_windows_test.gointernal/sandbox/windows_identity_windows.gointernal/sandbox/windows_identity_windows_test.gointernal/sandbox/windows_network.gointernal/sandbox/windows_network_test.gointernal/sandbox/windows_online_offline_test.gointernal/sandbox/windows_setup_windows.go
08ae591 to
2e9a8ce
Compare
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Restore an adopted principal's pre-existing ACLs when setup rolls back
internal/sandbox/windows_identity_runtime_windows.go:361
applyWindowsPrincipalACLsfirst removes every existing ACE for the principal, but deliberately discards that revoke operation's snapshot. This branch now retains adopted principals on a later setup failure: if the offline role is adopted, its ACLs are refreshed here, and provisioning or ACL application for the online role then fails,rollback()runs onlyrevertACL, whose snapshot is already after the revocation. The account and secret survive but the ACEs that made the previously working principal usable do not, so subsequent deny-mode commands log on and then lose their workspace/runtime access. Preserve and restore the pre-refresh ACL state for retained principals (or defer changing an adopted principal until the full dual-role setup can succeed). -
[P1] Reject a pre-existing offline group that is not owned by Zero
internal/sandbox/windows_identity_windows.go:262
ensureWindowsSandboxOfflineGroupdelegates toensureWindowsLocalGroup, which acceptsNERR_GroupExists/ERROR_ALIAS_EXISTSwithout checking the group's comment or other ownership marker. Setup then resolves that arbitrary group's SID and installs it on the persistent WFP deny filters. If another tool or policy already ownsZeroSandboxOffline, every existing member of that group can abruptly lose outbound access, and the sandbox principal also inherits that group's permissions. Verify the expected managed-group marker before reusing the group and fail on a foreign same-name group. -
[P2] Fail closed when the selected offline principal is no longer in the offline group
internal/sandbox/windows_identity_runtime_windows.go:106
Network denial now depends entirely onZeroSandboxOfflinemembership, but the command path only validates the account's ownership and privilege groups before logging it on. If an existing offline account loses that membership through local policy or administrative drift (and a re-setup then cannot re-add it), the prior secret and setup marker remain usable; aNetworkDenycommand selects the account and its token no longer matches the WFP group condition, so it gets egress. Recheck the selected role's required membership at token minting and refuse/fall back to the restricted-token path when it is absent; cover the failed re-setup case.
…rollback gaps Three findings from jatmn's review, all reachable only under the opt-in principal backend but all real. FILE_DELETE_CHILD is no longer granted. On a parent it authorises deleting a child whatever the child's own DACL says, so granting it on a write root handed back the carve-outs underneath: delete .git/config, recreate it, and the replacement inherits the grant with no deny of its own — restoring the credential.helper and core.hooksPath control the carve-out exists to prevent. It was granted to keep the mask symmetric with the deny mask, which is the wrong instinct: denying a capability is not a reason to grant it. The comment two lines up already made that argument for WRITE_DAC and WRITE_OWNER. DELETE alone still covers removing and renaming files inside the roots, which is what the grant is actually for — verified before removing it. Note this does NOT close the second route jatmn described: .git itself carries no ACE, so renaming the whole directory aside needs only DELETE. That needs a guard on .git and is not in this commit. ACL targets are now rejected when a PARENT is a reparse point. CreateFile resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the final-component check passed while elevated setup rewrote the DACL of an object outside the workspace. Junctions need no privilege to create, unlike symlinks, so this was reachable by exactly the unprivileged user the sandbox contains. GetFinalPathNameByHandle answers where the handle really landed, covering every component in one call instead of walking the path and racing between checks. The comparison is against the path's own resolved form, so a differently-cased or 8.3 spelling is still accepted. The revocation's rollback is returned instead of discarded. Discarding it was justified on the grounds that the only failure path removes the principal outright — true for a principal this run CREATED, false for one it ADOPTED, which #812 keeps alive on failure rather than destroying someone else's working account. The account survived with its previous ACEs stripped and the new ones rolled back: logged on, and unable to reach its own workspace. Teardown still discards it deliberately, since putting ACEs back on an account about to be deleted is the opposite of the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
applyWindowsPrincipalACLs revokes every ACE naming a trustee before applying the new plan, and discarded that revocation's restore. The justification was that the only failure path from there deletes the principal outright, so putting stale ACEs back for a doomed account would be the orphaned residue the revocation exists to prevent. Dual-role setup broke that premise. An adopted principal — one that already existed before this run — is deliberately retained by rollback. So if the offline role adopts an account, refreshes its ACLs, and the online role then fails, rollback ran only the grant's restore, whose snapshot was taken after the revocation. The account and its secret survived with none of the ACEs that made it usable: it would log on and lose its workspace and runtime access. revokeWindowsPrincipalACEs now returns its restore and applyWindowsPrincipalACLs hands back both, revocation first. Setup appends the revocation restore before the grant restore so the reverse unwind runs grant-then-revocation; each snapshot is a whole DACL, so that ordering is what walks a path back one operation at a time to what it held before setup. A grant failure undoes its own revocation inline, since the caller never receives a handle to it. Teardown still discards the restore — there the account really is going away. Reported by jatmn on #812.
ensureWindowsLocalGroup accepted NERR_GroupExists and ERROR_ALIAS_EXISTS as success without inspecting the group it was about to reuse. Setup then resolved that group's SID and installed it on the persistent WFP deny filters, and made the sandbox principal a member of it. If anything else on the machine already owns a group named ZeroSandboxOffline — another tool, a policy, a prior unrelated convention — that is not a no-op. Every existing member abruptly loses outbound access, because the filters now name their group. In the other direction the sandbox principal inherits whatever permissions that group carries, which is the opposite of what an offline principal is for. The add now reports its raw status and the already-exists branch verifies the group carries this setup's managed marker before adopting it, failing with an actionable message otherwise. A lookup error fails closed rather than adopting. NetLocalGroupAdd and the ownership lookup sit behind seams so the branch is reachable in tests without an elevated machine; the marker compared is the group's own, so the principals group and the offline group cannot be confused. Reported by jatmn on #812.
Network denial does not follow from picking the offline account. The WFP block filters match the offline GROUP'S SID, and LogonUser builds a token from the account's real memberships — so membership is the whole enforcement, and the command path never revalidated it. An account that drifts out of ZeroSandboxOffline through local policy, an administrator, or a re-setup that could not re-add it still resolves, still has its stored secret, and still logs on. Its token no longer satisfies the filter condition, so a NetworkDeny command gets full egress under a profile that asked for none. The stale setup marker keeps the whole path looking healthy. The offline role now confirms the membership its mode depends on before the secret is read, and falls back to the restricted token when it is absent. That direction is deliberate: the restricted token carries the offline marker the same filters match, so egress stays blocked, and only read confinement is lost. A failed lookup surfaces rather than downgrading silently. The online role is not checked, since it is not in that group by design. Reported by jatmn on #812.
|
@jatmn all three addressed in P1, adopted principal's ACEs on rollback. You were right about the premise: the "discard the revocation's restore" comment justified itself on "the only failure path from here removes the principal outright", and dual-role setup is exactly what broke that — an adopted principal is deliberately retained. P1, foreign offline group. The add now reports its raw status and the already-exists branch verifies the managed marker before adopting, failing with an actionable message otherwise. A lookup error fails closed. I put P2, membership at token minting. Agreed this is the sharp one: membership is the enforcement, since Two seams I added along the way that are worth a look:
|
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Merge conflict
This PR is currently marked CONFLICTING with its target branch (feat/windows-sandbox-identity). Please rebase it onto the current target and rerun the Windows build/tests on the merged tree; this stack has already demonstrated that a clean textual rebase can leave a broken call site.
Findings
-
[P1] Do not grant
FILE_DELETE_CHILDto write roots
internal/sandbox/windows_acl_apply_windows.go:244
AWindowsACLAllowWriteACE is inherited from a writable root, while the protected Git/config and hooks carve-outs are denied on children.FILE_DELETE_CHILDon the parent authorizes deleting a child regardless of that child's DACL, so the sandbox principal can delete.git/configor.git/hooks, recreate it under the inherited allow ACE, and then setcredential.helperorcore.hooksPath. This bypasses the control-plane restriction; the base implementation deliberately omitted this bit for exactly that reason. KeepDELETEfor ordinary allowed-file replacement, but do not grantFILE_DELETE_CHILDfrom the parent. -
[P1] Restore the ancestor reparse-point check before elevated ACL writes
internal/sandbox/windows_acl_apply_windows.go:180
FILE_FLAG_OPEN_REPARSE_POINTonly prevents following the final path component. RemovingverifyWindowsACLTargetNotRedirectedmeans a workspace owner can turn an ancestor such as.gitinto a junction before elevated setup; opening.git\\hooksthen lands on an external non-reparse target and setup rewrites that target's DACL as Administrator. The deleted helper and test covered this exact junction-ancestor path, so retain an equivalent final-handle/canonical-path verification. -
[P1] Keep fallback runtime roots out of the principal ACL path
internal/sandbox/runtime_state.go:48
When the user cache is inside the workspace, this now callsfallbackSandboxRuntimeRoot, which usesMkdirTempand only memoizes the result in-process. Elevated setup grants the principals access to temporary root A, but a later command is a new process and chooses root B forHOMEand caches, where the principal has no ACE; ordinary cache writes then fail withACCESS_DENIED. Teardown also creates and targets a third temporary directory rather than the real one. Restore the side-effect-free deterministic derivation (and omit the random fallback where it cannot be named reliably) instead of using the command-time fallback resolver for setup/teardown.
windowsPrincipalRevokePlan was implemented and tested but had no production caller, so nothing ever used it. applyWindowsACLPlan merges into the existing DACL, which means a re-run after narrowing a write root or shortening a deny list left the previous, wider ACEs sitting beside the new ones: the principal kept access the current policy no longer granted, and the sandbox silently widened as a result of being tightened. Setup does get the chance to notice — marker validation already refuses commands with "permission roots or deny lists changed" until setup runs again — so the re-apply is exactly where this belongs. The ACL step is extracted into applyWindowsPrincipalACLs: build the plan, revoke every ACE naming this trustee on the paths it touches, then apply. Revocation is by trustee rather than by remembered path, so it also clears grants written by an older version of Zero. Its rollback is discarded on purpose — the only failure path from here removes the principal outright, and restoring stale ACEs for an account about to be deleted is the residue this exists to prevent. Extracting it also makes the ordering testable without new provisioning seams, which #812 already adds with a different signature; adding them here would have collided on its rebase. Three tests: revocation actually drops a grant on a root that left the policy while keeping the one that stayed (asserted against the real DACL, counting deny ACEs as well as allow, since trustee revocation drops both); revoking a path that was never created is a no-op rather than an error; and the production path revokes BEFORE it applies. That last one is the one that matters — the first two pass just as happily with the call site deleted, and deleting it kills only the third. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rollback gaps Three findings from jatmn's review, all reachable only under the opt-in principal backend but all real. FILE_DELETE_CHILD is no longer granted. On a parent it authorises deleting a child whatever the child's own DACL says, so granting it on a write root handed back the carve-outs underneath: delete .git/config, recreate it, and the replacement inherits the grant with no deny of its own — restoring the credential.helper and core.hooksPath control the carve-out exists to prevent. It was granted to keep the mask symmetric with the deny mask, which is the wrong instinct: denying a capability is not a reason to grant it. The comment two lines up already made that argument for WRITE_DAC and WRITE_OWNER. DELETE alone still covers removing and renaming files inside the roots, which is what the grant is actually for — verified before removing it. Note this does NOT close the second route jatmn described: .git itself carries no ACE, so renaming the whole directory aside needs only DELETE. That needs a guard on .git and is not in this commit. ACL targets are now rejected when a PARENT is a reparse point. CreateFile resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the final-component check passed while elevated setup rewrote the DACL of an object outside the workspace. Junctions need no privilege to create, unlike symlinks, so this was reachable by exactly the unprivileged user the sandbox contains. GetFinalPathNameByHandle answers where the handle really landed, covering every component in one call instead of walking the path and racing between checks. The comparison is against the path's own resolved form, so a differently-cased or 8.3 spelling is still accepted. The revocation's rollback is returned instead of discarded. Discarding it was justified on the grounds that the only failure path removes the principal outright — true for a principal this run CREATED, false for one it ADOPTED, which #812 keeps alive on failure rather than destroying someone else's working account. The account survived with its previous ACEs stripped and the new ones rolled back: logged on, and unable to reach its own workspace. Teardown still discards it deliberately, since putting ACEs back on an account about to be deleted is the opposite of the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2040a3c to
d84bc54
Compare
The principal backend stood down whenever the network was denied, which is the default, so opting into it left the restricted-token path doing all the work in normal use. The reason was that network denial is enforced by block filters keyed to the offline-marker SID, and a principal token cannot carry it: LogonUser builds a token from an account's real group memberships, and the marker is a synthetic capability SID. A real local group closes that gap. ZeroSandboxOffline is created by setup, the block filters name its SID alongside the marker, and a principal is denied the network by being a member. Each workspace therefore gets two accounts that differ only in that membership, and the command's network mode selects between them. A group rather than each principal's own SID because principals are per workspace: one filter set covers every offline principal on the machine instead of needing a filter per workspace. Both principals are provisioned together even though a given setup run sees one profile, because setup needs elevation and commands do not. Provisioning lazily would mean an unelevated command discovering it needs an account it cannot create. They also get identical filesystem access, so an approved network command sees the same filesystem as an ordinary one. Two orderings are load bearing. The network plan is now built AFTER provisioning, because the group it keys to is created there; planning first installed filters naming only the marker and left every offline principal with an open network while looking correctly set up. And the role tag sits before the workspace hash in the account name, so truncation at the 20 character limit eats hash characters rather than the tag, which would otherwise collide the two roles onto one account on exactly the workspaces most likely to truncate. Anything that is not an explicit allow maps to the offline principal, so an unrecognised mode loses the network rather than keeping it. The filter identity set is resolved rather than assumed, and stays absent until the group exists, so a machine that never provisions principals computes the same plan as before. That matters because the plan is hashed into the setup marker and re-derived on every command; an identity set that differed between setup and the command path would fail every command as out of date. Cost worth stating: this doubles the sandbox accounts on a machine, to two per workspace.
removeWindowsSandboxIdentity is called with a DERIVED name, so it could be pointed at a name that happens to belong to somebody else's local account. Deleting a user is not a recoverable mistake, and the only thing standing between the two cases was the name matching a pattern we generate ourselves. Raised by CodeRabbit against the test fixtures, but the production teardown path had the same hazard, so the guard belongs there rather than in the tests. The ownership check itself now lives on the base branch, which grew the same helper to stop provisioning ADOPTING a squatted account. This applies it to the other end: an account that is not ours is left alone rather than deleted. The gated tests get the protection for free, since their pre-clean goes through the same helper. Also appends the trimmed offline-group SID rather than the raw one. Worth noting the reported consequence does not hold: newWindowsWFPUserCondition canonicalises before converting, so a padded value would have been trimmed before reaching StringToSid. The resolver returns SID.String(), which never carries whitespace, so this is defensive tidying rather than a fix.
…ned account Two follow-ups from review, both on the same theme: a control that quietly does nothing looks identical to one that works. The network plan must be built AFTER provisioning, because provisioning creates the group the block filters name. Built first, the filters name only the offline marker and every offline principal has an open network while setup reports success. That ordering is invisible at the call site, so setup now checks the plan actually names the offline group before installing anything and refuses if it does not. A later refactor that moves the plan build back fails loudly instead of producing a security control that enforces nothing. The predicate is separate so it can be asserted directly: a plan carrying only the marker must read as uncovered, one carrying the group as covered, case differences must not read as missing, and a host with no group provisioned has no principal to miss and must not be refused. Making coverage always report true fails that test. Removal also reported plain success when it declined to delete an account Zero did not create. Leaving it alone is right, but telling an operator cleanup completed when a name they may care about was deliberately retained is not. That case is now a distinguishable sentinel, and teardown treats it as success, since "no principal of ours under this name" is the goal state either way.
Provisioning already declined to delete an account it had adopted, and the outer setup rollback then appended an unconditional removal for every role that got that far. With two roles that is the common case rather than an unlucky one: the offline role usually succeeds, so a failure in the online role or in ACL application destroyed a principal that was working before the run started. The removal closure is now only appended for a principal this run created. Threads the workspace key into the delete path as well. Ownership was proven from the account comment alone, which on a name collision belongs to a DIFFERENT workspace, so deleting it would have been the same unrecoverable mistake the check exists to prevent. Policy DenyWrite now reaches the principal ACL plan here too, matching the single-principal path. Fixes the mode-independence test, which required exactly one identity SID and so failed on any Windows host that already had ZeroSandboxOffline, where the plan legitimately carries two. CI never saw it because the Linux and macOS jobs leave the hook nil and a fresh Windows runner has no group. The hook is now pinned, and the test additionally asserts the property it is named for in the group-present case, including that the infra hash changes when the group appears, which is the cross-workspace coupling raised for a maintainer decision.
Two review points on the tests added in the previous commit. The provisioning stub left resetWindowsSandboxUserPassword as a real call. Nothing under test reaches it any more, because rotation moved to the caller, but a test that resets a real managed account's password if the code ever moves back is not a risk worth carrying. It is now stubbed to fail the test instead, which also states the contract. The group-present assertion checked only that two identity SIDs were present. A duplicated offline marker or an unrelated SID would satisfy that while meaning something quite different, so it now pins both positions. Also drops a duplicated stub assignment left by the rebase.
ensureWindowsLocalGroup accepted NERR_GroupExists and ERROR_ALIAS_EXISTS as success without inspecting the group it was about to reuse. Setup then resolved that group's SID and installed it on the persistent WFP deny filters, and made the sandbox principal a member of it. If anything else on the machine already owns a group named ZeroSandboxOffline — another tool, a policy, a prior unrelated convention — that is not a no-op. Every existing member abruptly loses outbound access, because the filters now name their group. In the other direction the sandbox principal inherits whatever permissions that group carries, which is the opposite of what an offline principal is for. The add now reports its raw status and the already-exists branch verifies the group carries this setup's managed marker before adopting it, failing with an actionable message otherwise. A lookup error fails closed rather than adopting. NetLocalGroupAdd and the ownership lookup sit behind seams so the branch is reachable in tests without an elevated machine; the marker compared is the group's own, so the principals group and the offline group cannot be confused. Reported by jatmn on #812.
Network denial does not follow from picking the offline account. The WFP block filters match the offline GROUP'S SID, and LogonUser builds a token from the account's real memberships — so membership is the whole enforcement, and the command path never revalidated it. An account that drifts out of ZeroSandboxOffline through local policy, an administrator, or a re-setup that could not re-add it still resolves, still has its stored secret, and still logs on. Its token no longer satisfies the filter condition, so a NetworkDeny command gets full egress under a profile that asked for none. The stale setup marker keeps the whole path looking healthy. The offline role now confirms the membership its mode depends on before the secret is read, and falls back to the restricted token when it is absent. That direction is deliberate: the restricted token carries the offline marker the same filters match, so egress stays blocked, and only read confinement is lost. A failed lookup surfaces rather than downgrading silently. The online role is not checked, since it is not in that group by design. Reported by jatmn on #812.
e87133b to
90e2556
Compare
|
@jatmn rebased onto the current #808 head — new head is Closed without a code change:
So both were real on the head you reviewed, and both were artifacts of a stale base — which is exactly the failure mode you called out, and the reason you asked for the rebase first. Still open and mine to fix: the runtime-root P1 ( On the mechanics, in case it is useful for the rest of the stack: I did this as a per-commit rebase rather than a merge. The merge produced 77 conflict hunks across 11 files with neither side a clean winner; the rebase produced four small conflicts. One of them was informative — my commit Verified rather than assumed, since you warned that a clean textual rebase can still leave a broken call site — and it did: a test stub kept the old arity and only |
… all windowsSandboxRuntimeRootPath resolved through sandboxRuntimeRootFor, which falls back to os.MkdirTemp when the user cache lives inside the workspace and memoizes that only in-process. Elevated setup is its own process. It granted the principals an ACE on temp root A; the next command, being a new process, derived temp root B, where the principal has no ACE, and failed ordinary cache writes with a bare ACCESS_DENIED and nothing pointing at the sandbox. Teardown, a third process, cleaned a third directory. The three callers that have to agree exactly could not agree at all. Setup now uses the same side-effect-free derivation teardown already used, and reports no runtime root when that derivation is unusable rather than inventing one. A root only the granting process can name is worse than no root: the principal loses the runtime tree, which is a degraded sandbox, instead of the sandbox appearing provisioned while every command fails. TestTeardownPathDerivationCreatesNothing asserted the opposite — that setup "should still fall back to a usable tree" — so it is inverted here, with the reasoning recorded in the test. That assertion encoded the assumption this finding overturns: a per-process temp tree is not usable. Restoring the fallback fails it with the invented path in the message, and a new TestSetupAndTeardownDeriveTheSameRuntimeRoot pins the ordinary case, so "report none" cannot quietly become the answer everywhere. Reported by jatmn on #812.
|
@jatmn the third one is done as well — head is now Runtime roots ( Setup now uses that same deterministic derivation, and when it is unusable it reports no runtime root instead of inventing one. That is the part worth your eye: I took "omit the random fallback where it cannot be named reliably" literally rather than trying to make the fallback stable. The principal then gets no ACE on the runtime tree, which is a degraded sandbox — but a root only the granting process can name is worse, because the sandbox looks provisioned while every later command fails on a bare ACCESS_DENIED. One thing I want to flag rather than let you find: I inverted an existing test. Both directions are pinned: restoring the fallback fails with the invented path in the message ( Where the other two landed, since they were closed by the rebase rather than by code:
|
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
- [P1] Keep the principal write jail
internal/sandbox/windows_command_runner_windows.go:93
This now executes the rawLogonUsertoken after deleting theCreateRestrictedToken/WRITE_RESTRICTEDwrapping. The principal ACL plan only adds ACEs for configured paths; it cannot remove writes inherited through ambient memberships such asBUILTIN\\Usersand batch logon. Consequently an identity-enabled sandbox whose onlyWriteRootis its workspace can write to an unrelated user-writable location such asC:\\Users\\Public\\Documents. The deletedwindows_principal_jail_windows_test.goexercised exactly this boundary. Restore an equivalent write restriction (while retaining the principal SID needed for granted roots) and cover a real principal-token attempt outside the configured roots.
Maintainer Decision
internal/sandbox/windows_network.go:67
Creating ZeroSandboxOffline for workspace A adds its SID to NetworkInfraHash. On the next command in every already-initialized workspace B, ValidateWindowsSandboxSetupMarker recomputes the now-different hash and rejects the command as “network infrastructure changed,” requiring elevated setup for each unrelated workspace. The test intentionally pins this behavior and the author has explicitly left it for maintainer resolution. Please decide whether that availability break is acceptable for the experimental flag, or whether the global group must be made compatible with existing per-home markers.
-
[P2] Fail setup when the post-provisioning group lookup fails
internal/sandbox/windows_setup_windows.go:80
The secondresolveWindowsSandboxOfflineGroupSIDcall is used to enforce the load-bearing coverage assertion, but its error is silently ignored. This contradicts the stated fail-closed behavior: a transient lookup failure, group deletion, or replacement after planning lets setup install filters and record success without confirming that they cover the just-provisioned offline principal. Roll back and reportgroupErrbefore applying the network plan; this is also an unresolved CodeRabbit request on the current head. -
[P3] Let foreign-name collisions reach the retained-account cleanup path
internal/sandbox/windows_identity_runtime_windows.go:447
lookupWindowsSandboxIdentityreturnserrWindowsSandboxNameCollisionwhen the derived name belongs to a foreign account, but this branch returns that error beforeremoveWindowsSandboxIdentitycan classify the same state as the explicitly toleratederrWindowsSandboxForeignAccountRetained. Thus the claimed “teardown treats it as success” behavior does not hold for a collision: rollback/teardown fails instead of converging after correctly leaving the foreign account untouched. Treat the collision like an unavailable identity for the LSA-revocation step and continue to the ownership-checked removal path; this is also an unresolved CodeRabbit request on the current head.
Stacked on #808, and targets that branch rather than main so the diff is only the new work. #808 should land first.
What this fixes
#808 shipped with the principal backend standing down whenever the network was denied. Deny is the default, so opting in left the restricted-token path doing all the work in ordinary use and the new identity only engaged for approved network commands. That was the honest choice at the time, but it made the feature close to inert.
The cause: network denial is enforced by block filters keyed to the offline-marker SID, and a principal token cannot carry it.
LogonUserbuilds a token from an account's real group memberships, and the marker is a synthetic capability SID with no account behind it.How
A real local group.
ZeroSandboxOfflineis created by setup, the block filters name its SID alongside the marker, and a principal is denied the network by being a member of it. Each workspace gets two accounts that differ only in that membership, and the command's network mode picks between them.A group rather than each principal's own SID because principals here are per workspace: one filter set covers every offline principal on the machine, instead of needing a filter per workspace and a setup re-run whenever a workspace appears.
Both principals are provisioned together even though a setup run only sees one profile. Setup needs elevation and commands do not, so provisioning lazily would mean an unelevated command discovering it needs an account it cannot create. They also get identical filesystem access, so an approved network command sees the same filesystem as an ordinary one.
Two orderings that are load bearing
The network plan is now built after provisioning. The group it keys to is created there. Planning first installed filters naming only the marker and left every offline principal with an open network while the machine looked correctly set up. That is the same class of silent-downgrade bug this PR exists to remove, so it is worth naming.
The role tag sits before the workspace hash in the account name. Truncation at the 20 character limit then eats hash characters rather than the tag. A lost tag would collide the two roles onto one account, on exactly the workspaces most likely to truncate, and the dangerous direction of that collision is an ordinary command silently getting the online principal.
Anything that is not an explicit allow maps to the offline principal, so an unrecognised mode loses the network rather than keeps it.
Setup-marker compatibility
The filter identity set is resolved, not assumed, and stays absent until the group exists. A machine that never provisions principals computes exactly the plan it computed before. This matters because the plan is hashed into the setup marker and re-derived on every command: an identity set that differed between setup and the command path would fail every command with "setup is out of date". There is a test for the hash changing when the group is present and for a lookup failure propagating rather than silently degrading to "no group".
Verification, and what is not verified
gofmt,go vet,GOOS=windows go vet, and builds for linux, darwin and windows are clean.Before I added the last three tests, the full
internal/sandboxsuite passed on this branch with the single expected failure: the test asserting the old stand-down, which this replaces.The three new tests have not been run locally. Smart App Control on this machine is enforcing and started refusing every freshly built unsigned test binary partway through, including via
go test, and turning it off is not something I am going to do to get a green local run. CI is the check for those. They areTestPrincipalRoleFollowsNetworkMode,TestWindowsSandboxUserNameSeparatesRoles, and the twoTestNetworkInfraPlan...cases, the last of which are portable and will run on the Linux and macOS jobs too.Also unchanged from #808: the privileged half is still unexercised here, so
LogonUserand the LSA rights need a clean elevated box.Cost worth stating
This doubles the sandbox accounts, to two per workspace. Given the concern already raised about local account creation being visible to endpoint protection and enterprise policy, that is a real increase and a deliberate trade for making network denial work under a separate identity.
Summary by CodeRabbit