Skip to content

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

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

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

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

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

What this does NOT do yet

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

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

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

Why

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

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

What this does

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

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

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

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

Verification, and what is not verified

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

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

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

Honest caveats:

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

Worth deciding before this leaves draft

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

Summary by CodeRabbit

  • New Features
    • Added sandbox exec for running commands through the configured sandbox.
    • Added optional Windows sandbox identities with network-aware isolation and protected credentials.
    • Added safer runtime directory handling, read-access controls, and Git metadata protection.
    • Added refusal of nested Git repository initialization.
  • Bug Fixes
    • Strengthened protection against redirected paths, junctions, unsafe cleanup, and stale permissions.
    • Improved setup diagnostics, rollback safety, and deterministic runtime behavior.
  • Tests
    • Expanded coverage across sandbox execution, Windows security, networking, secrets, Git handling, and rollback.

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

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

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

Changes

Windows sandbox security and execution

Layer / File(s) Summary
Materialize and restore ACL targets
internal/sandbox/windows_acl*.go, internal/sandbox/windows_identity_acl.go
Adds ordered ACL planning, file-aware materialization, reparse-point rejection, identity tracking, handle-relative cleanup, and rollback restoration.
Provision identities and protect credentials
internal/sandbox/windows_identity_windows.go, internal/sandbox/windows_identity_logon_windows.go, internal/sandbox/windows_identity_secret_*.go
Adds managed accounts and groups, logon rights, DPAPI-protected secrets, privilege checks, lookup, retirement, and rollback.
Integrate setup and runtime selection
internal/sandbox/windows_setup*.go, internal/sandbox/windows_identity_runtime_windows.go, internal/sandbox/windows_command_runner_windows.go, internal/sandbox/windows_runner.go
Propagates principal configuration, derives runtime roots, applies ACL ledgers, validates network coverage, and selects principal or restricted tokens.
Expose execution and policy checks
internal/cli/sandbox*.go, internal/sandbox/profile.go, internal/sandbox/analyzer.go, internal/sandbox/risk.go, internal/doctor/hardening.go
Adds sandbox exec, Git carveout and nested-init handling, deterministic runtime fallback behavior, setup diagnostics, and principal status reporting.

Priority: ➖ Normal

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

Severity of issue fixed: Medium

Merge Risk: 🟠 High · up to 1056e

The Windows sandbox changes still carry material security, filesystem, and command-availability risks. In particular, elevated operations may alter unintended paths, ACL planning can create the wrong object type, and valid sandbox commands may be refused. These issues should be resolved before merge.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR provides substantial provisioning, ACL, secret-storage, and runtime foundations for #662, but it does not resolve the issue. Principal command launching remains unavailable, support is opt-in, … Implement and validate a supported command-launch mechanism for the sandbox principal, or another default Windows confinement mechanism. Ensure sandboxed commands cannot read the credential stores listed in #662, and complete principal-SID …
Out of Scope Changes check ⚠️ Warning The PR includes changes beyond the linked issue and principal foundation, including the new sandbox CLI command, nested Git initialization policy, signal-handling behavior, and the unrelated exported … Remove unrelated changes from this PR or link separate issues that define their requirements. Keep only the Windows principal foundation and directly required sandbox security changes.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 84.81% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 395 functions across 88 files. (9 skipped: …
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding Windows sandbox principal support as foundational work. The issue scope and incomplete status are also stated accurately.
Full details: Linked Issues check

Explanation

The PR provides substantial provisioning, ACL, secret-storage, and runtime foundations for #662, but it does not resolve the issue. Principal command launching remains unavailable, support is opt-in, and default Windows installs can still read credential stores.

Resolution

Implement and validate a supported command-launch mechanism for the sandbox principal, or another default Windows confinement mechanism. Ensure sandboxed commands cannot read the credential stores listed in #662, and complete principal-SID network-deny integration if required.

Full details: Out of Scope Changes check

Explanation

The PR includes changes beyond the linked issue and principal foundation, including the new sandbox CLI command, nested Git initialization policy, signal-handling behavior, and the unrelated exported peermsg directory helper.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/windows-sandbox-identity

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

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

Consolidate the five separate advapi32.dll lazy loads.

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

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

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

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

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

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

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

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

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

---

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

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

Scope

Head: 378b38c3634f
Changed files (134): internal/cli/sandbox.go, internal/cli/sandbox_exec.go, internal/cli/sandbox_exec_cancel_test.go, internal/cli/sandbox_exec_env_test.go, internal/cli/sandbox_exec_grace_unix_test.go, internal/cli/sandbox_exec_signal_other.go, internal/cli/sandbox_exec_signal_other_test.go, internal/cli/sandbox_exec_signal_windows.go, internal/cli/sandbox_exec_test.go, internal/doctor/hardening.go, internal/doctor/hardening_principal_windows_test.go, internal/peermsg/private_dir_other.go, and 122 more

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

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

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

Give the operator an exit when the principal backend breaks.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

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

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

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

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Changes requested.

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

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

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

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

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

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

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

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

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

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

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

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

Merge is kevin's call per the program gate.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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

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

Table is still not hermetic.

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

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

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

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

Still assumes every ACE is an ACCESS_ALLOWED_ACE.

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

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

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

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

Path traversal via ProtectedMetadataNames still unaddressed.

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

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

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

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

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

Source: Coding guidelines

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

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

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

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

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

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

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

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

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

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

---

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

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

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

---

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

The stale comment. Fixed, it is windowsSandboxWorkspaceKey.

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

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

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

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

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

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

Fixed in fbe340b:

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

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

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

gnanam1990
gnanam1990 previously approved these changes Jul 27, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Approve.

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

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

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

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

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

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

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

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

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

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

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

Merge is kevin's call per the program gate.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

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

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

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

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

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

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

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

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

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

📥 Commits

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

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

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

Copy link
Copy Markdown
Collaborator Author

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

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

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

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

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

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

Approve.

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

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

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

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

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

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

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

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

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

Merge is kevin's call per the program gate.

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

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review at 99fefdc

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

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

What this does

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

What's good

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

Verification performed

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

CodeRabbit's findings are addressed

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

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

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

Honest caveats (from the PR description, still accurate)

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

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

Verdict

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

@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. The requests below focus on correcting the underlying ownership, planning, and test-isolation contracts, with regression cases to show those contracts hold.

Merge readiness

The branch is two commits behind main at c1937dfa (#949 and #914). GitHub reports it mergeable and all 13 head checks pass, with no changed-file overlap with those commits. Please refresh against current main and rerun the applicable checks to meet AGENTS.md's fresh-base requirement. This is a merge-readiness requirement; the comparison does not establish a conflicting change or rollback of upstream behavior.

Findings

1. [P1] Preserve Git carveouts when the workspace owns a nested repository

internal/sandbox/profile.go:194

With both outer/.git/ and outer/project/.git/ present, using outer/project as the workspace causes gitMetadataWriteCarveoutSpecs to return no hooks/config carveouts. The local .git directory does not take the early pointer-file branch, and the later ancestor check suppresses its protections. Meanwhile, workspaceGovernedByAncestorRepository returns false whenever the workspace has its own .git, so the initialization refusal does not compensate. Commands receive workspace write access without the previous protections on that repository's .git/config and .git/hooks, including configuration capable of changing what Git executes.

The root cause is inconsistent ownership classification between profile construction and the refusal guard. An ancestor having a repository does not make it the owner of this workspace's metadata when the workspace already has its own repository. The same nested-own-directory case returns both carveouts on the merge base and current target, and none on this head.

Please make those classifications agree so an existing local repository retains its config/hooks protection. Preserve the file-shaped protection for linked-worktree pointers and the behavior that avoids manufacturing a competing .git in a workspace genuinely governed by an ancestor. A focused regression should create both repositories and inspect the resulting protections, alongside controls for a pointer file and an ancestor-governed workspace with no local .git. The existing own-Git guard assertion alone cannot catch a profile that has already lost its carveouts.

2. [P1] Make the unelevated runtime ACL plan applicable on first use

internal/sandbox/windows_runner.go:365

The command profile now includes both runtime candidates for the unelevated tier, while prepareSandboxRuntime creates only the selected candidate. Start with a usable cache and no previously provisioned fallback: the cache runtime exists, but the fallback is serialized as another required allow-write root. ensureWindowsUnelevatedSetup passes that plan to applyWindowsACLPlan, whose missing-target branch returns “windows ACL target does not exist.” The command fails before launch, and the unsuccessful setup records no marker that could change the next attempt.

The mismatch is between the plan's required objects and the objects provisioned by this execution route. Elevated setup ensures the candidate trees exist; the unelevated route does not perform that step. The actual command-argument and ACL-plan construction reproduces the absent required root on this head, while the corresponding base and target plans require only the prepared root. The native Windows rejection follows from the applier's required-target check; I have not executed that native failure here.

Please ensure every required runtime target in an unelevated plan is ready when that plan is applied. Keep the candidate agreement needed for elevated setup and marker validation, and retain the parent-derived paths: deriving candidates again inside the helper would use its redirected TEMP. A useful regression starts with fresh owned cache and fallback locations, takes the real unelevated route, and verifies that setup reaches command launch. Include the alternate runtime selection and a retry so the fix addresses provisioning rather than relying on a tree left by a previous test or elevated setup.

3. [P2] Close the final directory descriptor during fallback validation

internal/sandbox/runtime_state.go:287; internal/peermsg/private_dir_unix.go:25

The new per-command call to peermsg.EnsurePrivateDir exposes a descriptor-lifetime defect in that helper. defer unix.Close(parentFD) captures the initial descriptor value, but the traversal closes that descriptor and repeatedly assigns a new one to parentFD. The deferred operation therefore does not reliably close the directory held at the end of traversal. With an odd-depth fallback anchor, twenty runtime prepare/release cycles leave twenty additional descriptors open. The corresponding base and target fallback paths leave none.

The helper's bug predates this PR; its repeated use during fallback preparation is the causal change here. Closing the runtime lease does not release the helper's leaked descriptor, so a long-running process using this fallback can eventually exhaust its descriptors.

Please make descriptor ownership explicit throughout the traversal: release superseded descriptors and close the currently owned descriptor exactly once on success and error exits. Keep the no-follow traversal, owner validation, and private permissions. A regression should exercise repeated calls at different path depths, plus a failure partway through traversal, and verify that descriptors do not accumulate. Testing only one path depth can hide the defect through descriptor-number reuse.

4. [P2] Verify materialized child identity before rollback deletes it

internal/sandbox/windows_acl_apply_windows.go:967

Rollback verifies the anchor's identity, but the materialization record identifies descendants by name and whether they were created. If setup creates .git/config, that file is renamed aside, and another ordinary file is moved into the same name before a later ACL failure, rollback reopens the replacement and deletes it. The relative open protects against escaping through an ancestor path; it does not prove that the child is the object this setup created. The snapshot's TargetID guard is reached only afterward, and successful removal skips it entirely.

This deletion path becomes relevant to default restricted-token setup through the new protected-metadata materialization. The older rollback machinery already had unsafe pathname cleanup, but the default protected-write entries did not previously materialize these targets. This finding concerns ownership of a replaced descendant under an unchanged anchor. It is supported by the creation, close, reopen, and deletion code path; a native Windows replacement-race reproduction has not been run here.

Please make rollback remove only the actual objects created by this attempt. Any identity comparison needs to apply to the object being deleted, without reopening a mutable name between the check and deletion. Preserve handle-relative, no-follow traversal and conservative refusal when ownership cannot be established. Exercise an ordinary same-shape replacement after materialization, force a later failure, and assert that the replacement survives and the mismatch is surfaced. Also retain a control showing that unchanged objects created by the failed attempt are removed. Check both file leaves and created directory components, since the ownership record covers both.

5. [P2] Cover the inline Git alias that bypasses nested-repository protection

internal/sandbox/analyzer.go:341

In an ancestor-governed workspace with no local .git, this supported Git command creates a repository:

git -c alias.bootstrap=init bootstrap

commandCreatesGitRepository sees bootstrap, falls through its exact-subcommand switch, and does not set GitInit. A shell request with permission granted passes Evaluate, while the profile supplies no local hooks/config carveouts. Actual Git execution creates the repository. On macOS, the previous profile supplied Seatbelt write-denial rules for those future paths; this PR removes them, leaving the new repository's execution configuration writable.

The evaluator also allowed this alias on the base and target. The regression is that the passive protection is removed and the replacement refusal misses this command. It is separate from finding 1: this workspace starts without its own repository, so recognizing an existing local .git directory will not close the gap.

Please ensure this inline alias-to-init command obeys the same protection/refusal contract as direct git init. Keep normal Git operations and ancestor discovery working. The necessary outcome is bounded to this demonstrated gap; it does not require a general shell interpreter or a blanket ban on Git aliases. A regression should pass this spelling through Engine.Evaluate with the shell grant and network permission already present, assert the refusal or equivalent effective protection, and retain a harmless inline-alias control so the fix cannot pass by rejecting every alias.

6. [P2] Isolate runtime-producing Windows tests from the user's cache and temp

internal/sandbox/windows_identity_policy_windows_test.go:319; internal/sandbox/windows_workspace_canonical_windows_test.go:108

TestSetupGrantsTheRuntimeRootCommandsActuallyUse calls real runtime setup and preparation with only the workspace redirected. TestSetupAndPrepareRuntimeAgreeOnANonCanonicalRoot does the same. The resolved runtime roots therefore reach the user's actual cache and fallback temp. These calls create persistent trees there, and preparation calls the real cache reclaimer, which can remove older inactive user runtime trees. Releasing the lease closes the lease; it does not undo those filesystem effects.

The fixture owns the workspace but not every root resolved by the code under test. AGENTS.md explicitly requires isolation of real config/cache/state and applies that requirement to every test reaching the same storage. Setup stubs that redirect only the cache also leave their fallback temp candidate outside the fixture.

Please establish owned cache and temp roots before these production calls and keep their cleanup within the fixture. Apply the fixture to the sibling runtime-producing tests and stubs, including fallback selection. Preserve the real setup/preparation assertions so isolation does not hide the behavior being tested. Verify the actual resolved roots belong to the fixture and exercise both cache and fallback selection; redirecting only the preferred candidate would leave the same root cause on the alternate path.

7. [P3] Check resolver purity before creating the runtime

internal/sandbox/windows_workspace_canonical_windows_test.go:230

TestTeardownPathDerivationCreatesNothing snapshots temp before resolving setup paths, then calls prepareSandboxRuntime before its final assertCreatedNothing. With a fresh temp directory, preparation legitimately creates the fallback anchor. The assertion reports that new entry as a side effect of path derivation even though it was produced by the later preparation call. A pre-existing anchor hides the failure.

The observed interval contains both the pure operation being tested and an intentionally effectful operation. Isolating temp as requested above makes that ordering defect easier to expose; it does not fix it.

Please assert the resolver's lack of side effects immediately after resolution, then prepare the runtime and retain the setup/command root-membership check. Run this case with an initially absent fallback anchor. Both contracts matter: resolving paths creates nothing, and subsequent preparation creates a usable runtime at one of those paths.

8. [P3] Pin the principal mode in caller-identity transport tests

internal/sandbox/windows_setup_test.go:526

TestWindowsSandboxSetupArgsCarryTheCallerIdentity and TestWindowsSandboxSetupArgsOmitAnUnknownCallerIdentity leave PrincipalOptIn unset. With ZERO_WINDOWS_SANDBOX_IDENTITY=1, both fail in BuildWindowsSandboxSetupArgs at the principal-provisioning refusal before testing identity transport. Both failures reproduce with that environment value.

An unset option deliberately consults ambient configuration, so these transport fixtures have an uncontrolled input unrelated to their assertions. This leaves the earlier environment-isolation request unresolved for these tests.

Please explicitly select opt-out in these transport fixtures, through the option or a scoped environment setting. Preserve the production behavior for an unset option and the dedicated tests of ambient opt-in. Running these two tests with the outer environment both unset and set to 1 should exercise the same caller-identity assertions, without requiring principal provisioning to become available.

gitMetadataWriteCarveoutSpecs asked the ancestor question unconditionally, so
a workspace nested inside another repository lost its hooks and config
carveouts even when it owned its own .git. That metadata is the workspace's,
not the ancestor's, and nothing compensated: workspaceGovernedByAncestorRepository
answers the same question by testing for a local .git, so it reported such a
workspace as not governed and the initialization refusal never fired. Commands
received plain workspace write access over a live .git/config and .git/hooks,
which is the configuration that decides what git executes.

The ancestor branch is now gated on the lstat error, which makes the condition
literally the test workspaceGovernedByAncestorRepository applies and the shared
doc comment already claimed. It cannot reintroduce the competing-control-directory
problem it was written for, because the carveouts only materialize where .git
already exists and git's discovery walk therefore already stops at this
workspace. A permission failure on the lstat keeps today's behaviour.

Reported by @jatmn.
…icalization

PermissionProfileFromPolicy stores normalizeProfilePath(root), which resolves
symlinks, so comparing against a raw t.TempDir() path matched locally and failed
on both CI runners: macOS reaches the temp directory through /var -> /private/var,
and the Windows runner's profile directory has a short-name expansion. The
carveouts were present in both failures; only the lookup missed them.

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

The previously reported unelevated-runtime, descriptor-lifetime, materialization-identity, inline-alias, and test-isolation issues remain on 83194843. Their current failure paths are included below.

Merge readiness

  • [P2] Restore the failing Windows smoke check

    Smoke (windows-latest) fails in TestTeardownPathDerivationCreatesNothing: its shared-temp snapshot observes a directory created by another test package. Please isolate the observed directory and rerun the check. The separate purity-assertion ordering defect below also needs correction; it is not the shared-directory collision shown in this log.

Guidance for addressing these together

There are seven findings in production paths and six in tests. They have different impacts: a blocked unelevated launch, incorrect failure recovery or resource ownership, an incomplete refusal, and tests that either exercise the wrong contract or can affect real user state. The number should not be read as thirteen reproduced production outages. Some Windows findings are supported by the concrete call paths below rather than native execution.

The recurring problem is that related pieces agree on the successful local path but disagree at their boundaries. Runtime selection chooses one root while the ACL plan requires two; setup augments a profile while a direct command consumer retains the original; a public argument builder refuses provisioning while the actual helper can proceed. Please treat the runtime plan and the provisioning decision as contracts that every affected producer and consumer must honor. Fixing only the first failing test or caller will leave another route to the same disagreement. This can be addressed within the existing implementation; a new framework or a broader launch feature is not required.

A second pattern is that ownership is established too late or remembered incompletely. Setup modifies an existing DACL before recording the rollback baseline, rollback remembers a created child's name without its identity, lease creation happens before the owned path is validated, and a deferred close remembers a descriptor number whose ownership has already changed. For each affected operation, make the sequence explicit: validate the object or path, capture the state needed for recovery, perform the effect, and release or undo only the resources this attempt owns. The acceptance condition on a handled failure is that pre-existing valid state remains usable and unrelated objects are untouched. This asks for the error and retry guarantees implicated below, not a new general crash-recovery system.

The test findings show the same boundary problem in fixtures. A mocked provisioner can enter a real account-deletion path; redirecting a workspace leaves runtime storage ambient; a purity assertion encloses deliberate creation; and a transport test inherits an unrelated feature mode. Please establish fixture ownership before the first effect, isolate all inputs that select runtime storage, and make account mutation consistently real-and-explicitly-gated or fully mocked within each affected fixture. Cleanup must use the identity actually created and be registered before subsequent operations can fail. Retain the behavioral assertions that make these tests useful.

To address this review in one pass, work through each shared contract and its named callers rather than treating each paragraph as a one-line patch request. For runtime planning, cover setup, unelevated commands, direct smoke consumers, cache selection, and fallback selection. For ownership and rollback, cover existing state, fresh state, a failure after the first effect, and a retry. For fixtures, check the affected sibling callers of the same setup/provisioning helpers, including their cleanup and alternate-root behavior. These checks are bounded to the failure paths identified here; they are not a request to add unrelated features or rewrite the subsystem.

The findings remain separate where the corrective outcomes differ. Isolating temp does not fix an assertion that observes deliberate creation. Closing the helper gate does not make an unelevated runtime plan applicable. Giving rollback a child identity does not restore a DACL modified before its snapshot. A shared fix may resolve several items, but please demonstrate each affected outcome rather than relying only on a passing package run. The regression guidance below describes what to verify; it is not an assertion that those proposed native tests have already been run.

Findings

  • [P1] Make the fresh unelevated runtime plan applicable

    internal/sandbox/windows_runner.go:365

    The unelevated command now includes both runtime candidates, but prepareSandboxRuntime creates only the selected one. With a fresh usable cache, the unused fallback is absent. ensureWindowsUnelevatedSetup applies the expanded plan without creating that target, and the required AllowWrite entry fails with “windows ACL target does not exist.” The command never launches, including on retry. The base plan requires only the prepared root.

    Please make every required target usable when the unelevated plan is applied, without requiring elevated setup or an unusable unused cache candidate. Keep candidate derivation in the parent, before TEMP is redirected. Exercise the real planner with fresh roots and alternate selection; the handcrafted unelevated profiles miss this combination.

    Root cause and bounded fix: Candidate discovery and required-target selection have become conflated. A candidate being a valid possible runtime location does not mean that it exists or is usable for this particular invocation. Reconcile the required ACL plan with the actual preparation path, including the unelevated consumer; suppressing its missing-target error would leave the promised access unapplied. Either preparation or plan construction may need adjustment, but the resulting required entries must describe targets that this invocation can use.

    Regression acceptance: Start with both runtime namespaces absent and a usable cache, then repeat with fallback selected because the preferred location is unusable. Drive the real preparation and command-planning functions rather than constructing a small profile by hand. Every required entry must be applicable, and the unelevated command must reach execution without an administrator setup step. Also retain setup/command agreement for the elevated route. The reproduced evidence here is the portable plan mismatch plus the native applier's source path, not a native Windows launch result.

  • [P2] Preserve existing runtime grants when setup retries fail

    internal/sandbox/windows_setup_windows.go:96

    ensureWindowsSandboxRuntimeCandidates calls EnsurePrivateDir on existing roots. On Windows that replaces the DACL with a protected user/SYSTEM-only descriptor, removing the capability grant from a previously successful setup. This happens before the ACL transaction snapshots anything. If the next candidate fails validation, setup returns with the old grant gone; a later transaction failure also restores only the already-stripped baseline. The old marker remains, but subsequent commands fail runtime-capability verification.

    Please preserve or restore the grants from before this preparatory mutation when setup fails. Cover an existing working root followed by a failing second candidate and a later transaction failure, while retaining no-follow validation for new roots.

    Root cause and bounded fix: The transaction starts after a security-relevant mutation has already happened. A snapshot of the rewritten descriptor cannot recover the working state that existed when setup began. Include that preparation effect in the recoverable operation, or avoid destructively rewriting an existing valid root before recovery is available. Keep validation of ownership and redirected paths; skipping private-directory security checks would trade this failure for a different one.

    Regression acceptance: Begin with a successfully configured root and record its effective capability access. Make a second candidate fail, and separately inject a failure after ACL application begins. In both cases, the original root should retain its earlier access, its existing marker should remain meaningful, and a subsequent ordinary command should not fail because the retry stripped a grant. Also exercise fresh-root success. This is a source-traced Windows failure path; no native DACL experiment is claimed.

  • [P2] Verify created child identity before rollback deletes it

    internal/sandbox/windows_acl_apply_windows.go:968

    The materialization record verifies the anchor's identity but records descendants only by name and creation flags. After setup creates .git/config, another process can move it aside and put an ordinary file at that name beneath the unchanged anchor. A later setup failure deletes the replacement. Successful deletion sets targetRemoved, bypassing the snapshot's later TargetID check. Created directory components have the same gap.

    The new default capability materialization makes this relevant to protected metadata that the base did not create. Please delete only the objects this attempt actually created, with identity bound to the object being deleted. Preserve conservative refusal and handle-relative traversal; test ordinary file and directory replacement beneath an unchanged anchor.

    Root cause and bounded fix: “This name was created” is being used as proof that “the object currently at this name belongs to this attempt.” The anchor identity proves the parent is unchanged, not that a child has not been replaced. Record enough identity at creation to distinguish the created child from a replacement, and bind the deletion to that checked object. A pathname identity check followed by reopening the mutable name for deletion would preserve the race.

    Regression acceptance: After materialization, replace a created file beneath the same anchor and trigger a later setup failure; repeat for a created directory component. The replacement must survive, while an unchanged fixture-created object must remain eligible for cleanup. Keep cleanup nonrecursive and retain conservative refusal when identity cannot be established. This finding concerns the newly active default metadata materialization path; it does not imply that all older rollback code was safe or that a native replacement race was executed here.

  • [P2] Validate the persistent fallback path before creating its lease

    internal/sandbox/runtime_state.go:98

    The anchor check does not inspect its reusable v1 child. prepareSandboxRuntimeLease then uses pathname-based parent creation and opens the adjacent lease before ensureRuntimeTreeDirs validates the owned path. With v1 redirected to another directory, preparation creates a .lease there and only afterward returns a validation error. A contained Linux reproduction confirms that ordering.

    Unix scope includes the host temp directory, so a sandboxed same-user command can leave this state for the next process. The new predictable, persistent fallback makes that possible across fresh processes; the base allocates a new random fallback parent. Please validate and access the owned lease path without following redirected components before any filesystem effect, preserving legitimate aliases above the operator-owned base.

    Root cause and bounded fix: Lease acquisition is an effectful consumer of the same owned path that later validation is intended to protect. Validating only the anchor before lease acquisition leaves its reusable descendants untrusted. Establish safe access through the owned tail before parent creation or lease opening, and retain that trust through the operation. Merely moving a pathname check earlier is insufficient if a redirected component can be substituted before the subsequent pathname-based open.

    Regression acceptance: Use two fixture-owned directories, redirect the persistent fallback's v1 into the second, and call preparation as a fresh process would. Preparation must refuse without creating a lease or directory in the redirected target. Verify normal lease acquisition/release and legitimate aliases above the trusted operator base as controls. The demonstrated effect is an out-of-tail lease file before refusal; this finding does not claim arbitrary execution or attribute older primary-cache behavior to this PR.

  • [P2] Close the final descriptor during fallback validation

    internal/sandbox/runtime_state.go:287; internal/peermsg/private_dir_unix.go:25

    The new per-preparation EnsurePrivateDir call exposes an older descriptor bug: defer unix.Close(parentFD) captures the initial number, while traversal closes and replaces parentFD. At affected path depths, the final descriptor remains open. Twenty prepare/release cycles leave twenty extra descriptors; the corresponding base fallback leaves none. Releasing the runtime lease does not close these descriptors, so repeated fallback use can exhaust the process limit.

    Please release the currently owned descriptor exactly once on success and error paths. Preserve the ownership, permissions, and no-follow checks, and cover multiple path depths so descriptor-number reuse cannot hide the leak.

    Root cause and bounded fix: Traversal transfers ownership from one descriptor to another, but deferred cleanup retains the descriptor value from before those transfers. Make descriptor ownership explicit across reassignment and early returns, so cleanup closes the descriptor currently owned and cannot close it twice. Fix the helper's lifetime contract while retaining its existing traversal and permission decisions; removing fallback validation would conceal the activation without correcting the defect.

    Regression acceptance: Repeat preparation and release enough times to detect accumulation, using more than one path depth and both successful and failing validation. Descriptor usage should settle back to baseline after each completed attempt. The existing twenty-cycle observation demonstrates the affected success path and its base/head difference; varying path depth matters because operating-system descriptor-number reuse can make a single fixture appear leak-free.

  • [P2] Cover the inline Git alias in the nested-repository refusal

    internal/sandbox/analyzer.go:341

    In an ancestor-governed workspace without local .git, git -c alias.bootstrap=init bootstrap passes Evaluate with shell and network permission granted and creates a repository. The analyzer sees bootstrap, while the profile supplies no local config/hooks carveouts. The evaluator also allowed this spelling on the base, but the base retained passive Seatbelt protection for those future paths; this PR removes it and relies on the incomplete refusal.

    Please make this demonstrated alias obey the same refusal or effective protection as direct git init. Retain harmless aliases and ordinary Git operations; this does not require rejecting every alias or interpreting arbitrary shell programs.

    Root cause and bounded fix: The refusal reasons about the literal subcommand token, while Git executes the inline alias expansion. That gap becomes material when the PR removes the previous passive protection for future local metadata. Make the demonstrated inline init alias reach the same policy outcome as direct init, or retain effective protection for the affected metadata. Preserve the distinction between repository creation and ordinary Git use; a blanket alias ban would exceed this finding.

    Regression acceptance: In an ancestor-governed workspace with no local .git, compare direct git init with git -c alias.bootstrap=init bootstrap, granting the same shell/network permissions used by the reproduction. Both must respect the existing nested-repository restriction. Include a harmless inline alias and ordinary repository operations as controls. An evaluator-only assertion should not be used to claim OS write protection that the generated profile no longer provides.

  • [P2] Apply the provisioning refusal at the actual helper boundary

    internal/sandbox/windows_setup_windows.go:77

    windowsPrincipalLaunchAvailable is checked only by BuildWindowsSandboxSetupArgs. Both shipped setup entrypoints call RunWindowsSandboxSetup, whose parser accepts --sandbox-principal 1 and reaches this function directly. An elevated same-caller invocation passes the SID check and proceeds to account/secret/ACL provisioning without the launch-availability refusal. Handwritten helper arguments therefore create state the public builder expressly refuses to create.

    Please enforce the existing refusal before mutation in the actual helper path as well. This only needs to make the entrypoints agree; it does not require implementing the deferred launch mechanism.

    Root cause and bounded fix: A convenience builder is enforcing a mutation policy that the consuming entrypoint does not enforce. Arguments are an input format, not proof that the builder approved the operation. Apply the existing launch-availability decision before account, secret, or ACL effects in the shared helper path used by both shipped entrypoints. The caller-SID check serves a different purpose and does not establish launch availability.

    Regression acceptance: Invoke the public setup path with explicit principal-enabled arguments while launch is unavailable and with caller identity otherwise valid. Assert refusal before any provisioning effect, using isolated mutation seams. Retain builder-level coverage as well. The case is an elevated same-caller invocation; it is not an assertion that an unprivileged caller can elevate. The required result is the existing refusal at the real boundary, with no need to enable the deferred launch mechanism.

  • [P2] Keep the mocked rollback test away from real account deletion

    internal/sandbox/windows_identity_policy_windows_test.go:192

    The created-principal case uses stubWindowsProvisioning(false, ...), which fakes creation and makes the ownership check return true. Its injected LSA failure then reaches the real removeWindowsSandboxIdentity and NetUserDel. The target is the current user's deterministic C:\ws principal. An ordinary elevated test run can therefore delete an existing account the test never created; there is no provisioning-test opt-in gate here, and deletion errors are discarded.

    Please isolate the complete account-mutation boundary in this fixture, including rollback. Check the adopted callers too: legacy-comment upgrade remains a real mutation behind the same fixture. Keep the deliberately gated real provisioning test separate.

    Root cause and bounded fix: The fixture mocks the forward operation and ownership decision but leaves its inverse attached to the operating system. Once creation reports success, a later injected error takes a valid production rollback branch using fabricated evidence of ownership. Isolate creation, adoption/update, and removal consistently for this mocked fixture. Adding a production exception to the ownership guard or merely ignoring another error would not establish test isolation.

    Regression acceptance: Drive the created-principal LSA-failure case and assert that the expected rollback is requested through a fake removal boundary, with no real account mutation reachable. Inspect the adopted-principal callers of the same fixture for the real legacy-comment update as well. Preserve assertions about which account/key would be affected. These unit cases must be safe with an elevated test process and an existing matching account; they should not need to create or delete such an account to prove safety.

  • [P2] Clean up the second account created by the native round-trip test

    internal/sandbox/windows_identity_windows_test.go:285

    The second account is provisioned using windowsSandboxPrincipalKey(config), but cleanup passes the unrelated literal "ziptest01". The ownership guard consequently refuses deletion, and the error is ignored, leaving that account behind even after a successful test. Its cleanup is also registered only after secret reading and LogonUser, so failure there leaves the account and its logon rights without a cleanup attempt. The earlier cleanup covers only the first account.

    Please use a test-owned workspace/key for the second account, register cleanup immediately after successful provisioning, and report cleanup errors. Delete only the account created by the fixture, using its actual key; preserve the foreign-account ownership check and the explicit provisioning-test gate.

    Root cause and bounded fix: Cleanup is detached from both the provisioned identity and the moment ownership is acquired. The second account needs its own fixture-owned configuration and cleanup obligation; the first account's cleanup cannot cover it. Register that obligation immediately after successful creation, before secret reading and logon. If provisioning adopts an existing account, do not treat adoption as permission for this fixture to delete it.

    Regression acceptance: Verify that the cleanup key matches the second account's actual derived key and that failures during secret reading or logon still attempt cleanup for the fixture-created account. Surface cleanup errors as test failures so a green result cannot hide residual accounts or associated rights. Keep the explicit gate for real provisioning and strict ownership checks. A corrected literal alone would leave the late-registration and adopted-account hazards unresolved.

  • [P2] Isolate every runtime-producing Windows fixture

    internal/sandbox/windows_identity_policy_windows_test.go:320; internal/sandbox/windows_workspace_canonical_windows_test.go:107

    These tests redirect the workspace but call real runtime setup and preparation with ambient cache and temp roots. They create persistent trees in the developer's application cache and fallback namespace, then run the real cache reclaimer against inactive user runtime trees. Lease release does not undo those effects. The setup seams and stubWindowsPrincipalSetup also leave runtime roots outside their fixtures when only part of the environment is redirected.

    Please establish owned cache and temp roots for every affected caller, as AGENTS.md requires, and keep reclamation inside that fixture. Include fallback selection and the shared-temp observation tests; isolating only the preferred root leaves the same problem on the alternate path.

    Root cause and bounded fix: Workspace isolation is being treated as runtime-storage isolation, although those paths come from separate resolver inputs. These tests call production code that can create and reclaim state, so all preferred and fallback roots must be fixture-owned before setup or preparation begins. Apply the same isolation to affected helper callers rather than fixing only the test that happened to expose the problem. Keep the real runtime behavior under test within those owned roots.

    Regression acceptance: Exercise preferred-root and fallback selection, including setup, command preparation, and reclamation. Assert that resolved runtime paths remain under the fixture and that any reclaimer candidates are fixture-owned. Give directory-observation tests a private observation root so concurrent packages cannot change their expected contents. Lease cleanup and storage isolation solve different obligations: releasing a lease does not remove persistent directories or reverse reclamation of another tree.

  • [P2] Keep the real elevated smoke's command profile aligned with setup

    internal/sandbox/windows_setup.go:229; internal/sandbox/runner_windows_integration_test.go:405

    Setup-argument construction now adds runtime candidates, but TestWindowsRestrictedTokenRealSandboxSmoke still passes its original bare profile to runWindowsRealSmokeCommand, which calls the direct command-argument builder. With principal mode unset, the setup marker and first command disagree before the write probe executes. The exact builder/parse/marker sequence validates on the base and fails on this head, with nine setup entries versus five command entries.

    Please carry the matching runtime profile through this real smoke's command side. Preserve marker validation and the test's actual native assertions; running elevated setup first cannot repair a profile mismatch repeated by every command.

    Root cause and bounded fix: The setup builder now changes the effective plan, but this direct consumer assumes the input profile remains the complete plan. Passing the original profile to each command repeatedly disagrees with the marker established by setup. Carry the matching effective runtime profile through the smoke's setup and command sequence using the existing planning contract. Weakening marker validation would remove the signal without repairing the consumer.

    Regression acceptance: First verify the exact setup-builder, parser, marker, and direct-command sequence with principal mode unset; the setup and command plans must agree. Then run the gated native smoke in its appropriate environment so its actual read/write restrictions are exercised. The nine-versus-five entry mismatch was reproduced through the portable protocol sequence; that result does not replace native execution or prove the remainder of the smoke passes after alignment.

  • [P3] Assert resolver purity before deliberately preparing the runtime

    internal/sandbox/windows_workspace_canonical_windows_test.go:230

    beforeSetup snapshots temp before path resolution, but the final assertCreatedNothing runs after prepareSandboxRuntime. On a fresh temp root, preparation legitimately creates the fallback anchor, and the test attributes that creation to the pure resolver. A pre-existing anchor masks the failure.

    Please check purity immediately after resolution, then prepare the runtime and retain the selected-root membership assertion. Temp isolation alone does not fix this ordering defect; run the test with an initially absent anchor.

    Root cause and bounded fix: The assertion's observation interval spans two operations with opposite side-effect contracts: pure resolution and effectful preparation. Place the no-creation assertion at the boundary between them, then test preparation's selected-root behavior separately. Preserve both checks; removing the purity assertion or precreating the anchor solely to make it pass would hide the sequencing error.

    Regression acceptance: Use a private temp root with no fallback anchor and show that resolution creates nothing. Then call preparation and verify that the selected root belongs to the candidates. Repeat with an existing anchor if useful, but do not rely on that warm state. The observed CI collision is a separate shared-directory isolation problem; this cold-state assertion error follows from the ordering even after isolation is corrected.

  • [P3] Pin principal mode in the caller-identity transport tests

    internal/sandbox/windows_setup_test.go:529

    TestWindowsSandboxSetupArgsCarryTheCallerIdentity and TestWindowsSandboxSetupArgsOmitAnUnknownCallerIdentity leave PrincipalOptIn unset. With ZERO_WINDOWS_SANDBOX_IDENTITY=1, both stop at the launch refusal before exercising their transport assertions. This failure reproduces on the current head.

    Please explicitly select the mode these transport tests need. Keep production environment inheritance and the dedicated opt-in tests intact.

    Root cause and bounded fix: These fixtures intend to test caller identity serialization, but an unset option deliberately delegates mode selection to the host environment. The new refusal can therefore prevent them from reaching the behavior named by the tests. Specify the transport fixture's required mode at construction, while keeping environment inheritance in production and in tests specifically intended to exercise it.

    Regression acceptance: Run both transport tests with the opt-in environment unset and set to 1; they should reach the same caller-identity assertions in both runs. Keep separate coverage showing that genuinely opted-in setup still encounters the launch refusal. Avoid a package-wide environment override, which could make the transport tests pass by suppressing coverage of the opt-in contract elsewhere.

Validation: focused sandbox, peer-messaging, and doctor tests passed, as did focused runtime/CLI race checks, vet, formatting, and Windows cross-compilation. The CLI golden failure also occurs on the base with identical output. Windows-specific account and ACL failure paths above were inspected in source, not executed natively; cross-compilation is not a native smoke result.

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

Changes requested on head 83194843.

Two blockers are still directly observable:

  • Smoke (windows-latest) is failing in TestTeardownPathDerivationCreatesNothing; isolate the temp/runtime observation root and rerun the Windows smoke job.
  • The caller-identity transport tests depend on ambient principal mode. ZERO_WINDOWS_SANDBOX_IDENTITY=1 go test ./internal/sandbox -run 'TestWindowsSandboxSetupArgs(CarryTheCallerIdentity|OmitAnUnknownCallerIdentity)$' -count=1 fails both tests before their intended identity assertions because principal provisioning is refused. Pin the intended principal mode inside these fixtures and retain separate opt-in coverage.

The PR is also currently DIRTY, so it needs a rebase/conflict resolution against main before it can be merge-ready.

Checks run on this head: formatting and go vet ./... passed; GOOS=windows GOARCH=amd64 go build ./... passed. No dependency, vendored-code, submodule, or external-service integration changes were found.

Resolves the ACL conflicts with #1040 by keeping both: the anchor that main
attaches to a derived carveout, and the file materialization this branch adds.
The plan apply now opens its target without the branch's blanket ancestor
redirection check and relies on main's anchor containment instead, so a
workspace reached through a junction keeps working and a junction on the
derived tail is still refused. The rollback re-open relies on the recorded
object identity where it has one and keeps the blanket check where it does not.
… derivation test a private root

The two caller-identity transport tests left PrincipalOptIn unset, so with
ZERO_WINDOWS_SANDBOX_IDENTITY=1 in the host environment they stopped at the
launch refusal before reaching the assertions they exist for. They now pin
the mode they need; the opt-in tests keep exercising environment inheritance.

TestTeardownPathDerivationCreatesNothing snapshotted the shared temp directory,
which every other package under test writes to concurrently, and asserted
resolver purity only after preparation had legitimately created the fallback
anchor. It now observes a private root and asserts purity at the boundary
between resolution and preparation.
In an ancestor-governed workspace with no local .git, the nested-repository
refusal read the literal subcommand token, so git -c alias.bootstrap=init
bootstrap created a repository the profile carves nothing out for. The
classifier now resolves the alias.NAME=EXPANSION settings given on the command
line to the subcommand git will run, following a short alias chain, and treats
a shell alias as creation under this one guard because it cannot be classified.
Harmless aliases and ordinary git use are unchanged.
…n the argument builder

BuildWindowsSandboxSetupArgs refused to serialize a principal opt-in while no
launch path exists, but both shipped entrypoints reach runWindowsSandboxSetup
through the parser, and a handwritten --sandbox-principal 1 passed the caller
check and proceeded to account, secret and ACL provisioning for a principal
nothing can run. The helper now makes the same refusal before its first effect.
The seam harness opens the launch seam for the fixtures that test the flow
behind it, and a new test drives the refusal with the seam closed.
…n the smoke's command profile with setup

The mocked provisioning fixture faked creation and ownership but left the
inverse attached to the operating system, so an injected failure after a
faked create reached the real NetUserDel on the current user's C:\ws
principal. The deletion is a seam now, the fixture fakes and records it, and
the created and adopted cases assert exactly which removals were requested.

The real elevated smoke passed its bare profile to the command builder while
the setup builder folded runtime roots into its own, so every command
disagreed with the marker. The command side now carries the augmented
profile, and a portable test pins that the two plans hash the same.
… the second native account by its real key

Tests that redirected only the workspace still ran real setup, preparation and
reclamation against the developer's cache and temp, because those roots come
from separate resolver inputs. isolateSandboxRuntimeRoots now gives each such
fixture its own cache root and temp root, so the preferred candidate and the
fallback both land inside the fixture, and the principal-setup stub redirects
temp as well as cache.

The native round-trip test provisioned its second account under the key derived
from its config but cleaned up with an unrelated literal, which the ownership
guard refused, and registered that cleanup only after the secret read and the
logon could have failed. It now derives the key from a fixture-owned workspace,
registers cleanup as soon as ownership exists, deletes only what it created,
and reports cleanup failures.

peermsg.EnsurePrivateDir closed the descriptor it started with rather than the
one it ended on, leaking the final descriptor on every fallback-runtime
preparation; the deferred close now closes the descriptor currently owned, and
a watermark test pins it at three depths on the success and refusal paths.
… open the lease relative to it

prepareSandboxRuntimeLease created the parent by pathname and opened the lease
beside it before ensureRuntimeTreeDirs validated the owned path, so a link or
junction planted at the fallback anchor's reusable v1 child sent the .lease
into another directory and only then failed validation. The parent is now
walked and created without following links, and the lease is opened relative
to a no-follow handle on that validated directory, so a component swapped in
after the check cannot move the open either. Pinned on both platforms with a
redirected v1 and an untouched control.
The materialization record verified the anchor's identity but recorded the
created children by name, so after setup created .git/config another process
could rename it aside, put an ordinary file at that name beneath the unchanged
anchor, and have a later setup failure delete the replacement. Each created
directory and the leaf file now carry the identity read from the handle that
created them; rollback refuses to descend through a created component that is
no longer that object, and the delete disposition is set on a handle whose
identity was checked, so nothing renamed in between can be the thing removed.
Preparing the runtime candidates rewrote an existing root's DACL with a
protected owner-only descriptor before the ACL transaction had a baseline, so
a retry that failed on the second candidate, or in the apply, returned with the
earlier setup's capability grant gone while the old marker still claimed
success. Setup now snapshots each existing candidate's DACL and identity before
preparation and restores it, protection state included, on every failure that
follows, and after the transaction's own rollback when a later step fails.
The unelevated command's plan lists both runtime candidates, but nothing on
that tier created the one this process did not select, so with a fresh usable
cache the runner refused its own plan with "windows ACL target does not exist"
and the command never launched, on every retry. The parent now creates the
missing candidates before the runner starts, where TEMP is still the
operator's, using setup's own ownership and no-follow checks, and leaves an
existing candidate as it is so the grant the previous command applied is not
stripped and reapplied on every launch. A file or reparse point at a
candidate's name is refused by name.
…x regressions run under macOS's /var symlink
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn @gnanam1990 17f55f4e answers both of your rounds, thirteen findings plus the red check, and merges main again. Each item is its own commit with a regression that fails for the stated reason when only that fix is taken back out; the ones I could not execute natively are named as such.

Merge with main. #1040 landed while this was open and both branches touch the ACL apply, so the resolution is a policy decision and I want it on the record rather than buried in a merge commit. Main attaches an anchor to every derived carveout and refuses a junction on the derived tail while leaving the operator's own ancestors alone, because a workspace on a junction or a dev drive is a configuration that works. This branch refused any reparse point anywhere on an ACL target's path. The merged apply keeps main's rule: the plan apply opens its target with the final-component check only and relies on verifyWindowsACLHandleUnderAnchor, so #1040's two containment tests pass unchanged, while the blanket redirection check stays on the rollback re-open where no identity was recorded and on the runtime ACE, which have no anchor to reason with. MaterializeFile and Anchor both survive on the entry, and the carveout loop carries the anchor through this branch's materialization.

Merge readiness, the Windows smoke. TestTeardownPathDerivationCreatesNothing now observes a private temp root and asserts resolver purity at the boundary between resolution and preparation, so the other packages' temp directories and preparation's own fallback anchor are both out of the picture. Three runs green here.

Production, in the order of the review.

  1. Unelevated plan applicable. The parent now creates whichever runtime candidates are missing before the runner starts, where TEMP is still the operator's, using ensureRuntimeCandidateDir so a fresh candidate gets the same ownership and no-follow checks setup gives it, and leaves an existing candidate alone so the grant the previous command applied is not stripped and reapplied on every launch. A file or reparse point at a candidate's name is refused by name. TestUnelevatedPlanAppliesOnFreshRuntimeRoots starts with both namespaces absent, drives the real planner and the real runner-side apply, once with the cache candidate selected and once with the fallback selected; making the parent step a no-op fails it on both.
  2. Grants preserved across a failed retry. Setup snapshots each existing candidate's DACL and identity before ensureWindowsSandboxRuntimeCandidates and restores it, protection state included, on every failure between there and a completed apply, and after the transaction's own rollback when a later step fails. TestSetupRetryFailurePreservesAnExistingRuntimeGrant plants a real grant on the first candidate, then fails the second candidate's validation and, separately, the apply; with the restore a no-op both legs fail on stripped the earlier grant.
  3. Rollback bound to the created objects. Each created directory and the leaf file carry the identity read from the handle that created them. Rollback refuses to descend through a created component that is no longer that object, and the delete disposition is set on a handle whose identity was checked, so a rename in between cannot be the thing removed. TestRollbackRefusesAReplacedMaterializedChild moves the created .git/config, and separately .git/hooks, aside and puts an ordinary file or directory at the name beneath the unchanged anchor; with the identity checks off it fails on rollback deleted the replacement.
  4. Fallback path validated before the lease. prepareSandboxRuntimeLease now walks and creates the runtime root's parent with peermsg.EnsurePrivateDir, without following links, and then opens the lease relative to a no-follow handle on that directory, on both platforms. TestPrepareSandboxRuntimeRefusesARedirectedFallbackChild points v1 at another directory, as a junction here and a symlink in the unix build; the old sequence fails it with .lease in the redirected target before refusing.
  5. Descriptor closed once. The deferred close in EnsurePrivateDir captured the first descriptor while the walk replaced it every level; it now closes the one currently owned. TestEnsurePrivateDirClosesTheDescriptorItOwns watches the lowest free descriptor across forty walks at depths one, three and six, on the success path and the refused symlink path. This one I could only cross-compile; it runs on the Linux and macOS jobs.
  6. Inline git alias. The classifier resolves the alias.NAME=EXPANSION settings given as -c before the subcommand to the subcommand git will run, follows a short alias chain, and treats a shell alias as creation under this one guard because it cannot classify it. Harmless aliases, an unrelated -c setting and ordinary use are controls, and a standalone workspace is still not governed. Nothing changes for the direct spellings.
  7. Refusal at the helper boundary. runWindowsSandboxSetup makes the same launch-availability refusal the argument builder makes, before the caller check and before any effect. The seam harness opens the launch seam for the fixtures that test the flow behind it, and TestPrincipalSetupRefusesAtTheHelperWhileLaunchIsUnavailable drives a handwritten opt-in with the seam closed and asserts neither provisioning nor the apply was reached, with the opt-out path as the control.

Tests. The mocked provisioning fixture fakes and records the account deletion now, so the created-principal LSA failure asserts exactly one removal through the fake and the adopted case asserts none; the legacy-comment upgrade is faked there too. The native round-trip test derives its second account's key from a fixture-owned workspace, registers cleanup the moment ownership exists, deletes only what it created, and reports cleanup errors. isolateSandboxRuntimeRoots gives every runtime-producing fixture its own cache root and temp root, thirteen tests plus the principal-setup stub, so preferred and fallback selection, reclamation and the observation tests all stay inside the fixture. The real smoke's command side carries the augmented profile, and TestRealSmokeCommandProfileAgreesWithSetup pins the portable half: setup plans more entries than the bare profile and the augmented command plan hashes identical to setup's. The two transport tests pin PrincipalOptIn and pass with the environment set and unset.

CI is 12 of 12 at this head, including the descriptor test on the Linux and macOS jobs. Locally: internal/sandbox and internal/peermsg green on Windows, vet and gofmt clean for windows, linux and darwin, git diff --check clean. The provisioning round-trip and the real elevated smoke are gated and were not executed here.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai approve

coderabbitai[bot]
coderabbitai Bot previously approved these changes Sep 15, 2026
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown
✅ Action performed

Comments resolved and changes approved.

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

Re-review of head 17f55f4e.

The previous Windows smoke and ambient principal-mode failures are fixed: current CI is green, the focused sandbox/peermsg/CLI/doctor packages pass, and the opt-in transport tests now pass. The other previously reported runtime, rollback, helper-boundary, account-cleanup, and fixture-isolation paths have corresponding fixes and regression coverage.

[P2] Resolve the full inline Git alias chain before allowing repository creation

internal/sandbox/analyzer.go:361

gitSubcommandCreatesRepository returns false once depth >= 4. Git continues resolving longer alias chains, so this still bypasses the nested-repository guard. In an ancestor-governed workspace, the following command is allowed by the current analyzer and Git 2.55.0 creates the local .git directory:

git -c alias.a=b -c alias.b=c -c alias.c=d -c alias.d=e -c alias.e=init a .

A disposable regression test on this head observed action=allow with no block, and the real Git command exited 0 with local_git=yes. That leaves the new repository metadata without the carveouts this guard is meant to preserve.

Please resolve aliases until a terminal subcommand using cycle detection (and fail closed on cycle/depth exhaustion in this nested-workspace guard). Add both a chain longer than four ending in init and a harmless long chain ending in status as controls.

Process gate: the branch is currently three commits behind main; refresh it before the next review per repository policy.

Validation on this head: make fmt-check, go vet ./..., Windows cross-build, focused package tests, and the opt-in transport regression passed. All current GitHub checks, including Windows smoke and race detection, are green. No dependency, vendored-code, submodule, or external-service integration changes were introduced.

…losed when it has none

The depth cap gave up with false, so a five-link alias chain ending in init
passed the nested-repository guard and git created the repository. The
resolver now follows the chain until a terminal subcommand with the visited
names kept, and under this guard a cycle, a dangling alias or an empty
expansion is a refusal rather than a pass: an alias the analyzer cannot follow
to its end is not evidence that nothing gets created. A five-link chain ending
in status stays allowed.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@gnanam1990 you are right, and it was worse than a short cap: giving up with false made depth exhaustion a pass. 378b38c3 resolves the chain the way git does, with the visited names kept, and under this guard the two ways to run out are refusals: a cycle, a dangling alias or an empty expansion counts as creation, because an alias the analyzer cannot follow to its end is not evidence that nothing gets created. Your five-link chain to init is in the refused rows of TestNestedWorkspaceRefusesAnInlineGitAliasForInit along with alias.a=b alias.b=a a and a dangling one; the same five links ending in status is a control and stays allowed. Putting the cap back fails the test on exactly your command.

Main is merged again, three commits including the 0.9.0 release. CI is 12 of 12 at 378b38c3.

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

Approved head 378b38c.

The remaining inline-alias blocker is fixed. Alias resolution now follows the chain to a terminal command with cycle detection and fails closed for unresolved/empty cycles under the nested-workspace guard. The regression covers a five-link chain ending in init, plus an equally long harmless chain ending in status; both pass locally.

Prior runtime, rollback, helper-boundary, account-cleanup, fixture-isolation, Windows smoke, and ambient principal-mode findings remain closed on this head.

Validation: targeted alias and transport tests passed; sandbox and peermsg packages passed; formatting, go vet ./..., and the Windows cross-build passed. GitHub reports all 12 checks green, including Linux/macOS/Windows smoke and race detection. The PR is mergeable and current with main. No dependency, vendored-code, submodule, or external-service integration changes were introduced.

No evidence-backed blockers remain in the reviewed scope. Local internal/cli was stopped after 372 seconds without output; the exact-head CI suite completed successfully.

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

windowsPrincipalLaunchAvailable() currently always returns an error, so principal provisioning remains disabled. Could we complete the launch path with a short-lived trusted runner?

Proposed flow:

  1. Elevated setup provisions the identities, protected credentials, and ACL/network policy.
  2. Ordinary unelevated Zero launches a fixed runner under the selected sandbox account using CreateProcessWithLogonW.
  3. The runner restricts its own token with the required capability SIDs, then launches the command.
  4. Protected IPC carries requests, input/output, and cancellation, with runner identity verification and credentials kept out of arguments, environment variables, and logs.

This avoids requiring cross-account CreateProcessAsUser privileges in the ordinary parent.

One prerequisite: CreateProcessWithLogonW requires local-logon permission, which conflicts with the current SeDenyInteractiveLogonRight. Please resolve that policy trade-off explicitly; if batch-only logon is mandatory, another authenticated launch mechanism is needed.

Before enabling provisioning, verify native Windows execution from an unelevated caller after elevated setup, including allowed operations, denied private-fixture reads/out-of-scope writes, offline network enforcement, and cancellation cleanup.

Also update the description: it still says network-deny principals are ineligible, while the code now has offline/online roles and a separate launch refusal.

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

Re-reviewed exact head 378b38c3634f333993ea5e1778946bef2e28a31a (unchanged since my previous approval). One newly reproduced blocker supersedes that approval.

[P2] Parse alias expansion syntax before classifying repository creation

internal/sandbox/analyzer.go:380-384 uses strings.Fields(expansion) and classifies only fields[0]. Git parses quoting and global options inside an alias expansion, so both of these bypass the nested-workspace creation refusal:

git -c alias.a='-c alias.b=init b' a .
git -c 'alias.a="init"' a .

In separate temporary workspaces governed by a real ancestor repository, an exact-head diagnostic drove Engine.Evaluate and then executed the identical argument vector with Git 2.55.0. For BOTH cases: action=allow, Block=nil, Git exited successfully, and the workspace acquired its own .git. The new metadata therefore misses the config/hooks carveouts this guard substitutes for. The five-link alias regression passes but does not cover this parsing boundary.

Please classify the command Git actually executes: tokenize alias syntax with quoting, account for alias-local global options/settings, retain cycle detection, and fail closed when the expansion cannot be classified. Add both demonstrated cases through the nested-workspace gate, with quoted/option-bearing harmless aliases and standalone-workspace controls. This is one parsing-root-cause finding, not another depth-cap change.

Other notes: principal provisioning is intentionally closed by windowsPrincipalLaunchAvailable() at both setup entry points. I am not requiring a trusted launch runner in this foundation PR; enabling that lifecycle needs a separately agreed scope and native Windows proof. However, update the description's stale claims that network-deny principals are ineligible and network-allow commands engage the backend: eligibility now uses the opt-in, offline/online roles exist, and the provisioning entry point refuses all principal setup while launch is unavailable. Keep the foundation/not-a-fix-for-#662 scope explicit. The refreshed main is also one commit ahead (99721c7); synchronize before merge per repository policy.

Validation: sandbox/peermsg packages and affected race checks passed; opt-in caller transport/preflight controls passed; make fmt-check, go vet ./..., and Windows amd64 cross-build passed. Exact-head GitHub CI is green, including native Windows smoke. The two disposable alias regressions failed for the stated bypass; their diagnostic file was removed afterward, leaving a clean checkout. No dependency, vendored-code, submodule, or external-service integration changes were found. No implementation, commit, push, or merge was performed.

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.

6 participants