Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2133c7b
feat(sandbox): add Windows sandbox principals
Vasanthdev2004 Jul 26, 2026
29df087
feat(sandbox): use a sandbox principal for Windows command execution
Vasanthdev2004 Jul 26, 2026
93c4974
test(sandbox): name the per-shell env syntax in the provisioning skip
Vasanthdev2004 Jul 26, 2026
7b75a15
test(sandbox): cover the logon-rights and token-minting half
Vasanthdev2004 Jul 26, 2026
4d91a0e
feat(sandbox): provision a sandbox principal during elevated setup
Vasanthdev2004 Jul 26, 2026
57421b2
fix(sandbox): keep the restricted token when the network is denied
Vasanthdev2004 Jul 26, 2026
655c872
feat(sandbox): encrypt the stored principal password to the invoking …
Vasanthdev2004 Jul 26, 2026
da835b5
fix(sandbox): surface a squatted principal name instead of falling back
Vasanthdev2004 Jul 27, 2026
a594bbb
fix(sandbox): reset the password when the account already exists
Vasanthdev2004 Jul 27, 2026
12b0221
fix(sandbox): revoke logon rights before deleting a principal
Vasanthdev2004 Jul 27, 2026
52f843a
fix(sandbox): refuse a squatted account name and clean up partial pro…
Vasanthdev2004 Jul 27, 2026
99fefdc
fix(sandbox): drop the stored secret whenever provisioning fails
Vasanthdev2004 Jul 27, 2026
dbdbda1
test(sandbox): check the ACE type before decoding its trustee
Vasanthdev2004 Jul 28, 2026
3d49e20
fix(sandbox): grant delete to the principal and keep rollback able to…
Vasanthdev2004 Jul 28, 2026
d67267f
fix(sandbox): stop setup destroying a principal it did not create
Vasanthdev2004 Jul 29, 2026
a1a0edc
fix(sandbox): keep an adopted principal's logon rights on rollback
Vasanthdev2004 Jul 29, 2026
f4a2b9b
fix(sandbox): grant the principal the runtime tree commands write to
Vasanthdev2004 Jul 30, 2026
71ad0f1
fix(sandbox): refuse to adopt a principal in a privileged group
Vasanthdev2004 Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions internal/sandbox/runtime_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ type SandboxRuntime struct {
Temp string `json:"temp,omitempty"`
}

// sandboxRuntimeRootFor derives the per-workspace runtime root. It is separated
// from prepareSandboxRuntime because the elevated Windows setup path needs the
// same answer WITHOUT taking a lease or creating anything: a sandbox principal
// is a separate account with no inherited rights under the user cache, so setup
// has to grant it write access to this tree before any command runs.
//
// Both callers must agree exactly. If they ever drift, setup grants the ACE on
// one directory while commands write to another, and the failure is a bare
// ACCESS_DENIED from npm or go build with nothing pointing at the sandbox.
func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, error) {
digest := sha256.Sum256([]byte(workspaceRoot))
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
if !pathWithinRoot(workspaceRoot, root) {
return root, nil
}
return fallbackSandboxRuntimeRoot(workspaceRoot)
}

func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
if workspaceRoot == "" || workspaceRoot == "." {
Expand All @@ -49,13 +67,9 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
if cacheRoot == "" || cacheRoot == "." {
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
}
digest := sha256.Sum256([]byte(workspaceRoot))
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
if pathWithinRoot(workspaceRoot, root) {
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
lease, err := prepareSandboxRuntimeLease(root)
if err != nil {
Expand Down
28 changes: 27 additions & 1 deletion internal/sandbox/windows_acl_apply_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,33 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind
func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACCESS_MASK, error) {
switch action {
case WindowsACLAllowWrite:
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE, nil
// DELETE and FILE_DELETE_CHILD are part of the grant, not extras.
// FILE_GENERIC_WRITE covers creating and modifying but not removing or
// renaming, and a rename needs delete access on the source. Under the
// old same-user token that gap was invisible, because the caller already
// held inherited rights on its own tree; a sandbox principal is a
// separate account with no such inheritance, so without these it can
// write a file it can never delete. Ordinary editing and most git
// operations rewrite files by replacing them, so the omission fails
// normal work rather than an edge case.
//
// WindowsACLDenyWrite below already treats delete as part of write. This
// keeps the grant symmetric with the deny instead of covering less.
// WRITE_DAC and WRITE_OWNER stay out on purpose: they are in the deny
// mask to stop the principal rewriting its own restrictions, and
// granting them here would hand back exactly that.
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE | windows.DELETE | windowsFileDeleteChild, nil
case WindowsACLAllowRead:
// Read and traverse without write. A sandbox principal is a separate
// account with no inherent access to the caller's tree, so a read-only
// root has to be granted rather than assumed. Deliberately omits
// FILE_GENERIC_WRITE, DELETE and WRITE_DAC.
return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil
case windowsACLRevoke:
// REVOKE_ACCESS drops every ACE naming the trustee regardless of the mask,
// so the mask is ignored here. Used to retire a principal without having
// to remember which access each path was granted.
return windows.REVOKE_ACCESS, 0, nil
case WindowsACLDenyRead:
return windows.DENY_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil
case WindowsACLDenyWrite:
Expand Down
25 changes: 25 additions & 0 deletions internal/sandbox/windows_command_runner_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,31 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
// reads under that flag (#612). Profiles with DenyRead keep the fully
// restricted token, trading spawn capability for read-deny enforcement.
writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0

// A provisioned sandbox principal replaces the restricted token entirely: it
// is a separate account, so reads outside its granted roots are denied by the
// filesystem rather than left open the way a same-user restricted token has
// to leave them (#662). Absent, unprovisioned or opted-out, ok is false and
// the restricted-token backend below runs exactly as before.
principalToken, ok, err := windowsSandboxPrincipalToken(config)
if err != nil {
// The one path here that does not fall back, because a provisioned but
// unusable principal means the sandbox is broken rather than absent. Say
// how to get out of it, since the whole backend is opt-in.
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
}
if ok {
defer principalToken.Close()
exitCode, err := runWindowsCommandAsUser(principalToken, config)
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
return 1
}
return exitCode
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted)
if err != nil {
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
Expand Down
174 changes: 174 additions & 0 deletions internal/sandbox/windows_identity_acl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package sandbox

// ACLs for a sandbox principal.
//
// The capability-SID model this sits beside starts from "the caller can already
// read everything" and narrows writes, because the sandboxed child runs as the
// caller. A principal inverts that: a separate local account has no access to
// the caller's profile at all, so the interesting direction is what to GRANT.
//
// That inversion is the point. Credential stores under the user's profile are
// unreachable because the principal is a different account, not because a deny
// rule enumerated them, which is what makes this able to close #662 and #675 on
// Windows where a deny-read ACE against the caller's own SID never could. Deny
// rules stay useful only for objects that are readable by everyone.
//
// Grants are explicit and narrow: the workspace and any extra write roots get
// read+write, declared read-only roots get read, and the protected metadata
// carve-outs the profile already defines stay denied so .git internals and
// .zero/.agents cannot be rewritten from inside the sandbox.

import (
"errors"
"fmt"
"path/filepath"
)

// WindowsACLAllowRead grants read and execute without write. It exists for the
// principal model, where a read root must be granted rather than assumed.
const WindowsACLAllowRead WindowsACLAction = "allow-read"

// windowsPrincipalACLInput is everything needed to describe a principal's
// access. It is deliberately a plain struct rather than the full command config
// so the plan can be built and tested without a live sandbox.
type windowsPrincipalACLInput struct {
// PrincipalSID is the string SID of the sandbox account every ACE names.
PrincipalSID string
// WriteRoots receive read+write+execute. The workspace lives here.
WriteRoots []WritableRoot
// ReadRoots receive read+execute only.
ReadRoots []string
// DenyRead covers objects a principal could otherwise reach because they are
// world-readable; per-user secrets need no entry.
DenyRead []string
// DenyWrite carries the policy's own deny-write paths. The capability plan
// has always emitted these; the principal plan denied write only on
// protected metadata and read-only subpaths inside write roots, so a policy
// deny sitting anywhere else was simply not enforced once the runner used a
// principal token, and a shell child could write where the restricted-token
// backend would have blocked it.
DenyWrite []string
}

// buildWindowsPrincipalACLPlan turns a principal's access into ACL entries.
//
// Ordering matters at apply time: deny entries are emitted before allows so a
// carve-out inside a granted root survives, which mirrors how Windows evaluates
// an explicit DACL (deny ACEs first).
func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPlan, error) {
if input.PrincipalSID == "" {
return WindowsACLPlan{}, errors.New("windows principal ACL plan requires a principal SID")
}
if len(input.WriteRoots) == 0 && len(input.ReadRoots) == 0 {
return WindowsACLPlan{}, errors.New("windows principal ACL plan requires at least one root")
}

entries := make([]WindowsACLEntry, 0, len(input.WriteRoots)*2+len(input.ReadRoots)+len(input.DenyRead))

// Deny first. A deny ACE inside a write root (protected metadata, git
// internals) has to win over the grant that follows it.
// Materialized, matching the capability plan. applyWindowsACLPlan skips a
// target that does not exist, so without this a deny-read path created after
// setup ran never got an ACE at all and the principal could read it. The
// deny has to be in place before the object is.
for _, path := range normalizeProfilePaths(input.DenyRead) {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyRead,
Path: path,
Capability: input.PrincipalSID,
Materialize: true,
})
}
for _, path := range normalizeProfilePaths(input.DenyWrite) {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: path,
Capability: input.PrincipalSID,
Materialize: true,
})
}
for _, root := range input.WriteRoots {
// Normalized the same way as read and deny paths: a write root may arrive
// with "~" or as a relative path, and an ACE has to name the same absolute,
// symlink-resolved object the deny entries do or the two disagree.
cleaned := normalizeProfilePath(root.Root)
if cleaned == "" {
return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: unusable write root %q", root.Root)
}
// Materialized, like the metadata and policy denies below and above.
//
// These are the git control-plane carveouts (.git/config, .git/hooks). On a
// workspace where git has not run yet they do not exist at setup time, and
// applyWindowsACLPlan skips a target that is absent, so the ACEs were never
// written. Once git created those paths the principal still held inherited
// write access to the workspace and could install a hook or rewrite
// credential.helper. The capability plan gets away without this because its
// child runs as the caller; a separate principal account does not.
for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: subpath,
Capability: input.PrincipalSID,
Materialize: true,
})
}
for _, name := range root.ProtectedMetadataNames {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: filepath.Join(cleaned, name),
Capability: input.PrincipalSID,
Materialize: true,
})
Comment on lines +115 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject protected metadata names that escape the write root.

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

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

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

Suggested change
for _, name := range root.ProtectedMetadataNames {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: filepath.Join(cleaned, name),
Capability: input.PrincipalSID,
Materialize: true,
})
for _, name := range root.ProtectedMetadataNames {
if name == "" || name == "." || name == ".." || filepath.Base(name) != name {
return WindowsACLPlan{}, fmt.Errorf(
"windows principal ACL plan: invalid protected metadata name %q", name,
)
}
entries = append(entries, WindowsACLEntry{
Action: WindowsACLDenyWrite,
Path: filepath.Join(cleaned, name),
Capability: input.PrincipalSID,
Materialize: true,
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

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

}
}

// Then the grants the principal cannot work without.
for _, root := range input.WriteRoots {
cleaned := normalizeProfilePath(root.Root)
if cleaned == "" {
continue
}
entries = append(entries, WindowsACLEntry{
Action: WindowsACLAllowWrite,
Path: cleaned,
Capability: input.PrincipalSID,
})
}
for _, path := range normalizeProfilePaths(input.ReadRoots) {
entries = append(entries, WindowsACLEntry{
Action: WindowsACLAllowRead,
Path: path,
Capability: input.PrincipalSID,
})
}

return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil
}

// windowsPrincipalRevokePlan returns the entries whose ACEs should be removed
// when a principal is retired. Revocation is by TRUSTEE rather than by path:
// every ACE naming this principal is dropped, so cleanup does not depend on
// remembering which paths were granted, and a grant added by an older version
// is still removed.
//
// This is the removal path the capability-SID model never had, where a synthetic
// SID left ACEs behind on the user's tree with nothing to match them against.
func windowsPrincipalRevokePlan(principalSID string, paths []string) (WindowsACLPlan, error) {
if principalSID == "" {
return WindowsACLPlan{}, errors.New("windows principal revoke plan requires a principal SID")
}
cleaned := normalizeProfilePaths(paths)
entries := make([]WindowsACLEntry, 0, len(cleaned))
for _, path := range cleaned {
entries = append(entries, WindowsACLEntry{
Action: windowsACLRevoke,
Path: path,
Capability: principalSID,
})
}
return WindowsACLPlan{Entries: entries}, nil
}

// windowsACLRevoke removes every ACE naming the trustee on a path, whatever
// access it granted or denied.
const windowsACLRevoke WindowsACLAction = "revoke"
Loading
Loading