Skip to content

Windows native sandbox blocks all exec_command: 'permission roots or deny lists changed' #881

Description

@baoyu0

Version / branch / commit

  • zero 0.6.0 (npm @gitlawb/zero@0.6.0, win32-x64 binary)
  • Also present on main @ 7f39a63

OS and environment

  • Windows 11 (virtualized sandbox VM), PowerShell 5.1, elevated (Administrator)
  • Native backend: windows-restricted-token (zero sandbox setup completed successfully from an elevated terminal)
  • Go 1.26.5, Node 24.19

Steps to reproduce

  1. Install @gitlawb/zero@0.6.0 and run zero sandbox setup from an elevated terminal. It prints Windows sandbox setup complete.

  2. Run zero doctor from the same directory — sandbox.backend is [pass] (the setup marker validates for the cwd workspace root).

  3. In any small project directory, run:

    zero exec --output-format stream-json --max-turns 10 "Run 'go test ./...' and report the result."
  4. The agent's exec_command tool call aborts before the command executes.

Expected behavior

exec_command should run inside the native Windows restricted-token sandbox (as zero doctor suggests the backend is ready), just as write_file/read_file/edit_file work.

Actual behavior

Every exec_command aborts immediately with:

zero-windows-command-runner.exe: windows sandbox setup is out of date: permission roots or deny lists changed

The run ends with status: incomplete. File tools work fine — only shell execution is blocked.

Root cause (traced on main @ 7f39a63)

  1. internal/sandbox/runner.go — BuildCommandPlan builds the profile and then calls permissionProfileWithRuntime(profile, runtimeState) (line ~189).
  2. internal/sandbox/runtime_state.go — permissionProfileWithRuntime appends the per-workspace sandbox runtime root (<LocalAppData>/zero/runtime/v1/<sha256(workspaceRoot)[:8]>) as an extra FileSystem.WriteRoots entry when it isn't already present.
  3. internal/sandbox/windows_command_runner_windows.go (restricted-token tier) validates the stored setup marker using the command's profile: ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)).
  4. But zero sandbox setup (internal/cli/sandbox.go → runSandboxSetup) writes the marker via WriteWindowsSandboxSetupMarker from a profile built without the runtime root.
  5. internal/sandbox/windows_setup.go — ValidateWindowsSandboxSetupMarker compares ACLPlanHash/ACLPlanEntries of the stored marker (no runtime root) against the freshly computed plan from the command profile (with runtime root) → permanent mismatch → permission roots or deny lists changed.

Evidence

  • The runtime root appears after the first exec attempt: C:\Users\<user>\AppData\Local\zero\runtime\v1\55c03089a6535cfb, where 55c03089 = first 8 hex chars of sha256("<workspace>\zero-demo").
  • The stored marker's aclPlanEntries is stable for a given workspace (e.g., 5 for a workspace-only profile), while the command-time plan adds the runtime-root entries → counts/hashes never agree.
  • zero doctor reports the backend as [pass] because doctor builds its profile the same way as setup (without the runtime root), so the two sides of the validation disagree.

Suggested fix direction

Make the elevated Windows setup include the sandbox runtime root in the profile used both for applying ACLs and for writing the setup marker — i.e. prepare the runtime and apply permissionProfileWithRuntime before BuildWindowsACLPlan / WriteWindowsSandboxSetupMarker in the setup path (runWindowsSandboxSetup / runSandboxSetup), so the command-time profile matches the marker.

Relevant logs

[zero] zero-windows-command-runner.exe: windows sandbox setup is out of date: permission roots or deny lists changed

(zero doctor from the same directory: Overall: pass, sandbox.backend: [pass].)

Activity

  1. Vasanthdev2004 commented on Aug 13, 2026

    @Vasanthdev2004
    Collaborator

    Confirmed, and your root-cause trace is correct in every step. This is a real bug on main and the fix is already written, sitting in #808 waiting on review.

    I verified both halves rather than going from memory:

    That is exactly the asymmetry you identified. Setup fingerprinted the bare profile while every command arrived with the runtime root already appended by permissionProfileWithRuntime, so a marker written seconds earlier was rejected and no command could run. It hit me the same way while building the identity work, which is why the fix lives in that PR rather than a standalone one.

    Two details from fixing it that your analysis did not have, and that matter if you are testing:

    The augmentation has to add every runtime candidate, not the one the current process would select. sandboxRuntimeRootFor prefers the cache-derived root and falls back to a temp-derived one, and that choice is made per process, so setup granting only its own pick left a command that fell back writing to a tree with no ACE on it. Setup now covers both.

    The runner also cannot recompute the root itself. It runs re-exec'd as zero __windows-command-runner with TMP/TEMP already redirected into the sandbox runtime tree, so os.TempDir() there is not the operator's temp and the derivation comes out different. The profile is augmented in the parent and passed down.

    Workaround until #808 lands, with the trade-off stated plainly: turning the sandbox off in your user config with "sandbox": {"enabled": false} restores exec_command, at the cost of the confinement you set it up for. On an elevated VM you may be fine with that; on a real workstation I would not.

    Thanks for the report. The trace through BuildCommandPlan to ValidateWindowsSandboxSetupMarker is more work than most issues get, and it is why this took two minutes to confirm rather than an afternoon.

  2. Vasanthdev2004 commented on Aug 13, 2026

    @Vasanthdev2004
    Collaborator

    @baoyu0 update: the fix is now #901, a standalone PR against main, rather than waiting on #808.

    When I replied earlier I pointed you at #808. Accurate, but not a good answer for you: #808 is a 12,000 line Windows principal-identity change with open architecture questions on it, so you would have been waiting on a design decision to get your shell back. #901 is 560 lines and does one thing.

    Your suggested fix in the issue is what it implements, near enough word for word: prepare the runtime and apply the augmented profile before BuildWindowsACLPlan and WriteWindowsSandboxSetupMarker in the setup path. Two details on top of that, both of which broke it once while I was building:

    The augmentation has to grant every runtime candidate, not the one the current process would pick. sandboxRuntimeRootFor prefers the cache-derived root and falls back to the temp-derived one when the cache sits inside the workspace, and that choice is per process, so granting only setup's own pick left a command that fell back writing to a tree with no ACE on it.

    It also is not a pure lift onto main, which matters if you build it yourself. The fix depends on fallbackSandboxRuntimeRoot being deterministic, and on main that function is still os.MkdirTemp memoized in a process-global map: setup grants temp root A, the next command derives root B, teardown cleans a third. So the derivation change came across with it. It is now a hash of the workspace and creates nothing, so every process reaches the same answer without sharing state.

    The workaround stands until this lands. "sandbox": {"enabled": false} in your user config restores exec_command, at the cost of the confinement you turned it on for. On the virtualized VM you described that is likely an acceptable trade for a few days; I would not run that way on a workstation.

    If you can build the branch and confirm it clears on your machine, that is worth more than my Windows 11 box agreeing with itself, and I will say so on the PR. No obligation, and it lands either way.

  3. baoyu0 commented on Aug 14, 2026

    @baoyu0
    ContributorAuthor

    @Vasanthdev2004 verified #901 on my Windows 10 machine:

    • go build ./... — clean
    • go test ./internal/sandbox/ with the fix(sandbox): agree on the runtime root across the Windows setup marker #901-specific filters (Windows|RuntimeRoot|Marker) — all pass, including the runner-marker test that fails if the fix is reverted
    • Full internal/sandbox suite: 4 unrelated failures (TestBuildCommandPlanRejectsOutsideDirectory, TestResolveCommandDirAllowsExtraRootCwd, TestScopeAddNormalizesSymlinkedRoot, TestPermissionProfileIncludesReadOnlyGrantAsReadRootOnly) — I confirmed these fail identically on plain main, so they're a local-environment artifact (repo cloned under %TEMP% breaks workspace-path expectations), not a regression from this PR.

    Can confirm the fix clears on a real Windows box. Thanks for splitting #901 out of #808 — that was the right call.

  4. added
    issue-approvedReviewed and approved by the core team; community PRs may implement this issue.
    on Aug 25, 2026
  5. gnanam1990 commented on Aug 25, 2026

    @gnanam1990
    Collaborator

    Approved. The setup-marker/runtime-root mismatch is confirmed on current main, and PR #901 implements the focused fix. The reporter has also validated that branch on Windows 10. Please continue through #901 rather than opening a duplicate implementation.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    issue-approvedReviewed and approved by the core team; community PRs may implement this issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions