Skip to content

feat(sandbox): Windows sandbox principals (foundation for #662) - #808

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

feat(sandbox): Windows sandbox principals (foundation for #662)#808
Vasanthdev2004 wants to merge 16 commits into
mainfrom
feat/windows-sandbox-identity

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

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

Why

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

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

What this does

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

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

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

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

Verification, and what is not verified

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

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

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

Honest caveats:

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

Worth deciding before this leaves draft

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

Summary by CodeRabbit

  • New Features
    • Added an opt-in Windows sandbox principal flow (Windows-only) gated by an environment variable, including managed local account setup, batch logon rights, DPAPI-protected per-principal password secrets, and filesystem ACL enforcement.
    • Enhanced principal ACL planning/revocation with explicit read/write action handling, deny-before-allow ordering, protected-metadata protection, and deterministic revokes.
    • Updated Windows command execution to use the provisioned principal token when available.
  • Bug Fixes
    • Improved failure/rollback behavior to more reliably undo principal, logon rights, secrets, and ACL changes.
  • Tests
    • Expanded Windows test coverage for gating, ACL deny materialization/order, secret protection/DPAPI behavior, and Windows logon/LSA error handling.

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

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Windows sandbox identity provisioning, protected principal-secret storage, batch logon support, runtime token selection, and principal-specific ACL planning with Windows-focused unit and integration tests.

Changes

Windows sandbox principal

Layer / File(s) Summary
Provision and resolve sandbox identities
internal/sandbox/windows_identity_windows.go, internal/sandbox/windows_identity_windows_test.go, internal/sandbox/windows_identity_policy_windows_test.go, internal/sandbox/windows_identity_rollback_windows_test.go
Creates deterministic managed accounts, handles idempotent provisioning and teardown, resolves SIDs, supports lookup, preserves rollback ownership state, and tests identity behavior.
Store and protect principal secrets
internal/sandbox/windows_identity_dpapi_windows.go, internal/sandbox/windows_identity_secret_windows.go, internal/sandbox/windows_identity_secret_windows_test.go
Encrypts passwords with DPAPI, stores them under validated paths with protected owner/SYSTEM DACLs, handles unavailable or removed secrets, and tests storage security.
Grant rights and mint batch tokens
internal/sandbox/windows_identity_logon_windows.go, internal/sandbox/windows_identity_logon_windows_test.go, internal/sandbox/windows_identity_windows_test.go
Adds LSA policy helpers, grants batch and denied logon rights, mints tokens with LogonUserW, and tests Windows interop behavior.
Build and apply principal ACL plans
internal/sandbox/windows_identity_acl.go, internal/sandbox/windows_acl_apply_windows.go, internal/sandbox/windows_identity_acl_test.go, internal/sandbox/windows_identity_policy_windows_test.go, internal/sandbox/windows_identity_rollback_windows_test.go
Builds ordered deny/allow plans, protects metadata, creates revoke plans, maps ACL actions to Windows operations, and validates trustee and permission behavior.
Select principal execution at runtime
internal/sandbox/windows_identity_runtime_windows.go, internal/sandbox/windows_command_runner_windows.go, internal/sandbox/windows_setup_windows.go, internal/sandbox/windows_identity_runtime_windows_test.go
Adds workspace-keyed, opt-in principal setup and lookup; commands use the principal token when eligible and retain restricted-token fallback behavior.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • Gitlawb/zero#640: Both changes modify Windows ACL application and revoke/ACE handling.

Suggested reviewers: gnanam1990, anandh8x, jatmn

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 95.71% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding Windows sandbox principal support as a foundational feature.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/windows-sandbox-identity

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

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

Consolidate the five separate advapi32.dll lazy loads.

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

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

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

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

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

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

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

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

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

---

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

Comment on lines +85 to +91
for _, name := range root.ProtectedMetadataNames {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: filepath.Join(cleaned, name),
Capability: input.PrincipalSID,
Materialize: true,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject protected metadata names that escape the write root.

ProtectedMetadataNames is documented as names, but filepath.Join accepts .. and separator-containing values. A malformed value can materialize a deny ACE outside root.Root. Require one non-empty path component and add rejection tests.

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),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, name := range root.ProtectedMetadataNames {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: filepath.Join(cleaned, name),
Capability: input.PrincipalSID,
Materialize: true,
})
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),
Capability: input.PrincipalSID,
Materialize: true,
})
🤖 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 - 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.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

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

Scope

Head: a1a0edc51cea
Changed files (16): internal/sandbox/windows_acl_apply_windows.go, internal/sandbox/windows_command_runner_windows.go, internal/sandbox/windows_identity_acl.go, internal/sandbox/windows_identity_acl_test.go, internal/sandbox/windows_identity_dpapi_windows.go, internal/sandbox/windows_identity_logon_windows.go, internal/sandbox/windows_identity_logon_windows_test.go, internal/sandbox/windows_identity_policy_windows_test.go, internal/sandbox/windows_identity_rollback_windows_test.go, internal/sandbox/windows_identity_runtime_windows.go, internal/sandbox/windows_identity_runtime_windows_test.go, internal/sandbox/windows_identity_secret_windows.go, and 4 more

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

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

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

Give the operator an exit when the principal backend breaks.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

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

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

Comment thread internal/sandbox/windows_identity_runtime_windows_test.go
Comment on lines +38 to +64
// Provision this workspace's sandbox principal, when opted in. A principal is
// a separate local account, so it is created only on an explicit opt-in: it
// is visible in `net user`, and account creation is exactly the kind of thing
// endpoint protection and enterprise policy object to. Without the opt-in the
// capability-SID backend above is the whole of setup, unchanged.
if windowsSandboxIdentityEnabled(config.commandConfig().Env) {
principalRollback, err := setupWindowsSandboxPrincipal(config.commandConfig())
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
}
// Fold the principal into the existing rollback so every later failure
// path undoes it too, rather than each one having to remember.
aclRollback := rollback
rollback = func() error {
principalErr := principalRollback()
aclErr := aclRollback()
if principalErr != nil {
return principalErr
}
return aclErr
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate existing setup-level regression coverage without executing repository code.
rg -nP --glob '*_test.go' -C 3 '\brunWindowsSandboxSetup\s*\(' internal/sandbox
rg -nP --glob '*_test.go' -C 3 '\bsetupWindowsSandboxPrincipal\s*\(|\bwindowsSandboxIdentityEnabled\s*\(' internal/sandbox

Repository: Gitlawb/zero

Length of output: 1982


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files and inspect the setup implementation and its tests.
git ls-files 'internal/sandbox/*windows*' 'internal/sandbox/*_test.go'

printf '\n--- windows_setup_windows.go (outline) ---\n'
ast-grep outline internal/sandbox/windows_setup_windows.go --view expanded || true

printf '\n--- matching tests and references ---\n'
rg -n -C 3 'runWindowsSandboxSetup|setupWindowsSandboxPrincipal|windowsSandboxIdentityEnabled|rollback' internal/sandbox/*_test.go internal/sandbox/*.go

Repository: Gitlawb/zero

Length of output: 28424


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- windows_setup_test.go outline ---'
ast-grep outline internal/sandbox/windows_setup_test.go --view expanded || true

printf '\n%s\n' '--- references to runWindowsSandboxSetup and related setup helpers in windows_setup_test.go ---'
rg -n -C 4 'runWindowsSandboxSetup|setupWindowsSandboxPrincipal|windowsSandboxIdentityEnabled|WindowsSandboxSetupConfig|WriteWindowsSandboxSetupMarker|applyWindowsACLPlan|applyWindowsNetworkPlan' internal/sandbox/windows_setup_test.go

printf '\n%s\n' '--- first 220 lines of windows_setup_test.go ---'
cat -n internal/sandbox/windows_setup_test.go | sed -n '1,220p'

Repository: Gitlawb/zero

Length of output: 13408


Add runWindowsSandboxSetup coverage in internal/sandbox/windows_setup_test.go. The current tests stop at marker/config helpers; add one case for opt-out skipping setupWindowsSandboxPrincipal, and one for opt-in failure still calling the ACL 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 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.

Source: Coding guidelines

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Changes requested.

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

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

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

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

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

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

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

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

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

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

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

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

Merge is kevin's call per the program gate.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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

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

Table is still not hermetic.

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

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

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

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

Still assumes every ACE is an ACCESS_ALLOWED_ACE.

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

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

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

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

Path traversal via ProtectedMetadataNames still unaddressed.

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

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

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

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

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

Source: Coding guidelines

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

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

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

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

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

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

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

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

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

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

---

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

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

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

---

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

The stale comment. Fixed, it is windowsSandboxWorkspaceKey.

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

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

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

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

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

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

Comment thread internal/sandbox/windows_identity_windows_test.go
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

Fixed in fbe340b:

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

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

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

gnanam1990
gnanam1990 previously approved these changes Jul 27, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Approve.

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

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

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

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

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

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

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

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

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

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

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

Merge is kevin's call per the program gate.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

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

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

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

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Approve.

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

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

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

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

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

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

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

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

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

Merge is kevin's call per the program gate.

Groundwork for closing the Windows half of #662 and #675, where
credentialDenyReadPaths is a no-op today.

Every Windows backend currently derives its token from the calling user via
CreateRestrictedToken, so the sandbox can constrain writes but not reads: a deny
ACE that would stop the sandboxed child reading a credential store names the
same account Zero runs as, and would lock Zero out too. That is why deny-read is
skipped on Windows rather than merely unimplemented.

This adds a separate local account per workspace, held in one managed group, so
the sandbox has an identity of its own:

- provisioning: managed group, stable per-workspace account name inside the
  20-character limit, crypto/rand password meeting complexity policy, SID
  resolution, idempotent so setup re-runs converge
- logon rights: grants only SeBatchLogonRight and explicitly denies interactive,
  network, remote-interactive and service logon, then mints a token with
  LogonUser pinned to the local machine
- ACLs keyed to the principal: denies emitted before allows so carve-outs
  survive, workspace granted read+write, read roots granted read, protected
  metadata denied write and materialized
- removal: revocation by trustee, so retiring a principal drops every ACE naming
  it without needing a record of what was granted

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, and the same SID is what a write grant or a firewall
rule can be keyed to.

Nothing is wired into command execution yet: these paths are additive and no
existing behavior changes. See the pull request for the open question about
where the principal's password lives.
Wires the principal model into the runner and settles where the account's
password lives.

The secret is stored under the sandbox home with an explicit,
inheritance-protected DACL naming only the invoking user and SYSTEM. The sandbox
principal is deliberately absent from it: a principal that could read the file
could mint its own token and the identity boundary would be decorative. The ACL
is applied to an empty file before the password is written, so the bytes never
exist under the config directory's inherited permissions, and PROTECTED drops any
inherited ACE outright.

At command time the runner asks for a principal token first and uses it in place
of the restricted token, because a separate account has reads denied by the
filesystem rather than left open the way a same-user restricted token must leave
them. The lookup is fail-soft: opt-out, no provisioned account, or no stored
secret all report "not available" and the existing restricted-token path runs
unchanged. Only a provisioned-but-unusable identity surfaces an error, since
that means setup ran and the sandbox is broken rather than absent.

The backend stays behind ZERO_WINDOWS_SANDBOX_IDENTITY=1 while the privileged
paths are unvalidated, so no existing install changes behaviour.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Applied CodeRabbit's ACE type finding in dbdbda11. It was correct, and I checked it rather than taking it on faith.

windowsSecretACEList reinterprets what GetAce returns as an ACCESS_ALLOWED_ACE. Deny ACEs share that layout so they would survive, but an object ACE carries Flags and two GUIDs ahead of the trustee, so SidStart lands mid-structure and Copy reads whatever bytes follow. The caller asserts that no unexpected trustee appears in the DACL, and on such an ACE it would print a nonsense SID rather than name the entry that does not belong. A test that misreports its own failure is worse than one that does not run at all.

Nothing under test builds anything but allowed ACEs, so no current outcome changes. I confirmed the guard is live rather than dead code by pointing the comparison at ACCESS_DENIED_ACE_TYPE, which turns the DACL tests red with ACE 0 has type 0, want ACCESS_ALLOWED_ACE_TYPE. Full internal/sandbox passes on the real head.

@gnanam1990 @anandh8x apologies, this push dismissed both your approvals. Unavoidable, since a CodeRabbit change request only clears on a push. The delta from 99fefdc4 is 13 added lines in one test helper and no production code, so your reasoning about the change itself stands. Re-requested.

The caveat I flagged earlier is unaffected and still stands: the elevated LogonUser path has never executed on a real machine, so that half remains unproven by anything other than reading it.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

The ACE type finding is addressed in dbdbda11, which landed after your review at 06:35Z. windowsSecretACEList now rejects any ACE whose Header.AceType is not ACCESS_ALLOWED_ACE_TYPE instead of decoding its trustee regardless.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Current PR state =='
git status --short
git rev-parse --short HEAD
git show -s --format='%h %s' HEAD

printf '%s\n' '== Changed files relative to merge base =='
base="$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main)"
git diff --name-status "$base"...HEAD

printf '%s\n' '== Symbol outlines =='
ast-grep outline internal/sandbox/windows_identity_secret_windows_test.go --items all --match 'windowsSecretACEList' --view expanded || true
ast-grep outline internal/sandbox/windows_identity_secret_windows.go --items all --view expanded || true
ast-grep outline internal/sandbox/windows_identity_runtime_windows.go --items all --view expanded || true
ast-grep outline internal/sandbox/windows_identity_windows.go --items all --view expanded || true

printf '%s\n' '== ACE helper and its callers =='
rg -n -A45 -B12 'func windowsSecretACEList\b|\bwindowsSecretACEList\s*\(' internal/sandbox

printf '%s\n' '== Full current PR diff =='
git diff --find-renames --find-copies "$base"...HEAD -- internal/sandbox

Length of output: 126315


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Provisioning and cleanup with line numbers =='
nl -ba internal/sandbox/windows_identity_runtime_windows.go | sed -n '120,280p'
nl -ba internal/sandbox/windows_identity_windows.go | sed -n '380,455p'

printf '%s\n' '== ACL apply/revert implementation and plan types =='
ast-grep outline internal/sandbox/windows_acl_apply_windows.go --items all --view expanded
rg -n -A100 -B20 'func applyWindowsACLPlan\b|func windowsExplicitAccessEntries\b|type WindowsACLPlan\b|type WindowsACLEntry\b|type WritableRoot\b' internal/sandbox

printf '%s\n' '== Callers and tests for provisioning failures =='
rg -n -A12 -B8 '\bprovisionWindowsSandboxIdentity\s*\(|\bprovisionWindowsSandboxPrincipalForSetup\s*\(|\bsetupWindowsSandboxPrincipal\s*\(' internal/sandbox

printf '%s\n' '== Diff for latest ACE fix only =='
git show --stat --oneline dbdbda1
git show --format=fuller --find-renames dbdbda1 -- internal/sandbox/windows_identity_secret_windows_test.go

Length of output: 1385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

show_range() {
  file="$1"
  start="$2"
  end="$3"
  awk -v start="$start" -v end="$end" 'NR >= start && NR <= end { printf "%6d  %s\n", NR, $0 }' "$file"
}

printf '%s\n' '== Provisioning and cleanup with line numbers =='
show_range internal/sandbox/windows_identity_runtime_windows.go 120 280
show_range internal/sandbox/windows_identity_windows.go 380 455

printf '%s\n' '== ACL apply/revert implementation and plan types =='
ast-grep outline internal/sandbox/windows_acl_apply_windows.go --items all --view expanded || true
rg -n -A100 -B20 'func applyWindowsACLPlan\b|func windowsExplicitAccessEntries\b|type WindowsACLPlan\b|type WindowsACLEntry\b|type WritableRoot\b' internal/sandbox

printf '%s\n' '== Callers and tests for provisioning failures =='
rg -n -A12 -B8 '\bprovisionWindowsSandboxIdentity\s*\(|\bprovisionWindowsSandboxPrincipalForSetup\s*\(|\bsetupWindowsSandboxPrincipal\s*\(' internal/sandbox

printf '%s\n' '== Diff for latest ACE fix only =='
git show --stat --oneline dbdbda1
git show --format=fuller --find-renames dbdbda1 -- internal/sandbox/windows_identity_secret_windows_test.go

Length of output: 68712


@Vasanthdev2004 The ACE-type finding is addressed in dbdbda1: windowsSecretACEList now rejects non-ACCESS_ALLOWED_ACE_TYPE entries before decoding SidStart.

I found one remaining cleanup bug:

  • provisionWindowsSandboxPrincipalForSetup cannot remove an account created before an inner provisioning failure. provisionWindowsSandboxIdentity returns created == true but an empty windowsSandboxIdentity when addWindowsSandboxUserToGroup or resolveWindowsSandboxSID fails (Lines 424-429 in internal/sandbox/windows_identity_windows.go). The caller’s undo then calls removeWindowsSandboxIdentity(identity.Username) (Lines 166-168 in internal/sandbox/windows_identity_runtime_windows.go), with an empty username. Thus a newly created account remains after either failure—the exact partial-provisioning residue the rollback is intended to prevent.

    Preserve the derived username independently in the caller, or return an identity containing the username on every post-creation failure, and add a failure-injection test proving the created account is deleted when membership addition or SID resolution fails.

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the latest head dbdbda1. Two issues need fixing before this foundation is safe to merge.

[P1] internal/sandbox/windows_acl_apply_windows.go:226 — WindowsACLAllowWrite grants FILE_GENERIC_WRITE but neither DELETE nor FILE_DELETE_CHILD. That was sufficient for the old same-user token because the caller already had inherited rights, but the new principal has no such access. It can create and modify files yet cannot reliably delete or rename pre-existing workspace files/directories, so normal coding and git operations will fail. The principal write grant needs delete semantics, with an elevated Windows test that creates a file as the caller and deletes or renames it through the principal.

[P2] internal/sandbox/windows_identity_windows.go:424-429 and windows_identity_runtime_windows.go:166-168 — if NetUserAdd creates the account and group attachment or SID resolution then fails, provisionWindowsSandboxIdentity returns created=true with an empty identity. The advertised cleanup calls removeWindowsSandboxIdentity(identity.Username), therefore tries to remove an empty name and strands the newly-created local account. Preserve the derived username on partial failure or perform cleanup inside provisioning, and cover an injected post-creation failure.

… find it

Two findings from review, both consequences of the principal being a
separate account rather than the calling user.

WindowsACLAllowWrite granted FILE_GENERIC_WRITE, which covers creating and
modifying but not removing or renaming, and a rename needs delete on the
source. Under the old same-user token this was invisible because the caller
already held inherited rights on its own tree. A principal inherits nothing,
so it could write files it could never delete, which fails ordinary editing
and most git operations rather than an edge case. DELETE and
FILE_DELETE_CHILD are now part of the grant, matching WindowsACLDenyWrite,
which already treats delete as part of write. WRITE_DAC and WRITE_OWNER stay
out: they are denied so the principal cannot rewrite its own restrictions.

provisionWindowsSandboxIdentity returned a zero identity alongside
created=true when group attachment or SID resolution failed after NetUserAdd
had already created the account. The caller's rollback deletes by
identity.Username, so it was asked to delete the empty string and left the
account behind. Group attachment is the case that matters, being both the
enforcement boundary and something local policy can refuse. The name now
comes back with the error.

The four provisioning calls are indirected so the failure paths are
reachable in a test. Seaming only the post-creation pair would not have been
enough: every step needs an elevated caller, so the test would have stopped
at the group check and passed without reaching what it names.

Also seeds the empty-secret test with a genuinely empty file. The previous
whitespace seed was several bytes, so it never reached the length check and
failed later in DPAPI instead, which another test already covers.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both taken, in 3d49e20f. Thanks, the first one is a functional bug I would not have caught without a real machine.

[P1] delete rights. Correct, and worse than a missing capability: FILE_GENERIC_WRITE covers creating and modifying, but removing needs DELETE and a rename needs delete on the source, so the principal could write files it could never remove. Under the old same-user token this was invisible because the caller already held inherited rights on its own tree. WindowsACLAllowWrite now grants DELETE and FILE_DELETE_CHILD.

Worth noting the asymmetry that hid it: WindowsACLDenyWrite already listed both, so the deny side understood that write includes delete and only the grant side did not. WRITE_DAC and WRITE_OWNER stay out, since they are denied precisely so the principal cannot rewrite its own restrictions.

I could not run your elevated test, so the coverage is at the mask instead: the grant is asserted to carry DELETE, FILE_DELETE_CHILD and FILE_WRITE_DATA, and the read grant is asserted not to. Reverting the production line fails it with "write grant is missing DELETE". Atomic bits on purpose, my first attempt tested FILE_GENERIC_WRITE and passed on a read-only mask, because the read and write composites share READ_CONTROL and SYNCHRONIZE.

[P2] rollback. Also correct. The name now comes back with the error from both post-creation paths.

The interesting part was making it testable. I seamed all four provisioning calls rather than just the two you named, because every step needs an elevated caller: a test that replaced only the post-creation pair would stop at ensureWindowsSandboxGroup and pass without reaching the code it was named for. That is not hypothetical, it is what happened on my first run, and the same thing bit me again on #812 where a fifth call needed the same treatment.

Injected failure tests cover group attachment and SID resolution, plus a case asserting created stays false for an adopted account so the rollback cannot delete a working principal from an earlier setup. Reverting the fix fails them with "identity carries no username, so the rollback deletes "" and strands the account".

CodeRabbit's stale-secret finding was already addressed before its review landed: secretPath is computed from the account name before anything can fail and the undo removes it unconditionally. I also fixed its point about TestWindowsSandboxSecretEmptyIsSentinel, which seeded whitespace and so never reached the length check it named. It now covers a genuinely empty file and keeps the whitespace case.

#812 is rebased on this and carries the same fix for the offline-group step, which is the one your #812 note called out.

@Vasanthdev2004
Vasanthdev2004 requested a review from anandh8x July 28, 2026 16:07
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

Both findings are addressed in 3d49e20f, which landed after your review. The ACE type check is in, and the stale-secret concern was already covered before that review: secretPath is derived from the account name before anything can fail and the undo removes it unconditionally.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@kevincodex1 status update rather than a merge request, because these are not ready for you yet and I would rather say so than have you look and find nothing to press.

#808 and #812 are the last of my open PRs; everything else merged today. Both are green, and @anandh8x's change requests are addressed in 3d49e20f and 5db0e6ef. What they lack is approvals: my own fix pushes dismissed the ones they had, so both sit at zero of three. Re-requested from @anandh8x and @gnanam1990, and CodeRabbit re-triggered, since its change requests predate the fixes.

When they do land, order matters: #808 first, then #812. #812's base is feat/windows-sandbox-identity, so it is stacked and retargets once #808 is in.

The decision I flagged earlier still stands unchanged and is the real reason I am writing. The elevated LogonUser path has never executed. Smart App Control here blocks freshly built unsigned binaries, so TestGrantLogonRightsAndMintPrincipalToken has not run to completion anywhere, and neither gnanam nor anandan has a Windows host. Account and group creation are confirmed on a real elevated session; the half that mints a token for a provisioned account is verified by reading it and nothing else.

Landing a foundation with an unexercised path behind an off-by-default opt-in (ZERO_WINDOWS_SANDBOX_IDENTITY) is your call, not mine. If you would rather it ran on a clean elevated box first, that is reasonable and I will not argue it merged.

anandan's P1 is worth reading as evidence for that caution: the principal could not delete or rename files, because FILE_GENERIC_WRITE does not cover either and the old same-user token had inherited rights that hid it. Ordinary editing and most git operations would have failed. That is exactly the class of defect only a real machine finds, and it was sitting in a foundation three of us had already reviewed.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== PR head and follow-up commit =='
git rev-parse --short HEAD
git show -s --format='%h %s' HEAD
git merge-base --is-ancestor 3d49e20f HEAD && echo '3d49e20f is contained in HEAD'

printf '%s\n' '== Follow-up commit =='
git show --find-renames --stat --oneline 3d49e20f
git show --find-renames --format=fuller 3d49e20f -- internal/sandbox

printf '%s\n' '== Focused symbol outlines =='
ast-grep outline internal/sandbox/windows_identity_runtime_windows.go --items all --match 'provisionWindowsSandboxPrincipalForSetup|setupWindowsSandboxPrincipal' --view expanded || true
ast-grep outline internal/sandbox/windows_identity_windows.go --items all --match 'provisionWindowsSandboxIdentity|ensureWindowsSandboxUser' --view expanded || true
ast-grep outline internal/sandbox/windows_identity_secret_windows_test.go --items all --match 'windowsSecretACEList' --view expanded || true

printf '%s\n' '== Focused implementations and regression tests =='
rg -n -A120 -B15 \
  'func provisionWindowsSandboxPrincipalForSetup\b|func provisionWindowsSandboxIdentity\b|func windowsSecretACEList\b|secretPath|errWindowsSandboxNameCollision|injected|inject|addWindowsSandboxUserToGroup|resolveWindowsSandboxSID' \
  internal/sandbox/windows_identity_runtime_windows.go \
  internal/sandbox/windows_identity_windows.go \
  internal/sandbox/windows_identity_windows_test.go \
  internal/sandbox/windows_identity_secret_windows_test.go

Length of output: 78009


@Vasanthdev2004 Verified on current head 3d49e20f.

  • The ACE helper now rejects non-ACCESS_ALLOWED_ACE_TYPE entries before reading SidStart, so it no longer misinterprets object-ACE layouts.
  • The stale-secret cleanup is correctly available before post-provisioning failures: secretPath is derived from the deterministic account name, and undo removes it unconditionally.
  • The partial-provisioning rollback bug is fixed: failures from group attachment or SID resolution now return the derived username alongside created == true, allowing the existing undo path to remove the account rather than attempting to delete "".
  • The new Windows-only injected-failure tests cover both post-creation failure points and assert that the rollback identity retains the deletion target.

No remaining finding on these points.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x @gnanam1990 these two are the last open PRs on the board, so putting the ask in one place rather than on both.

Both are green and both change requests are addressed. The only reviews on either right now are CodeRabbit's, and both of those predate the fixes, so they should clear on the re-run I have triggered. What is missing is human approvals: my own fix pushes dismissed the ones you had already given, so #808 and #812 are each at zero of three. Sorry, that one is on me.

To make re-review cheap, what actually changed since each of you last looked:

#808 3d49e20f

  • WindowsACLAllowWrite now grants DELETE and FILE_DELETE_CHILD. This was anandan's P1 and it was a real functional bug: the principal could create and modify but not remove or rename, so ordinary editing and most git operations would have failed.
  • Post-creation failures return the account name, so the rollback stops deleting "" and stranding the account.
  • The four provisioning calls are seamed so those failure paths are reachable in a test at all.
  • The empty-secret test now seeds a genuinely empty file, which is CodeRabbit's point; the old whitespace seed never reached the branch it named.

#812 5db0e6ef

  • Rebased onto the above, with the same fix applied to the offline-group step.
  • ensureWindowsSandboxOfflineGroup seamed for the same reason.
  • The rebase also caught a clean merge that did not compile, where the runtime file still called windowsSandboxUserName without the role. Worth knowing it would have derived the secret path from the wrong role's account had it built.

Every fix is mutation-checked in both directions rather than just added, so reverting any one of them fails a named test.

Merge order when it comes to it: #808 first, then #812, since #812's base is feat/windows-sandbox-identity rather than main.

@gnanam1990 the delta from what you approved before is the ACL grant, the rollback return, and the test seams. No change to the identity model or the network design either of you already signed off.

Separately and not blocking your review: the elevated LogonUser path still has not executed anywhere, which is the open decision for @kevincodex1 rather than for you.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Findings

  • [P1] Stop deleting a pre-existing principal when ACL setup fails
    internal/sandbox/windows_identity_runtime_windows.go:221-228
    provisionWindowsSandboxPrincipalForSetup deliberately avoids deleting an account it did not create (created=false), but setupWindowsSandboxPrincipal calls removePrincipal() on any ACL plan/build/apply error with no such guard. Re-running elevated setup on a machine that already has a working principal and hitting a transient ACL failure therefore deletes the local account, removes the secret, and revokes logon rights. The same unconditional teardown runs from the folded setup rollback when a later step fails (windows_setup_windows.go:56-62, windows_identity_runtime_windows.go:230-239). Please scope teardown to what this run created, matching the created guard already used in provisionWindowsSandboxPrincipalForSetup.

  • [P1] Do not rotate an existing principal's password until the new secret is committed
    internal/sandbox/windows_identity_windows.go:435-437
    internal/sandbox/windows_identity_runtime_windows.go:154-168
    On re-setup of a managed principal, resetWindowsSandboxUserPassword runs inside provisionWindowsSandboxIdentity before logon-rights grant, secret write, or ACL apply. If any later step fails, undo() removes the on-disk secret and does not delete the pre-existing account (created=false), leaving a live account authenticated by a password nothing on disk knows. Commands then treat the missing secret as "not provisioned" and silently fall back to the restricted-token backend. Please defer password rotation until immediately before writeWindowsSandboxSecret, or restore the previous password on rollback.

  • [P2] Apply policy DenyWrite paths in the principal ACL plan
    internal/sandbox/windows_identity_runtime_windows.go:215-219
    internal/sandbox/windows_identity_acl.go:51-115
    The capability-SID setup path emits WindowsACLDenyWrite ACEs for every PermissionProfile.FileSystem.DenyWrite entry (windows_acl.go:52-63). The principal path only denies write on protected metadata and read-only subpaths inside write roots; it never passes policy DenyWrite into buildWindowsPrincipalACLPlan. When the runner uses a principal token (windows_command_runner_windows.go:93-100), those policy denies are not enforced at the OS layer and shell children can write where the restricted-token backend would block. Please mirror the capability plan's DenyWrite handling for principals.

  • [P2] Materialize principal DenyRead targets the way capability deny-read does
    internal/sandbox/windows_identity_acl.go:63-68
    internal/sandbox/windows_acl_apply_windows.go:93-97
    Capability deny-read entries set Materialize: true so absent paths still get a deny ACE before they exist (windows_acl.go:72-75). Principal deny-read entries omit Materialize, so applyWindowsACLPlan skips any target that does not exist at setup time. A deny-read path created later never gets a principal deny ACE. Please set Materialize: true on principal deny-read entries, matching the capability model.

  • [P2] Roll back partial LSA logon-right grants before deleting a new principal
    internal/sandbox/windows_identity_logon_windows.go:143-162
    internal/sandbox/windows_identity_runtime_windows.go:163-165
    grantWindowsSandboxLogonRights adds rights one at a time and returns on the first failure, but rightsGranted is only set after the full loop succeeds. If provisioning created a new account and grant fails mid-loop, undo() deletes the account without calling revokeWindowsSandboxLogonRights, leaving orphaned LSA entries keyed to a SID that no longer resolves. Please revoke whatever rights were granted before account deletion, or track partial grant state in rollback.

  • [P2] Reconsider silent downgrade when opt-in is set but the secret is unusable
    internal/sandbox/windows_identity_runtime_windows.go:107-112
    internal/sandbox/windows_command_runner_windows.go:84-101
    This is intentional fail-soft behavior, not an accidental regression: if the local principal account exists but the secret is missing, empty, corrupt, or cannot be DPAPI-decrypted, the runner falls through to the same-user restricted-token path with no stderr warning. That is weaker than principal isolation and unlike the explicit error path for logon failure (:116-120). If fail-soft is the right product choice, please at least warn on stderr when ZERO_WINDOWS_SANDBOX_IDENTITY=1 is set and the principal cannot be used, so operators know they are not getting the backend they opted into.

  • [P3] Disambiguate workspace hashes before truncating local account names
    internal/sandbox/windows_identity_windows.go:151-152
    windowsSandboxWorkspaceKey produces a 64-character hex digest, but windowsSandboxUserName keeps only the first 11 characters after the zero-sbx- prefix because of the 20-character NetAPI limit. Two workspaces whose digests share that prefix would collide on one local account, secret file, and ACL identity. Low probability on a single host, but please fold more entropy into the surviving name or detect and refuse collisions so distinct workspaces cannot share a principal.

Notes (not raised as merge blockers)

  • Network deny vs principal backend: Under default NetworkDeny policy, the principal backend is deliberately disabled because WFP filters key on the offline-marker SID (windows_identity_runtime_windows.go:69-76). That is tested and documented scope, not a bug; extending WFP to principal SIDs is follow-on work for #662.
  • Prior review items on head 3d49e20f: Delete rights on WindowsACLAllowWrite and pre-failure secretPath derivation for rollback appear addressed on the current head relative to anandh8x and CodeRabbit feedback on older commits.

Six findings from review, all on the elevated setup path.

Teardown was not scoped to what the run created. provisionWindowsSandbox
PrincipalForSetup was careful never to delete an account it had adopted,
and then setupWindowsSandboxPrincipal called removePrincipal on any ACL
failure with no such guard. Re-running elevated setup on a working machine
and hitting one transient ACL error therefore deleted the local account,
its secret and its logon rights. It now returns whether it created the
principal and the outer teardown honours it; ACEs are still reverted,
since this run applied them.

Password rotation moved to immediately before the secret is committed.
Resetting an adopted account's password at the top of provisioning meant
every later step ran against an account whose password had been replaced
with no copy stored. Any failure there left a live account authenticated
by a password nothing on disk knew, and since the account pre-existed the
rollback correctly declined to delete it, so the command path read the
absent secret as "not provisioned" and fell back to the weaker backend for
good. The two operations are now adjacent. The rollback also stops
removing the secret when this run neither created the account nor rotated
it, because that secret still works.

Policy DenyWrite now reaches the principal ACL plan. The capability plan
has always emitted these; the principal plan denied write only on
protected metadata and read-only subpaths, so once the runner used a
principal token a policy deny elsewhere was not enforced at all.

Principal deny-read entries are materialized, matching the capability
plan, so a path created after setup still gets a deny ACE.

Logon-right revocation is keyed to the attempt rather than to success.
Rights are added one at a time and the grant returns on first failure, so
a partial grant left LSA entries behind pointing at a SID that deleting
the account then made unresolvable.

The ownership comment now carries the full workspace key. The account name
holds only 11 characters of the digest, so two workspaces could derive one
name and silently share an account, a secret and an ACL identity; a
mismatch is now refused. Accounts provisioned before the key was recorded
are still adopted.

Also warns once on stderr when the opt-in is set and a provisioned
principal cannot be used, rather than downgrading in silence.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

All seven addressed in d67267f8. Thank you for this, the two P1s were both real and the first one is the more embarrassing of the two.

[P1] teardown scoping. Correct, and the reason it happened is worth stating: I added the created guard to provisioning's own rollback in response to earlier review, and left the outer one unguarded, so the care was undone one level up. provisionWindowsSandboxPrincipalForSetup now returns whether it created the principal and the outer teardown honours it. ACEs are still reverted, since this run applied them; the account is not this run's to destroy.

[P1] password rotation. Also correct. Rotation moved out of provisioning to sit immediately before writeWindowsSandboxSecret, so the two operations are adjacent. Windows offers no way to restore the previous password, so that is the smallest this window can be. The rollback also stops removing the secret when this run neither created the account nor rotated it, since that secret still authenticates. Removing it unconditionally was destroying a working credential whenever setup failed early on an already-provisioned machine.

Covered by a test asserting provisioning does not rotate an adopted account. Reverting it fails with "provisioning rotated the password; the window this closes lasts until the secret is committed".

[P2] policy DenyWrite now reaches buildWindowsPrincipalACLPlan, and [P2] principal deny-read is materialized. Both mutation checked: dropping either fails a named test rather than passing quietly.

[P2] partial LSA grants. Revocation is now keyed to the attempt rather than to the loop completing, so a grant that failed halfway is still unwound before the account is deleted. Revoking a right that was never granted is harmless, which is what makes that safe.

[P2] silent downgrade. Fail-soft is the right behaviour, staying quiet about it was not. It now warns once per process on stderr when the opt-in is set and a provisioned principal cannot be used. Once rather than per command, because this sits on the command path and a warning on every tool call gets filtered out by the reader rather than acted on.

[P3] name truncation. Taken as exact detection rather than more entropy. The ownership comment carries the full workspace key, so a collision is refused instead of two workspaces silently sharing one account, one secret and one ACL identity. Accounts provisioned before the key was recorded are still adopted. I also threaded the key into the delete path, since proving ownership from the marker alone would have let a collision delete another workspace's account.

On your note about prior items being addressed on 3d49e20f: yes, and thank you for checking rather than assuming. #812 is rebased on this with the dual-role version of the same rollback fix.

The LogonUser caveat is unchanged and still the open decision for @kevincodex1.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@Vasanthdev2004
Vasanthdev2004 requested a review from jatmn July 29, 2026 09:35
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

348-383: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Legacy bare-marker accounts still bypass workspace scoping at internal/sandbox/windows_identity_windows.go:348-383

windowsSandboxUserIsManaged accepts the unkeyed windowsSandboxUserComment for any workspaceKey, but never rewrites it to windowsSandboxUserCommentFor(workspaceKey). That keeps pre-key accounts reusable across colliding workspace names; migrate the comment here or make the legacy acceptance explicitly scoped elsewhere.

🤖 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 348 - 383, The
legacy bare marker branch in windowsSandboxUserIsManaged must be
workspace-scoped. When comment equals windowsSandboxUserComment, migrate or
rewrite the account comment to windowsSandboxUserCommentFor(workspaceKey) before
returning managed, or otherwise ensure acceptance is explicitly limited to the
intended workspace; preserve the keyed-comment comparison for normal accounts.
🤖 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.go`:
- Around line 348-383: The legacy bare marker branch in
windowsSandboxUserIsManaged must be workspace-scoped. When comment equals
windowsSandboxUserComment, migrate or rewrite the account comment to
windowsSandboxUserCommentFor(workspaceKey) before returning managed, or
otherwise ensure acceptance is explicitly limited to the intended workspace;
preserve the keyed-comment comparison for normal accounts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0f227da9-7534-49da-94a7-3cac5db69aee

📥 Commits

Reviewing files that changed from the base of the PR and between 99fefdc and d67267f.

📒 Files selected for processing (8)
  • internal/sandbox/windows_acl_apply_windows.go
  • internal/sandbox/windows_identity_acl.go
  • internal/sandbox/windows_identity_policy_windows_test.go
  • internal/sandbox/windows_identity_rollback_windows_test.go
  • internal/sandbox/windows_identity_runtime_windows.go
  • internal/sandbox/windows_identity_secret_windows_test.go
  • internal/sandbox/windows_identity_windows.go
  • internal/sandbox/windows_identity_windows_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • internal/sandbox/windows_identity_acl.go
  • internal/sandbox/windows_identity_runtime_windows.go
  • internal/sandbox/windows_identity_secret_windows_test.go
  • internal/sandbox/windows_identity_windows_test.go

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x @jatmn re-review please when you have a moment. You two are the only thing left on this one now: CodeRabbit came back COMMENTED rather than blocking on the current head, and 9 of 9 are green.

Both your change requests sit on commits older than the fix. Everything you each raised is addressed in d67267f8, with the detail in my replies above rather than repeated here.

Shortest version of the delta since you last looked:

  • anandan: the write grant now carries DELETE and FILE_DELETE_CHILD, and post-creation failures return the account name so the rollback stops deleting "".
  • jatmn: teardown is scoped to what the run created, rotation moved to sit immediately before the secret write, policy DenyWrite reaches the principal plan, deny-read is materialized, partial logon-right grants are unwound, and the ownership comment carries the full workspace key.

Every one of those fails a named test if reverted, so if something looks unverified it is more likely I mislabelled it than that it is untested. Say so and I will point at the specific test.

The LogonUser caveat is unchanged and remains @kevincodex1's call, not a reason for either of you to hold an approval.

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed the latest head d67267f. The two issues from my previous review are fixed: the principal write grant now includes DELETE and FILE_DELETE_CHILD, and post-creation failures return the derived username so rollback can remove the account. The new tests bind both fixes.

Two issues remain before this foundation is ready.

[P1] internal/sandbox/windows_identity_runtime_windows.go:195-202 — the new rollback still revokes every LSA right from an adopted principal whenever any later step fails. rightsAttempted is set for both newly-created and pre-existing accounts, while revokeWindowsSandboxLogonRights uses AllRights=1. On a re-run, a transient grant, secret-path, or secret-write failure therefore keeps the adopted account but removes its existing SeBatchLogonRight and deny-logon rights, destroying the working principal this commit is specifically trying to preserve. Only revoke all rights when this run created the account. For an adopted managed account, leaving a partial set of the desired rights is safer than deleting all pre-existing rights. Add an injected adopted-account failure test that asserts revocation is not called.

[P2] internal/sandbox/windows_identity_secret_windows.go:186-193 — the runtime warning and fail-soft contract say a missing or unreadable secret falls back, but ERROR_ACCESS_DENIED still becomes a hard error. This occurs when setup is elevated with another administrative account through runas or over-the-shoulder UAC: the protected DACL belongs to that account, so the normal user cannot read it and every sandbox command fails. Map permission-denied reads to errWindowsSandboxIdentityUnavailable so the existing warning and restricted-token fallback run. I would not swallow permission errors from removeWindowsSandboxSecret: reporting incomplete teardown is safer than deleting an account while its secret remains.

Second round of review findings, both on the elevated setup path.

The rollback revoked logon rights whenever they had been attempted,
without regard to whether this run created the account.
revokeWindowsSandboxLogonRights passes AllRights, which drops every right
the account holds and deletes its LSA object outright. On an adopted
principal that is not a rollback but destruction: a transient grant,
secret-path or secret-write failure during a re-run stripped the
SeBatchLogonRight and deny-logon rights an earlier setup had established,
leaving exactly the broken-but-present principal this path exists to
avoid. Revocation is now scoped to accounts this run created. The rights
granted to an adopted account are the ones it is supposed to hold, so
leaving them is the safe direction.

A secret the current user cannot read now falls back instead of failing
the command. The secret's DACL names whoever ran setup, so an operator who
elevated with a separate administrative account, through runas or an
over-the-shoulder UAC prompt, leaves a secret their ordinary account
cannot open. That is the documented fail-soft case, and treating it as a
hard error made every sandboxed command fail on a machine that was merely
set up by a different admin. Permission errors from the removal path are
deliberately still reported, since incomplete teardown is worth knowing
about.

Both are covered by injected-failure tests and fail if the guard is
removed. The secret read is seamed to inject the permission error, because
producing a real ERROR_ACCESS_DENIED needs DACL surgery and would test the
platform rather than the mapping.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both fixed in a1a0edc5. The first one is the fourth time this week that I have guarded one level and left the level above it open, which is worth me noticing rather than just fixing.

[P1] rights revocation. You are right and I had not looked at what AllRights does. I scoped the account deletion to created and left revocation on rightsAttempted alone, so a re-run that failed anywhere after the grant stripped an adopted principal's SeBatchLogonRight and deny-logon rights and deleted its LSA object. That is worse than the account deletion I had just fixed, because it leaves the account present and broken, which is precisely the state the commit was meant to prevent.

Revocation is now scoped to created. I took your reasoning on the adopted case: the rights this run granted are the ones the account is supposed to hold, so leaving a partial set beats deleting a working one.

Test is the injected adopted-account failure you asked for, with the created case alongside it so the guard cannot be satisfied by never revoking at all. Reverting to the unconditional form fails it with "rights were revoked for an adopted account; AllRights drops its pre-existing rights and deletes the LSA object".

[P2] permission-denied secret read. Also taken. os.IsPermission now maps to errWindowsSandboxIdentityUnavailable, so the existing warning and restricted-token fallback run instead of every command failing. The runas and over-the-shoulder UAC case you describe is a normal way to run an elevated setup, so failing closed there was punishing an ordinary configuration.

I left removeWindowsSandboxSecret alone, as you suggested: reporting incomplete teardown is better than deleting an account while its secret survives.

One note on how that is tested. The read is seamed and the permission error injected, rather than manufactured. I tried the cheap ways first and neither works on Windows: a 0000 file is still readable, and reading a directory reports "Incorrect function" rather than access denied. A real ERROR_ACCESS_DENIED needs DACL surgery on the fixture, which would test the platform rather than the mapping I changed. Say the word if you would rather have the heavier end-to-end version.

@Vasanthdev2004
Vasanthdev2004 requested a review from anandh8x July 29, 2026 14:35

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before I'm comfortable merging this.

Findings

  • [P1] Grant the principal write access to sandbox runtime directories added at command time
    internal/sandbox/windows_identity_runtime_windows.go:267-300
    internal/sandbox/runtime_state.go:39-53, 230-241
    internal/sandbox/windows_command_runner_windows.go:93-100
    internal/sandbox/windows_runner.go:330-345
    Each sandboxed command calls permissionProfileWithRuntime, which appends runtimeState.Root (under %LOCALAPPDATA%\zero\runtime\…, not inside the workspace) to WriteRoots and redirects HOME, GOCACHE, npm_config_cache, and similar env vars into that tree. Elevated setup applies principal ACLs from the profile at setup time and never sees that runtime root. On the default restricted-token path the child still runs as the caller, so it can write the user cache without an extra ACE. A principal is a separate local account with no inherited rights there, and once windowsSandboxPrincipalToken succeeds the runner does not apply capability ACLs for that command (ensureWindowsUnelevatedSetup only runs for the unelevated tier, and those ACEs name capability SIDs anyway). I expect opt-in principal commands to hit ACCESS_DENIED on normal npm/go/cargo/pip cache writes. Please grant the principal write access to the live runtime root on the command path (or extend elevated setup so the runtime tree is covered before commands run).

  • [P2] Verify workspace ownership at command-time lookup, not only during setup
    internal/sandbox/windows_identity_windows.go:517-523
    internal/sandbox/windows_identity_runtime_windows.go:93-132
    Setup refuses a truncated-name collision by checking the account comment against the full workspace key (windowsSandboxUserIsManaged, errWindowsSandboxNameCollision). lookupWindowsSandboxIdentity only maps windowsSandboxWorkspaceKeywindowsSandboxUserName → SID and never reads the comment. With real workspace keys I think accidental collision is rare: the key is a full SHA-256 hex digest and the account name keeps only 11 hex characters, so we're looking at roughly 2⁻⁴⁴ per pair. The hole is still real—workspace B can fail setup yet bind to workspace A's principal, secret file, and ACL identity when the suffix collides (your test keys aaaaaaaaaaaabbbbbbbb and aaaaaaaaaaaacccccccc force this). I'd like the stored comment verified against the caller's workspace key before lookup returns a principal, matching what provisioning already does.

  • [P2] Materialize git carveout deny-write ACEs for principals
    internal/sandbox/windows_identity_acl.go:98-103
    internal/sandbox/windows_acl_apply_windows.go:89-97
    internal/sandbox/profile.go:65-69
    Principal deny-write entries for ReadOnlySubpaths (.git/config, .git/hooks from gitMetadataWriteCarveouts) omit Materialize: true, while protected metadata and policy deny paths set it. applyWindowsACLPlan silently skips absent targets without Materialize. On a fresh workspace where .git does not exist yet, setup completes without those carveout ACEs; once git creates the paths, the principal still has inherited workspace write access and can modify git config or install hooks. The capability plan uses the same non-materialized pattern for subpath denies, but there the restricted token runs as the caller and the carveout is less load-bearing; for a separate principal account I think this gap is newly user-visible. Please set Materialize: true on those subpath denies, matching policy deny-read handling.

  • [P2] Fix or replace the gated provisioning round-trip test
    internal/sandbox/windows_identity_windows_test.go:202-264
    internal/sandbox/windows_identity_windows.go:455-470
    TestProvisionWindowsSandboxIdentityRoundTrip calls provisionWindowsSandboxIdentity twice, then logonWindowsSandboxPrincipal(again.Username, secondPassword). For an existing account, provisionWindowsSandboxIdentity deliberately does not rotate the password; rotation is deferred to provisionWindowsSandboxPrincipalForSetup. The second returned password is a fresh random string the account does not hold, so I believe the logon step fails when the test is enabled. CI skips the test today, so this is latent rather than blocking CI. The stale comment at lines 212-214 also says provisioning resets the password on leftover accounts. Please align the test with the deferred-rotation contract (exercise provisionWindowsSandboxPrincipalForSetup, or stop asserting logon with the second low-level return value).

  • [P2] Document or align setup-time and command-time opt-in for ZERO_WINDOWS_SANDBOX_IDENTITY
    internal/sandbox/windows_setup_windows.go:43
    internal/sandbox/windows_setup.go:151-158
    internal/sandbox/windows_identity_runtime_windows.go:35-39
    Elevated zero sandbox setup calls windowsSandboxIdentityEnabled(config.commandConfig().Env) with no Env field populated, so it reads only the process environment. Sandboxed commands pass --env-json, and windowsSandboxIdentityEnabled treats an explicit map entry as authoritative (TestWindowsSandboxIdentityEnvOverridesProcess). Setup can skip principal provisioning while commands opt in through child env, or provision a principal while commands opt out. That's avoidable when setup and commands share the same shell export, but the two paths can diverge silently. Please document that setup must export the variable, or thread the intended value through setup args.

  • [P2] Revoke stale principal ACEs when the profile shrinks or setup is re-run
    internal/sandbox/windows_identity_runtime_windows.go:267-300
    internal/sandbox/windows_identity_acl.go:138-159
    Principal ACL application merges new explicit entries into existing DACLs (ACLFromEntries(accessEntries, oldDACL)). Re-setup only applies the current plan; windowsPrincipalRevokePlan is implemented and tested but I don't see a production caller. Shrinking write/read roots or policy deny lists therefore leaves prior principal ACEs on disk. This matters mainly when profiles shrink or drift across versions; steady-state re-runs that only refresh the same grants are less affected. I'd like trustee-revocation (or snapshot-revert) before applying a new plan, or a call to windowsPrincipalRevokePlan over the paths that were previously granted.

  • [P3] Make TestWindowsSandboxIdentityGating hermetic for the absent-env case
    internal/sandbox/windows_identity_runtime_windows_test.go:11-28
    The "absent" case passes an empty env map, so windowsSandboxIdentityEnabled falls through to os.Getenv. On a machine where the test process already has ZERO_WINDOWS_SANDBOX_IDENTITY=1, the absent case fails. Please clear the process variable (or pass an explicit unset sentinel) before that subtest.

Claim drift (not merge blockers for me)

These are wording or scope notes, not bugs I'm asking you to fix in this PR unless you want to.

  • #662 / default network-deny: I see the principal backend is intentionally disabled when Network.Mode == NetworkDeny (windowsSandboxPrincipalEligible), and default policy is network-deny. With only ZERO_WINDOWS_SANDBOX_IDENTITY=1, commands still use the restricted same-user token and credentialDenyReadPaths remains a Windows no-op. You document the tradeoff in the body; I'd still narrow the title/body so it doesn't read like #662 is closed for the default install. Principal read confinement needs elevated setup plus a network-allow command profile until WFP is keyed to the principal SID.

  • “No existing install changes behaviour”: Principal provisioning is gated behind the env var, but the shared WindowsACLAllowWrite change (adding DELETE and FILE_DELETE_CHILD) applies to capability-SID ACL plans on every elevated setup re-run, not only when the opt-in is set. That's a real behaviour change for installs that re-run setup without the env var. Worth a line in the PR notes; it's separate from principal logic.

Where I stand on prior review

My changes-requested review at 3d49e20 asked you to stop deleting adopted principals on ACL failure, defer password rotation until secret commit, carry policy DenyWrite into the principal plan, materialize principal DenyRead, and roll back partial LSA grants safely. At head a1a0edc those look addressed: removePrincipal is gated on created, rotation happens immediately before writeWindowsSandboxSecret, policy deny-write and materialized deny-read are in buildWindowsPrincipalACLPlan, and undo() revokes LSA rights only when created is true (TestSetupRollbackRevokesRightsOnlyForCreatedPrincipals).

anandh8x's changes-requested review at d67267f asked for delete bits on the principal write grant and to stop revoking adopted principals' logon rights on rollback. I see DELETE | FILE_DELETE_CHILD on WindowsACLAllowWrite and the rightsAttempted && created guard at head; those two look fixed to me as well.

CodeRabbit's open changes-requested thread looks largely stale relative to head (password rotation timing, permission-denied secret reads, adopted-account rollback). The ACE-type check in windows_identity_secret_windows_test.go now validates AceType before decoding.

gnanam approved at 832f53a and noted a non-blocking follow-up to assert adopted accounts are not in Administrators; I still don't see that membership check.

Related work

Open PR #812 (feat/windows-sandbox-offline-online) extends this identity model with separate offline/online principals; I treat that as follow-on work, not a duplicate of this foundation PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants