From 114fccc9a55a7c9e6b0c76c6b56ddc4e1f7c1e6a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:07:09 +0530 Subject: [PATCH 01/45] feat(sandbox): add Windows sandbox principals Groundwork for closing the Windows half of #662 and #675, where credentialDenyReadPaths is a no-op today. Every Windows backend currently derives its token from the calling user via CreateRestrictedToken, so the sandbox can constrain writes but not reads: a deny ACE that would stop the sandboxed child reading a credential store names the same account Zero runs as, and would lock Zero out too. That is why deny-read is skipped on Windows rather than merely unimplemented. This adds a separate local account per workspace, held in one managed group, so the sandbox has an identity of its own: - provisioning: managed group, stable per-workspace account name inside the 20-character limit, crypto/rand password meeting complexity policy, SID resolution, idempotent so setup re-runs converge - logon rights: grants only SeBatchLogonRight and explicitly denies interactive, network, remote-interactive and service logon, then mints a token with LogonUser pinned to the local machine - ACLs keyed to the principal: denies emitted before allows so carve-outs survive, workspace granted read+write, read roots granted read, protected metadata denied write and materialized - removal: revocation by trustee, so retiring a principal drops every ACE naming it without needing a record of what was granted The inversion is the point. A separate account has no access to the caller's profile at all, so credential stores are unreachable by construction rather than by enumerating deny rules, and the same SID is what a write grant or a firewall rule can be keyed to. Nothing is wired into command execution yet: these paths are additive and no existing behavior changes. See the pull request for the open question about where the principal's password lives. --- internal/sandbox/windows_acl_apply_windows.go | 11 + internal/sandbox/windows_identity_acl.go | 144 ++++++++ internal/sandbox/windows_identity_acl_test.go | 182 +++++++++ .../sandbox/windows_identity_logon_windows.go | 203 +++++++++++ internal/sandbox/windows_identity_windows.go | 345 ++++++++++++++++++ .../sandbox/windows_identity_windows_test.go | 247 +++++++++++++ 6 files changed, 1132 insertions(+) create mode 100644 internal/sandbox/windows_identity_acl.go create mode 100644 internal/sandbox/windows_identity_acl_test.go create mode 100644 internal/sandbox/windows_identity_logon_windows.go create mode 100644 internal/sandbox/windows_identity_windows.go create mode 100644 internal/sandbox/windows_identity_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index c666aa9ef..a38789f4a 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -224,6 +224,17 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC switch action { case WindowsACLAllowWrite: return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE, 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: diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go new file mode 100644 index 000000000..c37393b71 --- /dev/null +++ b/internal/sandbox/windows_identity_acl.go @@ -0,0 +1,144 @@ +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 +} + +// 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. + for _, path := range normalizeProfilePaths(input.DenyRead) { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyRead, + Path: path, + Capability: input.PrincipalSID, + }) + } + 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) + } + for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyWrite, + Path: subpath, + Capability: input.PrincipalSID, + }) + } + for _, name := range root.ProtectedMetadataNames { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyWrite, + Path: filepath.Join(cleaned, name), + Capability: input.PrincipalSID, + Materialize: true, + }) + } + } + + // 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" diff --git a/internal/sandbox/windows_identity_acl_test.go b/internal/sandbox/windows_identity_acl_test.go new file mode 100644 index 000000000..b9026337a --- /dev/null +++ b/internal/sandbox/windows_identity_acl_test.go @@ -0,0 +1,182 @@ +package sandbox + +import ( + "path/filepath" + "strings" + "testing" +) + +const testPrincipalSID = "S-1-5-21-1111111111-2222222222-3333333333-1005" + +func testPrincipalInput() windowsPrincipalACLInput { + return windowsPrincipalACLInput{ + PrincipalSID: testPrincipalSID, + WriteRoots: []WritableRoot{{ + Root: filepath.FromSlash("/ws/project"), + ReadOnlySubpaths: []string{filepath.FromSlash("/ws/project/.git/config")}, + ProtectedMetadataNames: []string{".zero", ".agents"}, + }}, + ReadRoots: []string{filepath.FromSlash("/usr/lib")}, + DenyRead: []string{filepath.FromSlash("/shared/secrets")}, + } +} + +// Windows evaluates an explicit DACL deny-before-allow, so a carve-out inside a +// granted root only survives if its deny ACE is written first. If the grant on +// the workspace landed before the deny on .zero, the protected metadata would be +// writable from inside the sandbox. +func TestPrincipalACLPlanEmitsDeniesBeforeAllows(t *testing.T) { + plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput()) + if err != nil { + t.Fatalf("build: %v", err) + } + lastDeny, firstAllow := -1, -1 + for index, entry := range plan.Entries { + switch entry.Action { + case WindowsACLDenyRead, WindowsACLDenyWrite: + lastDeny = index + case WindowsACLAllowWrite, WindowsACLAllowRead: + if firstAllow == -1 { + firstAllow = index + } + } + } + if firstAllow == -1 || lastDeny == -1 { + t.Fatalf("plan is missing a deny or an allow: %+v", plan.Entries) + } + if lastDeny > firstAllow { + t.Fatalf("deny at %d comes after allow at %d; carve-outs would be overridden", lastDeny, firstAllow) + } +} + +// Every ACE must name the sandbox principal. An entry with any other trustee +// would change access for a real user. +func TestPrincipalACLPlanNamesOnlyThePrincipal(t *testing.T) { + plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput()) + if err != nil { + t.Fatalf("build: %v", err) + } + for _, entry := range plan.Entries { + if entry.Capability != testPrincipalSID { + t.Fatalf("entry %+v names %q, want the principal SID", entry, entry.Capability) + } + } +} + +// A principal is a separate account with no inherent access, so a write root +// must be granted read+write and a read root granted read. Without the grant the +// sandbox cannot open its own workspace. +func TestPrincipalACLPlanGrantsRoots(t *testing.T) { + plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput()) + if err != nil { + t.Fatalf("build: %v", err) + } + var grantedWrite, grantedRead bool + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowWrite && entry.Path == normalizeProfilePath(filepath.FromSlash("/ws/project")) { + grantedWrite = true + } + if entry.Action == WindowsACLAllowRead && entry.Path == normalizeProfilePath(filepath.FromSlash("/usr/lib")) { + grantedRead = true + } + } + if !grantedWrite { + t.Fatal("write root was not granted; the sandbox could not write its workspace") + } + if !grantedRead { + t.Fatal("read root was not granted; the sandbox could not read it") + } +} + +// Protected metadata is denied write and marked Materialize so the ACE is +// created even when the directory does not exist yet, closing the window where +// a sandboxed command creates .zero before the deny lands. +func TestPrincipalACLPlanProtectsMetadata(t *testing.T) { + plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput()) + if err != nil { + t.Fatalf("build: %v", err) + } + found := map[string]WindowsACLEntry{} + for _, entry := range plan.Entries { + found[entry.Path] = entry + } + for _, name := range []string{".zero", ".agents"} { + path := filepath.Join(normalizeProfilePath(filepath.FromSlash("/ws/project")), name) + entry, ok := found[path] + if !ok { + t.Fatalf("no entry protecting %s", path) + } + if entry.Action != WindowsACLDenyWrite { + t.Fatalf("%s has action %q, want deny-write", path, entry.Action) + } + if !entry.Materialize { + t.Fatalf("%s must be materialized so the deny exists before the directory does", path) + } + } +} + +// A missing principal SID must be a hard error: an empty trustee would either +// fail at apply time or, worse, be interpreted as some other account. +func TestPrincipalACLPlanRequiresSID(t *testing.T) { + input := testPrincipalInput() + input.PrincipalSID = "" + if _, err := buildWindowsPrincipalACLPlan(input); err == nil { + t.Fatal("an empty principal SID must be rejected") + } +} + +// A plan with no roots at all is a caller mistake rather than a valid empty +// grant, since the resulting sandbox could not run anything. +func TestPrincipalACLPlanRequiresRoots(t *testing.T) { + input := windowsPrincipalACLInput{PrincipalSID: testPrincipalSID} + if _, err := buildWindowsPrincipalACLPlan(input); err == nil { + t.Fatal("a plan with no roots must be rejected") + } +} + +// Revocation is keyed to the trustee, so retiring a principal removes every ACE +// naming it without having to remember what was granted. This is the cleanup +// path the capability-SID model lacks. +func TestPrincipalRevokePlanTargetsTrustee(t *testing.T) { + paths := []string{filepath.FromSlash("/ws/project"), filepath.FromSlash("/usr/lib")} + plan, err := windowsPrincipalRevokePlan(testPrincipalSID, paths) + if err != nil { + t.Fatalf("revoke plan: %v", err) + } + if len(plan.Entries) != len(paths) { + t.Fatalf("got %d entries, want %d", len(plan.Entries), len(paths)) + } + for _, entry := range plan.Entries { + if entry.Action != windowsACLRevoke { + t.Fatalf("entry %+v is not a revoke", entry) + } + if entry.Capability != testPrincipalSID { + t.Fatalf("revoke names %q, want the principal", entry.Capability) + } + } +} + +func TestPrincipalRevokePlanRequiresSID(t *testing.T) { + if _, err := windowsPrincipalRevokePlan("", []string{"/ws"}); err == nil { + t.Fatal("revoking without a principal SID must be rejected") + } +} + +// The action strings end up in a serialized plan consumed by the elevated +// helper, so they must stay stable and distinct from the existing actions. +func TestPrincipalACLActionsAreDistinct(t *testing.T) { + actions := []WindowsACLAction{ + WindowsACLAllowWrite, WindowsACLAllowRead, + WindowsACLDenyRead, WindowsACLDenyWrite, windowsACLRevoke, + } + seen := map[WindowsACLAction]bool{} + for _, action := range actions { + if strings.TrimSpace(string(action)) == "" { + t.Fatal("an action string is empty") + } + if seen[action] { + t.Fatalf("duplicate action %q", action) + } + seen[action] = true + } +} diff --git a/internal/sandbox/windows_identity_logon_windows.go b/internal/sandbox/windows_identity_logon_windows.go new file mode 100644 index 000000000..6f9d689fb --- /dev/null +++ b/internal/sandbox/windows_identity_logon_windows.go @@ -0,0 +1,203 @@ +//go:build windows + +package sandbox + +// Minting a token for a sandbox principal. +// +// A provisioned account is inert until something can log on as it. Windows +// gates that behind account rights held in the local security policy, so setup +// grants the principal exactly one: the right to be logged on as a batch job, +// which is what a non-interactive service-style logon needs. It is deliberately +// NOT granted interactive, network or remote-interactive logon, and those three +// are explicitly DENIED, so the account cannot be used to sign in at the +// console, over SMB, or through RDP even if its password leaked. The password +// exists only so LogonUser can mint a token; nobody is meant to type it. +// +// Rights are granted at setup (elevated) because LsaAddAccountRights requires +// administrator privileges. The per-command path only calls LogonUser, which +// needs no special privilege once the batch right is in place. + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // Logon type/provider for a non-interactive token. Batch is the closest + // match for "run this command as a service-like principal": it produces a + // full token without a desktop or network-credential footprint. + logon32LogonBatch = 4 + logon32ProviderDefault = 0 + + // LSA policy access rights needed to add account rights. + policyCreateAccount = 0x00000010 + policyLookupNames = 0x00000800 + + // Account rights. The sandbox principal gets the batch right and is denied + // every interactive path. + seBatchLogonRight = "SeBatchLogonRight" + seDenyInteractiveLogonRight = "SeDenyInteractiveLogonRight" + seDenyNetworkLogonRight = "SeDenyNetworkLogonRight" + seDenyRemoteInteractiveRight = "SeDenyRemoteInteractiveLogonRight" + seDenyServiceLogonRightName = "SeDenyServiceLogonRight" + windowsIdentityLogonRightsNote = "granted by `zero sandbox setup`" +) + +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") +) + +// lsaUnicodeString mirrors LSA_UNICODE_STRING. Length and MaximumLength are +// BYTE counts, not rune counts, which is the usual source of bugs here. +type lsaUnicodeString struct { + Length uint16 + MaximumLength uint16 + Buffer *uint16 +} + +// lsaObjectAttributes mirrors LSA_OBJECT_ATTRIBUTES. Every field except Length +// is unused for LsaOpenPolicy, but the struct must still be the right size. +type lsaObjectAttributes struct { + Length uint32 + RootDirectory windows.Handle + ObjectName *lsaUnicodeString + Attributes uint32 + SecurityDescriptor unsafe.Pointer + SecurityQualityOfService unsafe.Pointer +} + +// newLSAString builds an LSA_UNICODE_STRING over a UTF-16 buffer the caller +// keeps alive. The returned value borrows that buffer, so the buffer must +// outlive every use of the string. +func newLSAString(buffer []uint16) lsaUnicodeString { + if len(buffer) == 0 { + return lsaUnicodeString{} + } + // The buffer is NUL-terminated; the LSA length counts bytes WITHOUT the + // terminator, while MaximumLength counts bytes WITH it. + runes := len(buffer) - 1 + return lsaUnicodeString{ + Length: uint16(runes * 2), + MaximumLength: uint16(len(buffer) * 2), + Buffer: &buffer[0], + } +} + +// lsaStatusError converts an NTSTATUS from an Lsa* call into a Go error, going +// through LsaNtStatusToWinError so the message is the familiar Win32 one rather +// than a raw NTSTATUS. +func lsaStatusError(call string, status uintptr) error { + if status == 0 { + return nil + } + winErr, _, _ := procLsaNtStatusToWinErr.Call(status) + return fmt.Errorf("%s: %w", call, windows.Errno(winErr)) +} + +// grantWindowsSandboxLogonRights gives the principal the batch logon right and +// denies every interactive logon path. Idempotent: LsaAddAccountRights silently +// succeeds when the account already holds a right, so setup can re-run. +// +// Requires an elevated caller. +func grantWindowsSandboxLogonRights(sid *windows.SID) error { + if sid == nil { + return fmt.Errorf("grant sandbox logon rights: nil SID") + } + var attributes lsaObjectAttributes + attributes.Length = uint32(unsafe.Sizeof(attributes)) + var policy windows.Handle + status, _, _ := procLsaOpenPolicy.Call( + 0, // local system + uintptr(unsafe.Pointer(&attributes)), + uintptr(policyCreateAccount|policyLookupNames), + uintptr(unsafe.Pointer(&policy)), + ) + if err := lsaStatusError("LsaOpenPolicy", status); err != nil { + return err + } + defer procLsaClose.Call(uintptr(policy)) + + rights := []string{ + seBatchLogonRight, + seDenyInteractiveLogonRight, + seDenyNetworkLogonRight, + seDenyRemoteInteractiveRight, + seDenyServiceLogonRightName, + } + // Each right is added on its own call so one unsupported name on an odd SKU + // cannot silently drop the others. + for _, right := range rights { + buffer, err := windows.UTF16FromString(right) + if err != nil { + return err + } + entry := newLSAString(buffer) + status, _, _ := procLsaAddAccountRights.Call( + uintptr(policy), + uintptr(unsafe.Pointer(sid)), + uintptr(unsafe.Pointer(&entry)), + 1, + ) + if err := lsaStatusError("LsaAddAccountRights("+right+")", status); err != nil { + return err + } + // Keep the backing buffer alive until the call has returned. + runtimeKeepAliveUint16(buffer) + } + return nil +} + +// logonWindowsSandboxPrincipal mints a primary token for the sandbox account. +// The caller owns the returned token and must Close it. +// +// This needs no elevation: the batch logon right granted at setup is what makes +// it work, which is why the per-command path can run unelevated once setup has +// been done once. +func logonWindowsSandboxPrincipal(username string, password string) (windows.Token, error) { + user, err := windows.UTF16PtrFromString(username) + if err != nil { + return 0, err + } + // "." is the local machine, so the lookup never leaves this host even if the + // machine is domain-joined and a same-named domain account exists. + domain, err := windows.UTF16PtrFromString(".") + if err != nil { + return 0, err + } + secret, err := windows.UTF16PtrFromString(password) + if err != nil { + return 0, err + } + var token windows.Token + result, _, callErr := procLogonUserW.Call( + uintptr(unsafe.Pointer(user)), + uintptr(unsafe.Pointer(domain)), + uintptr(unsafe.Pointer(secret)), + logon32LogonBatch, + logon32ProviderDefault, + uintptr(unsafe.Pointer(&token)), + ) + if result == 0 { + if callErr != nil && callErr != windows.ERROR_SUCCESS { + return 0, fmt.Errorf("LogonUser(%s): %w", username, callErr) + } + return 0, fmt.Errorf("LogonUser(%s) failed", username) + } + return token, nil +} + +// runtimeKeepAliveUint16 keeps a UTF-16 buffer reachable across a syscall that +// borrows it. Declared rather than inlined so the intent is explicit at each +// call site; the compiler must not free the slice while LSA holds the pointer. +func runtimeKeepAliveUint16(buffer []uint16) { + if len(buffer) == 0 { + return + } + _ = buffer[0] +} diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go new file mode 100644 index 000000000..f8711d053 --- /dev/null +++ b/internal/sandbox/windows_identity_windows.go @@ -0,0 +1,345 @@ +//go:build windows + +package sandbox + +// Windows sandbox principals. +// +// Every other Windows backend here derives its token from the CALLING user via +// CreateRestrictedToken, which is why the sandbox can constrain writes but not +// reads: a deny ACE that would stop the sandboxed child reading a credential +// store names the same account Zero itself runs as, so it would lock Zero out +// too. Reads therefore stay on the caller's identity and +// credentialDenyReadPaths is a no-op on Windows (#662, #675). +// +// This file provisions a SEPARATE local account per workspace, held in one +// managed local group, so the sandbox has an identity of its own. A deny-read +// ACE naming that principal denies the sandboxed child and nothing else, and +// the same SID is what a firewall rule or a write grant can be keyed to. The +// accounts are created by the elevated `zero sandbox setup` path because +// NetUserAdd requires administrator rights; nothing here runs unelevated. +// +// Provisioning is idempotent: the "already exists" status from each API is a +// success, so setup can be re-run safely and a partially provisioned machine +// converges. + +import ( + "crypto/rand" + "encoding/base32" + "errors" + "fmt" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // windowsSandboxGroupName holds every sandbox principal. Grouping them means + // an ACE can name the group once instead of enumerating accounts, and it + // gives setup a single place to find what it previously created. + windowsSandboxGroupName = "ZeroSandboxUsers" + windowsSandboxGroupComment = "Zero sandbox principals (managed by zero sandbox setup)" + + // windowsSandboxUserPrefix keeps the accounts recognisable in `net user` and + // lets cleanup identify what belongs to Zero. Windows caps a local account + // name at 20 characters, which windowsSandboxUserName respects. + windowsSandboxUserPrefix = "zero-sbx-" + windowsSandboxUserComment = "Zero sandbox principal (managed)" + windowsSandboxUserNameMax = 20 +) + +// Win32 status codes that mean "already there". Treated as success so +// provisioning converges instead of failing on a second run. +const ( + nerrSuccess = 0 + nerrGroupExists = 2223 + nerrUserExists = 2224 + errorAliasExists = 1379 + errorMemberInAlias = 1378 + errorAccessDenied32 = 5 + nerrUserNotFound = 2221 +) + +// USER_INFO_1 privilege and flag values. +const ( + usrPrivUser = 1 + ufScript = 0x0001 + ufNormalAccount = 0x0200 + ufDontExpirePasswd = 0x10000 + windowsPasswordLength = 24 +) + +var ( + netapi32 = windows.NewLazySystemDLL("netapi32.dll") + procNetUserAdd = netapi32.NewProc("NetUserAdd") + procNetLocalGroupAdd = netapi32.NewProc("NetLocalGroupAdd") + procNetLocalGroupAddMembers = netapi32.NewProc("NetLocalGroupAddMembers") + procNetUserDel = netapi32.NewProc("NetUserDel") +) + +// userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32 +// struct exactly; it is passed to NetUserAdd as a raw buffer. +type userInfo1 struct { + Name *uint16 + Password *uint16 + PasswordAge uint32 + Priv uint32 + HomeDir *uint16 + Comment *uint16 + Flags uint32 + ScriptPath *uint16 +} + +// localGroupInfo1 mirrors LOCALGROUP_INFO_1. +type localGroupInfo1 struct { + Name *uint16 + Comment *uint16 +} + +// localGroupMembersInfo3 mirrors LOCALGROUP_MEMBERS_INFO_3, which identifies a +// member by name rather than SID. +type localGroupMembersInfo3 struct { + DomainAndName *uint16 +} + +// windowsSandboxIdentity is a provisioned sandbox principal: the account name +// and the SID that ACEs, tokens and firewall rules are keyed to. +type windowsSandboxIdentity struct { + Username string + SID *windows.SID +} + +// String renders the identity for logs without exposing the password, which is +// never stored on this struct. +func (identity windowsSandboxIdentity) String() string { + if identity.SID == nil { + return identity.Username + } + return identity.Username + " (" + identity.SID.String() + ")" +} + +// windowsSandboxUserName derives a stable account name for a workspace key. The +// key is hashed by the caller (see sandboxRuntimeKey) so the name reveals no +// path, and it is truncated to the 20-character local-account limit. The same +// workspace always maps to the same account, so re-running setup reuses the +// principal instead of accumulating accounts. +func windowsSandboxUserName(workspaceKey string) string { + cleaned := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + return r + case r >= 'A' && r <= 'Z': + return r + ('a' - 'A') + default: + return -1 + } + }, workspaceKey) + if cleaned == "" { + cleaned = "default" + } + name := windowsSandboxUserPrefix + cleaned + if len(name) > windowsSandboxUserNameMax { + name = name[:windowsSandboxUserNameMax] + } + return name +} + +// newWindowsSandboxPassword returns a random password for a sandbox principal. +// The account is never signed into interactively: the password exists only so +// LogonUser can mint a token for it, so it is generated per provisioning run, +// handed straight to the caller, and never persisted by this file. Base32 of +// crypto/rand bytes keeps it alphanumeric, which satisfies complexity policies +// that reject unusual punctuation, and a fixed suffix guarantees the mixed-case +// and digit classes even if the random draw happens to omit one. +func newWindowsSandboxPassword() (string, error) { + raw := make([]byte, windowsPasswordLength) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate sandbox password: %w", err) + } + encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw) + if len(encoded) > windowsPasswordLength { + encoded = encoded[:windowsPasswordLength] + } + return "Zs1!" + encoded, nil +} + +// netAPIStatus converts a netapi32 return value into an error, treating the +// supplied status codes as success so callers can spell out which "already +// exists" results are expected. +func netAPIStatus(call string, status uintptr, okStatuses ...uintptr) error { + if status == nerrSuccess { + return nil + } + for _, ok := range okStatuses { + if status == ok { + return nil + } + } + if status == errorAccessDenied32 { + return fmt.Errorf("%s: access denied (run `zero sandbox setup` from an elevated terminal)", call) + } + return fmt.Errorf("%s: status %d", call, status) +} + +// ensureWindowsSandboxGroup creates the managed local group, or leaves it alone +// when it already exists. +func ensureWindowsSandboxGroup() error { + name, err := windows.UTF16PtrFromString(windowsSandboxGroupName) + if err != nil { + return err + } + comment, err := windows.UTF16PtrFromString(windowsSandboxGroupComment) + if err != nil { + return err + } + info := localGroupInfo1{Name: name, Comment: comment} + 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 }() + return netAPIStatus("NetLocalGroupAdd", status, nerrGroupExists, errorAliasExists) +} + +// ensureWindowsSandboxUser creates a sandbox account with the supplied password, +// or leaves an existing account alone. The account is a plain local user with no +// home directory or logon script, flagged so its password never expires (nobody +// is there to rotate it) and so it is a normal, enabled account LogonUser can +// authenticate. +func ensureWindowsSandboxUser(username string, password string) error { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return err + } + secret, err := windows.UTF16PtrFromString(password) + if err != nil { + return err + } + comment, err := windows.UTF16PtrFromString(windowsSandboxUserComment) + if err != nil { + return err + } + info := userInfo1{ + Name: name, + Password: secret, + Priv: usrPrivUser, + Comment: comment, + Flags: ufScript | ufNormalAccount | ufDontExpirePasswd, + } + status, _, _ := procNetUserAdd.Call( + 0, // local machine + 1, // level: USER_INFO_1 + uintptr(unsafe.Pointer(&info)), + 0, + ) + defer func() { _ = info }() + return netAPIStatus("NetUserAdd", status, nerrUserExists) +} + +// addWindowsSandboxUserToGroup puts a principal in the managed group, ignoring +// the status that means it is already a member. +func addWindowsSandboxUserToGroup(username string) error { + group, err := windows.UTF16PtrFromString(windowsSandboxGroupName) + if err != nil { + return err + } + member, err := windows.UTF16PtrFromString(username) + if err != nil { + return err + } + entry := localGroupMembersInfo3{DomainAndName: member} + status, _, _ := procNetLocalGroupAddMembers.Call( + 0, // local machine + uintptr(unsafe.Pointer(group)), + 3, // level: LOCALGROUP_MEMBERS_INFO_3 + uintptr(unsafe.Pointer(&entry)), + 1, // one member + ) + defer func() { _ = entry }() + return netAPIStatus("NetLocalGroupAddMembers", status, errorMemberInAlias) +} + +// resolveWindowsSandboxSID looks up the SID for a provisioned principal. The SID +// is the durable handle: account names can collide with a pre-existing local +// user, so every ACE and firewall rule is keyed to the SID rather than the name. +func resolveWindowsSandboxSID(username string) (*windows.SID, error) { + sid, _, accountType, err := windows.LookupSID("", username) + if err != nil { + return nil, fmt.Errorf("look up sandbox principal %q: %w", username, err) + } + if accountType != windows.SidTypeUser { + return nil, fmt.Errorf("sandbox principal %q resolves to a non-user account (type %d)", username, accountType) + } + return sid, nil +} + +// provisionWindowsSandboxIdentity ensures the managed group and one sandbox +// principal for workspaceKey exist, and returns the identity plus the password +// the caller needs to mint a token with LogonUser. It is idempotent, so setup +// can run repeatedly. +// +// The password is returned rather than stored: on an account that already +// existed the returned value is the NEW password only if the caller resets it, +// so callers that need to log in must treat a pre-existing account as requiring +// a reset. That is handled a layer up, where the secret has somewhere safe to +// live; keeping it out of this file means no credential is written to disk here. +func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, string, error) { + if err := ensureWindowsSandboxGroup(); err != nil { + return windowsSandboxIdentity{}, "", err + } + username := windowsSandboxUserName(workspaceKey) + password, err := newWindowsSandboxPassword() + if err != nil { + return windowsSandboxIdentity{}, "", err + } + if err := ensureWindowsSandboxUser(username, password); err != nil { + return windowsSandboxIdentity{}, "", err + } + if err := addWindowsSandboxUserToGroup(username); err != nil { + return windowsSandboxIdentity{}, "", err + } + sid, err := resolveWindowsSandboxSID(username) + if err != nil { + return windowsSandboxIdentity{}, "", err + } + return windowsSandboxIdentity{Username: username, SID: sid}, password, nil +} + +// removeWindowsSandboxIdentity deletes a provisioned principal. Callers must +// revoke the principal's ACEs FIRST (see windowsPrincipalRevokePlan): deleting +// the account leaves any surviving ACE naming an unresolvable SID, which is what +// shows up in Explorer as an orphaned entry and is exactly the residue this +// model is meant to avoid. +// +// A missing account is success, so teardown converges the same way provisioning +// does. Requires an elevated caller. +func removeWindowsSandboxIdentity(username string) error { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return err + } + status, _, _ := procNetUserDel.Call(0, uintptr(unsafe.Pointer(name))) + return netAPIStatus("NetUserDel", status, nerrUserNotFound) +} + +// errWindowsSandboxIdentityUnavailable reports that no sandbox principal has +// been provisioned yet, so callers can fall back to the restricted-token +// backend instead of failing the command. +var errWindowsSandboxIdentityUnavailable = errors.New("no Zero sandbox principal is provisioned; run `zero sandbox setup` from an elevated terminal") + +// lookupWindowsSandboxIdentity resolves an already-provisioned principal without +// creating anything, so the unelevated command path can discover whether an +// identity exists. It returns errWindowsSandboxIdentityUnavailable when setup +// has not run. +func lookupWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, error) { + username := windowsSandboxUserName(workspaceKey) + sid, err := resolveWindowsSandboxSID(username) + if err != nil { + return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable + } + return windowsSandboxIdentity{Username: username, SID: sid}, nil +} diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go new file mode 100644 index 000000000..e98ef85d9 --- /dev/null +++ b/internal/sandbox/windows_identity_windows_test.go @@ -0,0 +1,247 @@ +//go:build windows + +package sandbox + +import ( + "os" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// A local Windows account name is capped at 20 characters, so the derived name +// must truncate rather than produce a name NetUserAdd rejects. +func TestWindowsSandboxUserNameRespectsLengthLimit(t *testing.T) { + name := windowsSandboxUserName(strings.Repeat("a", 64)) + if len(name) > windowsSandboxUserNameMax { + t.Fatalf("name %q is %d chars, want at most %d", name, len(name), windowsSandboxUserNameMax) + } + if !strings.HasPrefix(name, windowsSandboxUserPrefix) { + t.Fatalf("name %q lost the managed prefix", name) + } +} + +// The same workspace must map to the same principal, otherwise re-running setup +// would accumulate a new local account every time. +func TestWindowsSandboxUserNameIsStable(t *testing.T) { + first := windowsSandboxUserName("abc123") + second := windowsSandboxUserName("abc123") + if first != second { + t.Fatalf("name is not stable: %q vs %q", first, second) + } + if other := windowsSandboxUserName("def456"); other == first { + t.Fatalf("different workspaces produced the same principal %q", first) + } +} + +// The key is sanitised to characters a local account name accepts, so a hash or +// path fragment cannot smuggle a separator or a space into the name. +func TestWindowsSandboxUserNameRejectsUnsafeCharacters(t *testing.T) { + name := windowsSandboxUserName(`C:\Users\me\proj ect`) + for _, r := range strings.TrimPrefix(name, windowsSandboxUserPrefix) { + isLower := r >= 'a' && r <= 'z' + isDigit := r >= '0' && r <= '9' + if !isLower && !isDigit { + t.Fatalf("name %q contains unsafe rune %q", name, r) + } + } + if name == windowsSandboxUserPrefix { + t.Fatal("sanitising removed every character, leaving a bare prefix") + } +} + +// An empty or fully-sanitised-away key must still yield a usable name rather +// than the bare prefix. +func TestWindowsSandboxUserNameHandlesEmptyKey(t *testing.T) { + for _, key := range []string{"", "///", " "} { + if got := windowsSandboxUserName(key); got == windowsSandboxUserPrefix { + t.Fatalf("key %q produced a bare prefix", key) + } + } +} + +// The password must be fresh per call and carry the character classes a default +// Windows complexity policy demands, or NetUserAdd fails with ERROR_PASSWORD_RESTRICTION. +func TestNewWindowsSandboxPasswordIsRandomAndComplex(t *testing.T) { + first, err := newWindowsSandboxPassword() + if err != nil { + t.Fatalf("generate: %v", err) + } + second, err := newWindowsSandboxPassword() + if err != nil { + t.Fatalf("generate: %v", err) + } + if first == second { + t.Fatal("two generated passwords are identical, so they are not random") + } + if len(first) < 12 { + t.Fatalf("password is only %d chars", len(first)) + } + var hasUpper, hasLower, hasDigit bool + for _, r := range first { + switch { + case r >= 'A' && r <= 'Z': + hasUpper = true + case r >= 'a' && r <= 'z': + hasLower = true + case r >= '0' && r <= '9': + hasDigit = true + } + } + if !hasUpper || !hasLower || !hasDigit { + t.Fatalf("password %q lacks a required character class", first) + } +} + +// "Already exists" is the normal result of re-running setup and must not surface +// as an error, while a genuine failure must. +func TestNetAPIStatusTreatsExistingAsSuccess(t *testing.T) { + if err := netAPIStatus("NetUserAdd", nerrSuccess); err != nil { + t.Fatalf("success status returned %v", err) + } + if err := netAPIStatus("NetUserAdd", nerrUserExists, nerrUserExists); err != nil { + t.Fatalf("existing user must be success, got %v", err) + } + if err := netAPIStatus("NetLocalGroupAdd", nerrGroupExists, nerrGroupExists, errorAliasExists); err != nil { + t.Fatalf("existing group must be success, got %v", err) + } + if err := netAPIStatus("NetUserAdd", 2245); err == nil { + t.Fatal("an unexpected status must surface as an error") + } +} + +// Access-denied is the status an unelevated run gets, and it must say so rather +// than reporting a bare number the user cannot act on. +func TestNetAPIStatusExplainsAccessDenied(t *testing.T) { + err := netAPIStatus("NetUserAdd", errorAccessDenied32) + if err == nil { + t.Fatal("access denied must be an error") + } + if !strings.Contains(err.Error(), "elevated") { + t.Fatalf("error %q should point at elevation", err) + } +} + +// The Win32 structs are passed to netapi32 as raw buffers, so their layout must +// match what the API expects. A wrong size means silent memory corruption. +func TestWindowsIdentityStructLayouts(t *testing.T) { + ptr := unsafe.Sizeof(uintptr(0)) + if got, want := unsafe.Sizeof(localGroupMembersInfo3{}), ptr; got != want { + t.Fatalf("LOCALGROUP_MEMBERS_INFO_3 size = %d, want %d", got, want) + } + if got, want := unsafe.Sizeof(localGroupInfo1{}), 2*ptr; got != want { + t.Fatalf("LOCALGROUP_INFO_1 size = %d, want %d", got, want) + } + // USER_INFO_1 is four pointers plus three DWORDs, with the compiler padding + // each DWORD pair up to pointer alignment on amd64. + if got := unsafe.Sizeof(userInfo1{}); got < 4*ptr { + t.Fatalf("USER_INFO_1 size = %d, smaller than its four pointer fields", got) + } + if unsafe.Offsetof(userInfo1{}.Password) != ptr { + t.Fatal("USER_INFO_1.Password must directly follow Name") + } +} + +// LSA_UNICODE_STRING counts BYTES, not runes, and excludes the NUL terminator +// from Length while including it in MaximumLength. Getting either wrong makes +// LsaAddAccountRights read past the buffer or silently match no right, so pin it. +func TestNewLSAStringUsesByteLengths(t *testing.T) { + buffer, err := windows.UTF16FromString("SeBatchLogonRight") + if err != nil { + t.Fatalf("encode: %v", err) + } + entry := newLSAString(buffer) + const runes uint16 = uint16(len("SeBatchLogonRight")) + if entry.Length != runes*2 { + t.Fatalf("Length = %d, want %d (bytes, excluding NUL)", entry.Length, runes*2) + } + if entry.MaximumLength != (runes+1)*2 { + t.Fatalf("MaximumLength = %d, want %d (bytes, including NUL)", entry.MaximumLength, (runes+1)*2) + } + if entry.Buffer == nil { + t.Fatal("Buffer must point at the encoded string") + } +} + +// An empty buffer must not produce a struct pointing at nothing with a nonzero +// length, which would hand LSA a wild pointer. +func TestNewLSAStringHandlesEmptyBuffer(t *testing.T) { + entry := newLSAString(nil) + if entry.Buffer != nil || entry.Length != 0 || entry.MaximumLength != 0 { + t.Fatalf("empty buffer produced %+v, want a zero value", entry) + } +} + +// The LSA structs are passed to advapi32 as raw buffers, so their sizes must +// match the Win32 definitions. +func TestLSAStructLayouts(t *testing.T) { + ptr := unsafe.Sizeof(uintptr(0)) + // LSA_UNICODE_STRING: two uint16 then a pointer, padded to pointer alignment. + if got, want := unsafe.Sizeof(lsaUnicodeString{}), 2*ptr; got != want { + t.Fatalf("LSA_UNICODE_STRING size = %d, want %d", got, want) + } + if unsafe.Offsetof(lsaUnicodeString{}.Buffer) != ptr { + t.Fatal("LSA_UNICODE_STRING.Buffer must sit at the second pointer slot") + } + var attributes lsaObjectAttributes + if unsafe.Sizeof(attributes) < 6*ptr-ptr { + t.Fatalf("LSA_OBJECT_ATTRIBUTES size = %d, smaller than its fields", unsafe.Sizeof(attributes)) + } + if unsafe.Offsetof(attributes.ObjectName) == 0 { + t.Fatal("LSA_OBJECT_ATTRIBUTES.ObjectName must not alias Length") + } +} + +// Provisioning creates real local accounts, so it only runs when explicitly +// opted into on an elevated machine. Everything above covers the logic that can +// be exercised without touching the account database. +func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { + if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" { + t.Skip("set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1 on an elevated machine to exercise real provisioning") + } + if !windowsProcessIsElevated() { + t.Skip("provisioning requires an elevated process") + } + identity, password, err := provisionWindowsSandboxIdentity("ziptest01") + if err != nil { + t.Fatalf("provision: %v", err) + } + if identity.SID == nil { + t.Fatal("provisioned identity has no SID") + } + if password == "" { + t.Fatal("provisioning returned an empty password") + } + // Re-running must converge on the same principal rather than failing or + // creating a second account. + again, _, err := provisionWindowsSandboxIdentity("ziptest01") + if err != nil { + t.Fatalf("second provision: %v", err) + } + if again.Username != identity.Username || !again.SID.Equals(identity.SID) { + t.Fatalf("provisioning is not idempotent: %s then %s", identity, again) + } + // Lookup must find what provisioning created. + found, err := lookupWindowsSandboxIdentity("ziptest01") + if err != nil { + t.Fatalf("lookup after provision: %v", err) + } + if !found.SID.Equals(identity.SID) { + t.Fatalf("lookup returned %s, want %s", found, identity) + } +} + +// A workspace with no provisioned principal must report the actionable +// "run setup" error rather than a raw lookup failure, so the command path can +// fall back instead of surfacing a Win32 code. +func TestLookupWindowsSandboxIdentityUnprovisioned(t *testing.T) { + _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey9z") + if err == nil { + t.Skip("a principal for this key unexpectedly exists on this machine") + } + if err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("error = %v, want errWindowsSandboxIdentityUnavailable", err) + } +} From 303724fefd6dcfb00300a713357571622d6535df Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:13:04 +0530 Subject: [PATCH 02/45] feat(sandbox): use a sandbox principal for Windows command execution Wires the principal model into the runner and settles where the account's password lives. The secret is stored under the sandbox home with an explicit, inheritance-protected DACL naming only the invoking user and SYSTEM. The sandbox principal is deliberately absent from it: a principal that could read the file could mint its own token and the identity boundary would be decorative. The ACL is applied to an empty file before the password is written, so the bytes never exist under the config directory's inherited permissions, and PROTECTED drops any inherited ACE outright. At command time the runner asks for a principal token first and uses it in place of the restricted token, because a separate account has reads denied by the filesystem rather than left open the way a same-user restricted token must leave them. The lookup is fail-soft: opt-out, no provisioned account, or no stored secret all report "not available" and the existing restricted-token path runs unchanged. Only a provisioned-but-unusable identity surfaces an error, since that means setup ran and the sandbox is broken rather than absent. The backend stays behind ZERO_WINDOWS_SANDBOX_IDENTITY=1 while the privileged paths are unvalidated, so no existing install changes behaviour. --- .../sandbox/windows_command_runner_windows.go | 21 ++ .../windows_identity_runtime_windows.go | 143 +++++++++++++ .../windows_identity_secret_windows.go | 194 +++++++++++++++++ .../windows_identity_secret_windows_test.go | 196 ++++++++++++++++++ 4 files changed, 554 insertions(+) create mode 100644 internal/sandbox/windows_identity_runtime_windows.go create mode 100644 internal/sandbox/windows_identity_secret_windows.go create mode 100644 internal/sandbox/windows_identity_secret_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 0b9e8f64e..413e41899 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -75,6 +75,27 @@ 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 { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + 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 + } + token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go new file mode 100644 index 000000000..f7a4cef34 --- /dev/null +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -0,0 +1,143 @@ +//go:build windows + +package sandbox + +// Using a sandbox principal at command time. +// +// This is the seam between the identity model and the existing runner. It is +// deliberately fail-soft: when no principal is provisioned, when the secret is +// missing, or when the opt-in is off, it reports "not available" and the caller +// keeps using today's restricted-token backend. Only an outright failure to log +// on with a principal that IS provisioned surfaces as an error, because that +// means setup ran but the identity is broken, and silently downgrading the +// sandbox in that case would be the wrong kind of quiet. + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "strings" + + "golang.org/x/sys/windows" +) + +// windowsSandboxIdentityEnv opts a machine into the principal backend while it +// is still experimental. Provisioning is inert without it, so an existing +// install keeps the restricted-token behaviour until someone turns this on. +const windowsSandboxIdentityEnv = "ZERO_WINDOWS_SANDBOX_IDENTITY" + +// windowsSandboxIdentityEnabled reports whether the principal backend is opted +// into. Kept as a function so the check reads the environment at call time, +// which is what lets a test or an elevated setup run flip it. +func windowsSandboxIdentityEnabled(env map[string]string) bool { + if value, ok := env[windowsSandboxIdentityEnv]; ok { + return strings.TrimSpace(value) == "1" + } + return strings.TrimSpace(os.Getenv(windowsSandboxIdentityEnv)) == "1" +} + +// windowsSandboxWorkspaceKey derives the per-workspace key a principal is named +// after. It hashes the workspace root the same way the sandbox runtime keys its +// own state, so the account name leaks no path and one workspace always maps to +// one principal. +func windowsSandboxWorkspaceKey(workspaceRoots []string) string { + root := "" + for _, candidate := range workspaceRoots { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + root = normalizeProfilePath(trimmed) + break + } + } + if root == "" { + root = "default" + } + digest := sha256.Sum256([]byte(strings.ToLower(root))) + return hex.EncodeToString(digest[:]) +} + +// windowsSandboxPrincipalToken returns a token for this workspace's sandbox +// principal. +// +// ok is false, with a nil error, whenever the principal backend simply is not in +// play: the opt-in is off, setup has not provisioned an account, or no secret is +// stored. The caller falls back to the restricted token in those cases. An error +// means the identity exists but could not be used, which is worth surfacing +// rather than downgrading around. +func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.Token, bool, error) { + if !windowsSandboxIdentityEnabled(config.Env) { + return 0, false, nil + } + key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + identity, err := lookupWindowsSandboxIdentity(key) + if err != nil { + // Not provisioned: fall back quietly, this is the default state. + return 0, false, nil + } + secretPath, err := windowsSandboxSecretPath(config.SandboxHome, identity.Username) + if err != nil { + return 0, false, err + } + password, err := readWindowsSandboxSecret(secretPath) + if err != nil { + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + // The account exists but its password does not. Setup was interrupted + // or the secret was removed; fall back rather than fail the command. + return 0, false, nil + } + return 0, false, err + } + token, err := logonWindowsSandboxPrincipal(identity.Username, password) + if err != nil { + // Provisioned but unusable. Surface it: a wrong password or a revoked + // batch-logon right is a broken sandbox, not an absent one. + return 0, false, err + } + return token, true, nil +} + +// provisionWindowsSandboxPrincipalForSetup does the elevated half: create the +// account, grant it the batch logon right, and store its password locked to the +// invoking user. Called from `zero sandbox setup`. +// +// The password is written BEFORE the caller applies any ACL plan, so a setup +// that fails partway leaves a principal that can at least be logged on and +// therefore cleaned up, rather than an account nothing holds the secret for. +func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) (windowsSandboxIdentity, error) { + key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + identity, password, err := provisionWindowsSandboxIdentity(key) + if err != nil { + return windowsSandboxIdentity{}, err + } + if err := grantWindowsSandboxLogonRights(identity.SID); err != nil { + return windowsSandboxIdentity{}, err + } + secretPath, err := windowsSandboxSecretPath(config.SandboxHome, identity.Username) + if err != nil { + return windowsSandboxIdentity{}, err + } + // A pre-existing account keeps its old password, which this new one does not + // match, so the secret is rewritten every run to stay in step with whatever + // NetUserAdd left in place. On a fresh account the two agree by construction; + // on an existing one the caller resets it via ensureWindowsSandboxUser. + if err := writeWindowsSandboxSecret(secretPath, password); err != nil { + return windowsSandboxIdentity{}, err + } + return identity, nil +} + +// removeWindowsSandboxPrincipalForSetup retires a workspace's principal: secret +// first, then the account. ACE revocation is the caller's job and must happen +// before this, or ACEs naming a deleted SID are left behind. +func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) error { + key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + username := windowsSandboxUserName(key) + secretPath, err := windowsSandboxSecretPath(config.SandboxHome, username) + if err != nil { + return err + } + if err := removeWindowsSandboxSecret(secretPath); err != nil { + return err + } + return removeWindowsSandboxIdentity(username) +} diff --git a/internal/sandbox/windows_identity_secret_windows.go b/internal/sandbox/windows_identity_secret_windows.go new file mode 100644 index 000000000..37781032b --- /dev/null +++ b/internal/sandbox/windows_identity_secret_windows.go @@ -0,0 +1,194 @@ +//go:build windows + +package sandbox + +// Storing a sandbox principal's password. +// +// The elevated setup path provisions the account, but the per-command path runs +// UNELEVATED and needs the password to call LogonUser. So the secret has to +// cross that boundary on disk, and the only thing standing between it and the +// sandboxed child is the file's ACL. +// +// The file is locked to the invoking user: an explicit, INHERITANCE-PROTECTED +// DACL granting that user and SYSTEM, and nobody else. The sandbox principal is +// deliberately absent from it, which is the property that matters, because a +// principal that could read this file could mint its own token and the whole +// identity boundary would be decorative. Administrators are not added either; +// an admin can already take ownership, so naming them buys nothing and widens +// the visible grant. +// +// Ordering is load-bearing: the ACL is applied to an EMPTY file before the +// password is written, so the bytes never exist under the directory's inherited +// permissions even briefly. + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +// windowsSandboxSecretDirName holds per-principal secrets under the Zero config +// directory. Kept in its own directory so the whole set can be removed when the +// sandbox is torn down. +const windowsSandboxSecretDirName = "windows-sandbox" + +// windowsSandboxSecretPath returns where a principal's password lives. The +// account name is already sanitised to [a-z0-9-] by windowsSandboxUserName, so +// it cannot escape the directory. +func windowsSandboxSecretPath(configDir string, username string) (string, error) { + if strings.TrimSpace(configDir) == "" { + return "", errors.New("windows sandbox secret: empty config directory") + } + if strings.TrimSpace(username) == "" { + return "", errors.New("windows sandbox secret: empty principal name") + } + // Defence in depth against a caller passing something windowsSandboxUserName + // did not produce: refuse anything with a separator or a parent reference. + if strings.ContainsAny(username, `\/:`) || strings.Contains(username, "..") { + return "", fmt.Errorf("windows sandbox secret: unsafe principal name %q", username) + } + return filepath.Join(configDir, windowsSandboxSecretDirName, username+".secret"), nil +} + +// currentTokenUserSID returns the SID of the user this process runs as. Under +// UAC the elevated token keeps the same user SID as the desktop session, so +// setup and the later unelevated command path agree on the owner, which is what +// makes an owner-scoped ACL usable across the elevation boundary. +func currentTokenUserSID() (*windows.SID, error) { + var token windows.Token + if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { + return nil, fmt.Errorf("open process token: %w", err) + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil { + return nil, fmt.Errorf("get token user: %w", err) + } + // The SID points into a buffer owned by the Tokenuser, so copy it out before + // that buffer goes away. + copied, err := user.User.Sid.Copy() + if err != nil { + return nil, fmt.Errorf("copy token user SID: %w", err) + } + return copied, nil +} + +// lockWindowsSecretToOwner replaces a file's DACL with an explicit, +// inheritance-protected one granting only owner and SYSTEM. PROTECTED is what +// drops any ACE inherited from the config directory; without it a permissive +// parent would still grant access to whoever it names. +func lockWindowsSecretToOwner(path string, owner *windows.SID) error { + if owner == nil { + return errors.New("windows sandbox secret: nil owner SID") + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return fmt.Errorf("resolve SYSTEM SID: %w", err) + } + entries := []windows.EXPLICIT_ACCESS{ + { + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(owner), + }, + }, + { + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(system), + }, + }, + } + acl, err := windows.ACLFromEntries(entries, nil) + if err != nil { + return fmt.Errorf("build secret ACL: %w", err) + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + acl, + nil, + ); err != nil { + return fmt.Errorf("lock secret to owner: %w", err) + } + return nil +} + +// writeWindowsSandboxSecret stores a principal's password readable only by the +// invoking user. +// +// The file is created empty, locked down, and only then written, so the secret +// is never on disk under the directory's inherited ACL. An existing file is +// replaced rather than appended, since a stale password would make LogonUser +// fail in a way that looks like a sandbox bug. +func writeWindowsSandboxSecret(path string, password string) error { + owner, err := currentTokenUserSID() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create secret directory: %w", err) + } + // Truncate any previous secret first: the ACL below is applied to whatever + // inode ends up at this path, so create it before locking it. + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("create secret file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close secret file: %w", err) + } + if err := lockWindowsSecretToOwner(path, owner); err != nil { + // Do not leave an unprotected empty file behind. + _ = os.Remove(path) + return err + } + if err := os.WriteFile(path, []byte(password), 0o600); err != nil { + _ = os.Remove(path) + return fmt.Errorf("write secret: %w", err) + } + return nil +} + +// readWindowsSandboxSecret loads a principal's password. A missing file means +// setup has not run for this workspace, which the caller turns into a fallback +// rather than a hard failure. +func readWindowsSandboxSecret(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", errWindowsSandboxIdentityUnavailable + } + return "", fmt.Errorf("read sandbox secret: %w", err) + } + secret := strings.TrimSpace(string(data)) + if secret == "" { + return "", errWindowsSandboxIdentityUnavailable + } + return secret, nil +} + +// removeWindowsSandboxSecret deletes a stored password. Called before the +// account itself is removed so a secret never outlives the principal it +// authenticates. +func removeWindowsSandboxSecret(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove sandbox secret: %w", err) + } + return nil +} diff --git a/internal/sandbox/windows_identity_secret_windows_test.go b/internal/sandbox/windows_identity_secret_windows_test.go new file mode 100644 index 000000000..d1ebdfee8 --- /dev/null +++ b/internal/sandbox/windows_identity_secret_windows_test.go @@ -0,0 +1,196 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestWindowsSandboxSecretRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret") + const password = "Zs1!EXAMPLEPASSWORDVALUE" + + if err := writeWindowsSandboxSecret(path, password); err != nil { + t.Fatalf("write: %v", err) + } + got, err := readWindowsSandboxSecret(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if got != password { + t.Fatalf("read %q, want the stored password", got) + } +} + +// THE security property: the stored password must be readable only by the user +// who owns it. If any other trustee appears in the DACL, and in particular the +// sandbox principal, that account could mint its own token and the identity +// boundary would be worthless. +func TestWindowsSandboxSecretIsLockedToOwner(t *testing.T) { + path := filepath.Join(t.TempDir(), "locked.secret") + if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil { + t.Fatalf("write: %v", err) + } + + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + t.Fatalf("read back security info: %v", err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("read DACL: %v", err) + } + if dacl == nil { + t.Fatal("secret has a nil DACL, which grants everyone access") + } + + owner, err := currentTokenUserSID() + if err != nil { + t.Fatalf("owner SID: %v", err) + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatalf("SYSTEM SID: %v", err) + } + + entries, err := windowsSecretACEList(dacl) + if err != nil { + t.Fatalf("enumerate ACEs: %v", err) + } + if len(entries) == 0 { + t.Fatal("secret DACL has no ACEs") + } + for _, sid := range entries { + if sid.Equals(owner) || sid.Equals(system) { + continue + } + t.Fatalf("secret DACL grants an unexpected trustee %s; only the owner and SYSTEM may appear", sid) + } +} + +// The DACL must be inheritance-protected, otherwise a permissive ACE on the +// config directory would still reach the secret. +func TestWindowsSandboxSecretDaclIsProtected(t *testing.T) { + path := filepath.Join(t.TempDir(), "protected.secret") + if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil { + t.Fatalf("write: %v", err) + } + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read back security info: %v", err) + } + control, _, err := descriptor.Control() + if err != nil { + t.Fatalf("read control bits: %v", err) + } + if control&windows.SE_DACL_PROTECTED == 0 { + t.Fatal("secret DACL is not protected, so inherited ACEs still apply") + } +} + +// Rewriting must replace the previous secret rather than append to it, or +// LogonUser would be handed two concatenated passwords. +func TestWindowsSandboxSecretOverwrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "rewrite.secret") + if err := writeWindowsSandboxSecret(path, "Zs1!FIRST"); err != nil { + t.Fatalf("first write: %v", err) + } + if err := writeWindowsSandboxSecret(path, "Zs1!SECOND"); err != nil { + t.Fatalf("second write: %v", err) + } + got, err := readWindowsSandboxSecret(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if got != "Zs1!SECOND" { + t.Fatalf("read %q, want only the newest password", got) + } +} + +// A workspace whose setup has not run must report the actionable sentinel so the +// command path falls back to the restricted-token backend instead of failing. +func TestWindowsSandboxSecretMissingIsSentinel(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.secret") + if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("missing secret returned %v, want errWindowsSandboxIdentityUnavailable", err) + } +} + +// An empty file is a half-written secret, not a valid empty password. +func TestWindowsSandboxSecretEmptyIsSentinel(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.secret") + if err := os.WriteFile(path, []byte(" \r\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("empty secret returned %v, want the unavailable sentinel", err) + } +} + +// The principal name lands in a filename, so anything that could escape the +// directory has to be refused even though windowsSandboxUserName already +// sanitises its output. +func TestWindowsSandboxSecretPathRejectsTraversal(t *testing.T) { + for _, name := range []string{`..\evil`, "sub/dir", `C:\abs`, "..", ""} { + if _, err := windowsSandboxSecretPath(`C:\cfg`, name); err == nil { + t.Fatalf("principal name %q was accepted", name) + } + } + if _, err := windowsSandboxSecretPath("", "zero-sbx-a"); err == nil { + t.Fatal("empty config directory was accepted") + } + path, err := windowsSandboxSecretPath(`C:\cfg`, "zero-sbx-abc") + if err != nil { + t.Fatalf("valid name rejected: %v", err) + } + if !strings.HasSuffix(path, `zero-sbx-abc.secret`) { + t.Fatalf("unexpected secret path %q", path) + } +} + +// Removal must be idempotent so teardown converges the same way provisioning +// does, and must actually delete the secret. +func TestWindowsSandboxSecretRemoveIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "gone.secret") + if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil { + t.Fatalf("write: %v", err) + } + if err := removeWindowsSandboxSecret(path); err != nil { + t.Fatalf("first remove: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("secret still present after removal (stat err %v)", err) + } + if err := removeWindowsSandboxSecret(path); err != nil { + t.Fatalf("removing an absent secret must succeed, got %v", err) + } +} + +// windowsSecretACEList returns the trustee SID of every ACE in a DACL so a test +// can assert exactly who is named. +func windowsSecretACEList(dacl *windows.ACL) ([]*windows.SID, error) { + var out []*windows.SID + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + return nil, err + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + copied, err := sid.Copy() + if err != nil { + return nil, err + } + out = append(out, copied) + } + return out, nil +} From 48489807190989892d3ce83f0be47400910f9116 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:24:58 +0530 Subject: [PATCH 03/45] test(sandbox): name the per-shell env syntax in the provisioning skip The skip fired on an unset environment variable but read as though elevation was missing. `set VAR=1` is cmd syntax and sets a shell variable rather than an environment variable in PowerShell, so the test skipped silently after the operator believed they had enabled it. Spell out all three shells. --- internal/sandbox/windows_identity_windows_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index e98ef85d9..9449562d4 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -199,7 +199,10 @@ func TestLSAStructLayouts(t *testing.T) { // be exercised without touching the account database. func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" { - t.Skip("set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1 on an elevated machine to exercise real provisioning") + // Spelled out per shell because `set VAR=1` is cmd syntax and silently + // sets a shell variable rather than an environment variable in + // PowerShell, which makes this skip look like the elevation check failing. + t.Skip("provisioning test not enabled: PowerShell `$env:ZERO_WINDOWS_IDENTITY_PROVISION_TEST = \"1\"`, cmd `set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1`, bash `export ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1` (also needs an elevated terminal)") } if !windowsProcessIsElevated() { t.Skip("provisioning requires an elevated process") From 57eb9bc3d7c263ad05bc4cf4e48c916e22fce6ac Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:33:08 +0530 Subject: [PATCH 04/45] test(sandbox): cover the logon-rights and token-minting half Provisioning is now validated on real Windows, but LsaAddAccountRights and LogonUser had still never executed, so the principal was proven to exist without being proven usable. The batch logon doubles as the assertion that the rights grant worked: a LOGON32_LOGON_BATCH logon fails with ERROR_LOGON_TYPE_NOT_GRANTED unless SeBatchLogonRight is actually held, so a token coming back is evidence the grant landed rather than merely that the call returned success. Granting twice is exercised too, since setup re-runs must not fail on rights already held. The token's user SID is compared against the principal's. If a token came back belonging to the caller the identity boundary would be an illusion and reads would still run as the user, which is the whole thing this model exists to stop. Removes any leftover account first and cleans up after itself, because an interrupted earlier run would leave an account whose password no longer matches a freshly generated one. --- .../sandbox/windows_identity_windows_test.go | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index 9449562d4..480c61fd2 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -236,6 +236,68 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { } } +// The other half of the privileged chain: granting logon rights and actually +// minting a token. Provisioning proves the account exists; this proves it is +// USABLE, which is the part the runner depends on. +// +// The batch logon doubles as the assertion that LsaAddAccountRights worked. A +// LOGON32_LOGON_BATCH logon fails with ERROR_LOGON_TYPE_NOT_GRANTED (1385) +// unless SeBatchLogonRight is actually held, so a token coming back is proof the +// grant landed rather than merely that the call returned success. +// +// Creates a real local account and removes it again, so it is gated the same way +// as the provisioning round-trip. +func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) { + if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" { + t.Skip("provisioning test not enabled: PowerShell `$env:ZERO_WINDOWS_IDENTITY_PROVISION_TEST = \"1\"`, cmd `set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1`, bash `export ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1` (also needs an elevated terminal)") + } + if !windowsProcessIsElevated() { + t.Skip("granting logon rights requires an elevated process") + } + + const key = "ziplogon01" + // A leftover account from an interrupted run would keep its old password, + // which the freshly generated one will not match, so start from a clean slate. + _ = removeWindowsSandboxIdentity(windowsSandboxUserName(key)) + + identity, password, err := provisionWindowsSandboxIdentity(key) + if err != nil { + t.Fatalf("provision: %v", err) + } + t.Cleanup(func() { + if err := removeWindowsSandboxIdentity(identity.Username); err != nil { + t.Errorf("cleanup: %v", err) + } + }) + + // Exercises LsaAddAccountRights, including the LSA_UNICODE_STRING byte-length + // handling that nothing else has run. + if err := grantWindowsSandboxLogonRights(identity.SID); err != nil { + t.Fatalf("grant logon rights: %v", err) + } + // Idempotent: setup re-runs must not fail on rights the account already holds. + if err := grantWindowsSandboxLogonRights(identity.SID); err != nil { + t.Fatalf("granting logon rights twice must succeed: %v", err) + } + + token, err := logonWindowsSandboxPrincipal(identity.Username, password) + if err != nil { + t.Fatalf("logon as principal: %v", err) + } + defer token.Close() + + // The token must BE the principal. If this came back as the caller, the whole + // identity boundary would be an illusion and reads would still run as the user. + user, err := token.GetTokenUser() + if err != nil { + t.Fatalf("token user: %v", err) + } + if !user.User.Sid.Equals(identity.SID) { + t.Fatalf("token belongs to %s, want the principal %s", user.User.Sid, identity.SID) + } + t.Logf("minted a token for %s", identity) +} + // A workspace with no provisioned principal must report the actionable // "run setup" error rather than a raw lookup failure, so the command path can // fall back instead of surfacing a Win32 code. From 43bf81c63eacce24dd38f68866d4e825e6ee8648 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:35:41 +0530 Subject: [PATCH 05/45] feat(sandbox): provision a sandbox principal during elevated setup Completes the chain. Until now the principal entry points had no non-test callers, so `zero sandbox setup` created no account and the runner seam always fell back: the feature was inert end to end. Setup now provisions this workspace's principal, grants it the batch logon right, stores its password locked to the invoking user, and applies the ACL plan that gives it read+write on the workspace and read on the declared read roots. A principal is a separate account with no inherent access to the caller's tree, so those grants are what make the sandbox able to run at all, and their absence elsewhere is what puts credential stores out of reach. Provisioning is folded into the existing rollback rather than each later failure path having to remember it, and the rollback revokes ACEs before deleting the account: removing the account first would leave ACEs naming a SID that no longer resolves, which is the orphaned residue this model exists to avoid. Gated on the same opt-in as the runner. Account creation is visible in `net user` and is exactly what endpoint protection and enterprise policy object to, so it happens only when asked for; without the opt-in the capability-SID backend remains the whole of setup, unchanged. --- .../windows_identity_runtime_windows.go | 45 +++++++++++++ .../windows_identity_runtime_windows_test.go | 63 +++++++++++++++++++ internal/sandbox/windows_setup_windows.go | 27 ++++++++ 3 files changed, 135 insertions(+) create mode 100644 internal/sandbox/windows_identity_runtime_windows_test.go diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index f7a4cef34..6e885b684 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -126,6 +126,51 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig return identity, nil } +// setupWindowsSandboxPrincipal provisions this workspace's principal and grants +// it the filesystem access its permission profile describes. It returns a +// rollback that undoes everything it created, so a later setup step failing does +// not leave a half-provisioned account behind. +// +// Rollback order is the inverse of creation and matters: ACEs are revoked BEFORE +// the account is deleted, because removing the account first would leave ACEs +// naming a SID that no longer resolves, which is the orphaned-entry residue this +// model exists to avoid. +func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() error, error) { + identity, err := provisionWindowsSandboxPrincipalForSetup(config) + if err != nil { + return nil, err + } + removePrincipal := func() error { return removeWindowsSandboxPrincipalForSetup(config) } + + filesystem := config.PermissionProfile.FileSystem + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: identity.SID.String(), + WriteRoots: filesystem.WriteRoots, + ReadRoots: filesystem.ReadRoots, + DenyRead: filesystem.DenyRead, + }) + if err != nil { + _ = removePrincipal() + return nil, err + } + revertACL, err := applyWindowsACLPlan(plan) + if err != nil { + _ = removePrincipal() + return nil, err + } + return func() error { + aclErr := revertACL() + // Remove the principal even when the ACL revert failed, so a broken + // rollback does not also strand an account; report the ACL error since it + // is the one that leaves state behind. + removeErr := removePrincipal() + if aclErr != nil { + return aclErr + } + return removeErr + }, nil +} + // removeWindowsSandboxPrincipalForSetup retires a workspace's principal: secret // first, then the account. ACE revocation is the caller's job and must happen // before this, or ACEs naming a deleted SID are left behind. diff --git a/internal/sandbox/windows_identity_runtime_windows_test.go b/internal/sandbox/windows_identity_runtime_windows_test.go new file mode 100644 index 000000000..536cefea0 --- /dev/null +++ b/internal/sandbox/windows_identity_runtime_windows_test.go @@ -0,0 +1,63 @@ +//go:build windows + +package sandbox + +import "testing" + +// Setup must stay inert unless the principal backend is explicitly opted into. +// This is the property that makes the branch safe to merge while the privileged +// paths are still being validated: without the opt-in, `zero sandbox setup` +// creates no local account and the capability-SID backend is the whole of setup. +func TestWindowsSandboxIdentityGating(t *testing.T) { + for name, testCase := range map[string]struct { + env map[string]string + want bool + }{ + "absent": {env: map[string]string{}, want: false}, + "empty": {env: map[string]string{windowsSandboxIdentityEnv: ""}, want: false}, + "zero": {env: map[string]string{windowsSandboxIdentityEnv: "0"}, want: false}, + "true not one": {env: map[string]string{windowsSandboxIdentityEnv: "true"}, want: false}, + "one": {env: map[string]string{windowsSandboxIdentityEnv: "1"}, want: true}, + "one with space": {env: map[string]string{windowsSandboxIdentityEnv: " 1 "}, want: true}, + } { + t.Run(name, func(t *testing.T) { + if got := windowsSandboxIdentityEnabled(testCase.env); got != testCase.want { + t.Fatalf("enabled = %v, want %v for %q", got, testCase.want, testCase.env[windowsSandboxIdentityEnv]) + } + }) + } +} + +// The command environment wins over the process environment, so a run can opt in +// or out without depending on how the parent shell was launched. +func TestWindowsSandboxIdentityEnvOverridesProcess(t *testing.T) { + t.Setenv(windowsSandboxIdentityEnv, "1") + if windowsSandboxIdentityEnabled(map[string]string{windowsSandboxIdentityEnv: "0"}) { + t.Fatal("command env set to 0 must override a process env of 1") + } + if !windowsSandboxIdentityEnabled(map[string]string{}) { + t.Fatal("with no command-env entry the process env should apply") + } +} + +// One workspace maps to one principal, and different workspaces must not share +// an account, or two projects would run under the same identity and could reach +// each other's granted roots. +func TestWindowsSandboxWorkspaceKeyIsStableAndDistinct(t *testing.T) { + first := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`}) + again := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`}) + other := windowsSandboxWorkspaceKey([]string{`C:\ws\beta`}) + if first != again { + t.Fatalf("key is not stable: %q vs %q", first, again) + } + if first == other { + t.Fatal("two different workspaces produced the same principal key") + } + if first == "" { + t.Fatal("empty key") + } + // An empty root list still has to yield a usable key rather than a blank one. + if windowsSandboxWorkspaceKey(nil) == "" { + t.Fatal("no workspace roots produced an empty key") + } +} diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 888355397..ac8dc1243 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -35,6 +35,33 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 } + // Provision this workspace's sandbox principal, when opted in. A principal is + // a separate local account, so it is created only on an explicit opt-in: it + // is visible in `net user`, and account creation is exactly the kind of thing + // endpoint protection and enterprise policy object to. Without the opt-in the + // capability-SID backend above is the whole of setup, unchanged. + if windowsSandboxIdentityEnabled(config.commandConfig().Env) { + principalRollback, err := setupWindowsSandboxPrincipal(config.commandConfig()) + if err != nil { + if rollbackErr := rollback(); rollbackErr != nil { + fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) + return 1 + } + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + return 1 + } + // Fold the principal into the existing rollback so every later failure + // path undoes it too, rather than each one having to remember. + aclRollback := rollback + rollback = func() error { + principalErr := principalRollback() + aclErr := aclRollback() + if principalErr != nil { + return principalErr + } + return aclErr + } + } if err := applyWindowsNetworkPlan(networkPlan); err != nil { if rollbackErr := rollback(); rollbackErr != nil { fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) From 1438f70e4405f713e871d57bfc5e043ccdd8bbf1 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:43:35 +0530 Subject: [PATCH 06/45] fix(sandbox): keep the restricted token when the network is denied 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. Routing a denied-network command through a sandbox principal therefore left the block filters matching nothing and dropped egress enforcement altogether, and deny is the default mode. The principal now stands down whenever the network is denied and the restricted-token backend runs instead, so read confinement is never traded for a silent loss of network denial. Keying the filters to the principal's own SID is the follow-up that lifts the restriction. The decision sits in its own predicate rather than inline: on a machine with nothing provisioned the lookup declines for its own reasons, so a test that called through it would have passed with the guard removed. Also names the opt-out variable when a provisioned principal cannot be used, since the backend is opt-in and the operator needs a way back. --- .../sandbox/windows_command_runner_windows.go | 6 +++- .../windows_identity_runtime_windows.go | 22 ++++++++++++- .../windows_identity_runtime_windows_test.go | 32 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 413e41899..22d290d3e 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -83,7 +83,11 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // the restricted-token backend below runs exactly as before. principalToken, ok, err := windowsSandboxPrincipalToken(config) if err != nil { - fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + // 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 { diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 6e885b684..bc850b48f 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -56,6 +56,26 @@ func windowsSandboxWorkspaceKey(workspaceRoots []string) string { return hex.EncodeToString(digest[:]) } +// windowsSandboxPrincipalEligible reports whether the principal backend may be +// used for this command at all, before any account or secret is consulted. +// +// Kept separate from the lookup so the decision is observable on its own: on a +// machine with nothing provisioned the lookup declines anyway, which would let a +// missing guard here pass unnoticed. +func windowsSandboxPrincipalEligible(config WindowsSandboxCommandConfig) bool { + if !windowsSandboxIdentityEnabled(config.Env) { + return false + } + // Network denial is enforced by WFP filters keyed to the offline-marker SID, + // which the restricted token carries and a principal token cannot: LogonUser + // mints a token for the account, not for a synthetic capability SID. Using a + // principal here would leave those filters matching nothing and silently drop + // network enforcement, which is a worse trade than the read confinement it + // buys. Fall back to the restricted token, which still enforces the network, + // until the filters are also keyed to the principal's own SID. + return config.PermissionProfile.Network.Mode != NetworkDeny +} + // windowsSandboxPrincipalToken returns a token for this workspace's sandbox // principal. // @@ -65,7 +85,7 @@ func windowsSandboxWorkspaceKey(workspaceRoots []string) string { // means the identity exists but could not be used, which is worth surfacing // rather than downgrading around. func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.Token, bool, error) { - if !windowsSandboxIdentityEnabled(config.Env) { + if !windowsSandboxPrincipalEligible(config) { return 0, false, nil } key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) diff --git a/internal/sandbox/windows_identity_runtime_windows_test.go b/internal/sandbox/windows_identity_runtime_windows_test.go index 536cefea0..d8658a144 100644 --- a/internal/sandbox/windows_identity_runtime_windows_test.go +++ b/internal/sandbox/windows_identity_runtime_windows_test.go @@ -61,3 +61,35 @@ func TestWindowsSandboxWorkspaceKeyIsStableAndDistinct(t *testing.T) { t.Fatal("no workspace roots produced an empty key") } } + +// Network denial is enforced by WFP filters keyed to the offline-marker SID, +// which only the restricted token carries. A principal token would leave those +// filters matching nothing, so the principal backend must stand down whenever +// the network is denied rather than silently trading network enforcement for +// read confinement. +// The eligibility predicate is asserted rather than the token lookup, because on +// a machine with no principal provisioned the lookup declines for its own reasons +// and would report success here whether or not the guard existed. +func TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied(t *testing.T) { + eligible := func(mode NetworkMode, optIn string) bool { + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + WorkspaceRoots: []string{t.TempDir()}, + Env: map[string]string{windowsSandboxIdentityEnv: optIn}, + } + config.PermissionProfile.Network.Mode = mode + return windowsSandboxPrincipalEligible(config) + } + + if eligible(NetworkDeny, "1") { + t.Fatal("principal backend eligible with the network denied; the WFP filters key on the offline-marker SID, which a logon token does not carry, so egress would be unenforced") + } + // The guard must be specific to denial, not a blanket disable that would make + // the whole backend dead code. + if !eligible(NetworkAllow, "1") { + t.Fatal("principal backend refused with the network allowed; the guard is over-broad and disables the backend entirely") + } + if eligible(NetworkAllow, "0") { + t.Fatal("principal backend eligible without the opt-in") + } +} From 10b909d8e93c68cbdb963427cc83409c6e803048 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:43:43 +0530 Subject: [PATCH 07/45] feat(sandbox): encrypt the stored principal password to the invoking user The file ACL stays the primary control and is what keeps the sandbox principal from reading its own credential. It only binds while the filesystem is the one being asked, though, so a backup or a mounted image hands over the password in the clear. CryptProtectData ties the ciphertext to the invoking user's logon secret, which covers exactly that gap. The principal name is passed as entropy, so a blob copied onto another principal's path fails to decrypt rather than authenticating the wrong account. A secret written by an older build reads as unavailable and falls back to the restricted token; the next elevated setup rewrites it. The round-trip test needs no privilege, so it runs everywhere rather than joining the gated set, and it asserts the password does not appear verbatim in the stored bytes. --- .../sandbox/windows_identity_dpapi_windows.go | 76 +++++++++++++++++++ .../windows_identity_secret_windows.go | 31 +++++++- .../windows_identity_secret_windows_test.go | 76 +++++++++++++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 internal/sandbox/windows_identity_dpapi_windows.go diff --git a/internal/sandbox/windows_identity_dpapi_windows.go b/internal/sandbox/windows_identity_dpapi_windows.go new file mode 100644 index 000000000..d9a0bbf1d --- /dev/null +++ b/internal/sandbox/windows_identity_dpapi_windows.go @@ -0,0 +1,76 @@ +//go:build windows + +package sandbox + +// DPAPI wrapping for the stored sandbox principal password. +// +// The file ACL is the primary control and remains the thing that keeps the +// sandbox principal itself from reading its own credential. This adds the layer +// the ACL cannot: an ACL is only meaningful while the filesystem is being asked +// to enforce it, so a backup, a mounted disk image, or a copy taken by anyone +// who can bypass the DACL yields the password in the clear. CryptProtectData +// binds the ciphertext to the invoking user's logon secret, so an offline copy +// is inert without that user's credentials. +// +// The principal name is passed as optional entropy, which makes a blob usable +// only for the account it was minted for; moving one secret file over another +// then fails to decrypt instead of silently authenticating the wrong principal. + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +// protectWindowsSecret encrypts a password to the current user. +// +// CRYPTPROTECT_UI_FORBIDDEN matters here: this runs inside a CLI and, on the +// setup path, potentially without an interactive desktop, so DPAPI must fail +// rather than try to prompt. +func protectWindowsSecret(plaintext string, entropy string) ([]byte, error) { + if plaintext == "" { + return nil, errors.New("windows sandbox secret: refusing to protect an empty password") + } + in := windows.DataBlob{ + Size: uint32(len(plaintext)), + Data: &[]byte(plaintext)[0], + } + entropyBytes := []byte(entropy) + var entropyBlob *windows.DataBlob + if len(entropyBytes) > 0 { + entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]} + } + var out windows.DataBlob + if err := windows.CryptProtectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil { + return nil, fmt.Errorf("protect sandbox secret: %w", err) + } + // DPAPI allocates the output with LocalAlloc; copy it out and hand it back. + defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data))) + return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil +} + +// unprotectWindowsSecret reverses protectWindowsSecret. It fails for any user +// other than the one that wrote the blob, and for a blob minted with a different +// principal name as entropy. +func unprotectWindowsSecret(ciphertext []byte, entropy string) (string, error) { + if len(ciphertext) == 0 { + return "", errWindowsSandboxIdentityUnavailable + } + in := windows.DataBlob{ + Size: uint32(len(ciphertext)), + Data: &ciphertext[0], + } + entropyBytes := []byte(entropy) + var entropyBlob *windows.DataBlob + if len(entropyBytes) > 0 { + entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]} + } + var out windows.DataBlob + if err := windows.CryptUnprotectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil { + return "", fmt.Errorf("unprotect sandbox secret: %w", err) + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data))) + return string(unsafe.Slice(out.Data, out.Size)), nil +} diff --git a/internal/sandbox/windows_identity_secret_windows.go b/internal/sandbox/windows_identity_secret_windows.go index 37781032b..96cee6305 100644 --- a/internal/sandbox/windows_identity_secret_windows.go +++ b/internal/sandbox/windows_identity_secret_windows.go @@ -158,13 +158,28 @@ func writeWindowsSandboxSecret(path string, password string) error { _ = os.Remove(path) return err } - if err := os.WriteFile(path, []byte(password), 0o600); err != nil { + // Encrypt to the invoking user on top of the ACL, so a copy taken outside the + // filesystem's enforcement (backup, disk image) is inert. The principal name is + // the entropy, which keeps one principal's blob from authenticating another. + sealed, err := protectWindowsSecret(password, windowsSandboxSecretEntropy(path)) + if err != nil { + _ = os.Remove(path) + return err + } + if err := os.WriteFile(path, sealed, 0o600); err != nil { _ = os.Remove(path) return fmt.Errorf("write secret: %w", err) } return nil } +// windowsSandboxSecretEntropy derives the DPAPI entropy from the secret's own +// filename, which is the principal name. Deriving it rather than threading the +// name through keeps read and write agreeing by construction. +func windowsSandboxSecretEntropy(path string) string { + return strings.TrimSuffix(filepath.Base(path), ".secret") +} + // readWindowsSandboxSecret loads a principal's password. A missing file means // setup has not run for this workspace, which the caller turns into a fallback // rather than a hard failure. @@ -176,8 +191,18 @@ func readWindowsSandboxSecret(path string) (string, error) { } return "", fmt.Errorf("read sandbox secret: %w", err) } - secret := strings.TrimSpace(string(data)) - if secret == "" { + if len(data) == 0 { + return "", errWindowsSandboxIdentityUnavailable + } + secret, err := unprotectWindowsSecret(data, windowsSandboxSecretEntropy(path)) + if err != nil { + // A blob written by another user, for another principal, or by an older + // build that stored the password in the clear. Report it as unavailable so + // the caller falls back to the restricted token; the next elevated setup + // rewrites the secret in the current format. + return "", errWindowsSandboxIdentityUnavailable + } + if strings.TrimSpace(secret) == "" { return "", errWindowsSandboxIdentityUnavailable } return secret, nil diff --git a/internal/sandbox/windows_identity_secret_windows_test.go b/internal/sandbox/windows_identity_secret_windows_test.go index d1ebdfee8..b77d0f5a0 100644 --- a/internal/sandbox/windows_identity_secret_windows_test.go +++ b/internal/sandbox/windows_identity_secret_windows_test.go @@ -3,6 +3,8 @@ package sandbox import ( + "bytes" + "errors" "os" "path/filepath" "strings" @@ -194,3 +196,77 @@ func windowsSecretACEList(dacl *windows.ACL) ([]*windows.SID, error) { } return out, nil } + +// DPAPI round-trip through the real store, which needs no privilege and so is +// genuine coverage rather than a gated stub. +func TestWindowsSandboxSecretRoundTripsThroughDPAPI(t *testing.T) { + path, err := windowsSandboxSecretPath(t.TempDir(), "zero-sbx-roundtrip") + if err != nil { + t.Fatalf("secret path: %v", err) + } + const password = "S0me-Sandbox-P@ssw0rd-value" + if err := writeWindowsSandboxSecret(path, password); err != nil { + t.Fatalf("write secret: %v", err) + } + got, err := readWindowsSandboxSecret(path) + if err != nil { + t.Fatalf("read secret: %v", err) + } + if got != password { + t.Fatalf("round-trip returned %q, want %q", got, password) + } + // The point of the exercise: the password must not be recoverable by reading + // the file, or the encryption layer is decorative. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read raw secret file: %v", err) + } + if bytes.Contains(raw, []byte(password)) { + t.Fatal("the password appears verbatim in the stored file; it was not encrypted") + } +} + +// Entropy is the principal name, so a blob moved onto another principal's secret +// path must fail to decrypt rather than authenticate the wrong account. +func TestWindowsSandboxSecretDoesNotTransferBetweenPrincipals(t *testing.T) { + home := t.TempDir() + minePath, err := windowsSandboxSecretPath(home, "zero-sbx-mine") + if err != nil { + t.Fatalf("secret path: %v", err) + } + theirsPath, err := windowsSandboxSecretPath(home, "zero-sbx-theirs") + if err != nil { + t.Fatalf("secret path: %v", err) + } + if err := writeWindowsSandboxSecret(minePath, "a-password-for-mine"); err != nil { + t.Fatalf("write secret: %v", err) + } + blob, err := os.ReadFile(minePath) + if err != nil { + t.Fatalf("read blob: %v", err) + } + if err := os.WriteFile(theirsPath, blob, 0o600); err != nil { + t.Fatalf("plant blob: %v", err) + } + if _, err := readWindowsSandboxSecret(theirsPath); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Fatalf("a blob planted at another principal's path decrypted; got err = %v", err) + } +} + +// An older plaintext secret must degrade to a fallback rather than being handed +// to LogonUser as if it were a password. +func TestWindowsSandboxSecretRejectsLegacyPlaintext(t *testing.T) { + path, err := windowsSandboxSecretPath(t.TempDir(), "zero-sbx-legacy") + if err != nil { + t.Fatalf("secret path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte("plaintext-password"), 0o600); err != nil { + t.Fatalf("write legacy secret: %v", err) + } + if _, err := readWindowsSandboxSecret(path); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Fatalf("legacy plaintext secret was accepted; got err = %v", err) + } +} From 05633df2bab7e5bc8fae2dbf054dd588eca7f32f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 09:03:13 +0530 Subject: [PATCH 08/45] fix(sandbox): surface a squatted principal name instead of falling back lookupWindowsSandboxIdentity collapsed every SID-resolution failure into the "no principal is provisioned" sentinel, which threw away the check resolveWindowsSandboxSID deliberately makes: a name that resolves to a group or alias rather than a user account. The command path treats that sentinel as permission to fall back quietly, so an account name squatted by something that is not a user reached the operator as silence and a downgrade to the restricted token. Caught by gnanam in review. Only ERROR_NONE_MAPPED now means setup has not run. Anything else is a principal that exists but cannot be used, and the runtime path propagates it rather than swallowing it, which is where the description already said the line should sit. The decision lives in its own function 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 the classifier with a real error from a well-known local group, needs no privilege, and fails if the old collapse-everything behaviour is restored. Also corrects a comment pointing at sandboxRuntimeKey, which does not exist. The function is windowsSandboxWorkspaceKey. --- .../windows_identity_runtime_windows.go | 10 ++++- internal/sandbox/windows_identity_windows.go | 27 ++++++++++++- .../sandbox/windows_identity_windows_test.go | 38 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index bc850b48f..8ec6a43d3 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -91,8 +91,14 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) identity, err := lookupWindowsSandboxIdentity(key) if err != nil { - // Not provisioned: fall back quietly, this is the default state. - return 0, false, nil + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + // Not provisioned: fall back quietly, this is the default state. + return 0, false, nil + } + // The name resolves to something that is not a usable principal, most + // likely squatted by a local group or alias. That is a conflict an + // operator has to see, not a reason to pretend setup never ran. + return 0, false, err } secretPath, err := windowsSandboxSecretPath(config.SandboxHome, identity.Username) if err != nil { diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index f8711d053..2fad0ecbd 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -119,7 +119,7 @@ func (identity windowsSandboxIdentity) String() string { } // windowsSandboxUserName derives a stable account name for a workspace key. The -// key is hashed by the caller (see sandboxRuntimeKey) so the name reveals no +// key is hashed by the caller (see windowsSandboxWorkspaceKey) so the name reveals no // path, and it is truncated to the 20-character local-account limit. The same // workspace always maps to the same account, so re-running setup reuses the // principal instead of accumulating accounts. @@ -339,7 +339,30 @@ func lookupWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, username := windowsSandboxUserName(workspaceKey) sid, err := resolveWindowsSandboxSID(username) if err != nil { - return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable + return windowsSandboxIdentity{}, classifyWindowsSandboxLookupError(err) } return windowsSandboxIdentity{Username: username, SID: sid}, nil } + +// classifyWindowsSandboxLookupError decides whether a failed SID resolution +// means "setup has not run" or "this principal exists but is unusable". +// +// Only "no such account" is the former. Every other failure is a principal the +// caller must not paper over, including the deliberate refusal in +// resolveWindowsSandboxSID of a name squatted by a group or alias. Collapsing +// those into the unavailable sentinel would turn a real conflict into a silent +// fall back to the restricted token, which is exactly the case that should +// reach the operator rather than be absorbed. +// +// Split out from the lookup so the decision can be asserted on its own: the +// lookup derives its account name from a workspace key, so a test cannot hand +// it a name that resolves to a group. +func classifyWindowsSandboxLookupError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, windows.ERROR_NONE_MAPPED) { + return errWindowsSandboxIdentityUnavailable + } + return err +} diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index 480c61fd2..754952f66 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -3,6 +3,7 @@ package sandbox import ( + "errors" "os" "strings" "testing" @@ -310,3 +311,40 @@ func TestLookupWindowsSandboxIdentityUnprovisioned(t *testing.T) { t.Fatalf("error = %v, want errWindowsSandboxIdentityUnavailable", err) } } + +// A name that resolves to something other than a user account is a conflict, +// not an absent principal, and must not be reported as "setup has not run": the +// command path treats that sentinel as permission to fall back silently, so +// collapsing the two would hide a squatted account behind a quiet downgrade to +// the restricted token. +// +// Every machine already has well-known non-user names to test against, so this +// needs no privilege and no provisioning. +func TestLookupWindowsSandboxIdentityRejectsNonUserAccount(t *testing.T) { + // Groups that exist on any Windows install. Whichever resolves first is + // enough; localized machines may not carry the English name. + for _, group := range []string{"Administrators", "Users", "Guests"} { + sid, _, accountType, err := windows.LookupSID("", group) + if err != nil || sid == nil { + continue + } + if accountType == windows.SidTypeUser { + continue + } + resolveErr := func() error { + _, err := resolveWindowsSandboxSID(group) + return err + }() + if resolveErr == nil { + t.Fatalf("resolveWindowsSandboxSID(%q) accepted a non-user account (type %d)", group, accountType) + } + // The classification is the part that matters: the sentinel is what + // licenses the command path to fall back silently, so this refusal must + // survive it rather than be folded into it. + if classified := classifyWindowsSandboxLookupError(resolveErr); errors.Is(classified, errWindowsSandboxIdentityUnavailable) { + t.Fatalf("non-user account %q classified as unprovisioned, which would silently downgrade to the restricted token: %v", group, classified) + } + return + } + t.Skip("no well-known non-user account resolved on this machine") +} From b36e3cdf118182c0fc5543db573c4379cea94925 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 09:16:24 +0530 Subject: [PATCH 09/45] fix(sandbox): reset the password when the account already exists NetUserAdd leaves a pre-existing account completely untouched, password included, and ensureWindowsSandboxUser treated that status as success. So a second setup run generated a fresh random password, stored it as the secret, and left the account still authenticating with the old one. Every later command then failed to log on with a principal that looked correctly provisioned. Two comments claimed the caller reset the password in that case; nothing did. Caught by CodeRabbit. ensureWindowsSandboxUser now reports whether the account already existed, and provisioning resets the password through NetUserSetInfo when it did, so the value it returns is always the account's real password. The comments now describe what the code does. The gated provisioning test provisions twice and then logs on with the password from the SECOND run, which is the only honest assertion here: a stale password is indistinguishable from a correct one until something tries to authenticate with it. Also makes the syscall keep-alives explicit. The LSA and LogonUser call sites borrow Go memory that was either not kept alive at all (the policy attributes, the rights descriptor, the three logon strings) or kept alive only after the error check, so the failure path returned with it already collectable. The two netapi32 sites that used a deferred no-op closure now use runtime.KeepAlive as well, so one idiom is used throughout. --- .../sandbox/windows_identity_logon_windows.go | 15 ++- .../windows_identity_runtime_windows.go | 8 +- internal/sandbox/windows_identity_windows.go | 106 ++++++++++++++---- .../sandbox/windows_identity_windows_test.go | 19 +++- 4 files changed, 120 insertions(+), 28 deletions(-) diff --git a/internal/sandbox/windows_identity_logon_windows.go b/internal/sandbox/windows_identity_logon_windows.go index 6f9d689fb..6c7acd0df 100644 --- a/internal/sandbox/windows_identity_logon_windows.go +++ b/internal/sandbox/windows_identity_logon_windows.go @@ -19,6 +19,7 @@ package sandbox import ( "fmt" + "runtime" "unsafe" "golang.org/x/sys/windows" @@ -118,6 +119,9 @@ func grantWindowsSandboxLogonRights(sid *windows.SID) error { uintptr(policyCreateAccount|policyLookupNames), uintptr(unsafe.Pointer(&policy)), ) + // LsaOpenPolicy borrows the attributes struct by address, so it has to stay + // reachable until the call has returned. + runtime.KeepAlive(attributes) if err := lsaStatusError("LsaOpenPolicy", status); err != nil { return err } @@ -144,11 +148,14 @@ func grantWindowsSandboxLogonRights(sid *windows.SID) error { uintptr(unsafe.Pointer(&entry)), 1, ) + // Both the descriptor and the buffer it points at are borrowed by the + // call. Kept alive before the error check, not after, so the failure path + // does not return with them already collectable. + runtime.KeepAlive(entry) + runtimeKeepAliveUint16(buffer) if err := lsaStatusError("LsaAddAccountRights("+right+")", status); err != nil { return err } - // Keep the backing buffer alive until the call has returned. - runtimeKeepAliveUint16(buffer) } return nil } @@ -183,6 +190,10 @@ func logonWindowsSandboxPrincipal(username string, password string) (windows.Tok logon32ProviderDefault, uintptr(unsafe.Pointer(&token)), ) + // The three strings are borrowed for the duration of the call. + runtime.KeepAlive(user) + runtime.KeepAlive(domain) + runtime.KeepAlive(secret) if result == 0 { if callErr != nil && callErr != windows.ERROR_SUCCESS { return 0, fmt.Errorf("LogonUser(%s): %w", username, callErr) diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 8ec6a43d3..a3a201799 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -142,10 +142,10 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig if err != nil { return windowsSandboxIdentity{}, err } - // A pre-existing account keeps its old password, which this new one does not - // match, so the secret is rewritten every run to stay in step with whatever - // NetUserAdd left in place. On a fresh account the two agree by construction; - // on an existing one the caller resets it via ensureWindowsSandboxUser. + // The secret is rewritten every run so it stays in step with the account. + // provisionWindowsSandboxIdentity guarantees the password it returns is the + // account's real one, resetting it explicitly when the account already + // existed, so this write is always storing something that can log on. if err := writeWindowsSandboxSecret(secretPath, password); err != nil { return windowsSandboxIdentity{}, err } diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index 2fad0ecbd..2e1169dbe 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -27,6 +27,7 @@ import ( "encoding/base32" "errors" "fmt" + "runtime" "strings" "unsafe" @@ -75,6 +76,7 @@ var ( procNetLocalGroupAdd = netapi32.NewProc("NetLocalGroupAdd") procNetLocalGroupAddMembers = netapi32.NewProc("NetLocalGroupAddMembers") procNetUserDel = netapi32.NewProc("NetUserDel") + procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo") ) // userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32 @@ -90,6 +92,12 @@ type userInfo1 struct { ScriptPath *uint16 } +// userInfo1003 mirrors USER_INFO_1003, the password-only form NetUserSetInfo +// takes when nothing else about the account should change. +type userInfo1003 struct { + Password *uint16 +} + // localGroupInfo1 mirrors LOCALGROUP_INFO_1. type localGroupInfo1 struct { Name *uint16 @@ -199,29 +207,34 @@ func ensureWindowsSandboxGroup() error { 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 }() + // The struct holds pointers into Go memory that the syscall dereferences, so + // it has to stay reachable until the call has returned. + runtime.KeepAlive(info) + runtime.KeepAlive(name) + runtime.KeepAlive(comment) return netAPIStatus("NetLocalGroupAdd", status, nerrGroupExists, errorAliasExists) } -// ensureWindowsSandboxUser creates a sandbox account with the supplied password, -// or leaves an existing account alone. The account is a plain local user with no -// home directory or logon script, flagged so its password never expires (nobody -// is there to rotate it) and so it is a normal, enabled account LogonUser can -// authenticate. -func ensureWindowsSandboxUser(username string, password string) error { +// ensureWindowsSandboxUser creates a sandbox account with the supplied password. +// The account is a plain local user with no home directory or logon script, +// flagged so its password never expires (nobody is there to rotate it) and so it +// is a normal, enabled account LogonUser can authenticate. +// +// It reports whether the account already existed, because NetUserAdd leaves such +// an account completely untouched, password included. The caller has to reset it +// or the secret it goes on to store would not be the account's password at all. +func ensureWindowsSandboxUser(username string, password string) (bool, error) { name, err := windows.UTF16PtrFromString(username) if err != nil { - return err + return false, err } secret, err := windows.UTF16PtrFromString(password) if err != nil { - return err + return false, err } comment, err := windows.UTF16PtrFromString(windowsSandboxUserComment) if err != nil { - return err + return false, err } info := userInfo1{ Name: name, @@ -236,8 +249,47 @@ func ensureWindowsSandboxUser(username string, password string) error { uintptr(unsafe.Pointer(&info)), 0, ) - defer func() { _ = info }() - return netAPIStatus("NetUserAdd", status, nerrUserExists) + // The struct holds pointers into Go memory that the call dereferences, so + // everything it borrows has to outlive the call. + runtime.KeepAlive(info) + runtime.KeepAlive(name) + runtime.KeepAlive(secret) + runtime.KeepAlive(comment) + if status == nerrUserExists { + return true, nil + } + return false, netAPIStatus("NetUserAdd", status) +} + +// resetWindowsSandboxUserPassword sets the password on an account that already +// existed, so the secret the caller stores is actually the account's password. +// +// Without this, re-running setup produced a fresh random password, wrote it to +// disk, and left the account authenticating with the old one, so every later +// command failed to log on with a principal that looked correctly provisioned. +func resetWindowsSandboxUserPassword(username string, password string) error { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return err + } + secret, err := windows.UTF16PtrFromString(password) + if err != nil { + return err + } + // USER_INFO_1003 is a password-only update, so nothing else about the + // account is disturbed. + info := userInfo1003{Password: secret} + status, _, _ := procNetUserSetInfo.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 1003, // level: USER_INFO_1003 + uintptr(unsafe.Pointer(&info)), + 0, + ) + runtime.KeepAlive(info) + runtime.KeepAlive(name) + runtime.KeepAlive(secret) + return netAPIStatus("NetUserSetInfo", status) } // addWindowsSandboxUserToGroup puts a principal in the managed group, ignoring @@ -259,7 +311,9 @@ func addWindowsSandboxUserToGroup(username string) error { uintptr(unsafe.Pointer(&entry)), 1, // one member ) - defer func() { _ = entry }() + runtime.KeepAlive(entry) + runtime.KeepAlive(group) + runtime.KeepAlive(member) return netAPIStatus("NetLocalGroupAddMembers", status, errorMemberInAlias) } @@ -282,11 +336,11 @@ func resolveWindowsSandboxSID(username string) (*windows.SID, error) { // the caller needs to mint a token with LogonUser. It is idempotent, so setup // can run repeatedly. // -// The password is returned rather than stored: on an account that already -// existed the returned value is the NEW password only if the caller resets it, -// so callers that need to log in must treat a pre-existing account as requiring -// a reset. That is handled a layer up, where the secret has somewhere safe to -// live; keeping it out of this file means no credential is written to disk here. +// The password is returned rather than stored, so no credential is written to +// disk here; that happens a layer up where the secret has somewhere safe to +// live. The returned value is always the account's actual password, including +// when the account already existed, because that case is reset explicitly +// below. func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, string, error) { if err := ensureWindowsSandboxGroup(); err != nil { return windowsSandboxIdentity{}, "", err @@ -296,9 +350,19 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit if err != nil { return windowsSandboxIdentity{}, "", err } - if err := ensureWindowsSandboxUser(username, password); err != nil { + existed, err := ensureWindowsSandboxUser(username, password) + if err != nil { return windowsSandboxIdentity{}, "", err } + if existed { + // NetUserAdd left the account untouched, so the password above is not yet + // its password. Set it, or the secret stored by the caller would never + // authenticate and every command would fail to log on with a principal + // that looks perfectly provisioned. + if err := resetWindowsSandboxUserPassword(username, password); err != nil { + return windowsSandboxIdentity{}, "", err + } + } if err := addWindowsSandboxUserToGroup(username); err != nil { return windowsSandboxIdentity{}, "", err } diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index 754952f66..60c1fef4d 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -220,13 +220,30 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { } // Re-running must converge on the same principal rather than failing or // creating a second account. - again, _, err := provisionWindowsSandboxIdentity("ziptest01") + again, secondPassword, err := provisionWindowsSandboxIdentity("ziptest01") if err != nil { t.Fatalf("second provision: %v", err) } if again.Username != identity.Username || !again.SID.Equals(identity.SID) { t.Fatalf("provisioning is not idempotent: %s then %s", identity, again) } + // The password returned for an account that already existed has to BE that + // account's password. NetUserAdd leaves an existing account entirely alone, + // so without an explicit reset this second value is a fresh random string + // that never authenticates, and the caller would store it as the secret and + // leave every later command failing to log on with a principal that looks + // correctly provisioned. Logging on is the only honest way to assert it. + if secondPassword == "" { + t.Fatal("second provision returned an empty password") + } + if err := grantWindowsSandboxLogonRights(again.SID); err != nil { + t.Fatalf("grant logon rights: %v", err) + } + token, err := logonWindowsSandboxPrincipal(again.Username, secondPassword) + if err != nil { + t.Fatalf("logon with the password from the second provision: %v", err) + } + _ = token.Close() // Lookup must find what provisioning created. found, err := lookupWindowsSandboxIdentity("ziptest01") if err != nil { From bd02ba80f0cb7b9e20beca6d57a98f3d2962b7d3 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 09:29:28 +0530 Subject: [PATCH 10/45] fix(sandbox): revoke logon rights before deleting a principal Retiring a principal deleted the account but left its LSA account rights behind, keyed to a SID that no longer resolves. That is the orphaned residue this model is supposed to avoid, and the reason ACE revocation is keyed to the trustee rather than to a record of what was granted; the logon-rights half was simply missing. CodeRabbit spotted it as a test-cleanup gap, but the production teardown path had the same hole. revokeWindowsSandboxLogonRights drops every right held by the principal and removes its LSA entry, and setup teardown now calls it BEFORE deleting the account, while the SID still resolves. Removing all rights rather than naming them is deliberate: the principal is being retired, so rights granted by an older setup that this one no longer knows about should go too. An account that holds no rights is not an error, since that is the state teardown wants. That tolerance depends on STATUS_OBJECT_NAME_NOT_FOUND surviving LsaNtStatusToWinError as something errors.Is can still match, which is the sort of Windows errno assumption that is often wrong, so there is now an unprivileged test asserting it, including that the tolerance does not also swallow access-denied. Both gated tests now clean up rights and account, in that order. The provisioning round trip had no cleanup at all and, since it started granting a batch logon right, was leaving both behind on whatever machine ran it. --- .../sandbox/windows_identity_logon_windows.go | 58 ++++++++++++++++++- .../windows_identity_logon_windows_test.go | 41 +++++++++++++ .../windows_identity_runtime_windows.go | 12 ++++ .../sandbox/windows_identity_windows_test.go | 25 +++++++- 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 internal/sandbox/windows_identity_logon_windows_test.go diff --git a/internal/sandbox/windows_identity_logon_windows.go b/internal/sandbox/windows_identity_logon_windows.go index 6c7acd0df..45ebdaa5f 100644 --- a/internal/sandbox/windows_identity_logon_windows.go +++ b/internal/sandbox/windows_identity_logon_windows.go @@ -18,6 +18,7 @@ package sandbox // needs no special privilege once the batch right is in place. import ( + "errors" "fmt" "runtime" "unsafe" @@ -51,7 +52,10 @@ var ( 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") + // Retiring a principal has to drop its rights as well as its account, or the + // LSA policy database keeps an entry keyed to a SID that no longer resolves. + procLsaRemoveAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaRemoveAccountRights") + procLsaNtStatusToWinErr = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaNtStatusToWinError") ) // lsaUnicodeString mirrors LSA_UNICODE_STRING. Length and MaximumLength are @@ -160,6 +164,58 @@ func grantWindowsSandboxLogonRights(sid *windows.SID) error { return nil } +// revokeWindowsSandboxLogonRights drops every account right held by a principal +// and removes its entry from the LSA policy database. +// +// This is the logon-rights counterpart to revoking ACEs by trustee, and it has +// the same reason to exist: deleting the account on its own leaves the rights +// behind, keyed to a SID that no longer resolves, which is the orphaned residue +// this model is supposed to avoid. It must therefore run BEFORE the account is +// deleted, while the SID is still resolvable. +// +// Removing all rights rather than naming them is deliberate. The principal is +// being retired, so anything keyed to it should go, including rights a previous +// version of setup granted and this one no longer knows about. +// +// Requires an elevated caller. A principal that holds no rights is not an error: +// LsaRemoveAccountRights reports ERROR_FILE_NOT_FOUND for an account with no LSA +// entry, which is the state teardown is trying to reach anyway. +func revokeWindowsSandboxLogonRights(sid *windows.SID) error { + if sid == nil { + return fmt.Errorf("revoke sandbox logon rights: nil SID") + } + var attributes lsaObjectAttributes + attributes.Length = uint32(unsafe.Sizeof(attributes)) + var policy windows.Handle + status, _, _ := procLsaOpenPolicy.Call( + 0, // local system + uintptr(unsafe.Pointer(&attributes)), + uintptr(policyCreateAccount|policyLookupNames), + uintptr(unsafe.Pointer(&policy)), + ) + runtime.KeepAlive(attributes) + if err := lsaStatusError("LsaOpenPolicy", status); err != nil { + return err + } + defer procLsaClose.Call(uintptr(policy)) + + status, _, _ = procLsaRemoveAccountRights.Call( + uintptr(policy), + uintptr(unsafe.Pointer(sid)), + 1, // AllRights: drop everything and delete the LSA account object + 0, // UserRights ignored when AllRights is set + 0, // CountOfRights likewise + ) + runtime.KeepAlive(sid) + if err := lsaStatusError("LsaRemoveAccountRights", status); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return nil + } + return err + } + return nil +} + // logonWindowsSandboxPrincipal mints a primary token for the sandbox account. // The caller owns the returned token and must Close it. // diff --git a/internal/sandbox/windows_identity_logon_windows_test.go b/internal/sandbox/windows_identity_logon_windows_test.go new file mode 100644 index 000000000..503d12922 --- /dev/null +++ b/internal/sandbox/windows_identity_logon_windows_test.go @@ -0,0 +1,41 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +// revokeWindowsSandboxLogonRights treats "this account holds no rights" as +// success, because that is the state teardown is trying to reach anyway. That +// relies on the NTSTATUS for it surviving the trip through LsaNtStatusToWinError +// as an error errors.Is can still match, which is exactly the kind of Windows +// errno assumption that quietly turns out to be false. Assert it rather than +// trust it. +// +// Needs no privilege: LsaNtStatusToWinError is a pure status translation, so +// this runs everywhere rather than joining the gated set. +func TestLsaStatusErrorMapsObjectNameNotFound(t *testing.T) { + // STATUS_OBJECT_NAME_NOT_FOUND, what LsaRemoveAccountRights reports for an + // account that has no LSA entry. + const statusObjectNameNotFound = 0xC0000034 + + err := lsaStatusError("LsaRemoveAccountRights", statusObjectNameNotFound) + if err == nil { + t.Fatal("a nonzero NTSTATUS produced no error") + } + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + t.Fatalf("error = %v, want one errors.Is matches against ERROR_FILE_NOT_FOUND; "+ + "without that, revoking a principal that simply holds no rights fails teardown", err) + } + + // The tolerance must be specific. If any failure matched it, revoke would + // swallow a real one and teardown would report success having done nothing. + const statusAccessDenied = 0xC0000022 + if other := lsaStatusError("LsaRemoveAccountRights", statusAccessDenied); errors.Is(other, windows.ERROR_FILE_NOT_FOUND) { + t.Fatalf("access denied matched the not-found tolerance: %v", other) + } +} diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index a3a201799..54bc03dd1 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -210,5 +210,17 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e if err := removeWindowsSandboxSecret(secretPath); err != nil { return err } + // Drop the LSA account rights before the account itself. Deleting the account + // first would leave its rights behind keyed to a SID that no longer resolves, + // which is the same orphaned residue the trustee-keyed ACE revocation exists + // to avoid. A principal that was never provisioned has no SID to resolve and + // nothing to revoke, so that case is not an error. + if identity, err := lookupWindowsSandboxIdentity(windowsSandboxWorkspaceKey(config.WorkspaceRoots)); err == nil { + if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { + return err + } + } else if !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + return err + } return removeWindowsSandboxIdentity(username) } diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index 60c1fef4d..61d2d9d36 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -208,10 +208,28 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { if !windowsProcessIsElevated() { t.Skip("provisioning requires an elevated process") } + // A leftover account from an interrupted run is harmless now that + // provisioning resets the password, but starting clean keeps a failure here + // from being explained by residue from a previous one. + _ = removeWindowsSandboxIdentity(windowsSandboxUserName("ziptest01")) + identity, password, err := provisionWindowsSandboxIdentity("ziptest01") if err != nil { t.Fatalf("provision: %v", err) } + // Registered immediately after provisioning so every failure path below is + // covered. This test grants a real batch-logon right to a real local account; + // leaving either behind on a developer machine is not acceptable residue, and + // rights are revoked before the account so nothing is left keyed to a SID that + // no longer resolves. + t.Cleanup(func() { + if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { + t.Errorf("cleanup: revoke logon rights: %v", err) + } + if err := removeWindowsSandboxIdentity(identity.Username); err != nil { + t.Errorf("cleanup: remove principal: %v", err) + } + }) if identity.SID == nil { t.Fatal("provisioned identity has no SID") } @@ -283,8 +301,13 @@ func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) { t.Fatalf("provision: %v", err) } t.Cleanup(func() { + // Rights first, then the account: the reverse order strands an LSA entry + // keyed to a SID that no longer resolves. + if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { + t.Errorf("cleanup: revoke logon rights: %v", err) + } if err := removeWindowsSandboxIdentity(identity.Username); err != nil { - t.Errorf("cleanup: %v", err) + t.Errorf("cleanup: remove principal: %v", err) } }) From 78e5ec211169c0a1230cbeeddbbe4243187f0b65 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 13:35:30 +0530 Subject: [PATCH 11/45] fix(sandbox): refuse a squatted account name and clean up partial provisioning Two problems in the provisioning path, both raised in review. The account name is derived from a workspace hash rather than discovered, so it can be occupied by a local account that has nothing to do with Zero, whether by coincidence or because somebody put it there. Provisioning treated "NetUserAdd says it exists" as "this is ours", reset the account's password, added it to the managed group and adopted it. That is a stranger's account taken over during an elevated setup, on the strength of a name matching a pattern we generate ourselves. Ownership is now proven from the comment provisioning stamps before anything is touched, and a name held by an account Zero did not create fails with a typed collision error instead of being adopted. Second, a failure anywhere after the account existed left it behind. The rollback the setup path installs is only built once provisioning has returned successfully, so nothing could undo a failure between creating the account and storing its secret; the account, and possibly its granted logon rights, simply stayed. Provisioning now unwinds what the run actually did, in reverse, on every failure path. Scoped to what THIS run created, deliberately. 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 that 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 rather than failing. The ownership gate is asserted against real accounts every Windows install carries, which needs no privilege because it only has to establish that they are not ours. Classifying everything as managed makes it fail. --- .../windows_identity_runtime_windows.go | 45 ++++++++++- internal/sandbox/windows_identity_windows.go | 76 ++++++++++++++++--- .../sandbox/windows_identity_windows_test.go | 54 ++++++++++++- 3 files changed, 160 insertions(+), 15 deletions(-) diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 54bc03dd1..0876a2050 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -131,24 +131,63 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T // therefore cleaned up, rather than an account nothing holds the secret for. func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) (windowsSandboxIdentity, error) { key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) - identity, password, err := provisionWindowsSandboxIdentity(key) + identity, password, created, err := provisionWindowsSandboxIdentity(key) + + // Undo whatever this run actually did, in reverse, on any failure after the + // account exists. Without it a failure between creating the account and + // storing its secret left the account behind with no caller able to remove + // it: the rollback the setup path installs is only built once this function + // has returned successfully. + // + // Scoped to what THIS run created on purpose. An account that already existed + // and belongs to Zero is a working principal from an earlier setup, and + // deleting it because a later run failed would turn a partial failure into a + // total one. + rightsGranted := false + secretWritten := false + secretPath := "" + undo := func() { + if secretWritten && secretPath != "" { + // Dropping the secret is also the repair for a pre-existing account + // whose password this run reset: the stored secret no longer matches, + // and absent beats stale, since the command path treats a missing + // secret as "not provisioned" and falls back rather than failing. + _ = removeWindowsSandboxSecret(secretPath) + } + if identity.SID != nil && rightsGranted { + _ = revokeWindowsSandboxLogonRights(identity.SID) + } + if created { + _ = removeWindowsSandboxIdentity(identity.Username) + } + } + if err != nil { + // provisionWindowsSandboxIdentity can fail after creating the account, so + // this path needs the same cleanup even though nothing below ran. + undo() return windowsSandboxIdentity{}, err } if err := grantWindowsSandboxLogonRights(identity.SID); err != nil { + undo() return windowsSandboxIdentity{}, err } - secretPath, err := windowsSandboxSecretPath(config.SandboxHome, identity.Username) + rightsGranted = true + secretPath, err = windowsSandboxSecretPath(config.SandboxHome, identity.Username) if err != nil { + undo() return windowsSandboxIdentity{}, err } // The secret is rewritten every run so it stays in step with the account. // provisionWindowsSandboxIdentity guarantees the password it returns is the // account's real one, resetting it explicitly when the account already - // existed, so this write is always storing something that can log on. + // existed and belongs to Zero, so this write is always storing something that + // can log on. if err := writeWindowsSandboxSecret(secretPath, password); err != nil { + undo() return windowsSandboxIdentity{}, err } + secretWritten = true return identity, nil } diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index 2e1169dbe..a6b663313 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -77,6 +77,8 @@ var ( procNetLocalGroupAddMembers = netapi32.NewProc("NetLocalGroupAddMembers") procNetUserDel = netapi32.NewProc("NetUserDel") procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo") + procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo") + procNetApiBufferFree = netapi32.NewProc("NetApiBufferFree") ) // userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32 @@ -317,6 +319,49 @@ func addWindowsSandboxUserToGroup(username string) error { return netAPIStatus("NetLocalGroupAddMembers", status, errorMemberInAlias) } +// errWindowsSandboxNameCollision reports that the derived account name is taken +// by a local account Zero did not create. Setup refuses rather than adopting it. +var errWindowsSandboxNameCollision = errors.New("a local account with Zero's derived sandbox name already exists and was not created by Zero") + +// windowsSandboxUserIsManaged reports whether a local account is one Zero +// created, by reading back the comment provisioning stamps on it. +// +// This is the gate on adopting an existing account. The name is derived, not +// discovered, so an account can be sitting on it for reasons that have nothing +// to do with Zero, and taking it over means resetting a stranger's password. +// +// A missing account is not managed rather than an error, so callers can use this +// as a plain question without special-casing absence. +func windowsSandboxUserIsManaged(username string) (bool, error) { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return false, err + } + var buffer *byte + status, _, _ := procNetUserGetInfo.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 1, // level: USER_INFO_1 + uintptr(unsafe.Pointer(&buffer)), + ) + runtime.KeepAlive(name) + if status == nerrUserNotFound { + return false, nil + } + if err := netAPIStatus("NetUserGetInfo", status); err != nil { + return false, err + } + if buffer == nil { + return false, nil + } + defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer))) + info := (*userInfo1)(unsafe.Pointer(buffer)) + if info.Comment == nil { + return false, nil + } + return windows.UTF16PtrToString(info.Comment) == windowsSandboxUserComment, nil +} + // resolveWindowsSandboxSID looks up the SID for a provisioned principal. The SID // is the durable handle: account names can collide with a pre-existing local // user, so every ACE and firewall rule is keyed to the SID rather than the name. @@ -341,36 +386,49 @@ func resolveWindowsSandboxSID(username string) (*windows.SID, error) { // live. The returned value is always the account's actual password, including // when the account already existed, because that case is reset explicitly // below. -func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, string, error) { +func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, string, bool, error) { if err := ensureWindowsSandboxGroup(); err != nil { - return windowsSandboxIdentity{}, "", err + return windowsSandboxIdentity{}, "", false, err } username := windowsSandboxUserName(workspaceKey) password, err := newWindowsSandboxPassword() if err != nil { - return windowsSandboxIdentity{}, "", err + return windowsSandboxIdentity{}, "", false, err } existed, err := ensureWindowsSandboxUser(username, password) if err != nil { - return windowsSandboxIdentity{}, "", err + return windowsSandboxIdentity{}, "", false, err } if existed { - // NetUserAdd left the account untouched, so the password above is not yet + // Prove the account is ours before touching it. The name is derived from a + // workspace hash rather than discovered, so it can be occupied by an + // account that has nothing to do with Zero, whether by coincidence or + // because somebody created it deliberately. Adopting one means resetting + // its password, which is not something to do on the strength of a name + // matching a pattern we generate ourselves. + managed, err := windowsSandboxUserIsManaged(username) + if err != nil { + return windowsSandboxIdentity{}, "", false, err + } + if !managed { + return windowsSandboxIdentity{}, "", false, fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, username) + } + // Ours, and NetUserAdd left it untouched, so the password above is not yet // its password. Set it, or the secret stored by the caller would never // authenticate and every command would fail to log on with a principal // that looks perfectly provisioned. if err := resetWindowsSandboxUserPassword(username, password); err != nil { - return windowsSandboxIdentity{}, "", err + return windowsSandboxIdentity{}, "", false, err } } if err := addWindowsSandboxUserToGroup(username); err != nil { - return windowsSandboxIdentity{}, "", err + return windowsSandboxIdentity{}, "", !existed, err } sid, err := resolveWindowsSandboxSID(username) if err != nil { - return windowsSandboxIdentity{}, "", err + return windowsSandboxIdentity{}, "", !existed, err } - return windowsSandboxIdentity{Username: username, SID: sid}, password, nil + return windowsSandboxIdentity{Username: username, SID: sid}, password, !existed, nil } // removeWindowsSandboxIdentity deletes a provisioned principal. Callers must diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index 61d2d9d36..f477b518f 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -4,6 +4,7 @@ package sandbox import ( "errors" + "fmt" "os" "strings" "testing" @@ -213,7 +214,7 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { // from being explained by residue from a previous one. _ = removeWindowsSandboxIdentity(windowsSandboxUserName("ziptest01")) - identity, password, err := provisionWindowsSandboxIdentity("ziptest01") + identity, password, _, err := provisionWindowsSandboxIdentity("ziptest01") if err != nil { t.Fatalf("provision: %v", err) } @@ -238,7 +239,7 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { } // Re-running must converge on the same principal rather than failing or // creating a second account. - again, secondPassword, err := provisionWindowsSandboxIdentity("ziptest01") + again, secondPassword, _, err := provisionWindowsSandboxIdentity("ziptest01") if err != nil { t.Fatalf("second provision: %v", err) } @@ -296,7 +297,7 @@ func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) { // which the freshly generated one will not match, so start from a clean slate. _ = removeWindowsSandboxIdentity(windowsSandboxUserName(key)) - identity, password, err := provisionWindowsSandboxIdentity(key) + identity, password, _, err := provisionWindowsSandboxIdentity(key) if err != nil { t.Fatalf("provision: %v", err) } @@ -388,3 +389,50 @@ func TestLookupWindowsSandboxIdentityRejectsNonUserAccount(t *testing.T) { } t.Skip("no well-known non-user account resolved on this machine") } + +// The account name is derived from a workspace hash, not discovered, so it can +// be occupied by a local account that has nothing to do with Zero. Adopting one +// means resetting a stranger's password, so provisioning has to prove ownership +// first and refuse otherwise. +// +// Driven against real accounts every Windows install carries, which are +// definitively not ours. Unprivileged: it only has to establish that they are +// not classified as managed, so nothing is ever created or modified. +func TestWindowsSandboxUserIsManagedRefusesForeignAccounts(t *testing.T) { + checked := 0 + for _, name := range []string{"Administrator", "Guest", "DefaultAccount"} { + managed, err := windowsSandboxUserIsManaged(name) + if err != nil { + // Localized or disabled installs may not carry every one of these. + continue + } + checked++ + if managed { + t.Fatalf("%q classified as a Zero sandbox principal; provisioning would reset its password", name) + } + } + if checked == 0 { + t.Skip("no well-known local account could be queried on this machine") + } + // An absent account must answer false rather than error, since provisioning + // asks this question about names that usually do not exist yet. + managed, err := windowsSandboxUserIsManaged("zero-sbx-nosuchacct") + if err != nil { + t.Fatalf("querying a missing account: %v", err) + } + if managed { + t.Fatal("a missing account was classified as managed") + } +} + +// The refusal has to be a typed, recognisable collision rather than a generic +// failure, so setup can say what is wrong instead of reporting a Win32 status. +func TestWindowsSandboxNameCollisionIsTyped(t *testing.T) { + wrapped := fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, "zero-sbx-dexample") + if !errors.Is(wrapped, errWindowsSandboxNameCollision) { + t.Fatal("collision error does not match its sentinel") + } + if !strings.Contains(wrapped.Error(), "not created by Zero") { + t.Fatalf("collision message = %q, want it to say the account is not ours", wrapped.Error()) + } +} From c8d36db64321bd9689a025457639ff636f5ba060 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 14:24:30 +0530 Subject: [PATCH 12/45] fix(sandbox): drop the stored secret whenever provisioning fails The cleanup added for partial provisioning left the window it existed for uncovered. It only removed the on-disk secret when this run had written one, and it derived the secret path after the logon-rights grant, so a failure before that point had nothing to remove. That is exactly the case that matters. Provisioning ALWAYS sets the account's password, including resetting a pre-existing account's, so from the moment it returns the stored secret is already stale. A failure in the rights grant then left that stale secret on disk against a password that had just changed, and the next command failed the logon and reported a broken sandbox instead of falling back. The path is now resolved from the account name before anything can fail, and removal is unconditional rather than gated on having written one. Absent beats stale: the command path treats a missing secret as "not provisioned" and falls back to the restricted token, which is the outcome a failed setup should leave behind. Raised by CodeRabbit, twice from different angles, on the commit that added the cleanup. --- .../windows_identity_runtime_windows.go | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 0876a2050..2c18a42cd 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -144,14 +144,20 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig // deleting it because a later run failed would turn a partial failure into a // total one. rightsGranted := false - secretWritten := false - secretPath := "" + // Resolved from the account name rather than the identity, so it is known + // before anything can fail. Deriving it later, after the rights grant, left + // the one window this cleanup exists for uncovered: provisioning ALWAYS sets + // the password, including resetting a pre-existing account's, so from the + // moment it returns the stored secret is already stale. A failure before the + // path was computed then had nothing to remove. + secretPath, secretPathErr := windowsSandboxSecretPath(config.SandboxHome, windowsSandboxUserName(key)) undo := func() { - if secretWritten && secretPath != "" { - // Dropping the secret is also the repair for a pre-existing account - // whose password this run reset: the stored secret no longer matches, - // and absent beats stale, since the command path treats a missing - // secret as "not provisioned" and falls back rather than failing. + // Unconditionally, not only when this run wrote one. Provisioning has + // already replaced the account's password by the time any of this can + // fail, so whatever is on disk cannot authenticate. Absent beats stale: + // the command path treats a missing secret as "not provisioned" and falls + // back, while a stale one fails the logon and reports a broken sandbox. + if secretPath != "" { _ = removeWindowsSandboxSecret(secretPath) } if identity.SID != nil && rightsGranted { @@ -173,10 +179,9 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig return windowsSandboxIdentity{}, err } rightsGranted = true - secretPath, err = windowsSandboxSecretPath(config.SandboxHome, identity.Username) - if err != nil { + if secretPathErr != nil { undo() - return windowsSandboxIdentity{}, err + return windowsSandboxIdentity{}, secretPathErr } // The secret is rewritten every run so it stays in step with the account. // provisionWindowsSandboxIdentity guarantees the password it returns is the @@ -187,7 +192,6 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig undo() return windowsSandboxIdentity{}, err } - secretWritten = true return identity, nil } From 760714db22856947acc824617b314c847eec8653 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 28 Jul 2026 12:24:24 +0530 Subject: [PATCH 13/45] test(sandbox): check the ACE type before decoding its trustee GetAce returns a generic ACE_HEADER and the helper reinterprets it as an ACCESS_ALLOWED_ACE. That holds for the fixed-layout types, but an object ACE carries Flags and two GUIDs ahead of the trustee, so SidStart would land mid-structure and Copy would read whatever bytes follow. The caller asserts that no unexpected trustee appears in the DACL, and on such an ACE it would print a nonsense SID rather than name the entry that does not belong. Nothing under test builds anything but allowed ACEs today, so this changes no current outcome. It keeps the failure legible if that ever changes. --- .../sandbox/windows_identity_secret_windows_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/sandbox/windows_identity_secret_windows_test.go b/internal/sandbox/windows_identity_secret_windows_test.go index b77d0f5a0..5fce6d461 100644 --- a/internal/sandbox/windows_identity_secret_windows_test.go +++ b/internal/sandbox/windows_identity_secret_windows_test.go @@ -5,6 +5,7 @@ package sandbox import ( "bytes" "errors" + "fmt" "os" "path/filepath" "strings" @@ -187,6 +188,18 @@ func windowsSecretACEList(dacl *windows.ACL) ([]*windows.SID, error) { if err := windows.GetAce(dacl, index, &ace); err != nil { return nil, err } + // GetAce hands back a generic ACE_HEADER and we reinterpret it. That is + // only sound for the fixed-layout types: an object ACE carries Flags and + // two GUIDs ahead of the trustee, so SidStart would land mid-structure + // and Copy would read whatever bytes happen to be there. The caller's + // "unexpected trustee" assertion would then print a nonsense SID instead + // of naming the ACE that does not belong, which is the opposite of what + // a failing test should do. Nothing under test builds anything but + // allowed ACEs today, so this exists to keep the failure legible if that + // ever changes. + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return nil, fmt.Errorf("ACE %d has type %d, want ACCESS_ALLOWED_ACE_TYPE (%d); refusing to decode its trustee", index, ace.Header.AceType, windows.ACCESS_ALLOWED_ACE_TYPE) + } sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) copied, err := sid.Copy() if err != nil { From c2cbb805869c9db8f99c08cad2674ff7e3da408e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 28 Jul 2026 21:31:50 +0530 Subject: [PATCH 14/45] fix(sandbox): grant delete to the principal and keep rollback able to find it Two findings from review, both consequences of the principal being a separate account rather than the calling user. WindowsACLAllowWrite granted FILE_GENERIC_WRITE, which covers creating and modifying but not removing or renaming, and a rename needs delete on the source. Under the old same-user token this was invisible because the caller already held inherited rights on its own tree. A principal inherits nothing, so it could write files it could never delete, which fails ordinary editing and most git operations rather than an edge case. DELETE and FILE_DELETE_CHILD are now part of the grant, matching WindowsACLDenyWrite, which already treats delete as part of write. WRITE_DAC and WRITE_OWNER stay out: they are denied so the principal cannot rewrite its own restrictions. provisionWindowsSandboxIdentity returned a zero identity alongside created=true when group attachment or SID resolution failed after NetUserAdd had already created the account. The caller's rollback deletes by identity.Username, so it was asked to delete the empty string and left the account behind. Group attachment is the case that matters, being both the enforcement boundary and something local policy can refuse. The name now comes back with the error. The four provisioning calls are indirected so the failure paths are reachable in a test. Seaming only the post-creation pair would not have been enough: every step needs an elevated caller, so the test would have stopped at the group check and passed without reaching what it names. Also seeds the empty-secret test with a genuinely empty file. The previous whitespace seed was several bytes, so it never reached the length check and failed later in DPAPI instead, which another test already covers. --- internal/sandbox/windows_acl_apply_windows.go | 17 +- .../windows_identity_rollback_windows_test.go | 155 ++++++++++++++++++ .../windows_identity_secret_windows_test.go | 27 ++- internal/sandbox/windows_identity_windows.go | 38 ++++- 4 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 internal/sandbox/windows_identity_rollback_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index a38789f4a..d53927a08 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -223,7 +223,22 @@ 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 diff --git a/internal/sandbox/windows_identity_rollback_windows_test.go b/internal/sandbox/windows_identity_rollback_windows_test.go new file mode 100644 index 000000000..42128073c --- /dev/null +++ b/internal/sandbox/windows_identity_rollback_windows_test.go @@ -0,0 +1,155 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +// stubWindowsProvisioning replaces the four provisioning syscalls so the +// function can run on an ordinary machine. Every one of them needs an elevated +// caller and a real local account, so without this the test would stop at the +// first call and never reach the behaviour it is named for. +func stubWindowsProvisioning(t *testing.T, existed bool, groupErr error, sidErr error) { + t.Helper() + prevGroup, prevUser := ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn + prevAdd, prevSID := addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn + t.Cleanup(func() { + ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn = prevGroup, prevUser + addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn = prevAdd, prevSID + }) + + ensureWindowsSandboxGroupFn = func() error { return nil } + ensureWindowsSandboxUserFn = func(string, string) (bool, error) { return existed, nil } + addWindowsSandboxUserToGroupFn = func(string) error { return groupErr } + resolveWindowsSandboxSIDFn = func(username string) (*windows.SID, error) { + if sidErr != nil { + return nil, sidErr + } + return windows.CreateWellKnownSid(windows.WinLocalSystemSid) + } +} + +// A failure after the account has been created must still hand back the name. +// +// The caller's rollback deletes by identity.Username, so returning a zero +// identity alongside created=true asked it to delete "" and quietly left the +// account this run had just made. Group attachment is the case that matters +// most: it is the enforcement boundary and it can fail under local policy. +func TestProvisionWindowsSandboxIdentityReturnsNameForRollback(t *testing.T) { + groupFailure := errors.New("group attachment refused by policy") + sidFailure := errors.New("sid lookup failed") + + for name, testCase := range map[string]struct { + groupErr error + sidErr error + }{ + "group attachment fails": {groupErr: groupFailure}, + "sid resolution fails": {sidErr: sidFailure}, + } { + t.Run(name, func(t *testing.T) { + stubWindowsProvisioning(t, false, testCase.groupErr, testCase.sidErr) + + identity, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + if err == nil { + t.Fatal("provisioning reported success despite an injected failure") + } + if !created { + t.Fatal("created = false, so the rollback would skip an account this run made") + } + // The whole point: without a name there is nothing to delete. + if identity.Username == "" { + t.Fatal("identity carries no username, so the rollback deletes \"\" and strands the account") + } + if want := windowsSandboxUserName("workspacekey"); identity.Username != want { + t.Fatalf("username = %q, want %q", identity.Username, want) + } + }) + } +} + +// An account that already existed must not be deleted because a later step +// failed. created=false is what stops the rollback turning a partial failure +// into the loss of a working principal from an earlier setup. +func TestProvisionWindowsSandboxIdentityDoesNotClaimPreexistingAccount(t *testing.T) { + stubWindowsProvisioning(t, true, errors.New("group attachment refused"), nil) + + _, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + if err == nil { + t.Fatal("provisioning reported success despite an injected failure") + } + // created is the whole assertion. It is what stops the rollback deleting an + // account it did not make, and it must stay false however provisioning + // fails. The identity is deliberately not checked: an adopted account exits + // early at the ownership check, which is a real syscall and not stubbed + // here, so asserting on the name would be testing the stub rather than the + // contract. + if created { + t.Fatal("created = true for an account this run did not create; rollback would delete a working principal") + } +} + +// The write grant has to include delete. +// +// FILE_GENERIC_WRITE covers creating and modifying but not removing or +// renaming, and a rename needs delete on the source. The old same-user token hid +// this because the caller already held inherited rights on its own tree; a +// principal is a separate account with none, so without these it can write files +// it can never remove, which fails ordinary editing and most git operations +// rather than an edge case. +func TestWindowsACLAllowWriteGrantsDelete(t *testing.T) { + mode, mask, err := windowsACLAccess(WindowsACLAllowWrite) + if err != nil { + t.Fatalf("windowsACLAccess: %v", err) + } + if mode != windows.GRANT_ACCESS { + t.Fatalf("mode = %v, want GRANT_ACCESS", mode) + } + // Atomic bits only. FILE_GENERIC_READ and FILE_GENERIC_WRITE both carry + // READ_CONTROL and SYNCHRONIZE, so testing a composite constant with & is + // satisfied by any grant at all and proves nothing. + for label, bit := range map[string]windows.ACCESS_MASK{ + "DELETE": windows.DELETE, + "FILE_DELETE_CHILD": windowsFileDeleteChild, + "FILE_WRITE_DATA": windows.FILE_WRITE_DATA, + } { + if mask&bit == 0 { + t.Errorf("write grant is missing %s", label) + } + } + // Granting these would let the principal rewrite the very restrictions + // placed on it. They are in the deny mask for that reason and must not + // appear here. + for label, bit := range map[string]windows.ACCESS_MASK{ + "WRITE_DAC": windows.WRITE_DAC, + "WRITE_OWNER": windows.WRITE_OWNER, + } { + if mask&bit != 0 { + t.Errorf("write grant unexpectedly includes %s", label) + } + } +} + +// The read grant must stay read-only. Widening the write mask above is only +// safe if this one did not move with it. +func TestWindowsACLAllowReadGrantsNoDelete(t *testing.T) { + _, mask, err := windowsACLAccess(WindowsACLAllowRead) + if err != nil { + t.Fatalf("windowsACLAccess: %v", err) + } + // Atomic bits, for the same reason as above: the read and write composites + // overlap on the standard rights, so a composite check here would report a + // failure that is not real. + for label, bit := range map[string]windows.ACCESS_MASK{ + "DELETE": windows.DELETE, + "FILE_DELETE_CHILD": windowsFileDeleteChild, + "FILE_WRITE_DATA": windows.FILE_WRITE_DATA, + } { + if mask&bit != 0 { + t.Errorf("read grant unexpectedly includes %s", label) + } + } +} diff --git a/internal/sandbox/windows_identity_secret_windows_test.go b/internal/sandbox/windows_identity_secret_windows_test.go index 5fce6d461..0293ec573 100644 --- a/internal/sandbox/windows_identity_secret_windows_test.go +++ b/internal/sandbox/windows_identity_secret_windows_test.go @@ -130,13 +130,28 @@ func TestWindowsSandboxSecretMissingIsSentinel(t *testing.T) { } // An empty file is a half-written secret, not a valid empty password. +// +// Both seeds matter and only one of them tests what the name says. A +// zero-length file is the truncated-write case, and it is the only one that +// reaches the length check. Whitespace is several bytes, so it travels on to +// DPAPI and fails to unprotect instead, which is the path +// TestWindowsSandboxSecretRejectsLegacyPlaintext already covers. Seeding only +// the whitespace, as this did, left the branch in the test's own name +// unexercised. func TestWindowsSandboxSecretEmptyIsSentinel(t *testing.T) { - path := filepath.Join(t.TempDir(), "empty.secret") - if err := os.WriteFile(path, []byte(" \r\n"), 0o600); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable { - t.Fatalf("empty secret returned %v, want the unavailable sentinel", err) + for name, seed := range map[string][]byte{ + "truncated write": {}, + "whitespace only": []byte(" \r\n"), + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.secret") + if err := os.WriteFile(path, seed, 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("secret of %d bytes returned %v, want the unavailable sentinel", len(seed), err) + } + }) } } diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index a6b663313..e2d7b46eb 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -376,6 +376,21 @@ func resolveWindowsSandboxSID(username string) (*windows.SID, error) { return sid, nil } +// Indirected so a test can drive provisioning end to end and inject a failure +// at the two points that occur AFTER the account exists. Those are the paths +// whose return value the caller's rollback depends on. +// +// All four are seamed rather than just the last two: every step here needs an +// elevated caller and a real local account, so a test that only replaced the +// post-creation pair would never get past ensureWindowsSandboxGroup on an +// ordinary machine and would pass without reaching the code it names. +var ( + ensureWindowsSandboxGroupFn = ensureWindowsSandboxGroup + ensureWindowsSandboxUserFn = ensureWindowsSandboxUser + addWindowsSandboxUserToGroupFn = addWindowsSandboxUserToGroup + resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID +) + // provisionWindowsSandboxIdentity ensures the managed group and one sandbox // principal for workspaceKey exist, and returns the identity plus the password // the caller needs to mint a token with LogonUser. It is idempotent, so setup @@ -387,7 +402,7 @@ func resolveWindowsSandboxSID(username string) (*windows.SID, error) { // when the account already existed, because that case is reset explicitly // below. func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, string, bool, error) { - if err := ensureWindowsSandboxGroup(); err != nil { + if err := ensureWindowsSandboxGroupFn(); err != nil { return windowsSandboxIdentity{}, "", false, err } username := windowsSandboxUserName(workspaceKey) @@ -395,7 +410,7 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit if err != nil { return windowsSandboxIdentity{}, "", false, err } - existed, err := ensureWindowsSandboxUser(username, password) + existed, err := ensureWindowsSandboxUserFn(username, password) if err != nil { return windowsSandboxIdentity{}, "", false, err } @@ -421,12 +436,21 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit return windowsSandboxIdentity{}, "", false, err } } - if err := addWindowsSandboxUserToGroup(username); err != nil { - return windowsSandboxIdentity{}, "", !existed, err - } - sid, err := resolveWindowsSandboxSID(username) + // Both failures below can happen AFTER NetUserAdd created the account, so the + // name has to come back with them. The caller's rollback deletes by + // identity.Username, and returning a zero identity alongside created=true + // asked it to delete "", which silently stranded the account this run had + // just made. Group attachment in particular is not a formality: it can fail + // under local policy, and it is the enforcement boundary, so a half-created + // principal is exactly the state worth not leaving behind. The SID is absent + // here, which the rollback already tolerates, since nothing has been granted + // to it yet. + if err := addWindowsSandboxUserToGroupFn(username); err != nil { + return windowsSandboxIdentity{Username: username}, "", !existed, err + } + sid, err := resolveWindowsSandboxSIDFn(username) if err != nil { - return windowsSandboxIdentity{}, "", !existed, err + return windowsSandboxIdentity{Username: username}, "", !existed, err } return windowsSandboxIdentity{Username: username, SID: sid}, password, !existed, nil } From 508e1835762a8a4cd9c3a48470580f03e49a9dab Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 29 Jul 2026 13:50:34 +0530 Subject: [PATCH 15/45] fix(sandbox): stop setup destroying a principal it did not create Six findings from review, all on the elevated setup path. Teardown was not scoped to what the run created. provisionWindowsSandbox PrincipalForSetup was careful never to delete an account it had adopted, and then setupWindowsSandboxPrincipal called removePrincipal on any ACL failure with no such guard. Re-running elevated setup on a working machine and hitting one transient ACL error therefore deleted the local account, its secret and its logon rights. It now returns whether it created the principal and the outer teardown honours it; ACEs are still reverted, since this run applied them. Password rotation moved to immediately before the secret is committed. Resetting an adopted account's password at the top of provisioning meant every later step ran against an account whose password had been replaced with no copy stored. Any failure there left a live account authenticated by a password nothing on disk knew, and since the account pre-existed the rollback correctly declined to delete it, so the command path read the absent secret as "not provisioned" and fell back to the weaker backend for good. The two operations are now adjacent. The rollback also stops removing the secret when this run neither created the account nor rotated it, because that secret still works. Policy DenyWrite now reaches the principal ACL plan. The capability plan has always emitted these; the principal plan denied write only on protected metadata and read-only subpaths, so once the runner used a principal token a policy deny elsewhere was not enforced at all. Principal deny-read entries are materialized, matching the capability plan, so a path created after setup still gets a deny ACE. Logon-right revocation is keyed to the attempt rather than to success. Rights are added one at a time and the grant returns on first failure, so a partial grant left LSA entries behind pointing at a SID that deleting the account then made unresolvable. The ownership comment now carries the full workspace key. The account name holds only 11 characters of the digest, so two workspaces could derive one name and silently share an account, a secret and an ACL identity; a mismatch is now refused. Accounts provisioned before the key was recorded are still adopted. Also warns once on stderr when the opt-in is set and a provisioned principal cannot be used, rather than downgrading in silence. --- internal/sandbox/windows_identity_acl.go | 26 ++- .../windows_identity_policy_windows_test.go | 149 ++++++++++++++++++ .../windows_identity_rollback_windows_test.go | 7 +- .../windows_identity_runtime_windows.go | 118 ++++++++++---- internal/sandbox/windows_identity_windows.go | 71 ++++++--- .../sandbox/windows_identity_windows_test.go | 4 +- 6 files changed, 322 insertions(+), 53 deletions(-) create mode 100644 internal/sandbox/windows_identity_policy_windows_test.go diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index c37393b71..9dced2fbb 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -41,6 +41,13 @@ type windowsPrincipalACLInput struct { // 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. @@ -60,11 +67,24 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla // 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, + 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 { diff --git a/internal/sandbox/windows_identity_policy_windows_test.go b/internal/sandbox/windows_identity_policy_windows_test.go new file mode 100644 index 000000000..a5bd7b90e --- /dev/null +++ b/internal/sandbox/windows_identity_policy_windows_test.go @@ -0,0 +1,149 @@ +//go:build windows + +package sandbox + +import ( + "path/filepath" + "strings" + "testing" +) + +// Policy deny-write has to reach the principal plan. +// +// The capability plan has always emitted these. The principal plan denied write +// only on protected metadata and read-only subpaths inside write roots, so once +// the runner used a principal token a policy deny sitting anywhere else was not +// enforced at the OS layer at all, and a shell child could write where the +// restricted-token backend would have stopped it. +func TestPrincipalACLPlanCarriesPolicyDenyWrite(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspace") + denied := filepath.Join(root, "protected", "keep-out") + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-0-0-0-1001", + WriteRoots: []WritableRoot{{Root: root}}, + DenyWrite: []string{denied}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, denied) + if !ok { + t.Fatalf("no deny-write ACE for the policy path; plan = %+v", plan.Entries) + } + // Materialized for the same reason the capability plan does it: the applier + // skips targets that do not exist, so a deny on a path created after setup + // would never be written. + if !entry.Materialize { + t.Error("policy deny-write ACE is not materialized, so it is skipped when the path does not exist yet") + } +} + +// Deny-read has to be materialized too, which it was not. +func TestPrincipalACLPlanMaterializesDenyRead(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspace") + secret := filepath.Join(t.TempDir(), "elsewhere", "creds") + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-0-0-0-1001", + WriteRoots: []WritableRoot{{Root: root}}, + DenyRead: []string{secret}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyRead, secret) + if !ok { + t.Fatalf("no deny-read ACE emitted; plan = %+v", plan.Entries) + } + if !entry.Materialize { + t.Fatal("deny-read ACE is not materialized, so a path created after setup never gets one") + } +} + +// Deny entries must still precede the grants they carve out of, which is what +// makes them win under Windows DACL evaluation. Adding deny-write to the plan is +// only safe if it did not disturb that ordering. +func TestPrincipalACLPlanKeepsDeniesBeforeGrants(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspace") + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-0-0-0-1001", + WriteRoots: []WritableRoot{{Root: root}}, + DenyWrite: []string{filepath.Join(root, "nope")}, + DenyRead: []string{filepath.Join(root, "secret")}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + firstGrant := -1 + for i, entry := range plan.Entries { + switch entry.Action { + case WindowsACLAllowWrite, WindowsACLAllowRead: + if firstGrant == -1 { + firstGrant = i + } + case WindowsACLDenyRead, WindowsACLDenyWrite: + if firstGrant != -1 { + t.Fatalf("deny entry at %d follows a grant at %d; the grant would win", i, firstGrant) + } + } + } +} + +func findPrincipalACLEntry(plan WindowsACLPlan, action WindowsACLAction, path string) (WindowsACLEntry, bool) { + want := normalizeProfilePath(path) + for _, entry := range plan.Entries { + if entry.Action == action && entry.Path == want { + return entry, true + } + } + return WindowsACLEntry{}, false +} + +// An adopted account must not have its password rotated during provisioning. +// +// Rotating there left every later step running against an account whose password +// had already been replaced with nothing on disk holding it. Any failure in +// between stranded a working principal: the rollback correctly declined to +// delete an account it had not created, so what remained was a live account +// authenticated by a password no longer stored anywhere, and the command path +// read the absent secret as "not provisioned" and quietly fell back to the +// weaker backend. +func TestProvisionWindowsSandboxIdentityDefersPasswordRotation(t *testing.T) { + stubWindowsProvisioning(t, true, nil, nil) + + rotated := false + previous := resetWindowsSandboxUserPasswordFn + t.Cleanup(func() { resetWindowsSandboxUserPasswordFn = previous }) + resetWindowsSandboxUserPasswordFn = func(string, string) error { + rotated = true + return nil + } + + if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey"); err != nil { + t.Fatalf("provisioning an adopted account: %v", err) + } + if rotated { + t.Fatal("provisioning rotated the password; the window this closes lasts until the secret is committed") + } +} + +// The ownership comment carries the full workspace key, so two workspaces whose +// digests collide in the 11 characters the account name can hold are refused +// rather than silently sharing one account, one secret and one ACL identity. +func TestWindowsSandboxUserCommentDistinguishesWorkspaces(t *testing.T) { + first := windowsSandboxUserCommentFor("aaaaaaaaaaaabbbbbbbb") + second := windowsSandboxUserCommentFor("aaaaaaaaaaaacccccccc") + if first == second { + t.Fatal("two workspaces produced the same ownership comment, so a name collision would be adopted") + } + if !strings.HasPrefix(first, windowsSandboxUserComment) { + t.Fatalf("comment %q lost the marker prefix that identifies it as ours", first) + } + // The names DO collide, which is the whole reason the comment has to carry + // the key. If this stops being true the test is no longer covering anything. + if windowsSandboxUserName("aaaaaaaaaaaabbbbbbbb") != windowsSandboxUserName("aaaaaaaaaaaacccccccc") { + t.Skip("account names no longer collide for these keys; revisit what this test is for") + } +} diff --git a/internal/sandbox/windows_identity_rollback_windows_test.go b/internal/sandbox/windows_identity_rollback_windows_test.go index 42128073c..e4713d8e4 100644 --- a/internal/sandbox/windows_identity_rollback_windows_test.go +++ b/internal/sandbox/windows_identity_rollback_windows_test.go @@ -17,13 +17,18 @@ func stubWindowsProvisioning(t *testing.T, existed bool, groupErr error, sidErr t.Helper() prevGroup, prevUser := ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn prevAdd, prevSID := addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn + prevManaged := windowsSandboxUserIsManagedFn t.Cleanup(func() { ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn = prevGroup, prevUser addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn = prevAdd, prevSID + windowsSandboxUserIsManagedFn = prevManaged }) ensureWindowsSandboxGroupFn = func() error { return nil } - ensureWindowsSandboxUserFn = func(string, string) (bool, error) { return existed, nil } + // Adopted accounts are ours in these tests; the ownership check is a real + // syscall and would otherwise refuse before the code under test runs. + windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil } + ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return existed, nil } addWindowsSandboxUserToGroupFn = func(string) error { return groupErr } resolveWindowsSandboxSIDFn = func(username string) (*windows.SID, error) { if sidErr != nil { diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 2c18a42cd..7c0389800 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -16,8 +16,10 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "os" "strings" + "sync" "golang.org/x/sys/windows" ) @@ -109,6 +111,14 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T if errors.Is(err, errWindowsSandboxIdentityUnavailable) { // The account exists but its password does not. Setup was interrupted // or the secret was removed; fall back rather than fail the command. + // + // Falling back is right, staying quiet about it was not. The opt-in is + // set and an account IS provisioned, so the operator asked for + // principal isolation and is silently getting the weaker same-user + // restricted token instead. That is the one fail-soft case worth + // announcing: the others mean the backend was never set up, while this + // one means it was and has broken since. + warnWindowsSandboxPrincipalUnavailable(identity.Username) return 0, false, nil } return 0, false, err @@ -122,6 +132,25 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T return token, true, nil } +// warnWindowsSandboxPrincipalUnavailable tells the operator once per process +// that the backend they opted into is not the one running. +// +// Once, because this sits on the command path: a warning per command would be +// noise on every tool call for the whole session, and noise that repeats gets +// filtered out by the reader rather than acted on. Indirected through a var so a +// test can observe it without capturing stderr. +var warnWindowsSandboxPrincipalUnavailable = func(username string) { + windowsSandboxPrincipalWarnOnce.Do(func() { + fmt.Fprintf(os.Stderr, + "[zero] %s is set and sandbox principal %q is provisioned, but its stored password is missing or unreadable. "+ + "Falling back to the restricted-token sandbox, which does not confine reads. "+ + "Re-run `zero sandbox setup` from an elevated terminal to restore it.\n", + windowsSandboxIdentityEnv, username) + }) +} + +var windowsSandboxPrincipalWarnOnce sync.Once + // provisionWindowsSandboxPrincipalForSetup does the elevated half: create the // account, grant it the batch logon right, and store its password locked to the // invoking user. Called from `zero sandbox setup`. @@ -129,7 +158,7 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T // The password is written BEFORE the caller applies any ACL plan, so a setup // that fails partway leaves a principal that can at least be logged on and // therefore cleaned up, rather than an account nothing holds the secret for. -func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) (windowsSandboxIdentity, error) { +func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) (windowsSandboxIdentity, bool, error) { key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) identity, password, created, err := provisionWindowsSandboxIdentity(key) @@ -143,24 +172,32 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig // and belongs to Zero is a working principal from an earlier setup, and // deleting it because a later run failed would turn a partial failure into a // total one. - rightsGranted := false + rightsAttempted := false + rotated := false // Resolved from the account name rather than the identity, so it is known - // before anything can fail. Deriving it later, after the rights grant, left - // the one window this cleanup exists for uncovered: provisioning ALWAYS sets - // the password, including resetting a pre-existing account's, so from the - // moment it returns the stored secret is already stale. A failure before the - // path was computed then had nothing to remove. + // before anything can fail. secretPath, secretPathErr := windowsSandboxSecretPath(config.SandboxHome, windowsSandboxUserName(key)) undo := func() { - // Unconditionally, not only when this run wrote one. Provisioning has - // already replaced the account's password by the time any of this can - // fail, so whatever is on disk cannot authenticate. Absent beats stale: - // the command path treats a missing secret as "not provisioned" and falls - // back, while a stale one fails the logon and reports a broken sandbox. - if secretPath != "" { + // Only when this run invalidated it. The secret is removed if this run + // created the account, or if it rotated an existing account's password, + // because in both cases what is on disk cannot authenticate and absent + // beats stale: the command path treats a missing secret as "not + // provisioned" and falls back, while a stale one fails the logon and + // reports a broken sandbox. + // + // Removing it unconditionally, as this used to, destroyed a WORKING + // secret whenever setup failed before rotation on a machine that was + // already provisioned. The account kept its old password, the only copy + // of it was deleted, and the sandbox silently degraded. + if secretPath != "" && (created || rotated) { _ = removeWindowsSandboxSecret(secretPath) } - if identity.SID != nil && rightsGranted { + // Attempted rather than completed. grantWindowsSandboxLogonRights adds + // rights one at a time and returns on the first failure, so a partial + // grant is possible; gating revocation on success left those entries + // behind, keyed to a SID that deleting the account then made + // unresolvable. Revoking a right that was never granted is harmless. + if identity.SID != nil && rightsAttempted { _ = revokeWindowsSandboxLogonRights(identity.SID) } if created { @@ -172,27 +209,39 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig // provisionWindowsSandboxIdentity can fail after creating the account, so // this path needs the same cleanup even though nothing below ran. undo() - return windowsSandboxIdentity{}, err + return windowsSandboxIdentity{}, false, err } + rightsAttempted = true if err := grantWindowsSandboxLogonRights(identity.SID); err != nil { undo() - return windowsSandboxIdentity{}, err + return windowsSandboxIdentity{}, false, err } - rightsGranted = true if secretPathErr != nil { undo() - return windowsSandboxIdentity{}, secretPathErr + return windowsSandboxIdentity{}, false, secretPathErr + } + // Rotation happens HERE, immediately before the secret is committed, rather + // than inside provisioning where it used to. + // + // A new account already has this password from NetUserAdd, so only an + // adopted one needs setting. Doing it at the top of provisioning meant every + // step in between ran with the account's password already replaced and no + // copy of it stored, so any failure there stranded a working principal. The + // two operations are now adjacent, which is the smallest window this can + // have without a way to restore the previous password, which Windows does + // not offer. + if !created { + if err := resetWindowsSandboxUserPasswordFn(identity.Username, password); err != nil { + undo() + return windowsSandboxIdentity{}, false, err + } + rotated = true } - // The secret is rewritten every run so it stays in step with the account. - // provisionWindowsSandboxIdentity guarantees the password it returns is the - // account's real one, resetting it explicitly when the account already - // existed and belongs to Zero, so this write is always storing something that - // can log on. if err := writeWindowsSandboxSecret(secretPath, password); err != nil { undo() - return windowsSandboxIdentity{}, err + return windowsSandboxIdentity{}, false, err } - return identity, nil + return identity, created, nil } // setupWindowsSandboxPrincipal provisions this workspace's principal and grants @@ -205,11 +254,25 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig // naming a SID that no longer resolves, which is the orphaned-entry residue this // model exists to avoid. func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() error, error) { - identity, err := provisionWindowsSandboxPrincipalForSetup(config) + identity, created, err := provisionWindowsSandboxPrincipalForSetup(config) if err != nil { return nil, err } - removePrincipal := func() error { return removeWindowsSandboxPrincipalForSetup(config) } + // Scoped to what this run created, the same contract provisioning already + // applies to its own rollback. + // + // Unconditional removal here meant a transient ACL failure during a re-run of + // elevated setup deleted a principal that was working before the run started, + // taking its secret and logon rights with it. Provisioning was careful not to + // do that and then this undid the care one level up. A pre-existing principal + // is left alone: its ACEs are still reverted, since this run applied them, + // but the account itself is not this run's to destroy. + removePrincipal := func() error { + if !created { + return nil + } + return removeWindowsSandboxPrincipalForSetup(config) + } filesystem := config.PermissionProfile.FileSystem plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ @@ -217,6 +280,7 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er WriteRoots: filesystem.WriteRoots, ReadRoots: filesystem.ReadRoots, DenyRead: filesystem.DenyRead, + DenyWrite: filesystem.DenyWrite, }) if err != nil { _ = removePrincipal() diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index e2d7b46eb..fbda8fecb 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -44,9 +44,16 @@ const ( // windowsSandboxUserPrefix keeps the accounts recognisable in `net user` and // lets cleanup identify what belongs to Zero. Windows caps a local account // name at 20 characters, which windowsSandboxUserName respects. - windowsSandboxUserPrefix = "zero-sbx-" - windowsSandboxUserComment = "Zero sandbox principal (managed)" - windowsSandboxUserNameMax = 20 + windowsSandboxUserPrefix = "zero-sbx-" + // The comment doubles as the ownership marker AND records which workspace + // the account belongs to. The account NAME can only carry 11 characters of + // the workspace digest because of the 20-character local-account limit, so + // two workspaces whose digests share that prefix derive the same name. The + // full key here turns that from a silent share of one account, one secret + // and one ACL identity into a refusal. + windowsSandboxUserComment = "Zero sandbox principal (managed)" + windowsSandboxUserCommentKey = windowsSandboxUserComment + " key=" + windowsSandboxUserNameMax = 20 ) // Win32 status codes that mean "already there". Treated as success so @@ -154,6 +161,12 @@ func windowsSandboxUserName(workspaceKey string) string { return name } +// windowsSandboxUserCommentFor returns the ownership comment for a workspace, +// carrying the full key the account name could only hold 11 characters of. +func windowsSandboxUserCommentFor(workspaceKey string) string { + return windowsSandboxUserCommentKey + workspaceKey +} + // newWindowsSandboxPassword returns a random password for a sandbox principal. // The account is never signed into interactively: the password exists only so // LogonUser can mint a token for it, so it is generated per provisioning run, @@ -225,7 +238,7 @@ func ensureWindowsSandboxGroup() error { // It reports whether the account already existed, because NetUserAdd leaves such // an account completely untouched, password included. The caller has to reset it // or the secret it goes on to store would not be the account's password at all. -func ensureWindowsSandboxUser(username string, password string) (bool, error) { +func ensureWindowsSandboxUser(username string, password string, workspaceKey string) (bool, error) { name, err := windows.UTF16PtrFromString(username) if err != nil { return false, err @@ -234,7 +247,7 @@ func ensureWindowsSandboxUser(username string, password string) (bool, error) { if err != nil { return false, err } - comment, err := windows.UTF16PtrFromString(windowsSandboxUserComment) + comment, err := windows.UTF16PtrFromString(windowsSandboxUserCommentFor(workspaceKey)) if err != nil { return false, err } @@ -332,7 +345,7 @@ var errWindowsSandboxNameCollision = errors.New("a local account with Zero's der // // A missing account is not managed rather than an error, so callers can use this // as a plain question without special-casing absence. -func windowsSandboxUserIsManaged(username string) (bool, error) { +func windowsSandboxUserIsManaged(username string, workspaceKey string) (bool, error) { name, err := windows.UTF16PtrFromString(username) if err != nil { return false, err @@ -359,7 +372,14 @@ func windowsSandboxUserIsManaged(username string) (bool, error) { if info.Comment == nil { return false, nil } - return windows.UTF16PtrToString(info.Comment) == windowsSandboxUserComment, nil + comment := windows.UTF16PtrToString(info.Comment) + // An account provisioned before the key was recorded is still ours; it + // predates this check and cannot be attributed to a workspace, so it is + // adopted and its comment rewritten on the way through. + if comment == windowsSandboxUserComment { + return true, nil + } + return comment == windowsSandboxUserCommentFor(workspaceKey), nil } // resolveWindowsSandboxSID looks up the SID for a provisioned principal. The SID @@ -385,10 +405,12 @@ func resolveWindowsSandboxSID(username string) (*windows.SID, error) { // post-creation pair would never get past ensureWindowsSandboxGroup on an // ordinary machine and would pass without reaching the code it names. var ( - ensureWindowsSandboxGroupFn = ensureWindowsSandboxGroup - ensureWindowsSandboxUserFn = ensureWindowsSandboxUser - addWindowsSandboxUserToGroupFn = addWindowsSandboxUserToGroup - resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID + ensureWindowsSandboxGroupFn = ensureWindowsSandboxGroup + ensureWindowsSandboxUserFn = ensureWindowsSandboxUser + addWindowsSandboxUserToGroupFn = addWindowsSandboxUserToGroup + resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID + resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword + windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged ) // provisionWindowsSandboxIdentity ensures the managed group and one sandbox @@ -410,7 +432,7 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit if err != nil { return windowsSandboxIdentity{}, "", false, err } - existed, err := ensureWindowsSandboxUserFn(username, password) + existed, err := ensureWindowsSandboxUserFn(username, password, workspaceKey) if err != nil { return windowsSandboxIdentity{}, "", false, err } @@ -421,20 +443,29 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit // because somebody created it deliberately. Adopting one means resetting // its password, which is not something to do on the strength of a name // matching a pattern we generate ourselves. - managed, err := windowsSandboxUserIsManaged(username) + managed, err := windowsSandboxUserIsManagedFn(username, workspaceKey) if err != nil { return windowsSandboxIdentity{}, "", false, err } if !managed { return windowsSandboxIdentity{}, "", false, fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, username) } - // Ours, and NetUserAdd left it untouched, so the password above is not yet - // its password. Set it, or the secret stored by the caller would never - // authenticate and every command would fail to log on with a principal - // that looks perfectly provisioned. - if err := resetWindowsSandboxUserPassword(username, password); err != nil { - return windowsSandboxIdentity{}, "", false, err - } + // Deliberately NOT resetting the password here. + // + // NetUserAdd left an existing account untouched, so the password above is + // not yet its password and something has to set it. Doing that here, at + // the top of provisioning, opened a window that lasted until the secret + // was written several steps later: a failure anywhere in between left a + // live account whose password nothing on disk knew, and because the + // account already existed the rollback correctly declined to delete it. + // The command path then read the absent secret as "not provisioned" and + // quietly fell back to the weaker backend, so the sandbox was downgraded + // for good with nothing to show for it. + // + // The caller rotates instead, immediately before committing the secret, + // which narrows that window to a single operation. Until it does, the + // account keeps its old password and the old secret on disk still + // authenticates, so a failure before that point costs nothing. } // Both failures below can happen AFTER NetUserAdd created the account, so the // name has to come back with them. The caller's rollback deletes by diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index f477b518f..f8320bcd5 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -401,7 +401,7 @@ func TestLookupWindowsSandboxIdentityRejectsNonUserAccount(t *testing.T) { func TestWindowsSandboxUserIsManagedRefusesForeignAccounts(t *testing.T) { checked := 0 for _, name := range []string{"Administrator", "Guest", "DefaultAccount"} { - managed, err := windowsSandboxUserIsManaged(name) + managed, err := windowsSandboxUserIsManaged(name, "workspacekey") if err != nil { // Localized or disabled installs may not carry every one of these. continue @@ -416,7 +416,7 @@ func TestWindowsSandboxUserIsManagedRefusesForeignAccounts(t *testing.T) { } // An absent account must answer false rather than error, since provisioning // asks this question about names that usually do not exist yet. - managed, err := windowsSandboxUserIsManaged("zero-sbx-nosuchacct") + managed, err := windowsSandboxUserIsManaged("zero-sbx-nosuchacct", "workspacekey") if err != nil { t.Fatalf("querying a missing account: %v", err) } From 2f9b31c506daf3272a1ff2662ffe745d77b9e759 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 29 Jul 2026 20:05:17 +0530 Subject: [PATCH 16/45] fix(sandbox): keep an adopted principal's logon rights on rollback Second round of review findings, both on the elevated setup path. The rollback revoked logon rights whenever they had been attempted, without regard to whether this run created the account. revokeWindowsSandboxLogonRights passes AllRights, which drops every right the account holds and deletes its LSA object outright. On an adopted principal that is not a rollback but destruction: a transient grant, secret-path or secret-write failure during a re-run stripped the SeBatchLogonRight and deny-logon rights an earlier setup had established, leaving exactly the broken-but-present principal this path exists to avoid. Revocation is now scoped to accounts this run created. The rights granted to an adopted account are the ones it is supposed to hold, so leaving them is the safe direction. A secret the current user cannot read now falls back instead of failing the command. The secret's DACL names whoever ran setup, so an operator who elevated with a separate administrative account, through runas or an over-the-shoulder UAC prompt, leaves a secret their ordinary account cannot open. That is the documented fail-soft case, and treating it as a hard error made every sandboxed command fail on a machine that was merely set up by a different admin. Permission errors from the removal path are deliberately still reported, since incomplete teardown is worth knowing about. Both are covered by injected-failure tests and fail if the guard is removed. The secret read is seamed to inject the permission error, because producing a real ERROR_ACCESS_DENIED needs DACL surgery and would test the platform rather than the mapping. --- .../windows_identity_policy_windows_test.go | 74 +++++++++++++++++++ .../windows_identity_runtime_windows.go | 27 +++++-- .../windows_identity_secret_windows.go | 20 ++++- internal/sandbox/windows_identity_windows.go | 2 + 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/internal/sandbox/windows_identity_policy_windows_test.go b/internal/sandbox/windows_identity_policy_windows_test.go index a5bd7b90e..94e8da063 100644 --- a/internal/sandbox/windows_identity_policy_windows_test.go +++ b/internal/sandbox/windows_identity_policy_windows_test.go @@ -3,9 +3,13 @@ package sandbox import ( + "errors" + "os" "path/filepath" "strings" "testing" + + "golang.org/x/sys/windows" ) // Policy deny-write has to reach the principal plan. @@ -147,3 +151,73 @@ func TestWindowsSandboxUserCommentDistinguishesWorkspaces(t *testing.T) { t.Skip("account names no longer collide for these keys; revisit what this test is for") } } + +// Rollback must not strip an adopted principal's logon rights. +// +// revokeWindowsSandboxLogonRights passes AllRights, which drops every right the +// account holds and deletes its LSA object. On an account this run created that +// is a rollback; on one it adopted it destroys the SeBatchLogonRight and +// deny-logon rights an earlier setup established, which is the working +// principal this path exists to preserve. +func TestSetupRollbackRevokesRightsOnlyForCreatedPrincipals(t *testing.T) { + for name, testCase := range map[string]struct { + existed bool + wantRevoked bool + }{ + "adopted principal": {existed: true, wantRevoked: false}, + "created principal": {existed: false, wantRevoked: true}, + } { + t.Run(name, func(t *testing.T) { + stubWindowsProvisioning(t, testCase.existed, nil, nil) + + revoked := false + prevGrant, prevRevoke := grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn + t.Cleanup(func() { + grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn = prevGrant, prevRevoke + }) + // Fail the grant so the undo path runs with rights already attempted, + // which is the state that used to revoke unconditionally. + grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { + return errors.New("LSA grant refused by policy") + } + revokeWindowsSandboxLogonRightsFn = func(*windows.SID) error { + revoked = true + return nil + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + WorkspaceRoots: []string{`C:\ws`}, + } + if _, _, err := provisionWindowsSandboxPrincipalForSetup(config); err == nil { + t.Fatal("provisioning reported success despite an injected grant failure") + } + if revoked != testCase.wantRevoked { + if testCase.wantRevoked { + t.Fatal("rights were not revoked for an account this run created, leaving LSA entries keyed to a SID about to be deleted") + } + t.Fatal("rights were revoked for an adopted account; AllRights drops its pre-existing rights and deletes the LSA object") + } + }) + } +} + +// A secret the current user cannot read is unavailability, not breakage. +// +// The secret's DACL names whoever ran setup. An operator who elevated with a +// separate administrative account, via runas or an over-the-shoulder UAC +// prompt, leaves a secret their ordinary account cannot open. Treating that as a +// hard error made every sandboxed command fail on a machine that was merely set +// up by a different admin; it belongs in the same fail-soft path as a missing +// secret, so the warning fires and the restricted token takes over. +func TestReadWindowsSandboxSecretTreatsPermissionDeniedAsUnavailable(t *testing.T) { + previous := readWindowsSandboxSecretFile + t.Cleanup(func() { readWindowsSandboxSecretFile = previous }) + readWindowsSandboxSecretFile = func(string) ([]byte, error) { + return nil, &os.PathError{Op: "open", Path: "secret", Err: windows.ERROR_ACCESS_DENIED} + } + + if _, err := readWindowsSandboxSecret(`C:\anything.secret`); err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("permission-denied read returned %v, want errWindowsSandboxIdentityUnavailable so the command falls back", err) + } +} diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 7c0389800..d6f414e08 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -192,13 +192,24 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig if secretPath != "" && (created || rotated) { _ = removeWindowsSandboxSecret(secretPath) } - // Attempted rather than completed. grantWindowsSandboxLogonRights adds - // rights one at a time and returns on the first failure, so a partial - // grant is possible; gating revocation on success left those entries - // behind, keyed to a SID that deleting the account then made - // unresolvable. Revoking a right that was never granted is harmless. - if identity.SID != nil && rightsAttempted { - _ = revokeWindowsSandboxLogonRights(identity.SID) + // Only for an account this run created, and attempted rather than + // completed. + // + // Attempted, because grantWindowsSandboxLogonRights adds rights one at a + // time and returns on the first failure, so a partial grant is possible + // and gating on success left those entries behind, keyed to a SID that + // deleting the account then made unresolvable. + // + // Created, because revokeWindowsSandboxLogonRights passes AllRights, which + // drops every right the account holds and deletes its LSA object outright. + // On an adopted principal that is not a rollback, it is destruction: a + // transient failure anywhere below would strip the SeBatchLogonRight and + // deny-logon rights a previous setup established, leaving exactly the + // broken-but-present principal this whole function exists to avoid. The + // rights this run granted are the ones the account is supposed to have, so + // leaving them in place on an adopted account is the safe direction. + if identity.SID != nil && rightsAttempted && created { + _ = revokeWindowsSandboxLogonRightsFn(identity.SID) } if created { _ = removeWindowsSandboxIdentity(identity.Username) @@ -212,7 +223,7 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig return windowsSandboxIdentity{}, false, err } rightsAttempted = true - if err := grantWindowsSandboxLogonRights(identity.SID); err != nil { + if err := grantWindowsSandboxLogonRightsFn(identity.SID); err != nil { undo() return windowsSandboxIdentity{}, false, err } diff --git a/internal/sandbox/windows_identity_secret_windows.go b/internal/sandbox/windows_identity_secret_windows.go index 96cee6305..87463940b 100644 --- a/internal/sandbox/windows_identity_secret_windows.go +++ b/internal/sandbox/windows_identity_secret_windows.go @@ -180,15 +180,33 @@ func windowsSandboxSecretEntropy(path string) string { return strings.TrimSuffix(filepath.Base(path), ".secret") } +// Seamed so the permission-denied mapping in readWindowsSandboxSecret is +// testable. Producing a real ERROR_ACCESS_DENIED needs DACL surgery on Windows, +// since a 0000 file is still readable and reading a directory reports +// "Incorrect function", so a test built that way would exercise the platform +// rather than the mapping. +var readWindowsSandboxSecretFile = os.ReadFile + // readWindowsSandboxSecret loads a principal's password. A missing file means // setup has not run for this workspace, which the caller turns into a fallback // rather than a hard failure. func readWindowsSandboxSecret(path string) (string, error) { - data, err := os.ReadFile(path) + data, err := readWindowsSandboxSecretFile(path) if err != nil { if os.IsNotExist(err) { return "", errWindowsSandboxIdentityUnavailable } + // Permission denied is unavailability, not breakage. The secret's DACL + // names whoever ran setup, so an operator who elevated with a separate + // administrative account, through runas or an over-the-shoulder UAC + // prompt, ends up with a secret their ordinary account cannot open. That + // is the documented fail-soft case: fall back to the restricted token and + // let the warning say so. Treating it as a hard error instead made every + // sandboxed command fail on a machine that was merely set up by a + // different admin, which is a common way to run an elevated setup. + if os.IsPermission(err) { + return "", errWindowsSandboxIdentityUnavailable + } return "", fmt.Errorf("read sandbox secret: %w", err) } if len(data) == 0 { diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index fbda8fecb..cf8b690f8 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -411,6 +411,8 @@ var ( resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged + grantWindowsSandboxLogonRightsFn = grantWindowsSandboxLogonRights + revokeWindowsSandboxLogonRightsFn = revokeWindowsSandboxLogonRights ) // provisionWindowsSandboxIdentity ensures the managed group and one sandbox From ec9e131767732066d0809aa0fd579e567af04b9d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 11:08:58 +0530 Subject: [PATCH 17/45] fix(sandbox): grant the principal the runtime tree commands write to Four review findings on the elevated setup path. The principal had no access to the sandbox runtime root. permissionProfileWithRuntime appends that root to WriteRoots on every command and redirects HOME, GOCACHE, npm_config_cache and similar into it, but it lives under the user cache rather than the workspace, so the profile setup builds its ACL plan from never contains it. On the restricted-token path that costs nothing, since the child still runs as the caller. A principal is a separate local account with none of those rights, so every npm install, go build or pip install would have failed on a cache write with a bare ACCESS_DENIED and nothing naming the sandbox as the cause. Setup now resolves the same root and grants it. The derivation is extracted so both callers share it. If setup and prepareSandboxRuntime ever disagreed, the ACE would land on one directory while commands used another, which is the same failure with a harder diagnosis, so a test asserts the two agree. The git control-plane carveouts are materialized. .git/config and .git/hooks arrive as ReadOnlySubpaths, and applyWindowsACLPlan skips an absent target, so on a workspace where git had not run yet the deny ACEs were never written and the principal kept inherited write access once git created them. Command-time lookup verifies workspace ownership. The account name carries only 11 characters of the workspace digest; the comment carries all of it. Provisioning already refused a foreign account, but the command path resolved the name straight to a SID, so the workspace that lost a collision would have run as the other one's principal. SID resolution still runs first, so an absent account stays the unavailable sentinel rather than becoming a collision error. The gated round-trip test asserted a logon with the password from a second provisioning call. Rotation moved to the setup path, so that value is a fresh string the account never held. It now exercises the guarantee the setup path actually makes: the stored secret logs the principal on. --- internal/sandbox/runtime_state.go | 28 +++- internal/sandbox/windows_identity_acl.go | 16 ++- .../windows_identity_policy_windows_test.go | 132 ++++++++++++++++++ .../windows_identity_runtime_windows.go | 63 ++++++++- internal/sandbox/windows_identity_windows.go | 27 +++- .../sandbox/windows_identity_windows_test.go | 49 +++++-- 6 files changed, 290 insertions(+), 25 deletions(-) diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 4a5fdfc9a..dac689941 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -33,6 +33,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 == "." { @@ -46,13 +64,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 { diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index 9dced2fbb..bdbeba608 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -95,11 +95,21 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla 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, + Action: WindowsACLDenyWrite, + Path: subpath, + Capability: input.PrincipalSID, + Materialize: true, }) } for _, name := range root.ProtectedMetadataNames { diff --git a/internal/sandbox/windows_identity_policy_windows_test.go b/internal/sandbox/windows_identity_policy_windows_test.go index 94e8da063..145d17a6c 100644 --- a/internal/sandbox/windows_identity_policy_windows_test.go +++ b/internal/sandbox/windows_identity_policy_windows_test.go @@ -221,3 +221,135 @@ func TestReadWindowsSandboxSecretTreatsPermissionDeniedAsUnavailable(t *testing. t.Fatalf("permission-denied read returned %v, want errWindowsSandboxIdentityUnavailable so the command falls back", err) } } + +// A workspace must not bind to another workspace's principal. +// +// The account name carries only 11 characters of the workspace digest, so two +// workspaces can derive the same name. Provisioning refuses that case by +// checking the full key in the account comment, but the command path resolved +// the name straight to a SID. The workspace that lost the race would have failed +// setup and then quietly run as the other one's principal, using its secret and +// its ACL identity. +func TestLookupWindowsSandboxIdentityRejectsForeignWorkspace(t *testing.T) { + previous := windowsSandboxUserIsManagedFn + t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous }) + + prevSID := resolveWindowsSandboxSIDFn + t.Cleanup(func() { resolveWindowsSandboxSIDFn = prevSID }) + // The account resolves; whether it BELONGS to this workspace is the question. + resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) { + return windows.CreateWellKnownSid(windows.WinLocalSystemSid) + } + + var askedKey string + windowsSandboxUserIsManagedFn = func(_ string, workspaceKey string) (bool, error) { + askedKey = workspaceKey + return false, nil + } + _, err := lookupWindowsSandboxIdentity("workspacekey") + if err == nil { + t.Fatal("lookup accepted an account belonging to another workspace") + } + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Fatal("a foreign account must not read as unprovisioned; that would silently fall back instead of reporting the conflict") + } + if askedKey != "" && askedKey != "workspacekey" { + t.Fatalf("ownership was checked against %q, want the caller's workspace key", askedKey) + } +} + +// An account that does not exist must stay the unavailable sentinel rather than +// becoming a collision error, since that is the ordinary not-set-up state. +func TestLookupWindowsSandboxIdentityAbsentAccountIsUnavailable(t *testing.T) { + previous := windowsSandboxUserIsManagedFn + t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous }) + windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { + t.Fatal("ownership must not be consulted for an account that does not resolve") + return false, nil + } + if _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey"); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Fatalf("absent account returned %v, want errWindowsSandboxIdentityUnavailable", err) + } +} + +// The git control-plane carveouts must be materialized. +// +// gitMetadataWriteCarveouts supplies .git/config and .git/hooks as +// ReadOnlySubpaths of the workspace. On a workspace where git has not run yet +// they do not exist when setup applies the plan, and applyWindowsACLPlan skips +// an absent target, so the deny ACEs were never written. Once git created those +// paths the principal still held inherited write access and could install a +// hook or rewrite credential.helper. +func TestPrincipalACLPlanMaterializesReadOnlySubpaths(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspace") + carveout := filepath.Join(root, ".git", "config") + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-0-0-0-1001", + WriteRoots: []WritableRoot{{Root: root, ReadOnlySubpaths: []string{carveout}}}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, carveout) + if !ok { + t.Fatalf("no deny-write ACE for the git carveout; plan = %+v", plan.Entries) + } + if !entry.Materialize { + t.Fatal("git carveout deny-write is not materialized, so it is skipped on a workspace where .git does not exist yet") + } +} + +// Setup must grant the principal the same runtime root that commands write to. +// +// permissionProfileWithRuntime appends this root to WriteRoots on every command +// and redirects HOME, GOCACHE and npm_config_cache into it, but it lives under +// the user cache rather than the workspace, so the profile setup sees never +// contains it. A principal is a separate account with no rights there, so +// without a grant every npm install or go build fails on a cache write. +// +// The assertion that matters is that the two derivations agree. If they drift, +// setup writes the ACE on one directory while commands use another, and the +// symptom is a bare ACCESS_DENIED with nothing pointing at the sandbox. +func TestSetupGrantsTheRuntimeRootCommandsActuallyUse(t *testing.T) { + workspace := filepath.Join(t.TempDir(), "ws") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + + granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{workspace}, + }) + if err != nil { + t.Fatalf("setupWindowsSandboxRuntimeRoot: %v", err) + } + if granted == "" { + t.Fatal("no runtime root resolved for a configured workspace") + } + if info, err := os.Stat(granted); err != nil || !info.IsDir() { + t.Fatalf("runtime root %q was not created; applyWindowsACLPlan skips absent targets so the grant would no-op (stat err %v)", granted, err) + } + + // What a command would actually use. + runtimeState, release, err := prepareSandboxRuntime(workspace) + if err != nil { + t.Fatalf("prepareSandboxRuntime: %v", err) + } + if release != nil { + defer release() + } + if filepath.Clean(runtimeState.Root) != filepath.Clean(granted) { + t.Fatalf("setup granted %q but commands write to %q", granted, runtimeState.Root) + } +} + +// No workspace root means nothing to grant, which is not an error. +func TestSetupRuntimeRootWithoutWorkspaceIsNotAnError(t *testing.T) { + granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{}) + if err != nil { + t.Fatalf("no workspace root should not error: %v", err) + } + if granted != "" { + t.Fatalf("granted %q with no workspace configured", granted) + } +} diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index d6f414e08..916cf27f0 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "sync" @@ -286,9 +287,28 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er } filesystem := config.PermissionProfile.FileSystem + writeRoots := filesystem.WriteRoots + // The runtime tree has to be granted here, at setup, because nothing grants it + // later. + // + // permissionProfileWithRuntime appends the per-workspace runtime root to + // WriteRoots on every COMMAND, and redirects HOME, GOCACHE, npm_config_cache + // and friends into it. That root lives under the user cache, not the + // workspace, so the profile setup sees never contains it. On the + // restricted-token path that costs nothing, since the child still runs as the + // caller and already has rights there. A principal is a separate local account + // with none, so without this every npm install, go build or pip install fails + // on a cache write with a bare ACCESS_DENIED and nothing pointing at the + // sandbox as the cause. + if runtimeRoot, err := setupWindowsSandboxRuntimeRoot(config); err != nil { + _ = removePrincipal() + return nil, err + } else if runtimeRoot != "" { + writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: runtimeRoot}) + } plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ PrincipalSID: identity.SID.String(), - WriteRoots: filesystem.WriteRoots, + WriteRoots: writeRoots, ReadRoots: filesystem.ReadRoots, DenyRead: filesystem.DenyRead, DenyWrite: filesystem.DenyWrite, @@ -342,3 +362,44 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e } return removeWindowsSandboxIdentity(username) } + +// setupWindowsSandboxRuntimeRoot resolves this workspace's runtime root and +// makes sure it exists, so the principal ACL plan can name it. +// +// It is created here rather than left to the first command because +// applyWindowsACLPlan skips a target that does not exist: granting write on a +// directory that setup never made would silently no-op, and the failure would +// only show up later as a denied cache write. Creating it under the elevated +// setup process is safe, since it lives under the invoking user's own cache +// directory and prepareSandboxRuntime would create it on the same path anyway. +// +// An empty return means there is no runtime root to grant (no workspace root +// configured), which is not an error: the caller simply grants nothing extra. +func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) (string, error) { + workspaceRoot := "" + for _, candidate := range config.WorkspaceRoots { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + workspaceRoot = filepath.Clean(trimmed) + break + } + } + if workspaceRoot == "" { + return "", nil + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return "", fmt.Errorf("resolve user cache directory for sandbox runtime: %w", err) + } + cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot)) + if cacheRoot == "" || cacheRoot == "." { + return "", errors.New("user cache directory is unavailable for sandbox runtime") + } + root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) + if err != nil { + return "", err + } + if err := os.MkdirAll(root, 0o700); err != nil { + return "", fmt.Errorf("create sandbox runtime root: %w", err) + } + return root, nil +} diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index cf8b690f8..c9152512b 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -516,10 +516,35 @@ var errWindowsSandboxIdentityUnavailable = errors.New("no Zero sandbox principal // has not run. func lookupWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, error) { username := windowsSandboxUserName(workspaceKey) - sid, err := resolveWindowsSandboxSID(username) + // Ownership is checked here as well as at provisioning, because the account + // NAME cannot carry the whole workspace key. + // + // The name keeps 11 characters of the digest; the comment holds all of it. + // Provisioning refuses a name whose comment names a different workspace, and + // without the same check here the workspace that LOST that race would still + // resolve the name to a SID and quietly use the other workspace's principal, + // its secret and its ACL identity. Setup would have failed for it, so this is + // the path that decides whether the refusal actually holds. + // + // A collision is very unlikely with real keys, roughly 2^-44 per pair, but the + // cost of being wrong is one workspace running as another's identity, and the + // check is one syscall on a path that is already doing several. + // SID resolution runs FIRST so "no such account" stays the unavailable + // sentinel. windowsSandboxUserIsManaged answers false for both an absent + // account and one belonging to someone else, so checking it before this would + // report an unprovisioned workspace as a name collision and turn the ordinary + // not-set-up case into an error the operator has to interpret. + sid, err := resolveWindowsSandboxSIDFn(username) if err != nil { return windowsSandboxIdentity{}, classifyWindowsSandboxLookupError(err) } + managed, err := windowsSandboxUserIsManagedFn(username, workspaceKey) + if err != nil { + return windowsSandboxIdentity{}, err + } + if !managed { + return windowsSandboxIdentity{}, fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, username) + } return windowsSandboxIdentity{Username: username, SID: sid}, nil } diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index f8320bcd5..11d64c741 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -209,9 +209,10 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { if !windowsProcessIsElevated() { t.Skip("provisioning requires an elevated process") } - // A leftover account from an interrupted run is harmless now that - // provisioning resets the password, but starting clean keeps a failure here - // from being explained by residue from a previous one. + // Starting clean keeps a failure here from being explained by residue from a + // previous run. Provisioning no longer resets an adopted account's password, + // so a leftover account would otherwise be adopted with a password this test + // never learns. _ = removeWindowsSandboxIdentity(windowsSandboxUserName("ziptest01")) identity, password, _, err := provisionWindowsSandboxIdentity("ziptest01") @@ -246,23 +247,45 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { if again.Username != identity.Username || !again.SID.Equals(identity.SID) { t.Fatalf("provisioning is not idempotent: %s then %s", identity, again) } - // The password returned for an account that already existed has to BE that - // account's password. NetUserAdd leaves an existing account entirely alone, - // so without an explicit reset this second value is a fresh random string - // that never authenticates, and the caller would store it as the secret and - // leave every later command failing to log on with a principal that looks - // correctly provisioned. Logging on is the only honest way to assert it. if secondPassword == "" { t.Fatal("second provision returned an empty password") } - if err := grantWindowsSandboxLogonRights(again.SID); err != nil { - t.Fatalf("grant logon rights: %v", err) + // Deliberately NOT logging on with secondPassword. Provisioning does not + // rotate an adopted account any more, so that value is a fresh random string + // the account does not hold; rotation happens in + // provisionWindowsSandboxPrincipalForSetup, immediately before the secret is + // written, to keep the window where no stored password authenticates as small + // as possible. + // + // The guarantee worth asserting is therefore the one the setup path makes: + // after it returns, the stored secret logs the principal on. That covers + // rotation, the secret write and the logon right in one assertion, and it is + // the thing a broken re-setup would actually break. + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + WorkspaceRoots: []string{`C:\ziptest01`}, + } + setupIdentity, _, err := provisionWindowsSandboxPrincipalForSetup(config) + if err != nil { + t.Fatalf("setup provision: %v", err) + } + secretPath, err := windowsSandboxSecretPath(config.SandboxHome, setupIdentity.Username) + if err != nil { + t.Fatalf("secret path: %v", err) } - token, err := logonWindowsSandboxPrincipal(again.Username, secondPassword) + storedPassword, err := readWindowsSandboxSecret(secretPath) if err != nil { - t.Fatalf("logon with the password from the second provision: %v", err) + t.Fatalf("read stored secret: %v", err) + } + token, err := logonWindowsSandboxPrincipal(setupIdentity.Username, storedPassword) + if err != nil { + t.Fatalf("logon with the secret the setup path stored: %v", err) } _ = token.Close() + t.Cleanup(func() { + _ = revokeWindowsSandboxLogonRights(setupIdentity.SID) + _ = removeWindowsSandboxIdentity(setupIdentity.Username) + }) // Lookup must find what provisioning created. found, err := lookupWindowsSandboxIdentity("ziptest01") if err != nil { From 1ecb46025049324ec015592ddc80d3dc68a7fec8 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 11:24:49 +0530 Subject: [PATCH 18/45] fix(sandbox): refuse to adopt a principal in a privileged group Adoption takes over an account whose name and ownership comment match, resets its password and hands it to the sandbox. An account that is also in Administrators, Power Users or Backup Operators would give the sandbox the rights it exists to withhold: rewriting the ACLs confining it, reading the secret locked to the invoking user, and stopping Zero. The name is derived rather than discovered, so an account can match without anyone intending it to. Membership is resolved by well-known SID rather than by group name, so a localised install where the group is Administratoren or Administrateurs is still recognised. Raised as a non-blocking follow-up in review; it is cheap enough to do now rather than track. --- .../windows_identity_policy_windows_test.go | 36 ++++++ internal/sandbox/windows_identity_windows.go | 110 ++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/internal/sandbox/windows_identity_policy_windows_test.go b/internal/sandbox/windows_identity_policy_windows_test.go index 145d17a6c..f1137cebd 100644 --- a/internal/sandbox/windows_identity_policy_windows_test.go +++ b/internal/sandbox/windows_identity_policy_windows_test.go @@ -353,3 +353,39 @@ func TestSetupRuntimeRootWithoutWorkspaceIsNotAnError(t *testing.T) { t.Fatalf("granted %q with no workspace configured", granted) } } + +// An account in a privileged group must not be adopted, even when name and +// ownership comment say it is ours. +// +// Adoption resets the password and hands the account to the sandbox. If that +// account is also in Administrators, the sandbox gains the rights the sandbox +// exists to withhold: rewriting the ACLs confining it, reading the secret locked +// to the invoking user, and stopping Zero. +func TestProvisionWindowsSandboxIdentityRefusesPrivilegedAccount(t *testing.T) { + stubWindowsProvisioning(t, true, nil, nil) + + previous := windowsSandboxUserIsPrivilegedFn + t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous }) + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return true, nil } + + _, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + if !errors.Is(err, errWindowsSandboxPrivilegedAccount) { + t.Fatalf("provisioning adopted a privileged account, err = %v", err) + } + if created { + t.Fatal("created must stay false for an account this run refused to adopt") + } +} + +// The ordinary adopted account is unaffected. +func TestProvisionWindowsSandboxIdentityAdoptsUnprivilegedAccount(t *testing.T) { + stubWindowsProvisioning(t, true, nil, nil) + + previous := windowsSandboxUserIsPrivilegedFn + t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous }) + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil } + + if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey"); err != nil { + t.Fatalf("an unprivileged managed account must still be adopted: %v", err) + } +} diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index c9152512b..819ef94f3 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -86,6 +86,7 @@ var ( procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo") procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo") procNetApiBufferFree = netapi32.NewProc("NetApiBufferFree") + procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups") ) // userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32 @@ -334,6 +335,8 @@ func addWindowsSandboxUserToGroup(username string) error { // errWindowsSandboxNameCollision reports that the derived account name is taken // by a local account Zero did not create. Setup refuses rather than adopting it. +var errWindowsSandboxPrivilegedAccount = errors.New("the local account matching Zero's derived sandbox name belongs to a privileged group (Administrators, Power Users or Backup Operators); refusing to adopt it as a sandbox principal") + var errWindowsSandboxNameCollision = errors.New("a local account with Zero's derived sandbox name already exists and was not created by Zero") // windowsSandboxUserIsManaged reports whether a local account is one Zero @@ -382,6 +385,99 @@ func windowsSandboxUserIsManaged(username string, workspaceKey string) (bool, er return comment == windowsSandboxUserCommentFor(workspaceKey), nil } +// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0: one group name pointer. +type localGroupUsersInfo0 struct { + Name *uint16 +} + +// windowsSandboxUserIsPrivileged reports whether an account belongs to a local +// group that would make it a poor sandbox principal. +// +// Adoption is the reason this exists. Provisioning will take over an account +// whose name and ownership comment match, and an account that is also in +// Administrators would hand the sandbox exactly the rights the sandbox is meant +// to withhold: it could rewrite the ACLs confining it, read the secret locked to +// the invoking user, and terminate Zero. The name is derived rather than +// discovered, so an account can end up matching without anyone intending it. +// +// Membership is resolved by SID rather than by name so a localised install, where +// the group is called Administrateurs or Administratoren, is still recognised. +func windowsSandboxUserIsPrivileged(username string) (bool, error) { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return false, err + } + var ( + buffer *byte + entries uint32 + total uint32 + ) + status, _, _ := procNetUserGetLocalGroups.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 0, // level: LOCALGROUP_USERS_INFO_0 + 0, // flags: direct membership only + uintptr(unsafe.Pointer(&buffer)), + uintptr(^uint32(0)), // MAX_PREFERRED_LENGTH + uintptr(unsafe.Pointer(&entries)), + uintptr(unsafe.Pointer(&total)), + ) + runtime.KeepAlive(name) + if status == nerrUserNotFound { + return false, nil + } + if err := netAPIStatus("NetUserGetLocalGroups", status); err != nil { + return false, err + } + if buffer == nil || entries == 0 { + return false, nil + } + defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer))) + + privileged, err := privilegedLocalGroupNames() + if err != nil { + return false, err + } + groups := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buffer)), entries) + for _, group := range groups { + if group.Name == nil { + continue + } + if privileged[strings.ToLower(windows.UTF16PtrToString(group.Name))] { + return true, nil + } + } + return false, nil +} + +// privilegedLocalGroupNames resolves the local names of the groups a sandbox +// principal must not belong to. Resolved from well-known SIDs so the comparison +// survives a localised Windows install. +func privilegedLocalGroupNames() (map[string]bool, error) { + out := map[string]bool{} + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{ + windows.WinBuiltinAdministratorsSid, + windows.WinBuiltinPowerUsersSid, + windows.WinBuiltinBackupOperatorsSid, + } { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + // A group this build of Windows does not define is not a membership + // anyone can hold, so it cannot make an account privileged. + continue + } + account, _, _, err := sid.LookupAccount("") + if err != nil { + continue + } + out[strings.ToLower(account)] = true + } + if len(out) == 0 { + return nil, errors.New("could not resolve any privileged local group name") + } + return out, nil +} + // resolveWindowsSandboxSID looks up the SID for a provisioned principal. The SID // is the durable handle: account names can collide with a pre-existing local // user, so every ACE and firewall rule is keyed to the SID rather than the name. @@ -411,6 +507,7 @@ var ( resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged + windowsSandboxUserIsPrivilegedFn = windowsSandboxUserIsPrivileged grantWindowsSandboxLogonRightsFn = grantWindowsSandboxLogonRights revokeWindowsSandboxLogonRightsFn = revokeWindowsSandboxLogonRights ) @@ -452,6 +549,19 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit if !managed { return windowsSandboxIdentity{}, "", false, fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, username) } + // Ours by name and comment is not enough to adopt it. An account that also + // sits in Administrators (or Power Users, or Backup Operators) would give + // the sandbox the rights the sandbox exists to withhold: it could rewrite + // the ACLs confining it, read the secret locked to the invoking user, and + // stop Zero. Refuse rather than quietly take it over, and say which account + // so an operator can look at it. + privileged, err := windowsSandboxUserIsPrivilegedFn(username) + if err != nil { + return windowsSandboxIdentity{}, "", false, err + } + if privileged { + return windowsSandboxIdentity{}, "", false, fmt.Errorf("%w: %q", errWindowsSandboxPrivilegedAccount, username) + } // Deliberately NOT resetting the password here. // // NetUserAdd left an existing account untouched, so the password above is From 7104a1666e052ec9a87cd2bd9c457fee5f6d5deb Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 22:04:32 +0530 Subject: [PATCH 19/45] fix(sandbox): materialize .git/config as a file, not a directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materializing the git control-plane carveouts creates a missing target so the deny-write ACE is in place before git first runs. It did that with os.MkdirAll on the full path, which is right for .git/hooks and wrong for .git/config: git wants a file there. The consequence is worse than a mis-ACL'd path. On a fresh workspace neither carveout exists — which is exactly the case materialization was added for, so this is the common path rather than a corner — and elevated setup would leave a directory where git's config file belongs: warning: unable to access '/.git/config': Permission denied fatal: unknown error occurred while reading the configuration files git init then fails outright and the workspace is unusable. Materialization now takes the shape from the carveout definition: gitMetadataWriteCarveoutSpecs is the single source of truth and gitMetadataWriteCarveouts derives its list from it, so a carveout cannot be added in one place and have its shape forgotten in the other. A file target gets its parent chain created and then an empty file; a directory target is unchanged. A racing creator winning the O_EXCL is treated as success, since the target existing is all materialization needed. The regression test runs a real `git init` over the applied plan. It names Guests as the principal rather than Everyone — with Everyone the deny ACE also denies the test process and git fails for an unrelated reason, which would have made the test pass for the wrong reason once the shape was fixed. Co-Authored-By: Claude Opus 5 --- internal/sandbox/profile.go | 28 +++++++- internal/sandbox/windows_acl.go | 5 ++ internal/sandbox/windows_acl_apply_windows.go | 34 ++++++++-- .../windows_git_carveout_windows_test.go | 66 +++++++++++++++++++ internal/sandbox/windows_identity_acl.go | 18 +++-- 5 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 internal/sandbox/windows_git_carveout_windows_test.go diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 47947316c..400683feb 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -63,9 +63,31 @@ var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"} // subprocesses. Nonexistent paths are harmless no-ops in every backend's // enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry). func gitMetadataWriteCarveouts(root string) []string { - return []string{ - filepath.Join(root, ".git", "hooks"), - filepath.Join(root, ".git", "config"), + specs := gitMetadataWriteCarveoutSpecs(root) + out := make([]string, 0, len(specs)) + for _, spec := range specs { + out = append(out, spec.Path) + } + return out +} + +// gitMetadataCarveout is a write-denied .git path together with the shape git +// expects it to have. The shape matters to exactly one backend: the Windows ACL +// plan creates a missing carveout so the deny ACE is in place before git first +// runs, and creating .git/config as a directory makes `git init` fail outright. +// Every other backend only ever names the path, so it can ignore IsFile. +type gitMetadataCarveout struct { + Path string + IsFile bool +} + +// gitMetadataWriteCarveoutSpecs is the single source of truth for the carveout +// set. gitMetadataWriteCarveouts derives its list from this so a path can never +// be added in one place and have its shape forgotten in the other. +func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout { + return []gitMetadataCarveout{ + {Path: filepath.Join(root, ".git", "hooks")}, + {Path: filepath.Join(root, ".git", "config"), IsFile: true}, } } diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 55d37d347..b59e0659f 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -19,6 +19,11 @@ type WindowsACLEntry struct { Path string `json:"path"` Capability string `json:"capability"` Materialize bool `json:"materialize,omitempty"` + // MaterializeFile makes Materialize create an empty FILE instead of a + // directory. Only meaningful with Materialize. .git/config is the case that + // forces the distinction: created as a directory it does not merely carry + // the wrong ACL, it makes `git init` fail outright. + MaterializeFile bool `json:"materializeFile,omitempty"` } type WindowsACLPlan struct { diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index d53927a08..54f906220 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "sort" "strings" @@ -15,9 +16,10 @@ import ( const windowsFileDeleteChild windows.ACCESS_MASK = 0x00000040 type windowsACLPathGroup struct { - Path string - Entries []WindowsACLEntry - Materialize bool + Path string + Entries []WindowsACLEntry + Materialize bool + MaterializeFile bool } type windowsACLSnapshot struct { @@ -61,6 +63,7 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup { } group.Entries = append(group.Entries, entry) group.Materialize = group.Materialize || entry.Materialize + group.MaterializeFile = group.MaterializeFile || entry.MaterializeFile } out := make([]windowsACLPathGroup, 0, len(byPath)) for _, group := range byPath { @@ -96,7 +99,7 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo } return windowsACLSnapshot{}, false, nil } - if err := os.MkdirAll(path, 0o700); err != nil { + if err := materializeWindowsACLTarget(path, group.MaterializeFile); err != nil { return windowsACLSnapshot{}, false, fmt.Errorf("materialize windows ACL target %s: %w", path, err) } materialized = true @@ -291,3 +294,26 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { } return errors.Join(errs...) } + +// materializeWindowsACLTarget creates a missing ACL target with the shape the +// owning tool expects. A directory target is created whole; a file target gets +// its parent chain created and then an empty file, because creating it as a +// directory would break the tool that owns it rather than just mis-ACL it. +func materializeWindowsACLTarget(path string, asFile bool) error { + if !asFile { + return os.MkdirAll(path, 0o700) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + handle, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + // A racing creator winning is fine — the target exists, which is all + // materialization needed. Anything else is a real failure. + if errors.Is(err, os.ErrExist) { + return nil + } + return err + } + return handle.Close() +} diff --git a/internal/sandbox/windows_git_carveout_windows_test.go b/internal/sandbox/windows_git_carveout_windows_test.go new file mode 100644 index 000000000..2f444f06c --- /dev/null +++ b/internal/sandbox/windows_git_carveout_windows_test.go @@ -0,0 +1,66 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// The git control-plane carveouts are two different shapes: .git/hooks is a +// directory, .git/config is a FILE. Materialization has to respect that. +// +// On a fresh workspace neither exists yet, which is precisely the case +// Materialize was added for — so this is the common path, not a corner. Creating +// .git/config as a directory does not just mis-ACL it: it makes the workspace +// permanently unusable, because git refuses to initialise over a directory +// where its config file belongs. +func TestPrincipalACLPlanMaterializesGitConfigAsFile(t *testing.T) { + workspace := t.TempDir() + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + // Guests, deliberately: the deny-write ACE must land on the sandbox + // principal, not on whoever runs the test. With Everyone (S-1-1-0) the + // ACE denies the test process too and `git init` fails with "Permission + // denied" for a reason that has nothing to do with the shape bug. + PrincipalSID: "S-1-5-32-546", + WriteRoots: []WritableRoot{{ + Root: workspace, + ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + t.Cleanup(func() { + if rollback != nil { + _ = rollback() + } + }) + + configPath := filepath.Join(workspace, ".git", "config") + if info, err := os.Stat(configPath); err == nil && info.IsDir() { + t.Errorf(".git/config was materialized as a directory; git requires a file") + } + hooksPath := filepath.Join(workspace, ".git", "hooks") + if info, err := os.Stat(hooksPath); err == nil && !info.IsDir() { + t.Errorf(".git/hooks was materialized as a file; git requires a directory") + } + + // The failure users would actually hit. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH; shape assertions above still ran") + } + cmd := exec.Command("git", "init") + cmd.Dir = workspace + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("git init failed on a workspace after sandbox setup: %v\n%s", err, out) + } +} diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index bdbeba608..b1f3211b1 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -104,12 +104,22 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla // 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. + // Which carveouts are files rather than directories comes from the same + // spec list the profile built ReadOnlySubpaths from, so a new carveout + // cannot be added without its shape coming along. + fileCarveouts := map[string]bool{} + for _, spec := range gitMetadataWriteCarveoutSpecs(cleaned) { + if spec.IsFile { + fileCarveouts[normalizeProfilePath(spec.Path)] = true + } + } for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) { entries = append(entries, WindowsACLEntry{ - Action: WindowsACLDenyWrite, - Path: subpath, - Capability: input.PrincipalSID, - Materialize: true, + Action: WindowsACLDenyWrite, + Path: subpath, + Capability: input.PrincipalSID, + Materialize: true, + MaterializeFile: fileCarveouts[subpath], }) } for _, name := range root.ProtectedMetadataNames { From 3175939265da4e52320dd93f7f072e80c67049aa Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 22:38:18 +0530 Subject: [PATCH 20/45] fix(sandbox): re-check principal privilege when minting a command token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provisioning refuses an account that is already in Administrators, Power Users or Backup Operators, but group membership is not frozen at setup. An account provisioned clean can be added afterwards — by an operator, or by an attacker who already has that access and would like the sandbox to hand it back. Every command after that minted a token for a privileged account. The re-check goes on the path that mints the token, not inside lookupWindowsSandboxIdentity. Teardown resolves the same identity to revoke its logon rights before deleting the account, so refusing there would leave the very account this guards against permanently undeletable by Zero. The command path already propagates anything that is not the not-provisioned sentinel, so this surfaces to the operator instead of silently dropping back to the restricted token. Also make the gating test hermetic. Its "absent" case passed an empty map, which falls through to os.Getenv, so a developer with the opt-in exported saw a different result from CI: ZERO_WINDOWS_SANDBOX_IDENTITY=1 go test ./internal/sandbox/ --- FAIL: TestWindowsSandboxIdentityGating/absent enabled = true, want false for "" Every case there supplies an explicit map entry, so the process variable is now pinned to prove none of them consult it. The os.Getenv fallback is what elevated setup actually runs on — it passes no Env — so it gets its own table rather than riding on a case that also has a map entry. Co-Authored-By: Claude Opus 5 --- ...identity_privilege_recheck_windows_test.go | 92 +++++++++++++++++++ .../windows_identity_runtime_windows.go | 2 +- .../windows_identity_runtime_windows_test.go | 45 ++++++++- internal/sandbox/windows_identity_windows.go | 30 ++++++ 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 internal/sandbox/windows_identity_privilege_recheck_windows_test.go diff --git a/internal/sandbox/windows_identity_privilege_recheck_windows_test.go b/internal/sandbox/windows_identity_privilege_recheck_windows_test.go new file mode 100644 index 000000000..9544b867a --- /dev/null +++ b/internal/sandbox/windows_identity_privilege_recheck_windows_test.go @@ -0,0 +1,92 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +// Group membership is not frozen at setup. An account provisioned clean can be +// added to Administrators afterwards — by an operator, or by an attacker who +// already has that access and wants the sandbox to hand it back. Provisioning's +// refusal cannot see that; only the path that mints the token can. +func TestLookupPrincipalForCommandRefusesAnAccountThatBecamePrivileged(t *testing.T) { + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatalf("CreateWellKnownSid: %v", err) + } + restoreLookupSeams(t, sid) + + privilegedCalls := 0 + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { + privilegedCalls++ + return true, nil + } + + _, err = lookupWindowsSandboxPrincipalForCommand("workspace-key") + if !errors.Is(err, errWindowsSandboxPrivilegedAccount) { + t.Fatalf("err = %v, want errWindowsSandboxPrivilegedAccount", err) + } + if privilegedCalls != 1 { + t.Errorf("privileged check ran %d times, want exactly 1", privilegedCalls) + } + // It must be a hard refusal, not the unavailable sentinel — that one is the + // quiet "not provisioned" fallback and would silently drop the sandbox back + // to the restricted token instead of telling the operator. + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Error("privileged refusal must not read as the not-provisioned fallback") + } +} + +func TestLookupPrincipalForCommandAcceptsAnUnprivilegedAccount(t *testing.T) { + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatalf("CreateWellKnownSid: %v", err) + } + restoreLookupSeams(t, sid) + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil } + + identity, err := lookupWindowsSandboxPrincipalForCommand("workspace-key") + if err != nil { + t.Fatalf("lookupWindowsSandboxPrincipalForCommand: %v", err) + } + if identity.Username == "" { + t.Error("expected the resolved principal") + } +} + +// Teardown must stay able to clean up an account that has become privileged. +// If the refusal lived inside lookupWindowsSandboxIdentity, the account this +// guard exists to catch would become undeletable by Zero. +func TestLookupIdentityItselfDoesNotConsultPrivilege(t *testing.T) { + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatalf("CreateWellKnownSid: %v", err) + } + restoreLookupSeams(t, sid) + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { + t.Error("teardown's lookup must not be gated on privilege") + return true, nil + } + + if _, err := lookupWindowsSandboxIdentity("workspace-key"); err != nil { + t.Fatalf("lookupWindowsSandboxIdentity: %v", err) + } +} + +func restoreLookupSeams(t *testing.T, sid *windows.SID) { + t.Helper() + prevResolve := resolveWindowsSandboxSIDFn + prevManaged := windowsSandboxUserIsManagedFn + prevPrivileged := windowsSandboxUserIsPrivilegedFn + t.Cleanup(func() { + resolveWindowsSandboxSIDFn = prevResolve + windowsSandboxUserIsManagedFn = prevManaged + windowsSandboxUserIsPrivilegedFn = prevPrivileged + }) + resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) { return sid, nil } + windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil } +} diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 916cf27f0..0427438e7 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -92,7 +92,7 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T return 0, false, nil } key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) - identity, err := lookupWindowsSandboxIdentity(key) + identity, err := lookupWindowsSandboxPrincipalForCommand(key) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { // Not provisioned: fall back quietly, this is the default state. diff --git a/internal/sandbox/windows_identity_runtime_windows_test.go b/internal/sandbox/windows_identity_runtime_windows_test.go index d8658a144..26f2852a8 100644 --- a/internal/sandbox/windows_identity_runtime_windows_test.go +++ b/internal/sandbox/windows_identity_runtime_windows_test.go @@ -2,7 +2,10 @@ package sandbox -import "testing" +import ( + "os" + "testing" +) // Setup must stay inert unless the principal backend is explicitly opted into. // This is the property that makes the branch safe to merge while the privileged @@ -13,7 +16,8 @@ func TestWindowsSandboxIdentityGating(t *testing.T) { env map[string]string want bool }{ - "absent": {env: map[string]string{}, want: false}, + // An explicit map entry is authoritative; these cases never reach the + // process environment. "empty": {env: map[string]string{windowsSandboxIdentityEnv: ""}, want: false}, "zero": {env: map[string]string{windowsSandboxIdentityEnv: "0"}, want: false}, "true not one": {env: map[string]string{windowsSandboxIdentityEnv: "true"}, want: false}, @@ -21,6 +25,11 @@ func TestWindowsSandboxIdentityGating(t *testing.T) { "one with space": {env: map[string]string{windowsSandboxIdentityEnv: " 1 "}, want: true}, } { t.Run(name, func(t *testing.T) { + // Pin the process variable too. Every case here supplies an explicit + // map entry so none of them should consult it, and pinning proves + // that rather than assuming it: without this a developer who exports + // the opt-in would see different results from CI. + t.Setenv(windowsSandboxIdentityEnv, "1") if got := windowsSandboxIdentityEnabled(testCase.env); got != testCase.want { t.Fatalf("enabled = %v, want %v for %q", got, testCase.want, testCase.env[windowsSandboxIdentityEnv]) } @@ -28,6 +37,38 @@ func TestWindowsSandboxIdentityGating(t *testing.T) { } } +// With no map entry the process environment decides. That fallback is what the +// elevated setup path actually runs on — it passes no Env — so it needs its own +// coverage rather than riding on a case that also has a map entry. +func TestWindowsSandboxIdentityGatingFallsBackToTheProcessEnvironment(t *testing.T) { + for name, testCase := range map[string]struct { + value string + set bool + want bool + }{ + "unset": {set: false, want: false}, + "empty": {value: "", set: true, want: false}, + "zero": {value: "0", set: true, want: false}, + "one": {value: "1", set: true, want: true}, + "one with space": {value: " 1 ", set: true, want: true}, + } { + t.Run(name, func(t *testing.T) { + // t.Setenv registers the restore even when the variable is then + // cleared, which is the only way to test a genuinely absent variable + // without leaking that state into the rest of the package. + t.Setenv(windowsSandboxIdentityEnv, testCase.value) + if !testCase.set { + if err := os.Unsetenv(windowsSandboxIdentityEnv); err != nil { + t.Fatalf("unset %s: %v", windowsSandboxIdentityEnv, err) + } + } + if got := windowsSandboxIdentityEnabled(nil); got != testCase.want { + t.Fatalf("enabled = %v, want %v (set=%v value=%q)", got, testCase.want, testCase.set, testCase.value) + } + }) + } +} + // The command environment wins over the process environment, so a run can opt in // or out without depending on how the parent shell was launched. func TestWindowsSandboxIdentityEnvOverridesProcess(t *testing.T) { diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index 819ef94f3..1968d9f60 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -658,6 +658,36 @@ func lookupWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, return windowsSandboxIdentity{Username: username, SID: sid}, nil } +// lookupWindowsSandboxPrincipalForCommand resolves the principal a command will +// actually run as, and refuses one that has since joined a privileged group. +// +// Provisioning already refuses a privileged account, but group membership is not +// frozen at setup: the account can be added to Administrators, Power Users or +// Backup Operators afterwards. Minting a token for it would hand the sandboxed +// command exactly the privileges the sandbox exists to withhold, so the check has +// to run again on the path that mints the token, not only on the path that +// created the account. +// +// Deliberately NOT folded into lookupWindowsSandboxIdentity: teardown resolves +// the same identity to revoke its logon rights before deleting it, and it must +// stay able to clean up an account that has become privileged rather than +// refusing to touch it. Refusing there would leave the very account this guards +// against permanently undeletable by Zero. +func lookupWindowsSandboxPrincipalForCommand(workspaceKey string) (windowsSandboxIdentity, error) { + identity, err := lookupWindowsSandboxIdentity(workspaceKey) + if err != nil { + return windowsSandboxIdentity{}, err + } + privileged, err := windowsSandboxUserIsPrivilegedFn(identity.Username) + if err != nil { + return windowsSandboxIdentity{}, err + } + if privileged { + return windowsSandboxIdentity{}, fmt.Errorf("%w: %q", errWindowsSandboxPrivilegedAccount, identity.Username) + } + return identity, nil +} + // classifyWindowsSandboxLookupError decides whether a failed SID resolution // means "setup has not run" or "this principal exists but is unusable". // From bf303e22b95804e4ec11fdcef875ef3e3c4c221d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 22:44:37 +0530 Subject: [PATCH 21/45] fix(sandbox): revoke stale principal ACEs before re-applying the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit windowsPrincipalRevokePlan was implemented and tested but had no production caller, so nothing ever used it. applyWindowsACLPlan merges into the existing DACL, which means a re-run after narrowing a write root or shortening a deny list left the previous, wider ACEs sitting beside the new ones: the principal kept access the current policy no longer granted, and the sandbox silently widened as a result of being tightened. Setup does get the chance to notice — marker validation already refuses commands with "permission roots or deny lists changed" until setup runs again — so the re-apply is exactly where this belongs. The ACL step is extracted into applyWindowsPrincipalACLs: build the plan, revoke every ACE naming this trustee on the paths it touches, then apply. Revocation is by trustee rather than by remembered path, so it also clears grants written by an older version of Zero. Its rollback is discarded on purpose — the only failure path from here removes the principal outright, and restoring stale ACEs for an account about to be deleted is the residue this exists to prevent. Extracting it also makes the ordering testable without new provisioning seams, which #812 already adds with a different signature; adding them here would have collided on its rebase. Three tests: revocation actually drops a grant on a root that left the policy while keeping the one that stayed (asserted against the real DACL, counting deny ACEs as well as allow, since trustee revocation drops both); revoking a path that was never created is a no-op rather than an error; and the production path revokes BEFORE it applies. That last one is the one that matters — the first two pass just as happily with the call site deleted, and deleting it kills only the third. Co-Authored-By: Claude Opus 5 --- internal/sandbox/windows_identity_acl.go | 14 ++ .../windows_identity_runtime_windows.go | 64 ++++++-- internal/sandbox/windows_identity_windows.go | 5 + .../sandbox/windows_stale_ace_windows_test.go | 151 ++++++++++++++++++ 4 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 internal/sandbox/windows_stale_ace_windows_test.go diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index b1f3211b1..a57ca3647 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -182,3 +182,17 @@ func windowsPrincipalRevokePlan(principalSID string, paths []string) (WindowsACL // windowsACLRevoke removes every ACE naming the trustee on a path, whatever // access it granted or denied. const windowsACLRevoke WindowsACLAction = "revoke" + +// windowsACLPlanPaths returns each distinct path a plan touches, in plan order. +func windowsACLPlanPaths(plan WindowsACLPlan) []string { + seen := make(map[string]struct{}, len(plan.Entries)) + paths := make([]string, 0, len(plan.Entries)) + for _, entry := range plan.Entries { + if _, ok := seen[entry.Path]; ok { + continue + } + seen[entry.Path] = struct{}{} + paths = append(paths, entry.Path) + } + return paths +} diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 0427438e7..9eb33db68 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -306,18 +306,7 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er } else if runtimeRoot != "" { writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: runtimeRoot}) } - plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ - PrincipalSID: identity.SID.String(), - WriteRoots: writeRoots, - ReadRoots: filesystem.ReadRoots, - DenyRead: filesystem.DenyRead, - DenyWrite: filesystem.DenyWrite, - }) - if err != nil { - _ = removePrincipal() - return nil, err - } - revertACL, err := applyWindowsACLPlan(plan) + revertACL, err := applyWindowsPrincipalACLs(identity.SID.String(), filesystem, writeRoots) if err != nil { _ = removePrincipal() return nil, err @@ -403,3 +392,54 @@ func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) (string, } return root, nil } + +// revokeWindowsPrincipalACEs drops every ACE naming principalSID on paths. +// A path that does not exist is skipped rather than failing: revocation is +// cleanup, and there is nothing to clean on a path that was never created. +func revokeWindowsPrincipalACEs(principalSID string, paths []string) error { + if len(paths) == 0 { + return nil + } + plan, err := windowsPrincipalRevokePlan(principalSID, paths) + if err != nil { + return err + } + if _, err := applyWindowsACLPlanFn(plan); err != nil { + return err + } + return nil +} + +// applyWindowsPrincipalACLs writes the principal's ACEs for one policy: it +// revokes whatever this trustee already had on the paths the plan touches, then +// applies the plan. +// +// The order is the whole point. applyWindowsACLPlan MERGES into the existing +// DACL, so without the revocation first a re-run after narrowing a write root +// or shortening a deny list leaves the previous, wider ACEs beside the new ones +// and the principal keeps access the current policy no longer grants — the +// sandbox silently widens as a result of tightening it. Setup does get the +// chance to notice: marker validation refuses commands with "permission roots +// or deny lists changed" until setup runs again. +// +// Revocation is by TRUSTEE, so it drops every ACE naming this principal on +// these paths whatever an older version of Zero granted. Its rollback is +// discarded on purpose: the only failure path from here removes the principal +// outright, and restoring stale ACEs for an account about to be deleted is the +// residue this exists to prevent. +func applyWindowsPrincipalACLs(principalSID string, filesystem FileSystemPolicy, writeRoots []WritableRoot) (func() error, error) { + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principalSID, + WriteRoots: writeRoots, + ReadRoots: filesystem.ReadRoots, + DenyRead: filesystem.DenyRead, + DenyWrite: filesystem.DenyWrite, + }) + if err != nil { + return nil, err + } + if err := revokeWindowsPrincipalACEs(principalSID, windowsACLPlanPaths(plan)); err != nil { + return nil, err + } + return applyWindowsACLPlanFn(plan) +} diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index 1968d9f60..03504b135 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -510,6 +510,11 @@ var ( windowsSandboxUserIsPrivilegedFn = windowsSandboxUserIsPrivileged grantWindowsSandboxLogonRightsFn = grantWindowsSandboxLogonRights revokeWindowsSandboxLogonRightsFn = revokeWindowsSandboxLogonRights + // applyWindowsACLPlanFn is a seam so a test can pin the ORDER of setup's ACL + // work. The revocation below only prevents a stale grant if it runs before + // the plan that re-adds the current one; a test that exercised the revoke + // helper on its own would pass just as happily with the call site deleted. + applyWindowsACLPlanFn = applyWindowsACLPlan ) // provisionWindowsSandboxIdentity ensures the managed group and one sandbox diff --git a/internal/sandbox/windows_stale_ace_windows_test.go b/internal/sandbox/windows_stale_ace_windows_test.go new file mode 100644 index 000000000..6b0a910f1 --- /dev/null +++ b/internal/sandbox/windows_stale_ace_windows_test.go @@ -0,0 +1,151 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// applyWindowsACLPlan merges into the existing DACL, so narrowing a policy and +// re-running setup used to leave the wider ACEs in place next to the new ones. +// The principal kept access the current policy no longer grants — a silent +// widening of the sandbox produced by tightening it. +func TestRevokeDropsStalePrincipalACEsBeforeReapply(t *testing.T) { + root := t.TempDir() + kept := filepath.Join(root, "kept") + dropped := filepath.Join(root, "dropped") + for _, dir := range []string{kept, dropped} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + // Guests: a real, resolvable trustee that the test process is not a member + // of, so the ACEs below are observable without affecting this process. + principal := "S-1-5-32-546" + + // First setup: both roots writable. + wide, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principal, + WriteRoots: []WritableRoot{{Root: kept}, {Root: dropped}}, + }) + if err != nil { + t.Fatalf("wide plan: %v", err) + } + if _, err := applyWindowsACLPlan(wide); err != nil { + t.Fatalf("apply wide plan: %v", err) + } + if !hasACEForTrustee(t, dropped, principal) { + t.Fatal("precondition: the wide plan should have granted the root it is about to lose") + } + + // Policy narrows: "dropped" is no longer a write root. + narrow, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principal, + WriteRoots: []WritableRoot{{Root: kept}}, + }) + if err != nil { + t.Fatalf("narrow plan: %v", err) + } + // Revocation has to cover the paths the OLD plan touched, not just the new + // one — the whole point is the path that left the policy. + if err := revokeWindowsPrincipalACEs(principal, windowsACLPlanPaths(wide)); err != nil { + t.Fatalf("revoke: %v", err) + } + if _, err := applyWindowsACLPlan(narrow); err != nil { + t.Fatalf("apply narrow plan: %v", err) + } + + if hasACEForTrustee(t, dropped, principal) { + t.Error("principal kept its grant on a root the narrowed policy removed") + } + if !hasACEForTrustee(t, kept, principal) { + t.Error("revocation also dropped the grant the narrowed policy still wants") + } +} + +// Revoking a path that was never created is cleanup with nothing to clean, not +// an error — setup would otherwise fail on any carveout git has not made yet. +func TestRevokeIgnoresPathsThatDoNotExist(t *testing.T) { + missing := filepath.Join(t.TempDir(), "never-created") + if err := revokeWindowsPrincipalACEs("S-1-5-32-546", []string{missing}); err != nil { + t.Fatalf("revoke over a missing path: %v", err) + } +} + +func hasACEForTrustee(t *testing.T, path string, trustee string) bool { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo(%s): %v", path, err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("DACL(%s): %v", path, err) + } + if dacl == nil { + return false + } + want, err := windows.StringToSid(trustee) + if err != nil { + t.Fatalf("StringToSid(%s): %v", trustee, err) + } + // Deny ACEs count here as much as allow ACEs: revocation is by trustee and + // drops both, so an assertion that only saw allows would call a leftover + // deny "revoked". + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var header *windows.ACE_HEADER + if err := windows.GetAce(dacl, index, (**windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(&header))); err != nil { + continue + } + ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(header)) + switch ace.Header.AceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, windows.ACCESS_DENIED_ACE_TYPE: + default: + continue + } + if (*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(want) { + return true + } + } + return false +} + +// The mechanism working is not the same as the production path using it. This +// pins the call site and its ORDER: revocation is only worth anything if it +// runs before the plan that re-adds the current grants. +func TestApplyPrincipalACLsRevokesBeforeApplying(t *testing.T) { + prevApply := applyWindowsACLPlanFn + t.Cleanup(func() { applyWindowsACLPlanFn = prevApply }) + + var actions []WindowsACLAction + applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) { + if len(plan.Entries) > 0 { + actions = append(actions, plan.Entries[0].Action) + } + return func() error { return nil }, nil + } + + workspace := t.TempDir() + filesystem := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + } + if _, err := applyWindowsPrincipalACLs("S-1-5-32-546", filesystem, filesystem.WriteRoots); err != nil { + t.Fatalf("applyWindowsPrincipalACLs: %v", err) + } + + if len(actions) != 2 { + t.Fatalf("saw %d ACL plans (%v), want a revocation then the grants", len(actions), actions) + } + if actions[0] != windowsACLRevoke { + t.Errorf("first plan was %q, want the trustee revocation to go first", actions[0]) + } + if actions[1] == windowsACLRevoke { + t.Error("second plan was another revocation; the current grants were never applied") + } +} From 91ae39224baa8a1c0499d8511d65a5f8d1398586 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 22:51:11 +0530 Subject: [PATCH 22/45] fix(sandbox): revoke ACEs on teardown and key setup off the resolved root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways setup and the command path disagreed. Teardown removed the secret, the LSA rights and the account, but never the ACEs. Once the account is gone its SID stops resolving and every ACE naming it becomes an orphaned raw-SID entry on the user's own tree — precisely the residue the capability-SID model left behind and this one exists to avoid. Revocation now runs while the SID still resolves, by trustee so it also clears grants written by older versions. A revoke failure is deliberately not fatal: a path the user has since deleted cannot be cleaned, and refusing to remove the account over it would strand the principal and its logon rights permanently, which is worse than a leftover ACE. The runtime root was derived from filepath.Clean(WorkspaceRoots[0]) at setup while Engine.resolveCommandDir cleans, absolutizes and then EvalSymlinks it. That needs no symlink to diverge — Windows opens a path in any casing and EvalSymlinks canonicalizes it: setup sees c:\users\me\myworkspace command sees C:\Users\me\MyWorkspace so setup granted the principal one runtime tree and every command used another. The grant that exists to make npm/go/pip caches writable landed where nothing reads, surfacing as a bare ACCESS_DENIED on a cache write. Both now go through canonicalWindowsSandboxWorkspaceRoot. An unresolvable root falls back to the cleaned absolute path, matching the command path rather than failing. setupWindowsSandboxRuntimeRoot is split into derivation and creation so teardown can name the tree without making directories on its way out. The first version of the divergence test called the canonicalization helper directly. It passed, and reverting setup to filepath.Clean — the actual bug — left it passing. It now drives windowsSandboxRuntimeRootPath, and that mutation fails it. Co-Authored-By: Claude Opus 5 --- .../windows_identity_runtime_windows.go | 97 +++++++++++++++++-- ...indows_workspace_canonical_windows_test.go | 75 ++++++++++++++ 2 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 internal/sandbox/windows_workspace_canonical_windows_test.go diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 9eb33db68..1cfa08e64 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -324,9 +324,10 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er }, nil } -// removeWindowsSandboxPrincipalForSetup retires a workspace's principal: secret -// first, then the account. ACE revocation is the caller's job and must happen -// before this, or ACEs naming a deleted SID are left behind. +// removeWindowsSandboxPrincipalForSetup retires a workspace's principal in the +// order that leaves nothing behind: secret, then ACEs, then LSA logon rights, +// then the account itself. Everything keyed to the SID has to go while the SID +// still resolves. func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) error { key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) username := windowsSandboxUserName(key) @@ -343,6 +344,19 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // to avoid. A principal that was never provisioned has no SID to resolve and // nothing to revoke, so that case is not an error. if identity, err := lookupWindowsSandboxIdentity(windowsSandboxWorkspaceKey(config.WorkspaceRoots)); err == nil { + // ACEs first, for the same reason: once the account is gone its SID stops + // resolving and every ACE naming it becomes an orphaned raw-SID entry on + // the user's own tree, which is precisely the residue the capability-SID + // model left behind and this one exists to avoid. Revocation is by + // trustee, so it clears grants written by older versions too. + // + // Failing to revoke is not fatal. A path the user has since deleted or + // renamed cannot be cleaned, and refusing to remove the account over it + // would strand the principal and its logon rights permanently — a worse + // outcome than a leftover ACE on a path that may not exist any more. + if paths, pathsErr := windowsPrincipalTeardownPaths(config, identity.SID.String()); pathsErr == nil { + _ = revokeWindowsPrincipalACEs(identity.SID.String(), paths) + } if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { return err } @@ -364,11 +378,11 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // // An empty return means there is no runtime root to grant (no workspace root // configured), which is not an error: the caller simply grants nothing extra. -func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) (string, error) { +func windowsSandboxRuntimeRootPath(config WindowsSandboxCommandConfig) (string, error) { workspaceRoot := "" for _, candidate := range config.WorkspaceRoots { if trimmed := strings.TrimSpace(candidate); trimmed != "" { - workspaceRoot = filepath.Clean(trimmed) + workspaceRoot = canonicalWindowsSandboxWorkspaceRoot(trimmed) break } } @@ -383,8 +397,15 @@ func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) (string, if cacheRoot == "" || cacheRoot == "." { return "", errors.New("user cache directory is unavailable for sandbox runtime") } - root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) - if err != nil { + return sandboxRuntimeRootFor(workspaceRoot, cacheRoot) +} + +// setupWindowsSandboxRuntimeRoot resolves the runtime root AND creates it. +// Teardown wants the name without the side effect, so the derivation lives in +// windowsSandboxRuntimeRootPath above and this only adds the mkdir. +func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) (string, error) { + root, err := windowsSandboxRuntimeRootPath(config) + if err != nil || root == "" { return "", err } if err := os.MkdirAll(root, 0o700); err != nil { @@ -443,3 +464,65 @@ func applyWindowsPrincipalACLs(principalSID string, filesystem FileSystemPolicy, } return applyWindowsACLPlanFn(plan) } + +// windowsPrincipalTeardownPaths names every path this principal could hold an +// ACE on, derived the same way setup derived them: the policy's roots plus the +// per-workspace runtime tree. The runtime root is resolved without creating it, +// since teardown has no business making directories on its way out. +func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principalSID string) ([]string, error) { + filesystem := config.PermissionProfile.FileSystem + writeRoots := filesystem.WriteRoots + runtimeRoot, err := windowsSandboxRuntimeRootPath(config) + if err != nil { + return nil, err + } + if runtimeRoot != "" { + writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: runtimeRoot}) + } + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principalSID, + WriteRoots: writeRoots, + ReadRoots: filesystem.ReadRoots, + DenyRead: filesystem.DenyRead, + DenyWrite: filesystem.DenyWrite, + }) + if err != nil { + return nil, err + } + return windowsACLPlanPaths(plan), nil +} + +// canonicalWindowsSandboxWorkspaceRoot normalizes a workspace root the way the +// COMMAND path already does, so setup and commands agree on what they are keyed +// to. +// +// Engine.resolveCommandDir cleans, absolutizes and then EvalSymlinks the root +// (internal/sandbox/runner.go). Setup only cleaned it, and the runtime root is a +// hash of that string, so the two disagreed whenever resolution changed +// anything. That does not take a symlink: Windows opens a path in any case and +// EvalSymlinks canonicalizes it, so a workspace entered with different casing +// hashes one way at setup and another at command time. +// +// Setup then granted the principal one runtime tree while every command used a +// different one, so the grant that exists to make npm/go/pip caches writable +// landed somewhere nothing reads and the failure surfaced as a bare +// ACCESS_DENIED on a cache write. +// +// EvalSymlinks failing is not an error: an unresolvable root still needs a +// stable key, and falling back to the cleaned absolute path is what the command +// path does too. +func canonicalWindowsSandboxWorkspaceRoot(root string) string { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if cleaned == "" || cleaned == "." { + return "" + } + if !filepath.IsAbs(cleaned) { + if absolute, err := filepath.Abs(cleaned); err == nil { + cleaned = absolute + } + } + if resolved, err := filepath.EvalSymlinks(cleaned); err == nil { + return resolved + } + return cleaned +} diff --git a/internal/sandbox/windows_workspace_canonical_windows_test.go b/internal/sandbox/windows_workspace_canonical_windows_test.go new file mode 100644 index 000000000..db6adf996 --- /dev/null +++ b/internal/sandbox/windows_workspace_canonical_windows_test.go @@ -0,0 +1,75 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Setup grants the principal a runtime tree; every command derives that tree +// again from the workspace root. If the two normalize differently the grant +// lands somewhere nothing reads, and the only symptom is a bare ACCESS_DENIED +// on the first cache write. +// +// This needs no symlink and no privilege. Windows opens a path whatever its +// casing, and Engine.resolveCommandDir runs EvalSymlinks (runner.go) which +// canonicalizes it, while setup used to only Clean. +func TestSetupRuntimeRootMatchesTheCommandPathForADifferentlyCasedRoot(t *testing.T) { + base := t.TempDir() + workspace := filepath.Join(base, "MyWorkspace") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + lowered := strings.ToLower(workspace) + if _, err := os.Stat(lowered); err != nil { + t.Skipf("filesystem is case-sensitive here: %v", err) + } + if lowered == workspace { + t.Skip("temp path has no case to differ on") + } + + // What the command path ends up keyed to, per resolveCommandDir. + commandRoot := lowered + if resolved, err := filepath.EvalSymlinks(filepath.Clean(lowered)); err == nil { + commandRoot = resolved + } + if commandRoot == lowered { + t.Skip("EvalSymlinks changed nothing on this host; no divergence to assert") + } + + // Drive the PRODUCTION derivation, not the helper. A test that called + // canonicalWindowsSandboxWorkspaceRoot directly would pass just as happily + // with setup still doing filepath.Clean, which is exactly the bug. + fromSetup, err := windowsSandboxRuntimeRootPath(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{lowered}, + CommandCWD: lowered, + }) + if err != nil { + t.Fatalf("windowsSandboxRuntimeRootPath: %v", err) + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + t.Fatalf("sandboxUserCacheDir: %v", err) + } + fromCommand, err := sandboxRuntimeRootFor(commandRoot, filepath.Clean(cacheRoot)) + if err != nil { + t.Fatalf("sandboxRuntimeRootFor(command): %v", err) + } + if fromSetup != fromCommand { + t.Errorf("setup grants a runtime tree commands never use:\n setup: %s\n command: %s", fromSetup, fromCommand) + } +} + +// An unresolvable root still needs a stable key rather than an empty one. +func TestCanonicalWorkspaceRootFallsBackWhenResolutionFails(t *testing.T) { + missing := filepath.Join(t.TempDir(), "never-created", "deeper") + if got := canonicalWindowsSandboxWorkspaceRoot(missing); got != filepath.Clean(missing) { + t.Errorf("canonical(%q) = %q, want the cleaned path", missing, got) + } + if canonicalWindowsSandboxWorkspaceRoot(" ") != "" { + t.Error("a blank root should stay blank, not become the process directory") + } +} From 5ad6c65d0acaa4fd17e42358f6ea834c448cc2d5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 23:13:32 +0530 Subject: [PATCH 23/45] fix(sandbox): canonicalize the workspace root on both sides, not just setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI caught two faults in the previous commit. Both came from canonicalizing one side of a pair. setupWindowsSandboxRuntimeRoot resolved the workspace root while prepareSandboxRuntime still only cleaned it, so the two disagreed exactly where they had to agree. It passed locally because my temp paths were already canonical; a Windows runner's TEMP is an 8.3 short path that resolution expands: setup granted ...\runtime\v1\5e2d212300ccdfba commands use ...\runtime\v1\92c31f8cf536dfde The canonicalization moves to canonicalSandboxWorkspaceRoot in runtime_state.go and both sides call it, which is what the original fix should have done. The carveout shape was rebuilt from the RESOLVED write root and compared against subpaths that cannot resolve, since .git/config does not exist at setup and normalizeProfilePath falls back to Clean when EvalSymlinks fails. Two spellings of the same path therefore missed the lookup and .git/config went back to being created as a directory — the original bug, reintroduced quietly by its own fix. gitMetadataCarveoutIsFile now matches on the trailing segments, derived from the spec list so it cannot drift from it, and no reconstructed absolute path is compared at all. Both failures now have regression tests that reproduce the non-canonical root by lowercasing, which needs no short name and no privilege. Reverting either fix fails them. Co-Authored-By: Claude Opus 5 --- internal/sandbox/profile.go | 33 ++++++++ internal/sandbox/runtime_state.go | 34 +++++++- internal/sandbox/windows_identity_acl.go | 8 +- .../windows_identity_runtime_windows.go | 37 +-------- ...indows_workspace_canonical_windows_test.go | 82 ++++++++++++++++++- 5 files changed, 147 insertions(+), 47 deletions(-) diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 400683feb..02b0742be 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -91,6 +91,39 @@ func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout { } } +// gitMetadataCarveoutSuffixBase is a sentinel root used only to recover the +// trailing segments of the carveout specs. It is never touched on disk. +const gitMetadataCarveoutSuffixBase = string(filepath.Separator) + "zero-carveout-base" + +// gitMetadataCarveoutIsFile reports whether path names a carveout git expects +// to be a file. +// +// It matches on the trailing segments rather than on a whole reconstructed +// path. The subpaths reaching the ACL plan are already normalized — resolved +// through EvalSymlinks where that succeeds — while a rebuilt spec path cannot +// be, because .git/config does not exist yet at setup and resolution falls back +// to a plain Clean. On a host where two spellings of the same path differ (an +// 8.3 short name, different casing) a whole-path equality check silently misses +// and the carveout is created as a directory again, which is the original bug +// reintroduced quietly. The suffix cannot drift from the spec list because it +// is derived from it. +func gitMetadataCarveoutIsFile(path string) bool { + candidate := strings.ToLower(filepath.Clean(strings.TrimSpace(path))) + if candidate == "" { + return false + } + for _, spec := range gitMetadataWriteCarveoutSpecs(gitMetadataCarveoutSuffixBase) { + if !spec.IsFile { + continue + } + suffix := strings.ToLower(strings.TrimPrefix(spec.Path, gitMetadataCarveoutSuffixBase)) + if suffix != "" && strings.HasSuffix(candidate, suffix) { + return true + } + } + return false +} + func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile { if policy.Mode == "" { policy = DefaultPolicy() diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index dac689941..d1ff8d967 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -52,7 +52,7 @@ func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, erro } func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { - workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) + workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) if workspaceRoot == "" || workspaceRoot == "." { return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root") } @@ -240,3 +240,35 @@ func permissionProfileWithRuntime(profile PermissionProfile, runtimeState Sandbo profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root}) return profile } + +// canonicalSandboxWorkspaceRoot normalizes a workspace root the way +// Engine.resolveCommandDir already does — clean, absolutize, then resolve +// symlinks — so every derivation keyed to a workspace agrees on the string. +// +// The runtime root is a hash of this, and the elevated Windows setup grants the +// principal that tree while commands derive it again. Cleaning alone was not +// enough for the two to agree, and it does not take a symlink for them to +// differ: a path opened in different casing, or through an 8.3 short name (what +// a Windows CI runner's TEMP looks like), resolves to a different spelling. +// Setup then granted one tree and every command used another, so the grant that +// makes npm/go/pip caches writable landed where nothing reads and surfaced as a +// bare ACCESS_DENIED. +// +// Resolution failing is not an error: an unresolvable root still needs a stable +// key, and falling back to the cleaned absolute path is what the command path +// does too. +func canonicalSandboxWorkspaceRoot(root string) string { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if cleaned == "" || cleaned == "." { + return "" + } + if !filepath.IsAbs(cleaned) { + if absolute, err := filepath.Abs(cleaned); err == nil { + cleaned = absolute + } + } + if resolved, err := filepath.EvalSymlinks(cleaned); err == nil { + return resolved + } + return cleaned +} diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index a57ca3647..d21f50792 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -107,19 +107,13 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla // Which carveouts are files rather than directories comes from the same // spec list the profile built ReadOnlySubpaths from, so a new carveout // cannot be added without its shape coming along. - fileCarveouts := map[string]bool{} - for _, spec := range gitMetadataWriteCarveoutSpecs(cleaned) { - if spec.IsFile { - fileCarveouts[normalizeProfilePath(spec.Path)] = true - } - } for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) { entries = append(entries, WindowsACLEntry{ Action: WindowsACLDenyWrite, Path: subpath, Capability: input.PrincipalSID, Materialize: true, - MaterializeFile: fileCarveouts[subpath], + MaterializeFile: gitMetadataCarveoutIsFile(subpath), }) } for _, name := range root.ProtectedMetadataNames { diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 1cfa08e64..d69e104bd 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -382,7 +382,7 @@ func windowsSandboxRuntimeRootPath(config WindowsSandboxCommandConfig) (string, workspaceRoot := "" for _, candidate := range config.WorkspaceRoots { if trimmed := strings.TrimSpace(candidate); trimmed != "" { - workspaceRoot = canonicalWindowsSandboxWorkspaceRoot(trimmed) + workspaceRoot = canonicalSandboxWorkspaceRoot(trimmed) break } } @@ -491,38 +491,3 @@ func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principal } return windowsACLPlanPaths(plan), nil } - -// canonicalWindowsSandboxWorkspaceRoot normalizes a workspace root the way the -// COMMAND path already does, so setup and commands agree on what they are keyed -// to. -// -// Engine.resolveCommandDir cleans, absolutizes and then EvalSymlinks the root -// (internal/sandbox/runner.go). Setup only cleaned it, and the runtime root is a -// hash of that string, so the two disagreed whenever resolution changed -// anything. That does not take a symlink: Windows opens a path in any case and -// EvalSymlinks canonicalizes it, so a workspace entered with different casing -// hashes one way at setup and another at command time. -// -// Setup then granted the principal one runtime tree while every command used a -// different one, so the grant that exists to make npm/go/pip caches writable -// landed somewhere nothing reads and the failure surfaced as a bare -// ACCESS_DENIED on a cache write. -// -// EvalSymlinks failing is not an error: an unresolvable root still needs a -// stable key, and falling back to the cleaned absolute path is what the command -// path does too. -func canonicalWindowsSandboxWorkspaceRoot(root string) string { - cleaned := filepath.Clean(strings.TrimSpace(root)) - if cleaned == "" || cleaned == "." { - return "" - } - if !filepath.IsAbs(cleaned) { - if absolute, err := filepath.Abs(cleaned); err == nil { - cleaned = absolute - } - } - if resolved, err := filepath.EvalSymlinks(cleaned); err == nil { - return resolved - } - return cleaned -} diff --git a/internal/sandbox/windows_workspace_canonical_windows_test.go b/internal/sandbox/windows_workspace_canonical_windows_test.go index db6adf996..579bd0f49 100644 --- a/internal/sandbox/windows_workspace_canonical_windows_test.go +++ b/internal/sandbox/windows_workspace_canonical_windows_test.go @@ -41,7 +41,7 @@ func TestSetupRuntimeRootMatchesTheCommandPathForADifferentlyCasedRoot(t *testin } // Drive the PRODUCTION derivation, not the helper. A test that called - // canonicalWindowsSandboxWorkspaceRoot directly would pass just as happily + // canonicalSandboxWorkspaceRoot directly would pass just as happily // with setup still doing filepath.Clean, which is exactly the bug. fromSetup, err := windowsSandboxRuntimeRootPath(WindowsSandboxCommandConfig{ WorkspaceRoots: []string{lowered}, @@ -66,10 +66,86 @@ func TestSetupRuntimeRootMatchesTheCommandPathForADifferentlyCasedRoot(t *testin // An unresolvable root still needs a stable key rather than an empty one. func TestCanonicalWorkspaceRootFallsBackWhenResolutionFails(t *testing.T) { missing := filepath.Join(t.TempDir(), "never-created", "deeper") - if got := canonicalWindowsSandboxWorkspaceRoot(missing); got != filepath.Clean(missing) { + if got := canonicalSandboxWorkspaceRoot(missing); got != filepath.Clean(missing) { t.Errorf("canonical(%q) = %q, want the cleaned path", missing, got) } - if canonicalWindowsSandboxWorkspaceRoot(" ") != "" { + if canonicalSandboxWorkspaceRoot(" ") != "" { t.Error("a blank root should stay blank, not become the process directory") } } + +// The pair has to agree, not just each side individually. CI caught this the +// hard way: canonicalizing only the setup side made setup and +// prepareSandboxRuntime disagree on a Windows runner, whose TEMP is an 8.3 +// short path that resolution expands. Lowercasing reproduces the same class of +// non-canonical spelling without needing a short name or any privilege. +func TestSetupAndPrepareRuntimeAgreeOnANonCanonicalRoot(t *testing.T) { + base := t.TempDir() + workspace := filepath.Join(base, "MyWs") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + lowered := strings.ToLower(workspace) + if _, err := os.Stat(lowered); err != nil { + t.Skipf("filesystem is case-sensitive here: %v", err) + } + + granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{lowered}, + CommandCWD: lowered, + }) + if err != nil { + t.Fatalf("setupWindowsSandboxRuntimeRoot: %v", err) + } + state, release, err := prepareSandboxRuntime(lowered) + if err != nil { + t.Fatalf("prepareSandboxRuntime: %v", err) + } + if release != nil { + defer release() + } + if filepath.Clean(granted) != filepath.Clean(state.Root) { + t.Errorf("setup granted %q but commands write to %q", granted, state.Root) + } +} + +// The carveout shape has to survive a non-canonical root too. The first fix +// rebuilt the spec paths from the RESOLVED write root and compared them against +// subpaths that could not resolve (.git/config does not exist yet), so on a +// short-name or differently-cased path the match missed and .git/config went +// back to being created as a directory. +func TestGitConfigCarveoutShapeSurvivesANonCanonicalRoot(t *testing.T) { + base := t.TempDir() + workspace := filepath.Join(base, "MyWs") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + lowered := strings.ToLower(workspace) + if _, err := os.Stat(lowered); err != nil { + t.Skipf("filesystem is case-sensitive here: %v", err) + } + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-32-546", + WriteRoots: []WritableRoot{{ + Root: lowered, + ReadOnlySubpaths: gitMetadataWriteCarveouts(lowered), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + found := false + for _, entry := range plan.Entries { + if !strings.EqualFold(filepath.Base(entry.Path), "config") { + continue + } + found = true + if !entry.MaterializeFile { + t.Errorf(".git/config entry %q lost its file shape on a non-canonical root", entry.Path) + } + } + if !found { + t.Fatal("no .git/config entry in the plan") + } +} From 6e90d09f8ecbcfdeffb6ccd90e752bbec8031ed1 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 23:27:19 +0530 Subject: [PATCH 24/45] fix(sandbox): normalize the cache root too, not just the workspace root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sandboxRuntimeRootFor compares the workspace root against the runtime root it derives from the cache root, and falls back to a private temp tree when the derived root would land inside the workspace. The previous commit canonicalized only the workspace root, so that comparison ran on two different spellings of the same path and the containment check missed: macOS: /var/folders/... vs /private/var/folders/... Windows: C:\Users\RUNNER~1\... vs C:\Users\runneradmin\... The fallback never fired and the runtime tree was placed inside the workspace it exists to stay out of. Both CI runners caught it; my box did not, because its temp paths are already canonical and 8.3 alias creation is disabled on the volume, so I could not reproduce either spelling locally. Both inputs now go through canonicalSandboxWorkspaceRoot, on the cross-platform path and the Windows setup path. The regression test uses a symlink, which is the portable way to produce a spelling only resolution reconciles — Clean cannot see through one. It skips on Windows, where creating one needs privilege, and runs on the platforms that caught the bug. Two things about that test are deliberate. My first version used a redundant-segment path, which Clean already normalizes, so reverting the fix left it passing. My second resolved nothing before asserting, and through the link the runtime root shares no textual prefix with the workspace — it would have called a root sitting physically inside the workspace "outside" and passed against the exact bug it exists for. It now resolves before comparing. Co-Authored-By: Claude Opus 5 --- internal/sandbox/runtime_state.go | 9 +++- internal/sandbox/runtime_state_test.go | 47 +++++++++++++++++++ .../windows_identity_runtime_windows.go | 5 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index d1ff8d967..91f3f0045 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -60,7 +60,14 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) if err != nil { return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err) } - cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot)) + // Canonicalized the SAME way as the workspace root, because + // sandboxRuntimeRootFor compares the two: it falls back to a private temp + // tree when the derived runtime root would land inside the workspace. + // Normalizing only one side made that comparison run on two different + // spellings of the same path — /var vs /private/var on macOS, an 8.3 short + // name vs its long form on Windows — so the containment check missed and the + // fallback never fired. + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) if cacheRoot == "" || cacheRoot == "." { return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable") } diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index b707f67f2..619fa4bde 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -329,3 +329,50 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) { } cleanupLease.release() } + +// sandboxRuntimeRootFor compares the workspace root against the derived runtime +// root to decide whether to fall back to a private temp tree. Both sides +// therefore have to be the same spelling of the same path. +// +// Canonicalizing only the workspace root broke this on CI: the workspace +// resolved (/var to /private/var on macOS, an 8.3 short name to its long form +// on Windows) while the cache root kept its original spelling, so the +// containment check compared two different strings, the fallback never fired, +// and the runtime tree was placed inside the workspace it exists to stay out of. +// +// A symlink is the portable way to produce a spelling that only resolution +// reconciles — Clean cannot see through one. Windows refuses to create symlinks +// without privilege, so this skips there; the platforms that CI caught the bug +// on are the ones that run it. +func TestPrepareSandboxRuntimeNormalizesTheCacheRootBeforeComparingIt(t *testing.T) { + workspace := t.TempDir() + link := filepath.Join(t.TempDir(), "workspace-link") + if err := os.Symlink(workspace, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + // The cache root reaches us spelled through the symlink; the workspace does + // not. Resolved, it is plainly inside the workspace and the fallback must + // fire. Unresolved, the two strings share no prefix and it does not. + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return filepath.Join(link, ".cache"), nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + runtimeState, release, err := prepareSandboxRuntime(workspace) + if err != nil { + t.Fatalf("prepareSandboxRuntime: %v", err) + } + if release != nil { + defer release() + } + // Resolve before comparing. Spelled through the link the runtime root shares + // no textual prefix with the workspace, so an unresolved comparison would + // call it "outside" while it sits physically inside — the test would pass + // against the very bug it exists for. + resolved := runtimeState.Root + if actual, err := filepath.EvalSymlinks(runtimeState.Root); err == nil { + resolved = actual + } + if pathWithinRoot(workspace, resolved) { + t.Fatalf("runtime root %q resolves to %q, inside workspace %q; the containment check did not see through the cache root's spelling", runtimeState.Root, resolved, workspace) + } +} diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index d69e104bd..67f8053ec 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -18,7 +18,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "strings" "sync" @@ -393,7 +392,9 @@ func windowsSandboxRuntimeRootPath(config WindowsSandboxCommandConfig) (string, if err != nil { return "", fmt.Errorf("resolve user cache directory for sandbox runtime: %w", err) } - cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot)) + // Same canonicalization as the workspace root above: sandboxRuntimeRootFor + // compares them, so they have to be the same spelling of the same path. + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) if cacheRoot == "" || cacheRoot == "." { return "", errors.New("user cache directory is unavailable for sandbox runtime") } From f65ac36cd924b3a5870586a15c868ddea69e5ef8 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 23:33:56 +0530 Subject: [PATCH 25/45] fix(sandbox): resolve through path segments that do not exist yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix normalized both the workspace root and the cache root, and macOS CI still failed the same way. EvalSymlinks fails outright when the LEAF does not exist, and a cache root has not been created at the point it is first normalized — so the workspace resolved (/var to /private/var) while the cache root did not, and the containment check that decides whether the runtime tree must move out of the workspace compared the two anyway. canonicalSandboxWorkspaceRoot now resolves the longest existing ancestor and re-appends the remainder, so a path normalizes the same way whether or not its final segments exist: /var/.../001/.cache leaf missing, walk up /var/.../001 resolves /private/var/.../001/.cache Terminates at the filesystem root, where it falls back to the cleaned absolute path, and a path with no symlink anywhere along it is unchanged. The regression test needs a symlink to produce a spelling only resolution reconciles, so it skips on Windows — where creating one needs privilege — and runs on the platforms that caught this. I could not reproduce either CI spelling locally: this box's temp paths are already canonical and 8.3 alias creation is disabled on the volume. Co-Authored-By: Claude Opus 5 --- internal/sandbox/runtime_state.go | 30 +++++++++++++++++++++++--- internal/sandbox/runtime_state_test.go | 29 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 91f3f0045..959a45bcb 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -274,8 +274,32 @@ func canonicalSandboxWorkspaceRoot(root string) string { cleaned = absolute } } - if resolved, err := filepath.EvalSymlinks(cleaned); err == nil { - return resolved + // EvalSymlinks fails outright when the LEAF does not exist, which is the + // normal case for a cache or runtime root that has not been created yet. A + // plain call therefore resolved an existing workspace while leaving a + // not-yet-created cache root unresolved, and the two were compared against + // each other — the containment check that decides whether the runtime tree + // must move out of the workspace then ran on /private/var/... versus + // /var/..., missed, and left the tree inside the workspace. + // + // Resolve the longest existing ancestor and re-append the rest, so a path + // normalizes the same way whether or not its final segments exist yet. + remainder := "" + current := cleaned + for { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + if remainder == "" { + return resolved + } + return filepath.Join(resolved, remainder) + } + parent := filepath.Dir(current) + if parent == current { + // Nothing along the path resolved; the cleaned absolute form is the + // best stable key available. + return cleaned + } + remainder = filepath.Join(filepath.Base(current), remainder) + current = parent } - return cleaned } diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index 619fa4bde..7dce23780 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -376,3 +376,32 @@ func TestPrepareSandboxRuntimeNormalizesTheCacheRootBeforeComparingIt(t *testing t.Fatalf("runtime root %q resolves to %q, inside workspace %q; the containment check did not see through the cache root's spelling", runtimeState.Root, resolved, workspace) } } + +// A path whose final segments do not exist yet must still normalize the same +// way as one that does. This is the shape macOS CI hit: t.TempDir() sits under +// /var, a symlink to /private/var, and the cache root it derives has not been +// created when it is first normalized. Resolving only the workspace left the +// two sides of the containment check spelled differently. +func TestCanonicalSandboxWorkspaceRootResolvesThroughAMissingLeaf(t *testing.T) { + real := t.TempDir() + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + + existing := canonicalSandboxWorkspaceRoot(link) + if existing != canonicalSandboxWorkspaceRoot(real) { + t.Fatalf("an existing symlinked dir did not resolve: %q vs %q", existing, canonicalSandboxWorkspaceRoot(real)) + } + + // The leaf, and its parent, do not exist. + missing := filepath.Join(link, ".cache", "zero") + got := canonicalSandboxWorkspaceRoot(missing) + want := filepath.Join(existing, ".cache", "zero") + if got != want { + t.Errorf("missing leaf normalized to %q, want %q — the ancestor was not resolved", got, want) + } + if !pathWithinRoot(existing, got) { + t.Errorf("%q should be inside %q once both are canonical", got, existing) + } +} From 739323929b3f91dcb5e73c0b8557e2cc5c52b679 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 23:45:10 +0530 Subject: [PATCH 26/45] test(sandbox): assert the ancestor walk, not the old all-or-nothing contract TestCanonicalWorkspaceRootFallsBackWhenResolutionFails asserted that a path with missing segments came back as the plain cleaned path. That was the behaviour before the ancestor walk, and Windows CI failed it correctly: canonical("C:\Users\RUNNER~1\...\001\never-created\deeper") = "C:\Users\runneradmin\...\001\never-created\deeper", want the cleaned path The existing ancestor resolved and the missing remainder was re-appended, which is precisely what the walk exists to do. The assertion now says that: the result equals the canonical parent joined with the segments that do not exist, and those segments survive rather than collapsing to the ancestor. Co-Authored-By: Claude Opus 5 --- ...indows_workspace_canonical_windows_test.go | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/internal/sandbox/windows_workspace_canonical_windows_test.go b/internal/sandbox/windows_workspace_canonical_windows_test.go index 579bd0f49..c63299be4 100644 --- a/internal/sandbox/windows_workspace_canonical_windows_test.go +++ b/internal/sandbox/windows_workspace_canonical_windows_test.go @@ -63,11 +63,25 @@ func TestSetupRuntimeRootMatchesTheCommandPathForADifferentlyCasedRoot(t *testin } } -// An unresolvable root still needs a stable key rather than an empty one. -func TestCanonicalWorkspaceRootFallsBackWhenResolutionFails(t *testing.T) { - missing := filepath.Join(t.TempDir(), "never-created", "deeper") - if got := canonicalSandboxWorkspaceRoot(missing); got != filepath.Clean(missing) { - t.Errorf("canonical(%q) = %q, want the cleaned path", missing, got) +// A root whose final segments do not exist still normalizes: the existing +// ancestor resolves and the missing remainder is re-appended. +// +// This asserted the whole cleaned path unchanged at first, which was the +// all-or-nothing behaviour the ancestor walk replaced. Windows CI failed it — +// correctly — because RUNNER~1 resolved to runneradmin while never-created +// stayed put, which is exactly the behaviour the walk exists to produce. +func TestCanonicalWorkspaceRootResolvesTheExistingAncestor(t *testing.T) { + parent := t.TempDir() + missing := filepath.Join(parent, "never-created", "deeper") + + got := canonicalSandboxWorkspaceRoot(missing) + want := filepath.Join(canonicalSandboxWorkspaceRoot(parent), "never-created", "deeper") + if got != want { + t.Errorf("canonical(%q) = %q, want %q", missing, got, want) + } + // The missing segments must survive rather than be dropped to the ancestor. + if !strings.HasSuffix(got, filepath.Join("never-created", "deeper")) { + t.Errorf("canonical(%q) = %q, lost the segments that do not exist yet", missing, got) } if canonicalSandboxWorkspaceRoot(" ") != "" { t.Error("a blank root should stay blank, not become the process directory") From 382792d1f62aa08280609a3f06377639afb24c41 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 12:14:14 +0530 Subject: [PATCH 27/45] fix(sandbox): close the delete-through-parent, junction-ancestor and rollback gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from jatmn's review, all reachable only under the opt-in principal backend but all real. FILE_DELETE_CHILD is no longer granted. On a parent it authorises deleting a child whatever the child's own DACL says, so granting it on a write root handed back the carve-outs underneath: delete .git/config, recreate it, and the replacement inherits the grant with no deny of its own — restoring the credential.helper and core.hooksPath control the carve-out exists to prevent. It was granted to keep the mask symmetric with the deny mask, which is the wrong instinct: denying a capability is not a reason to grant it. The comment two lines up already made that argument for WRITE_DAC and WRITE_OWNER. DELETE alone still covers removing and renaming files inside the roots, which is what the grant is actually for — verified before removing it. Note this does NOT close the second route jatmn described: .git itself carries no ACE, so renaming the whole directory aside needs only DELETE. That needs a guard on .git and is not in this commit. ACL targets are now rejected when a PARENT is a reparse point. CreateFile resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the final-component check passed while elevated setup rewrote the DACL of an object outside the workspace. Junctions need no privilege to create, unlike symlinks, so this was reachable by exactly the unprivileged user the sandbox contains. GetFinalPathNameByHandle answers where the handle really landed, covering every component in one call instead of walking the path and racing between checks. The comparison is against the path's own resolved form, so a differently-cased or 8.3 spelling is still accepted. The revocation's rollback is returned instead of discarded. Discarding it was justified on the grounds that the only failure path removes the principal outright — true for a principal this run CREATED, false for one it ADOPTED, which #812 keeps alive on failure rather than destroying someone else's working account. The account survived with its previous ACEs stripped and the new ones rolled back: logged on, and unable to reach its own workspace. Teardown still discards it deliberately, since putting ACEs back on an account about to be deleted is the opposite of the point. Co-Authored-By: Claude Opus 5 --- internal/sandbox/windows_acl_apply_windows.go | 43 ++++++---- .../sandbox/windows_acl_reparse_windows.go | 68 ++++++++++++++++ .../windows_acl_reparse_windows_test.go | 79 +++++++++++++++++++ .../windows_identity_rollback_windows_test.go | 18 +++-- .../windows_identity_runtime_windows.go | 59 +++++++++++--- .../sandbox/windows_stale_ace_windows_test.go | 60 +++++++++++++- 6 files changed, 294 insertions(+), 33 deletions(-) create mode 100644 internal/sandbox/windows_acl_reparse_windows.go create mode 100644 internal/sandbox/windows_acl_reparse_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 54f906220..9db9420c2 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -181,6 +181,12 @@ func openWindowsACLTarget(path string) (windows.Handle, bool, error) { _ = windows.CloseHandle(handle) return 0, false, fmt.Errorf("refusing to apply ACL to reparse-point target %s: possible path swap during elevated setup", path) } + // Ancestors are resolved by CreateFile even with FILE_FLAG_OPEN_REPARSE_POINT, + // so the check above is not enough on its own. + if err := verifyWindowsACLTargetNotRedirected(handle, path); err != nil { + _ = windows.CloseHandle(handle) + return 0, false, err + } isDir := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 return handle, isDir, nil } @@ -226,22 +232,29 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACCESS_MASK, error) { switch action { case WindowsACLAllowWrite: - // 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. + // DELETE is part of the grant, not an extra. 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 DELETE 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. + // + // FILE_DELETE_CHILD is deliberately NOT granted, for the same reason + // WRITE_DAC and WRITE_OWNER are not. On a parent it authorises deleting a + // child whatever the child's own DACL says, so granting it on a write root + // hands back the write-denied carve-outs underneath it: a principal could + // delete .git/config and recreate it, and the replacement inherits this + // grant with no deny of its own — restoring exactly the credential.helper + // and core.hooksPath control the carve-out exists to prevent. // - // 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 + // It was granted here originally to keep the mask symmetric with + // WindowsACLDenyWrite, which does treat FILE_DELETE_CHILD as part of + // write. Symmetry is the wrong goal: denying a capability is not a reason + // to grant it. DELETE alone covers removing and renaming files the + // principal owns inside its roots, which is what the grant is for. + return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE | windows.DELETE, 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 diff --git a/internal/sandbox/windows_acl_reparse_windows.go b/internal/sandbox/windows_acl_reparse_windows.go new file mode 100644 index 000000000..bebbf5b0f --- /dev/null +++ b/internal/sandbox/windows_acl_reparse_windows.go @@ -0,0 +1,68 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +// GetFinalPathNameByHandle flags. x/sys/windows does not export these. +const ( + windowsFileNameNormalized uint32 = 0x0 + windowsVolumeNameDOS uint32 = 0x0 +) + +// verifyWindowsACLTargetNotRedirected fails when an opened handle resolved +// somewhere other than the requested path, which means a component along the way +// is a reparse point. +// +// openWindowsACLTarget's FILE_FLAG_OPEN_REPARSE_POINT check covers the FINAL +// component only; CreateFile still resolves ANCESTORS. A user who controls the +// workspace can turn an ancestor — .git, say — into a junction before elevated +// setup runs, and setup would then apply its DACL change to an object outside +// the approved tree while the final-component check still passed. Junctions need +// no privilege to create, unlike symlinks, so this is reachable by exactly the +// unprivileged user the sandbox exists to contain. +// +// GetFinalPathNameByHandle answers where the handle actually landed, covering +// every component in one call rather than walking the path and re-checking each +// component (which would also race between the checks). +// +// The comparison is against the path's own resolved form rather than the raw +// string, because a legitimate target can be spelled with different casing or an +// 8.3 short name and still be the same object. Only a genuine redirect makes the +// two disagree. +func verifyWindowsACLTargetNotRedirected(handle windows.Handle, path string) error { + buffer := make([]uint16, windows.MAX_LONG_PATH) + n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), windowsFileNameNormalized|windowsVolumeNameDOS) + if err != nil { + return fmt.Errorf("resolve windows ACL target %s: %w", path, err) + } + if int(n) < len(buffer) { + buffer = buffer[:n] + } + actual := trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer)) + expected := trimWindowsExtendedPathPrefix(canonicalSandboxWorkspaceRoot(path)) + if !strings.EqualFold(filepath.Clean(actual), filepath.Clean(expected)) { + return fmt.Errorf("refusing to apply ACL to %s: it resolves to %s, so a parent directory is a reparse point (possible path swap during elevated setup)", path, actual) + } + return nil +} + +// trimWindowsExtendedPathPrefix strips the \?\ form GetFinalPathNameByHandle +// returns so it can be compared with an ordinary path. +func trimWindowsExtendedPathPrefix(path string) string { + // Built from filepath.Separator rather than written as literals so the + // backslashes cannot be miscounted by whatever writes this file. + sep := string(filepath.Separator) + devicePrefix := sep + sep + "?" + sep + uncPrefix := devicePrefix + "UNC" + sep + if strings.HasPrefix(path, uncPrefix) { + return sep + sep + strings.TrimPrefix(path, uncPrefix) + } + return strings.TrimPrefix(path, devicePrefix) +} diff --git a/internal/sandbox/windows_acl_reparse_windows_test.go b/internal/sandbox/windows_acl_reparse_windows_test.go new file mode 100644 index 000000000..c529f461b --- /dev/null +++ b/internal/sandbox/windows_acl_reparse_windows_test.go @@ -0,0 +1,79 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// An unprivileged user who controls the workspace can turn an ancestor of a +// configured ACL target into a junction before elevated setup runs. CreateFile +// resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the +// final-component check passes and setup would rewrite the DACL of an object +// outside the approved tree. +// +// Junctions, unlike symlinks, need no privilege — which is what makes this +// reachable by exactly the user the sandbox is containing. +func TestOpenWindowsACLTargetRefusesAJunctionAncestor(t *testing.T) { + base := t.TempDir() + workspace := filepath.Join(base, "workspace") + outside := filepath.Join(base, "OUTSIDE") + for _, dir := range []string{workspace, outside} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + // The object the attacker wants setup to touch. + victim := filepath.Join(outside, "hooks") + if err := os.MkdirAll(victim, 0o700); err != nil { + t.Fatalf("mkdir victim: %v", err) + } + + // .git is the ancestor, and it is a junction to OUTSIDE. + gitDir := filepath.Join(workspace, ".git") + if out, err := exec.Command("cmd", "/c", "mklink", "/J", gitDir, outside).CombinedOutput(); err != nil { + t.Skipf("cannot create a junction here: %v %s", err, out) + } + + // This is the path setup would be configured with. + target := filepath.Join(gitDir, "hooks") + if _, err := os.Stat(target); err != nil { + t.Fatalf("precondition: the junction should make %s reachable: %v", target, err) + } + + handle, _, err := openWindowsACLTarget(target) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatal("opened an ACL target through a junction ancestor; setup would have rewritten a DACL outside the workspace") + } + if !strings.Contains(err.Error(), "reparse point") { + t.Errorf("error = %v, want it to name the reparse point", err) + } + t.Logf("refused as expected: %v", err) +} + +// The guard must not reject ordinary targets, including ones spelled +// non-canonically — a differently-cased path is the same object, not a redirect. +func TestOpenWindowsACLTargetAcceptsAnOrdinaryTarget(t *testing.T) { + dir := t.TempDir() + nested := filepath.Join(dir, "Nested") + if err := os.MkdirAll(nested, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + for _, spelling := range []string{nested, strings.ToLower(nested)} { + handle, isDir, err := openWindowsACLTarget(spelling) + if err != nil { + t.Fatalf("openWindowsACLTarget(%q): %v", spelling, err) + } + if !isDir { + t.Errorf("%q reported as not a directory", spelling) + } + _ = windows.CloseHandle(handle) + } +} diff --git a/internal/sandbox/windows_identity_rollback_windows_test.go b/internal/sandbox/windows_identity_rollback_windows_test.go index e4713d8e4..e8593b816 100644 --- a/internal/sandbox/windows_identity_rollback_windows_test.go +++ b/internal/sandbox/windows_identity_rollback_windows_test.go @@ -117,9 +117,8 @@ func TestWindowsACLAllowWriteGrantsDelete(t *testing.T) { // READ_CONTROL and SYNCHRONIZE, so testing a composite constant with & is // satisfied by any grant at all and proves nothing. for label, bit := range map[string]windows.ACCESS_MASK{ - "DELETE": windows.DELETE, - "FILE_DELETE_CHILD": windowsFileDeleteChild, - "FILE_WRITE_DATA": windows.FILE_WRITE_DATA, + "DELETE": windows.DELETE, + "FILE_WRITE_DATA": windows.FILE_WRITE_DATA, } { if mask&bit == 0 { t.Errorf("write grant is missing %s", label) @@ -128,9 +127,18 @@ func TestWindowsACLAllowWriteGrantsDelete(t *testing.T) { // Granting these would let the principal rewrite the very restrictions // placed on it. They are in the deny mask for that reason and must not // appear here. + // + // FILE_DELETE_CHILD belongs in this set and was originally in the one + // above, on the reasoning that the grant should mirror the deny mask. On a + // parent it authorises deleting a child whatever the child's own DACL says, + // so on a write root it hands back the write-denied carve-outs underneath: + // delete .git/config, recreate it, and the replacement inherits the grant + // with no deny of its own. Mirroring the deny mask is the wrong instinct — + // denying a capability is not a reason to grant it. for label, bit := range map[string]windows.ACCESS_MASK{ - "WRITE_DAC": windows.WRITE_DAC, - "WRITE_OWNER": windows.WRITE_OWNER, + "WRITE_DAC": windows.WRITE_DAC, + "WRITE_OWNER": windows.WRITE_OWNER, + "FILE_DELETE_CHILD": windowsFileDeleteChild, } { if mask&bit != 0 { t.Errorf("write grant unexpectedly includes %s", label) diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 67f8053ec..2982aef01 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -353,8 +353,12 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // renamed cannot be cleaned, and refusing to remove the account over it // would strand the principal and its logon rights permanently — a worse // outcome than a leftover ACE on a path that may not exist any more. + // + // The rollback is discarded here on purpose, unlike at setup: this is + // teardown, the account is about to be deleted, and putting its ACEs back + // is the opposite of what the caller asked for. if paths, pathsErr := windowsPrincipalTeardownPaths(config, identity.SID.String()); pathsErr == nil { - _ = revokeWindowsPrincipalACEs(identity.SID.String(), paths) + _, _ = revokeWindowsPrincipalACEs(identity.SID.String(), paths) } if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { return err @@ -415,21 +419,20 @@ func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) (string, return root, nil } -// revokeWindowsPrincipalACEs drops every ACE naming principalSID on paths. +// revokeWindowsPrincipalACEs drops every ACE naming principalSID on paths and +// returns a rollback that puts them back. +// // A path that does not exist is skipped rather than failing: revocation is // cleanup, and there is nothing to clean on a path that was never created. -func revokeWindowsPrincipalACEs(principalSID string, paths []string) error { +func revokeWindowsPrincipalACEs(principalSID string, paths []string) (func() error, error) { if len(paths) == 0 { - return nil + return func() error { return nil }, nil } plan, err := windowsPrincipalRevokePlan(principalSID, paths) if err != nil { - return err - } - if _, err := applyWindowsACLPlanFn(plan); err != nil { - return err + return nil, err } - return nil + return applyWindowsACLPlanFn(plan) } // applyWindowsPrincipalACLs writes the principal's ACEs for one policy: it @@ -460,10 +463,44 @@ func applyWindowsPrincipalACLs(principalSID string, filesystem FileSystemPolicy, if err != nil { return nil, err } - if err := revokeWindowsPrincipalACEs(principalSID, windowsACLPlanPaths(plan)); err != nil { + // The revocation's own rollback matters, and discarding it was a real bug. + // + // It was discarded on the reasoning that the only failure path from here + // removes the principal outright, so restoring stale ACEs for an account + // about to be deleted would be pointless. That holds for a principal this + // run CREATED. It is false for one this run ADOPTED: setup keeps a + // pre-existing account on failure rather than destroying someone else's + // working principal, so discarding the snapshot left that account alive with + // its previous ACEs stripped and the new ones rolled back — logged on and + // unable to reach its own workspace. + // + // Restoring the pre-revocation DACL first, then the grant, unwinds in the + // reverse order they were applied. + restoreRevoked, err := revokeWindowsPrincipalACEs(principalSID, windowsACLPlanPaths(plan)) + if err != nil { return nil, err } - return applyWindowsACLPlanFn(plan) + revertGrant, err := applyWindowsACLPlanFn(plan) + if err != nil { + if restoreRevoked != nil { + _ = restoreRevoked() + } + return nil, err + } + return func() error { + grantErr := revertGrant() + // Restore the pre-revocation ACEs even when reverting the grant failed: + // leaving the principal with neither set is the state this exists to + // avoid. Report the grant error, since that is the one leaving residue. + var restoreErr error + if restoreRevoked != nil { + restoreErr = restoreRevoked() + } + if grantErr != nil { + return grantErr + } + return restoreErr + }, nil } // windowsPrincipalTeardownPaths names every path this principal could hold an diff --git a/internal/sandbox/windows_stale_ace_windows_test.go b/internal/sandbox/windows_stale_ace_windows_test.go index 6b0a910f1..21e1013d4 100644 --- a/internal/sandbox/windows_stale_ace_windows_test.go +++ b/internal/sandbox/windows_stale_ace_windows_test.go @@ -53,7 +53,7 @@ func TestRevokeDropsStalePrincipalACEsBeforeReapply(t *testing.T) { } // Revocation has to cover the paths the OLD plan touched, not just the new // one — the whole point is the path that left the policy. - if err := revokeWindowsPrincipalACEs(principal, windowsACLPlanPaths(wide)); err != nil { + if _, err := revokeWindowsPrincipalACEs(principal, windowsACLPlanPaths(wide)); err != nil { t.Fatalf("revoke: %v", err) } if _, err := applyWindowsACLPlan(narrow); err != nil { @@ -72,7 +72,7 @@ func TestRevokeDropsStalePrincipalACEsBeforeReapply(t *testing.T) { // an error — setup would otherwise fail on any carveout git has not made yet. func TestRevokeIgnoresPathsThatDoNotExist(t *testing.T) { missing := filepath.Join(t.TempDir(), "never-created") - if err := revokeWindowsPrincipalACEs("S-1-5-32-546", []string{missing}); err != nil { + if _, err := revokeWindowsPrincipalACEs("S-1-5-32-546", []string{missing}); err != nil { t.Fatalf("revoke over a missing path: %v", err) } } @@ -149,3 +149,59 @@ func TestApplyPrincipalACLsRevokesBeforeApplying(t *testing.T) { t.Error("second plan was another revocation; the current grants were never applied") } } + +// The revocation's rollback has to be returned, not discarded. +// +// Discarding it was justified on the grounds that the only failure path from +// applyWindowsPrincipalACLs removes the principal outright, so restoring ACEs +// for a doomed account would be pointless. That holds only for a principal the +// run CREATED. #812 keeps an ADOPTED principal alive on failure rather than +// destroying a working account someone else provisioned — and then the discarded +// snapshot left it logged-on and unable to reach its own workspace, with its +// previous ACEs revoked and the new ones rolled back. +func TestApplyPrincipalACLsRollbackRestoresTheRevokedACEs(t *testing.T) { + prevApply := applyWindowsACLPlanFn + t.Cleanup(func() { applyWindowsACLPlanFn = prevApply }) + + var applied []WindowsACLAction + var reverted []WindowsACLAction + applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) { + action := WindowsACLAction("") + if len(plan.Entries) > 0 { + action = plan.Entries[0].Action + } + applied = append(applied, action) + return func() error { + reverted = append(reverted, action) + return nil + }, nil + } + + workspace := t.TempDir() + filesystem := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + } + rollback, err := applyWindowsPrincipalACLs("S-1-5-32-546", filesystem, filesystem.WriteRoots) + if err != nil { + t.Fatalf("applyWindowsPrincipalACLs: %v", err) + } + if len(applied) != 2 || applied[0] != windowsACLRevoke { + t.Fatalf("applied %v, want a revocation then the grants", applied) + } + if err := rollback(); err != nil { + t.Fatalf("rollback: %v", err) + } + + // Both halves must unwind, and in reverse order: the grant comes off first, + // then the ACEs the revocation removed go back. + if len(reverted) != 2 { + t.Fatalf("rollback reverted %v, want both the grant and the revocation", reverted) + } + if reverted[0] == windowsACLRevoke { + t.Error("rollback undid the revocation before the grant; the grant would survive") + } + if reverted[1] != windowsACLRevoke { + t.Errorf("rollback never restored the revoked ACEs, got %v", reverted) + } +} From e02272e9a06892600b6c879aaec03041f2314c4c Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 12:18:23 +0530 Subject: [PATCH 28/45] fix(sandbox): stop teardown creating a directory while naming one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit windowsPrincipalTeardownPaths said the runtime root was "resolved without creating it, since teardown has no business making directories on its way out". That was my comment and it was false: it went through sandboxRuntimeRootFor, whose fallback calls os.MkdirTemp when the cache-derived root would land inside the workspace. So cleanup created a fresh temp directory, and a useless one — the fallback root is random per process and could never match the tree the commands actually used. sandboxRuntimeRootFor is split: deterministicSandboxRuntimeRoot computes the cache-derived path and says whether it is usable, creating nothing, and the existing resolver keeps the fallback on top of it. Teardown takes the pure one and simply has no runtime tree to revoke when it reports unusable, which is correct — there is no way to name the random root from here anyway. The first version of the test called the pure resolver directly. It passed, and reverting the call site to the creating one left it passing. It now drives windowsPrincipalTeardownPaths and counts temp-directory entries across the call, and that mutation fails it. Co-Authored-By: Claude Opus 5 --- internal/sandbox/runtime_state.go | 20 ++++++- .../windows_identity_runtime_windows.go | 46 ++++++++++++-- ...indows_workspace_canonical_windows_test.go | 60 +++++++++++++++++++ 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 959a45bcb..061bde8ea 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -43,14 +43,28 @@ type SandboxRuntime struct { // 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) { + if root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot); ok { return root, nil } return fallbackSandboxRuntimeRoot(workspaceRoot) } +// deterministicSandboxRuntimeRoot returns the cache-derived runtime root and +// whether it is usable, meaning it lands outside the workspace. It creates +// nothing, which sandboxRuntimeRootFor cannot promise: its fallback calls +// os.MkdirTemp. +// +// Callers that only need to NAME the tree — teardown, working out which paths a +// principal could hold an ACE on — have to use this. Going through +// sandboxRuntimeRootFor there would create a fresh temp directory on the way +// out, and a useless one at that, since the fallback root is random per process +// and would never match the one the commands actually used. +func deterministicSandboxRuntimeRoot(workspaceRoot string, cacheRoot string) (string, bool) { + digest := sha256.Sum256([]byte(workspaceRoot)) + root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) + return root, !pathWithinRoot(workspaceRoot, root) +} + func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) if workspaceRoot == "" || workspaceRoot == "." { diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 2982aef01..1f8d160f4 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -405,6 +405,39 @@ func windowsSandboxRuntimeRootPath(config WindowsSandboxCommandConfig) (string, return sandboxRuntimeRootFor(workspaceRoot, cacheRoot) } +// windowsSandboxDeterministicRuntimeRootPath names the cache-derived runtime +// tree without creating anything, and returns "" when that tree is unusable +// because it would land inside the workspace. +// +// Teardown needs this rather than windowsSandboxRuntimeRootPath: that one ends +// in sandboxRuntimeRootFor, whose fallback calls os.MkdirTemp, so merely asking +// for the name would make a directory on the way out. +func windowsSandboxDeterministicRuntimeRootPath(config WindowsSandboxCommandConfig) (string, error) { + workspaceRoot := "" + for _, candidate := range config.WorkspaceRoots { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + workspaceRoot = canonicalSandboxWorkspaceRoot(trimmed) + break + } + } + if workspaceRoot == "" { + return "", nil + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return "", fmt.Errorf("resolve user cache directory for sandbox runtime: %w", err) + } + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) + if cacheRoot == "" || cacheRoot == "." { + return "", errors.New("user cache directory is unavailable for sandbox runtime") + } + root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot) + if !ok { + return "", nil + } + return root, nil +} + // setupWindowsSandboxRuntimeRoot resolves the runtime root AND creates it. // Teardown wants the name without the side effect, so the derivation lives in // windowsSandboxRuntimeRootPath above and this only adds the mkdir. @@ -504,13 +537,18 @@ func applyWindowsPrincipalACLs(principalSID string, filesystem FileSystemPolicy, } // windowsPrincipalTeardownPaths names every path this principal could hold an -// ACE on, derived the same way setup derived them: the policy's roots plus the -// per-workspace runtime tree. The runtime root is resolved without creating it, -// since teardown has no business making directories on its way out. +// ACE on: the policy's roots plus the per-workspace runtime tree. +// +// The runtime root is derived through deterministicSandboxRuntimeRoot rather +// than the resolver setup uses, because teardown must create nothing on its way +// out and that resolver's fallback calls os.MkdirTemp. When the deterministic +// root is unusable there is simply no runtime tree to revoke: the fallback root +// commands used was random and per-process, so nothing here could name it +// anyway. func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principalSID string) ([]string, error) { filesystem := config.PermissionProfile.FileSystem writeRoots := filesystem.WriteRoots - runtimeRoot, err := windowsSandboxRuntimeRootPath(config) + runtimeRoot, err := windowsSandboxDeterministicRuntimeRootPath(config) if err != nil { return nil, err } diff --git a/internal/sandbox/windows_workspace_canonical_windows_test.go b/internal/sandbox/windows_workspace_canonical_windows_test.go index c63299be4..0997c3113 100644 --- a/internal/sandbox/windows_workspace_canonical_windows_test.go +++ b/internal/sandbox/windows_workspace_canonical_windows_test.go @@ -163,3 +163,63 @@ func TestGitConfigCarveoutShapeSurvivesANonCanonicalRoot(t *testing.T) { t.Fatal("no .git/config entry in the plan") } } + +// Teardown must name the runtime tree without creating anything. The comment +// on windowsPrincipalTeardownPaths claimed that and it was false: the resolver +// it used ends in sandboxRuntimeRootFor, whose fallback calls os.MkdirTemp, so +// a workspace whose cache root sits inside it made setup's cleanup path create +// a fresh temp directory on its way out — and a useless one, since the fallback +// root is random per process and never matches what commands used. +func TestTeardownPathDerivationCreatesNothing(t *testing.T) { + workspace := t.TempDir() + // Force the branch that falls back: cache root inside the workspace. + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + before := tempDirEntryCount(t) + + // Drive the PRODUCTION teardown path, not the helper. Calling the resolver + // directly passes just as happily with the call site reverted to the one + // that creates. + paths, err := windowsPrincipalTeardownPaths(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{workspace}, + CommandCWD: workspace, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + }, + }, "S-1-5-32-546") + if err != nil { + t.Fatalf("windowsPrincipalTeardownPaths: %v", err) + } + if len(paths) == 0 { + t.Error("teardown named no paths at all; the workspace root should still be revoked") + } + if after := tempDirEntryCount(t); after != before { + t.Errorf("temp directory gained %d entries; naming the paths must not create one", after-before) + } + + // And the setup resolver, which is allowed to create, still does. + created, err := windowsSandboxRuntimeRootPath(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{workspace}, + CommandCWD: workspace, + }) + if err != nil { + t.Fatalf("windowsSandboxRuntimeRootPath: %v", err) + } + if created == "" { + t.Error("setup's resolver should still fall back to a usable tree") + } +} + +func tempDirEntryCount(t *testing.T) int { + t.Helper() + entries, err := os.ReadDir(os.TempDir()) + if err != nil { + t.Fatalf("read temp dir: %v", err) + } + return len(entries) +} From d84bc54cd32b8927c8c91ce33413beb42bc37b9f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 23:14:54 +0530 Subject: [PATCH 29/45] fix(sandbox): surface a failed stale-secret cleanup after rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provisioning rollback removes the stored secret when this run created the account or rotated an adopted one's password, because in both cases what is on disk cannot authenticate and absent beats stale: the command path treats a missing secret as "not provisioned" and falls back, while a stale one fails the logon and reports a broken sandbox. That removal ignored its own error. When it failed, the invariant it exists to keep was not restored — a credential for a password that no longer works stayed on disk — and setup said nothing. The operator met a provisioned-but-unusable principal on the next command instead of hearing it from the run that broke it. undo now returns that one error and the five failure paths join it onto the error they were already returning, so the original cause and the cleanup failure both surface. The message names the file and what to do about it. The other undo steps still swallow: they leave residue, while this one leaves a credential. Reported by jatmn on #808. --- .../windows_identity_runtime_windows.go | 45 ++++++--- .../windows_stale_secret_windows_test.go | 99 +++++++++++++++++++ 2 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 internal/sandbox/windows_stale_secret_windows_test.go diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 1f8d160f4..7b1ca58a4 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -160,7 +160,7 @@ var windowsSandboxPrincipalWarnOnce sync.Once // therefore cleaned up, rather than an account nothing holds the secret for. func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) (windowsSandboxIdentity, bool, error) { key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) - identity, password, created, err := provisionWindowsSandboxIdentity(key) + identity, password, created, err := provisionWindowsSandboxIdentityFn(key) // Undo whatever this run actually did, in reverse, on any failure after the // account exists. Without it a failure between creating the account and @@ -177,7 +177,7 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig // Resolved from the account name rather than the identity, so it is known // before anything can fail. secretPath, secretPathErr := windowsSandboxSecretPath(config.SandboxHome, windowsSandboxUserName(key)) - undo := func() { + undo := func() error { // Only when this run invalidated it. The secret is removed if this run // created the account, or if it rotated an existing account's password, // because in both cases what is on disk cannot authenticate and absent @@ -189,8 +189,21 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig // secret whenever setup failed before rotation on a machine that was // already provisioned. The account kept its old password, the only copy // of it was deleted, and the sandbox silently degraded. + // + // This one failure is reported rather than swallowed. The others below + // leave residue; this one leaves a CREDENTIAL for a password that no + // longer works, which is the stale-secret state the invariant above + // exists to prevent. Staying quiet would claim the invariant was restored + // when it was not, and the operator would instead meet a + // provisioned-but-unusable principal on the next command. + var cleanupErr error if secretPath != "" && (created || rotated) { - _ = removeWindowsSandboxSecret(secretPath) + if err := removeWindowsSandboxSecretFn(secretPath); err != nil { + cleanupErr = fmt.Errorf( + "sandbox secret %s is stale and could not be removed, so the next command will "+ + "fail to log the principal on rather than falling back; delete it and re-run "+ + "`zero sandbox setup`: %w", secretPath, err) + } } // Only for an account this run created, and attempted rather than // completed. @@ -214,22 +227,20 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig if created { _ = removeWindowsSandboxIdentity(identity.Username) } + return cleanupErr } if err != nil { // provisionWindowsSandboxIdentity can fail after creating the account, so // this path needs the same cleanup even though nothing below ran. - undo() - return windowsSandboxIdentity{}, false, err + return windowsSandboxIdentity{}, false, errors.Join(err, undo()) } rightsAttempted = true if err := grantWindowsSandboxLogonRightsFn(identity.SID); err != nil { - undo() - return windowsSandboxIdentity{}, false, err + return windowsSandboxIdentity{}, false, errors.Join(err, undo()) } if secretPathErr != nil { - undo() - return windowsSandboxIdentity{}, false, secretPathErr + return windowsSandboxIdentity{}, false, errors.Join(secretPathErr, undo()) } // Rotation happens HERE, immediately before the secret is committed, rather // than inside provisioning where it used to. @@ -243,14 +254,12 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig // not offer. if !created { if err := resetWindowsSandboxUserPasswordFn(identity.Username, password); err != nil { - undo() - return windowsSandboxIdentity{}, false, err + return windowsSandboxIdentity{}, false, errors.Join(err, undo()) } rotated = true } - if err := writeWindowsSandboxSecret(secretPath, password); err != nil { - undo() - return windowsSandboxIdentity{}, false, err + if err := writeWindowsSandboxSecretFn(secretPath, password); err != nil { + return windowsSandboxIdentity{}, false, errors.Join(err, undo()) } return identity, created, nil } @@ -567,3 +576,11 @@ func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principal } return windowsACLPlanPaths(plan), nil } + +// Seams for the two elevated calls the provisioning rollback depends on, so the +// stale-secret recovery path is reachable in tests without an elevated machine. +var ( + provisionWindowsSandboxIdentityFn = provisionWindowsSandboxIdentity + removeWindowsSandboxSecretFn = removeWindowsSandboxSecret + writeWindowsSandboxSecretFn = writeWindowsSandboxSecret +) diff --git a/internal/sandbox/windows_stale_secret_windows_test.go b/internal/sandbox/windows_stale_secret_windows_test.go new file mode 100644 index 000000000..7e4e3ebef --- /dev/null +++ b/internal/sandbox/windows_stale_secret_windows_test.go @@ -0,0 +1,99 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// "Absent beats stale" is the invariant the rollback's secret removal exists to +// keep: the command path treats a missing secret as not-provisioned and falls +// back, while a stale one fails the logon and reports a broken sandbox. +// +// So when the removal itself fails after a password rotation, the invariant was +// NOT restored — a credential for a password that no longer works is still on +// disk. Swallowing that error claims otherwise, and the operator finds out on +// the next command instead of from the setup that broke it. +func TestProvisionSurfacesFailedStaleSecretCleanup(t *testing.T) { + for name, testCase := range map[string]struct { + rotate bool + removeErr error + wantInErr string + wantRemove bool + }{ + "rotation happened and the stale secret cannot be removed": { + rotate: true, removeErr: errors.New("access is denied"), + wantRemove: true, wantInErr: "stale and could not be removed", + }, + "rotation happened and cleanup succeeds": { + rotate: true, wantRemove: true, + }, + } { + t.Run(name, func(t *testing.T) { + prevProvision := provisionWindowsSandboxIdentityFn + prevRemove := removeWindowsSandboxSecretFn + prevReset := resetWindowsSandboxUserPasswordFn + prevGrant := grantWindowsSandboxLogonRightsFn + prevWrite := writeWindowsSandboxSecretFn + t.Cleanup(func() { + provisionWindowsSandboxIdentityFn = prevProvision + removeWindowsSandboxSecretFn = prevRemove + resetWindowsSandboxUserPasswordFn = prevReset + grantWindowsSandboxLogonRightsFn = prevGrant + writeWindowsSandboxSecretFn = prevWrite + }) + + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatal(err) + } + // created=false so the run ADOPTS an account and rotation applies. + provisionWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, string, bool, error) { + return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, "pw", false, nil + } + grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { return nil } + removed := false + removeWindowsSandboxSecretFn = func(string) error { + removed = true + return testCase.removeErr + } + // Rotate, then fail immediately after so undo runs with rotated=true. + resetWindowsSandboxUserPasswordFn = func(string, string) error { + if testCase.rotate { + return nil + } + return errors.New("no rotation") + } + + // The only step after rotation; failing it is what drives undo with + // rotated=true, which is the state the invariant is about. + writeWindowsSandboxSecretFn = func(string, string) error { + return errors.New("secret store refused") + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\ws`, + WorkspaceRoots: []string{`C:\ws`}, + } + _, _, err = provisionWindowsSandboxPrincipalForSetup(config) + + if removed != testCase.wantRemove { + t.Fatalf("stale secret removal attempted = %v, want %v", removed, testCase.wantRemove) + } + if testCase.wantInErr == "" { + return + } + if err == nil { + t.Fatal("a failed stale-secret cleanup was swallowed") + } + if !strings.Contains(err.Error(), testCase.wantInErr) { + t.Fatalf("error = %q, want it to mention %q", err, testCase.wantInErr) + } + }) + } +} From dd3520a6cf2a1d75a44c32c746b8ec0c06cb60c2 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 1 Aug 2026 17:49:33 +0530 Subject: [PATCH 30/45] fix(sandbox): reject reparse ancestors before creating an ACL target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FILE_FLAG_OPEN_REPARSE_POINT only stops the FINAL path component being followed. materializeWindowsACLTarget built its target with os.MkdirAll and os.OpenFile on the pathname, and both resolve ancestors — so the ancestor reparse check on the subsequent no-follow re-open ran only after the objects already existed. An ordinary workspace owner needs no privilege to create a junction. Turning .git into one before elevated setup runs, with config absent, had setup create the target at a location of the attacker's choosing as Administrator. Rejecting it afterwards does not undo that, and the failure path removes only the final component, so every intermediate directory MkdirAll created outside the workspace survived permanently. Creation now goes through makeWindowsACLDirChainNoFollow, which walks up to the deepest existing ancestor and verifies it no-follow first. One check suffices for the whole chain above it because GetFinalPathNameByHandle answers for the entire resolved path. Missing components are then created one at a time, each re-verified immediately after creation, so a component swapped for a junction mid-walk is caught before anything lands underneath it. Taken over the relative-handle NtCreateFile route because x/sys/windows offers no ergonomic relative-create primitive, and this leaves a window of one component with an immediate post-create check rather than create-everything-then-verify. Reported by jatmn on #808. --- internal/sandbox/windows_acl_apply_windows.go | 50 +++++++++- ...dows_acl_junction_ancestor_windows_test.go | 94 +++++++++++++++++++ .../sandbox/windows_acl_reparse_windows.go | 38 ++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 internal/sandbox/windows_acl_junction_ancestor_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 9db9420c2..002ee9c4e 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -314,9 +314,9 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { // directory would break the tool that owns it rather than just mis-ACL it. func materializeWindowsACLTarget(path string, asFile bool) error { if !asFile { - return os.MkdirAll(path, 0o700) + return makeWindowsACLDirChainNoFollow(path) } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + if err := makeWindowsACLDirChainNoFollow(filepath.Dir(path)); err != nil { return err } handle, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) @@ -330,3 +330,49 @@ func materializeWindowsACLTarget(path string, asFile bool) error { } return handle.Close() } + +// makeWindowsACLDirChainNoFollow is a reparse-safe os.MkdirAll. It walks up to +// the deepest ancestor that already exists and verifies it no-follow; because +// GetFinalPathNameByHandle answers for the whole resolved path, that one check +// clears every ancestor above it too. Only then does it create the missing +// components, one level at a time, re-verifying each immediately after creating +// it so a component swapped for a junction mid-walk is caught before anything is +// created underneath it. +// +// os.MkdirAll cannot be used here: it resolves ancestors, so a workspace owner +// who turned .git into a junction before elevated setup ran got the target +// CREATED outside the approved tree, and openWindowsACLTarget's reparse check +// only rejected it afterwards — too late to un-create it, and the error path +// removes only the final component, leaving every intermediate directory behind. +func makeWindowsACLDirChainNoFollow(dir string) error { + cleaned := filepath.Clean(strings.TrimSpace(dir)) + if cleaned == "" || cleaned == "." { + return fmt.Errorf("materialize windows ACL target: empty directory path %q", dir) + } + var missing []string + current := cleaned + for { + err := verifyWindowsACLPathComponentNotRedirected(current) + if err == nil { + break + } + if !errors.Is(err, os.ErrNotExist) { + return err + } + missing = append(missing, current) + parent := filepath.Dir(current) + if parent == current { + return fmt.Errorf("materialize windows ACL target %s: no existing ancestor to anchor on", dir) + } + current = parent + } + for index := len(missing) - 1; index >= 0; index-- { + if err := os.Mkdir(missing[index], 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return err + } + if err := verifyWindowsACLPathComponentNotRedirected(missing[index]); err != nil { + return err + } + } + return nil +} diff --git a/internal/sandbox/windows_acl_junction_ancestor_windows_test.go b/internal/sandbox/windows_acl_junction_ancestor_windows_test.go new file mode 100644 index 000000000..63efdf946 --- /dev/null +++ b/internal/sandbox/windows_acl_junction_ancestor_windows_test.go @@ -0,0 +1,94 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// makeJunction points link at target, skipping the test when the environment +// refuses to create one. A junction needs no privilege, which is exactly why +// this attack is reachable by an ordinary workspace owner. +func makeJunction(t *testing.T, link, target string) { + t.Helper() + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil { + t.Skipf("cannot create a junction here: %v: %s", err, out) + } + // Assert it actually redirects. A junction that silently did nothing would + // green this test while proving nothing. + probe := filepath.Join(link, "redirect-probe") + if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil { + t.Fatalf("write through junction: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "redirect-probe")); err != nil { + t.Fatalf("junction does not redirect, so this test would prove nothing: %v", err) + } + if err := os.Remove(probe); err != nil { + t.Fatalf("clean probe: %v", err) + } +} + +// Elevated setup must not create anything through a reparse-point ancestor. +// +// FILE_FLAG_OPEN_REPARSE_POINT only stops the FINAL component being followed. +// materializeWindowsACLTarget used os.MkdirAll/os.OpenFile on the pathname, both +// of which resolve ancestors, so a workspace owner who turned .git into a +// junction before setup ran got objects created at a location of their choosing +// — as Administrator — and the no-follow check only rejected it afterwards, far +// too late to un-create them. +func TestMaterializeRefusesAncestorJunctionBeforeCreating(t *testing.T) { + for name, asFile := range map[string]bool{"file target": true, "directory target": false} { + t.Run(name, func(t *testing.T) { + external := t.TempDir() + workspace := t.TempDir() + gitDir := filepath.Join(workspace, ".git") + makeJunction(t, gitDir, external) + + target := filepath.Join(gitDir, "hooks", "config") + err := materializeWindowsACLTarget(target, asFile) + if err == nil { + t.Fatalf("materialized %s through a junction ancestor instead of refusing", target) + } + if !strings.Contains(err.Error(), "reparse") { + t.Fatalf("refused for the wrong reason: %v", err) + } + // Nothing may survive on the other side of the junction. The old code + // left every intermediate directory MkdirAll had created. + leaked, lerr := os.ReadDir(external) + if lerr != nil { + t.Fatalf("read external dir: %v", lerr) + } + if len(leaked) != 0 { + names := make([]string, 0, len(leaked)) + for _, entry := range leaked { + names = append(names, entry.Name()) + } + t.Fatalf("created %v outside the workspace through the junction", names) + } + }) + } +} + +// The ordinary path still works: no reparse point anywhere, target gets made. +func TestMaterializeStillCreatesOrdinaryTargets(t *testing.T) { + root := t.TempDir() + for name, asFile := range map[string]bool{"file target": true, "directory target": false} { + t.Run(name, func(t *testing.T) { + target := filepath.Join(root, name, "nested", "deeper", "target") + if err := materializeWindowsACLTarget(target, asFile); err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("target was not created: %v", err) + } + if info.IsDir() == asFile { + t.Fatalf("target isDir=%v, wanted file=%v", info.IsDir(), asFile) + } + }) + } +} diff --git a/internal/sandbox/windows_acl_reparse_windows.go b/internal/sandbox/windows_acl_reparse_windows.go index bebbf5b0f..7465e677d 100644 --- a/internal/sandbox/windows_acl_reparse_windows.go +++ b/internal/sandbox/windows_acl_reparse_windows.go @@ -66,3 +66,41 @@ func trimWindowsExtendedPathPrefix(path string) string { } return strings.TrimPrefix(path, devicePrefix) } + +// verifyWindowsACLPathComponentNotRedirected opens one path component no-follow +// and refuses it if it is a reparse point or resolves anywhere other than its own +// pathname. Because GetFinalPathNameByHandle answers for the WHOLE resolved path, +// verifying a single existing component also clears every ancestor above it. +// +// A missing component surfaces as os.ErrNotExist so the caller can walk further +// up. Only FILE_READ_ATTRIBUTES is requested: this inspects, it never writes, and +// asking for more would fail on ancestors the setup process has no rights on. +func verifyWindowsACLPathComponentNotRedirected(path string) error { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return fmt.Errorf("encode windows ACL path component %s: %w", path, err) + } + handle, err := windows.CreateFile( + utf16Path, + windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + // syscall.Errno.Is maps ERROR_FILE_NOT_FOUND/ERROR_PATH_NOT_FOUND to + // os.ErrNotExist, so the caller's errors.Is check keeps working. + return fmt.Errorf("open windows ACL path component %s: %w", path, err) + } + defer func() { _ = windows.CloseHandle(handle) }() + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("inspect windows ACL path component %s: %w", path, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path) + } + return verifyWindowsACLTargetNotRedirected(handle, path) +} From 331a26545a22a11e879be6e3bbc6db810641b85f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 1 Aug 2026 21:01:26 +0530 Subject: [PATCH 31/45] fix(sandbox): keep the principal inside the Windows write jail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The principal branch handed the raw LogonUser token to CreateProcessAsUser. That token is a full token for the account, so the sandboxed child kept every write its ambient memberships grant. The ACL plan can add grants and denies at named paths, but it cannot revoke what BUILTIN\Users, Authenticated Users or NT AUTHORITY\BATCH already allow elsewhere — so an opted-in command whose profile permitted writes only to the workspace and runtime roots could still write C:\Users\Public\Documents, which grants BATCH modify and which a batch logon therefore satisfies. The principal now gets its own identity AND the restricted token, not one or the other: reads stay confined by its ACEs, writes by the restricted-SID check. The principal's own SID joins the capability SIDs deliberately. applyWindowsPrincipalACLs grants the workspace to identity.SID rather than to a capability SID, so omitting it would leave the workspace grant matching nothing in the restricted list — a jail that locks out the inmate and no one else. The SID is read back from the token itself rather than threaded through the call, so it cannot drift from the identity actually running. Not fixed here, and separate from this finding: worldSID is unconditionally in the restricted-SID list, so any path whose DACL grants Everyone still satisfies the restricted check on both this path and the pre-existing fallback. That predates the principal work and is raised with the maintainers separately. Reported by jatmn on #808. --- .../sandbox/windows_command_runner_windows.go | 23 +++++- .../windows_principal_jail_windows_test.go | 77 +++++++++++++++++++ internal/sandbox/windows_token_windows.go | 48 ++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/windows_principal_jail_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 22d290d3e..e42b85385 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -92,7 +92,28 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ } if ok { defer principalToken.Close() - exitCode, err := runWindowsCommandAsUser(principalToken, config) + // The principal gets its own identity AND the write jail, not one or the + // other. Its ACEs confine reads; without the restricted token it would + // still hold every write its ambient memberships grant, so a profile + // permitting writes only to the workspace could still write anywhere + // BATCH or BUILTIN\Users may — C:\Users\Public\Documents, for one. + // + // The principal's own SID joins the capability SIDs because the ACL plan + // grants the workspace to that SID; leaving it out jails the principal + // out of the tree it is supposed to own. + principalUser, err := principalToken.GetTokenUser() + if err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": read sandbox principal SID: "+err.Error()) + return 1 + } + jailSIDs := append(append([]string{}, tokenSIDs...), principalUser.User.Sid.String()) + jailedToken, err := restrictWindowsTokenForCapabilitySIDs(principalToken, jailSIDs, writeRestricted) + if err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } + defer jailedToken.Close() + exitCode, err := runWindowsCommandAsUser(jailedToken, config) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 diff --git a/internal/sandbox/windows_principal_jail_windows_test.go b/internal/sandbox/windows_principal_jail_windows_test.go new file mode 100644 index 000000000..873a710c3 --- /dev/null +++ b/internal/sandbox/windows_principal_jail_windows_test.go @@ -0,0 +1,77 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// The principal path must apply the write jail, not just hand over the account's +// own token. A LogonUser token carries every write the account's ambient +// memberships grant, so without this the profile's write roots are advisory. +// +// Driven with the process token as base because minting a real principal token +// needs an elevated, provisioned machine; the restriction machinery under test +// is identical either way. +func TestRestrictWindowsTokenJailsWritesOutsideCapabilitySIDs(t *testing.T) { + var base windows.Token + desired := uint32(windows.TOKEN_DUPLICATE | windows.TOKEN_QUERY | windows.TOKEN_ASSIGN_PRIMARY | + windows.TOKEN_ADJUST_DEFAULT | windows.TOKEN_ADJUST_SESSIONID | windows.TOKEN_ADJUST_PRIVILEGES) + if err := windows.OpenProcessToken(windows.CurrentProcess(), desired, &base); err != nil { + t.Skipf("cannot open the process token here: %v", err) + } + defer base.Close() + + // A capability SID granted nowhere near the probe directory. + capSID, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid) + if err != nil { + t.Fatal(err) + } + jailed, err := restrictWindowsTokenForCapabilitySIDs(base, []string{capSID.String()}, true) + if err != nil { + t.Skipf("cannot build a restricted token here: %v", err) + } + defer jailed.Close() + + // Setup assertion: the unrestricted process can write here, so a denial below + // is the jail and not a broken fixture. + dir := t.TempDir() + target := filepath.Join(dir, "written.txt") + if err := os.WriteFile(target, []byte("probe"), 0o600); err != nil { + t.Fatalf("SETUP INVALID: the test process itself cannot write %s: %v", target, err) + } + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + + config := WindowsSandboxCommandConfig{ + CommandCWD: dir, + WorkspaceRoots: []string{dir}, + Command: []string{"cmd", "/c", "echo probe> " + target}, + } + if _, err := runWindowsCommandAsUser(jailed, config); err != nil { + t.Fatalf("run under the jailed token: %v", err) + } + if _, err := os.Stat(target); err == nil { + t.Error("the jailed token wrote a path no capability SID covers; the write jail is not applied") + } +} + +// parseWindowsCapabilitySIDs must reject an empty list rather than build an +// unrestricted token, and must not leak the SIDs it already parsed on failure. +func TestParseWindowsCapabilitySIDsRejectsEmptyAndBadInput(t *testing.T) { + if _, err := parseWindowsCapabilitySIDs(nil); err == nil { + t.Error("empty SID list accepted; that would build a token with no restriction") + } + valid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid) + if err != nil { + t.Fatal(err) + } + if _, err := parseWindowsCapabilitySIDs([]string{valid.String(), "not-a-sid"}); err == nil { + t.Error("an unparseable SID was accepted") + } +} diff --git a/internal/sandbox/windows_token_windows.go b/internal/sandbox/windows_token_windows.go index a02e9b001..41e5ac53f 100644 --- a/internal/sandbox/windows_token_windows.go +++ b/internal/sandbox/windows_token_windows.go @@ -48,6 +48,54 @@ func (sid windowsLocalSID) close() { } } +// restrictWindowsTokenForCapabilitySIDs applies the same write jail to an +// arbitrary base token that createWindowsRestrictedTokenForCapabilitySIDs +// applies to the calling process's own. +// +// The sandbox principal path needs this. A LogonUser token is a full token for +// that account: the ACL plan can deny it at named paths, but it cannot revoke +// what the account's ambient memberships already grant, so an opted-in command +// could still write any path whose DACL admits BUILTIN\Users, Authenticated +// Users, or NT AUTHORITY\BATCH - C:\Users\Public\Documents being the obvious +// one - regardless of the profile's write roots. +// +// The caller must include the principal's OWN SID among the capability SIDs. +// The plan grants the workspace to that SID rather than to a capability SID, so +// without it the restricted-SID check has nothing to match and the principal +// loses its own workspace: a jail that locks out the inmate and no one else. +func restrictWindowsTokenForCapabilitySIDs(base windows.Token, capabilitySIDStrings []string, writeRestricted bool) (windows.Token, error) { + capabilitySIDs, err := parseWindowsCapabilitySIDs(capabilitySIDStrings) + if err != nil { + return 0, err + } + defer func() { + for _, sid := range capabilitySIDs { + sid.close() + } + }() + return createWindowsRestrictedTokenFromBase(base, capabilitySIDs, writeRestricted) +} + +// parseWindowsCapabilitySIDs converts SID strings, closing what it already +// allocated if one fails to parse. +func parseWindowsCapabilitySIDs(values []string) ([]windowsLocalSID, error) { + if len(values) == 0 { + return nil, errors.New("windows restricted token requires at least one capability SID") + } + parsed := make([]windowsLocalSID, 0, len(values)) + for _, value := range values { + sid, err := newWindowsLocalSID(value) + if err != nil { + for _, existing := range parsed { + existing.close() + } + return nil, fmt.Errorf("parse windows capability SID %q: %w", value, err) + } + parsed = append(parsed, sid) + } + return parsed, nil +} + func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string, writeRestricted bool) (windows.Token, error) { if len(capabilitySIDStrings) == 0 { return 0, errors.New("windows restricted token requires at least one capability SID") From f7f24ea014d7f72d5784ce62d77030c9765c6777 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 3 Aug 2026 11:36:23 +0530 Subject: [PATCH 32/45] fix(sandbox): carry the principal opt-in through the setup protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elevated setup and the commands that later use a principal each read ZERO_WINDOWS_SANDBOX_IDENTITY from their own process environment, and setup runs in a separate, UAC-elevated process whose environment is not the caller's. commandConfig() returned a nil Env, so the gate in windows_setup_windows.go fell through to os.Getenv in the elevated helper. The two halves could disagree and nothing detected it. Direction B is the dangerous one: command opted in, setup did not. Marker validation passed (the marker recorded nothing about the opt-in), the principal lookup then declined with a nil error, and the command ran on the same-user restricted token — which by its own comment does not confine reads — while the operator believed an account boundary was isolating it. No warning fired. Direction A left an orphaned account holding batch-logon rights and workspace ACEs that teardown never retires, because teardown sits inside the same opt-in branch. The opt-in is now resolved in the shell the user typed `zero sandbox setup` into and serialized across the UAC boundary as --sandbox-principal 0|1. The marker records it (schema 4 -> 5) and validation refuses on mismatch, before the ACL and network checks. Both directions refuse rather than fall back silently: an unreadable value is rejected outright, since guessing "off" would provision a weaker sandbox than asked for and report success. PrincipalOptIn is a *bool, not a bool. Unset means "consult the environment" rather than "opted out", so the existing smoke-test callers that do not set it keep working instead of serializing --sandbox-principal 0 while the command half still consults os.Getenv. Dropped the deny-mode fallback warning added alongside this. Announcing it looked right, since deny is the default mode and an opted-in operator therefore never gets a principal for ordinary commands. But this runner is re-exec'd per command, so its sync.Once is once per COMMAND: the notice would print on nearly every tool call, and noise that repeats gets filtered rather than acted on. It is also not actionable per command. `zero doctor` carries the opt-in now and is the right surface for a standing configuration fact. The deny-mode behaviour stays pinned by TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied. Reported by jatmn on #808. --- internal/cli/sandbox.go | 7 + internal/doctor/hardening.go | 4 + .../runner_windows_integration_test.go | 9 + .../windows_identity_runtime_windows.go | 61 +++- .../windows_identity_runtime_windows_test.go | 98 +++++++ internal/sandbox/windows_setup.go | 133 ++++++++- internal/sandbox/windows_setup_test.go | 263 ++++++++++++++++++ 7 files changed, 559 insertions(+), 16 deletions(-) diff --git a/internal/cli/sandbox.go b/internal/cli/sandbox.go index 56e09988a..7c850a99b 100644 --- a/internal/cli/sandbox.go +++ b/internal/cli/sandbox.go @@ -163,10 +163,17 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app if !setupHelper.Available() { return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider) } + // Resolved here, in the shell the user typed `zero sandbox setup` into, and + // carried in the args. The helper may be launched elevated, and an elevated + // process does not inherit this shell's environment. Stated explicitly rather + // than left nil (which resolves the same way) because this is the call site + // the opt-in is about. + principalOptIn := zeroSandbox.WindowsSandboxPrincipalOptIn(nil) setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{ CommandCWD: workspaceRoot, WorkspaceRoots: []string{workspaceRoot}, PermissionProfile: profile, + PrincipalOptIn: &principalOptIn, }) if err != nil { return writeAppError(stderr, err.Error(), exitCrash) diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index baf21e04c..90ffb7d23 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -104,6 +104,10 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo CommandCWD: workspaceRoot, WorkspaceRoots: []string{workspaceRoot}, PermissionProfile: profile, + // Same opt-in a command would resolve, so doctor reports the principal + // mismatch as out-of-date setup instead of passing a check the next command + // will fail. + PrincipalOptIn: sandbox.WindowsSandboxPrincipalOptIn(nil), } if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil { result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{ diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index d0f715744..a54d1227d 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -351,6 +351,15 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) { t.Helper() + // options.PrincipalOptIn is deliberately left nil by both call sites, which + // makes BuildWindowsSandboxSetupArgs resolve the opt-in from this process's + // environment — the same value the command half resolves, since the smoke + // WindowsSandboxCommandArgsOptions carries no explicit entry either. Do not + // "fix" this by setting it to false: anyone running this suite with + // ZERO_WINDOWS_SANDBOX_IDENTITY=1 (the only way to exercise the principal + // backend) would then serialize `--sandbox-principal 0`, disagree with the + // command half, and fail every command at marker validation instead of + // testing the sandbox. args, err := BuildWindowsSandboxSetupArgs(options) if err != nil { t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 7b1ca58a4..5f1bc62d9 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -24,20 +24,10 @@ import ( "golang.org/x/sys/windows" ) -// windowsSandboxIdentityEnv opts a machine into the principal backend while it -// is still experimental. Provisioning is inert without it, so an existing -// install keeps the restricted-token behaviour until someone turns this on. -const windowsSandboxIdentityEnv = "ZERO_WINDOWS_SANDBOX_IDENTITY" - -// windowsSandboxIdentityEnabled reports whether the principal backend is opted -// into. Kept as a function so the check reads the environment at call time, -// which is what lets a test or an elevated setup run flip it. -func windowsSandboxIdentityEnabled(env map[string]string) bool { - if value, ok := env[windowsSandboxIdentityEnv]; ok { - return strings.TrimSpace(value) == "1" - } - return strings.TrimSpace(os.Getenv(windowsSandboxIdentityEnv)) == "1" -} +// The opt-in itself (windowsSandboxIdentityEnv and windowsSandboxIdentityEnabled) +// lives in windows_setup.go: it is part of the setup protocol, which the elevated +// half and the command half both have to read the same way, so it cannot be +// Windows-only. // windowsSandboxWorkspaceKey derives the per-workspace key a principal is named // after. It hashes the workspace root the same way the sandbox runtime keys its @@ -88,13 +78,34 @@ func windowsSandboxPrincipalEligible(config WindowsSandboxCommandConfig) bool { // rather than downgrading around. func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.Token, bool, error) { if !windowsSandboxPrincipalEligible(config) { + // Deliberately silent, and this is a change of mind worth recording. + // + // Announcing it looks right: deny is the DEFAULT network mode, so an + // operator who opted in never gets a principal for ordinary commands, and + // that is worth knowing. But the warning cannot be delivered here. This + // runner is re-exec'd per command as `zero __windows-command-runner`, so the + // sync.Once below is once per COMMAND, not once per session — the notice + // would land on the stderr of essentially every sandboxed tool call. Noise + // that repeats gets filtered by the reader rather than acted on, which is + // the exact failure the helper's own comment warns about. + // + // It is also not actionable per command: windowsSandboxPrincipalEligible + // prefers network enforcement over read confinement on purpose, so there is + // nothing to do differently. A standing configuration fact belongs on a + // surface read once — `zero doctor`, which carries the opt-in now. return 0, false, nil } key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) identity, err := lookupWindowsSandboxPrincipalForCommand(key) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { - // Not provisioned: fall back quietly, this is the default state. + // Not provisioned. On the restricted-token tier the marker check has + // already refused a command whose opt-in disagrees with setup, so reaching + // here means the unelevated tier, which validates no marker at all and + // cannot provision an account (that needs Administrator). Falling back is + // right — refusing would break every machine-wide opt-in that relies on + // the unelevated tier — but it must not be silent. + warnWindowsSandboxPrincipalNotUsed("no sandbox principal is provisioned for this workspace; `zero sandbox setup` from an elevated (Administrator) terminal provisions one") return 0, false, nil } // The name resolves to something that is not a usable principal, most @@ -151,6 +162,26 @@ var warnWindowsSandboxPrincipalUnavailable = func(username string) { var windowsSandboxPrincipalWarnOnce sync.Once +// warnWindowsSandboxPrincipalNotUsed covers the other ways an opted-in command +// ends up on the restricted token: the principal is ineligible for this +// command's policy, or none is provisioned on a tier that validates no marker. +// Neither is an error — both are correct fallbacks — but both leave the operator +// believing an account boundary is isolating them when it is not, which is the +// one thing this backend must never do quietly. +// +// Once per process and behind its own sync.Once, so it neither silences nor is +// silenced by the provisioned-but-secretless warning above. +var warnWindowsSandboxPrincipalNotUsed = func(reason string) { + windowsSandboxPrincipalNotUsedWarnOnce.Do(func() { + fmt.Fprintf(os.Stderr, + "[zero] %s is set, but this command is not running as a sandbox principal: %s. "+ + "Falling back to the restricted-token sandbox, which does not confine reads.\n", + windowsSandboxIdentityEnv, reason) + }) +} + +var windowsSandboxPrincipalNotUsedWarnOnce sync.Once + // provisionWindowsSandboxPrincipalForSetup does the elevated half: create the // account, grant it the batch logon right, and store its password locked to the // invoking user. Called from `zero sandbox setup`. diff --git a/internal/sandbox/windows_identity_runtime_windows_test.go b/internal/sandbox/windows_identity_runtime_windows_test.go index 26f2852a8..9b7bc5ae5 100644 --- a/internal/sandbox/windows_identity_runtime_windows_test.go +++ b/internal/sandbox/windows_identity_runtime_windows_test.go @@ -4,9 +4,107 @@ package sandbox import ( "os" + "strings" + "sync" "testing" ) +// An opted-in command that ends up on the restricted token anyway must say so. +// Both cases below are correct fallbacks, not errors — but silence leaves the +// operator believing an account boundary is isolating them when it is not, which +// is the same failure the setup-protocol opt-in check exists to prevent, reached +// from the other side. The deny case matters most: deny is the DEFAULT network +// mode, so a fully provisioned, fully agreeing setup still never uses the +// principal for an ordinary command. +func TestWindowsSandboxPrincipalFallbackIsAnnounced(t *testing.T) { + testCases := []struct { + name string + mode NetworkMode + reason string + }{ + // The network-deny case is deliberately absent. It used to warn here, but + // this runner is re-exec'd per command, so the sync.Once guarding the notice + // is once per COMMAND — and deny is the default mode, so the warning landed + // on nearly every tool call. That fact belongs to `zero doctor` now, which is + // read once. The deny-mode BEHAVIOUR is still pinned, by + // TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied below. + {name: "no principal provisioned on this machine", mode: NetworkAllow, reason: "no sandbox principal is provisioned"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var warned []string + originalWarn := warnWindowsSandboxPrincipalNotUsed + warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) } + windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{} + t.Cleanup(func() { + warnWindowsSandboxPrincipalNotUsed = originalWarn + windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{} + }) + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: testCase.mode}, + }, + Env: map[string]string{windowsSandboxIdentityEnv: "1"}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo"}, + } + // Assert the precondition rather than assume it: this must be the quiet + // fallback path, not a token this host actually minted and not an error. + token, ok, err := windowsSandboxPrincipalToken(config) + if ok { + token.Close() + t.Fatalf("host unexpectedly provisioned a principal; this test cannot measure the fallback") + } + if err != nil { + t.Fatalf("windowsSandboxPrincipalToken error = %v, want the quiet fallback", err) + } + if len(warned) != 1 { + t.Fatalf("opted-in fallback warnings = %v, want exactly one naming %q", warned, testCase.reason) + } + if !strings.Contains(warned[0], testCase.reason) { + t.Fatalf("warning = %q, want it to name %q", warned[0], testCase.reason) + } + }) + } +} + +// The opt-out must stay silent, or the warning becomes noise every user learns +// to ignore. +func TestWindowsSandboxPrincipalFallbackIsSilentWhenOptedOut(t *testing.T) { + var warned []string + originalWarn := warnWindowsSandboxPrincipalNotUsed + warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) } + windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{} + t.Cleanup(func() { + warnWindowsSandboxPrincipalNotUsed = originalWarn + windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{} + }) + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + Env: map[string]string{windowsSandboxIdentityEnv: "0"}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo"}, + } + if _, ok, err := windowsSandboxPrincipalToken(config); ok || err != nil { + t.Fatalf("windowsSandboxPrincipalToken ok=%v err=%v, want the quiet opted-out fallback", ok, err) + } + if len(warned) != 0 { + t.Fatalf("opted-out command warned %v, want silence", warned) + } +} + // Setup must stay inert unless the principal backend is explicitly opted into. // This is the property that makes the branch safe to merge while the privileged // paths are still being validated: without the opt-in, `zero sandbox setup` diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 3fc9634e8..269730457 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -15,13 +15,78 @@ import ( const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe" -const windowsSandboxSetupMarkerSchemaVersion = 4 +const windowsSandboxSetupMarkerSchemaVersion = 5 + +// windowsSandboxIdentityEnv opts a machine into the principal backend while it +// is still experimental. Provisioning is inert without it, so an existing +// install keeps the restricted-token behaviour until someone turns this on. +// +// Lives here, beside the setup protocol rather than beside the Windows-only +// runtime, because the opt-in is part of that protocol: it has to be readable on +// every platform so the setup args and the marker can carry it. +const windowsSandboxIdentityEnv = "ZERO_WINDOWS_SANDBOX_IDENTITY" + +// windowsSandboxIdentityEnabled reports whether the principal backend is opted +// into. An explicit entry in env is authoritative; otherwise the process +// environment decides. +func windowsSandboxIdentityEnabled(env map[string]string) bool { + if value, ok := env[windowsSandboxIdentityEnv]; ok { + return strings.TrimSpace(value) == "1" + } + return strings.TrimSpace(os.Getenv(windowsSandboxIdentityEnv)) == "1" +} + +// WindowsSandboxPrincipalOptIn resolves the principal opt-in for callers outside +// this package (the `zero sandbox setup` CLI and `zero doctor`), so both sides +// of the setup protocol read the opt-in the same way. Pass nil to consult the +// current process environment. +func WindowsSandboxPrincipalOptIn(env map[string]string) bool { + return windowsSandboxIdentityEnabled(env) +} + +func windowsSandboxPrincipalOptInValue(optIn bool) string { + if optIn { + return "1" + } + return "0" +} type WindowsSandboxSetupArgsOptions struct { SandboxHome string CommandCWD string WorkspaceRoots []string PermissionProfile PermissionProfile + // PrincipalOptIn is the caller's principal opt-in, serialized into the setup + // args. Elevated setup runs in its own process — a UAC-elevated one whose + // environment is not the caller's — so it must be told the value rather than + // left to sample an environment nobody set. + // + // Tri-state on purpose. nil means "this caller did not resolve the opt-in", + // and BuildWindowsSandboxSetupArgs then resolves it from the environment of + // the process building the args — which is the caller's own process, the one + // place where the ambient value is the value the operator typed. A plain bool + // could not say that: its zero value asserts "opted out", so every caller that + // simply did not know about this field would serialize `--sandbox-principal 0` + // while the command half still resolved the opt-in from its environment. Under + // a machine-wide opt-in the two halves would then disagree and marker + // validation would refuse every command — the same silent-disagreement bug + // this flag exists to remove, re-created one layer up. + // + // Set it only to override the environment (a caller holding a command's Env + // map, or a test pinning a value); leave it nil to mean "whatever this shell + // says", which is what `zero sandbox setup` and `zero doctor` want. + PrincipalOptIn *bool +} + +// principalOptIn resolves the tri-state. It runs inside +// BuildWindowsSandboxSetupArgs, i.e. in the caller's process, before the args +// cross the UAC boundary — so an unset caller still ships an explicit 0|1 that +// the elevated helper can trust. +func (options WindowsSandboxSetupArgsOptions) principalOptIn() bool { + if options.PrincipalOptIn != nil { + return *options.PrincipalOptIn + } + return windowsSandboxIdentityEnabled(nil) } type WindowsSandboxSetupConfig struct { @@ -29,6 +94,7 @@ type WindowsSandboxSetupConfig struct { CommandCWD string WorkspaceRoots []string PermissionProfile PermissionProfile + PrincipalOptIn bool } type WindowsSandboxSetupMarker struct { @@ -43,6 +109,11 @@ type WindowsSandboxSetupMarker struct { NetworkInfraHash string `json:"networkInfraHash"` OfflineFilterSID string `json:"offlineFilterSid"` NetworkFilters int `json:"networkFilters"` + // PrincipalOptIn records whether the run that wrote this marker provisioned a + // sandbox principal. Without it the two halves each sampled their own + // environment and could disagree silently — see + // ValidateWindowsSandboxSetupMarker. + PrincipalOptIn bool `json:"principalOptIn"` } func WindowsSandboxSetupMarkerPath(sandboxHome string) string { @@ -74,6 +145,10 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str "--sandbox-home", sandboxHome, "--command-cwd", commandCWD, "--permission-profile", string(profileJSON), + // Always explicit, never omitted-means-false: the elevated helper must be + // able to tell "the caller wants no principal" from "an older caller said + // nothing", and only the first of those is safe to run silently. + "--sandbox-principal", windowsSandboxPrincipalOptInValue(options.principalOptIn()), } for _, root := range workspaceRoots { args = append(args, "--workspace-root", root) @@ -117,6 +192,24 @@ func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, err } profileJSON = strings.TrimSpace(value) index = next + case "--sandbox-principal": + value, next, err := nextWindowsSandboxFlagValue(args, index) + if err != nil { + return WindowsSandboxSetupConfig{}, err + } + switch strings.TrimSpace(value) { + case "1": + config.PrincipalOptIn = true + case "0": + config.PrincipalOptIn = false + default: + // Refused rather than treated as off: a value this helper cannot read + // is a caller it does not understand, and guessing "no principal" + // there would provision a weaker sandbox than the caller asked for + // while reporting success. + return WindowsSandboxSetupConfig{}, fmt.Errorf("invalid --sandbox-principal %q, want 0 or 1", value) + } + index = next default: return WindowsSandboxSetupConfig{}, fmt.Errorf("unknown windows sandbox setup flag %q", arg) } @@ -148,22 +241,33 @@ func RunWindowsSandboxSetup(args []string, stderr io.Writer) int { return runWindowsSandboxSetup(config, stderr) } +// commandConfig is the command-shaped view the setup half plans against. Its Env +// carries the opt-in the caller serialized into the setup args, so every +// downstream windowsSandboxIdentityEnabled call — the gate that decides whether +// elevated setup provisions a principal at all — reads the caller's intent +// rather than sampling the elevated helper's own environment, which UAC does not +// inherit from the shell the user typed in. func (config WindowsSandboxSetupConfig) commandConfig() WindowsSandboxCommandConfig { return WindowsSandboxCommandConfig{ SandboxHome: config.SandboxHome, CommandCWD: config.CommandCWD, WorkspaceRoots: cloneStrings(config.WorkspaceRoots), PermissionProfile: config.PermissionProfile, + Env: map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(config.PrincipalOptIn)}, SandboxLevel: WindowsSandboxLevelRestrictedToken, } } +// WindowsSandboxSetupConfigFromCommand is how a command asks "was setup run for +// what I need?". It carries the command's own opt-in so marker validation can +// compare it against what setup actually provisioned. func WindowsSandboxSetupConfigFromCommand(config WindowsSandboxCommandConfig) WindowsSandboxSetupConfig { return WindowsSandboxSetupConfig{ SandboxHome: config.SandboxHome, CommandCWD: config.CommandCWD, WorkspaceRoots: cloneStrings(config.WorkspaceRoots), PermissionProfile: config.PermissionProfile, + PrincipalOptIn: windowsSandboxIdentityEnabled(config.Env), } } @@ -198,6 +302,7 @@ func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa NetworkInfraHash: infraHash, OfflineFilterSID: offlineSID, NetworkFilters: len(infraPlan.Filters), + PrincipalOptIn: config.PrincipalOptIn, }, nil } @@ -255,6 +360,32 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { if actual.SchemaVersion != expected.SchemaVersion { return fmt.Errorf("windows sandbox setup is out of date: schema %d, want %d", actual.SchemaVersion, expected.SchemaVersion) } + // The two halves of the protocol run in different processes, so they can + // disagree about the principal opt-in. Refuse the command rather than pick a + // winner. + // + // The direction that matters is the first one: the opt-in is on, setup never + // provisioned an account, and the runtime's lookup declines with a nil error — + // so without this the command runs on the restricted token, which does not + // confine reads, while the operator believes a principal is isolating them. + // A sandbox that is weaker than advertised has to be loud. + // + // The reverse is refused too. It is not the dangerous direction — the command + // gets the well-worn restricted token it asked for — but setup did create a + // local account and grant it ACEs on the workspace, and letting commands run + // as if that had not happened leaves nothing to reconcile it. Both directions + // clear the same way: run `zero sandbox setup` again with the environment you + // actually want. + if actual.PrincipalOptIn != expected.PrincipalOptIn { + if expected.PrincipalOptIn { + return fmt.Errorf("windows sandbox setup is out of date: %s=1 asks for a sandbox principal, but setup provisioned none — "+ + "re-run `zero sandbox setup` from an elevated (Administrator) terminal with %s=1, or unset it to use the restricted-token sandbox", + windowsSandboxIdentityEnv, windowsSandboxIdentityEnv) + } + return fmt.Errorf("windows sandbox setup is out of date: setup provisioned a sandbox principal, but %s is not set for this command — "+ + "set %s=1, or re-run `zero sandbox setup` from an elevated (Administrator) terminal without it to retire the principal", + windowsSandboxIdentityEnv, windowsSandboxIdentityEnv) + } if actual.ACLPlanHash != expected.ACLPlanHash || actual.ACLPlanEntries != expected.ACLPlanEntries { return errors.New("windows sandbox setup is out of date: permission roots or deny lists changed") } diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 0a3c6f044..94170e255 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -156,6 +156,170 @@ func TestWindowsSandboxSetupMarkerRejectsOldSchema(t *testing.T) { } } +// The principal opt-in has to travel in the setup args, because the elevated +// half runs in its own process: a UAC-elevated helper does not inherit the +// environment of the shell that asked for setup. Sampling the ambient +// environment there let the two halves disagree, so the serialized value must +// win over the environment in BOTH directions. +func TestWindowsSandboxSetupPrincipalOptInSurvivesElevatedEnvironment(t *testing.T) { + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkAllow}, + } + testCases := []struct { + name string + optIn bool + ambientEnv string + }{ + // The reported case: the caller's shell opted in, the elevated helper's + // environment has nothing. Without the serialized value setup provisions no + // principal and every later command silently falls back. + {name: "opted in, elevated environment empty", optIn: true, ambientEnv: ""}, + // The mirror: the elevated helper happens to have a machine-wide opt-in the + // caller did not ask for. Setup must not create an account on its own say-so. + {name: "opted out, elevated environment opted in", optIn: false, ambientEnv: "1"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv(windowsSandboxIdentityEnv, testCase.ambientEnv) + optIn := testCase.optIn + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + PrincipalOptIn: &optIn, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + config, err := ParseWindowsSandboxSetupArgs(args) + if err != nil { + t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err) + } + if config.PrincipalOptIn != testCase.optIn { + t.Fatalf("parsed PrincipalOptIn = %v, want %v", config.PrincipalOptIn, testCase.optIn) + } + // This is the value the elevated setup gate actually reads before it + // decides to provision an account. + if got := windowsSandboxIdentityEnabled(config.commandConfig().Env); got != testCase.optIn { + t.Fatalf("elevated setup opt-in = %v, want %v (ambient %s=%q must not decide)", + got, testCase.optIn, windowsSandboxIdentityEnv, testCase.ambientEnv) + } + }) + } +} + +// A setup helper that cannot read the opt-in must refuse rather than default to +// "no principal": provisioning less than the caller asked for and reporting +// success is the silent downgrade this protocol exists to prevent. +func TestParseWindowsSandboxSetupArgsRejectsUnreadablePrincipalOptIn(t *testing.T) { + optIn := true + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{FileSystem: FileSystemPolicy{Kind: FileSystemRestricted}, Network: NetworkPolicy{Mode: NetworkDeny}}, + PrincipalOptIn: &optIn, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + for index, arg := range args { + if arg == "--sandbox-principal" { + args[index+1] = "yes" + } + } + if _, err := ParseWindowsSandboxSetupArgs(args); err == nil || !strings.Contains(err.Error(), "--sandbox-principal") { + t.Fatalf("ParseWindowsSandboxSetupArgs error = %v, want rejection of the unreadable opt-in", err) + } +} + +// Setup and the commands that follow it run in separate processes, so they can +// disagree about the opt-in. The marker records what setup provisioned and the +// command refuses on a mismatch — most of all when the command opted in and +// setup did not, because the runtime's principal lookup declines with a nil +// error and the command would otherwise run on the read-unconfined +// restricted-token backend while the operator believes a principal is isolating +// it. +func TestWindowsSandboxSetupMarkerRejectsPrincipalOptInMismatch(t *testing.T) { + // Neutral ambient environment: the disagreement under test is between the two + // recorded intents, not between either of them and this process. + t.Setenv(windowsSandboxIdentityEnv, "") + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkAllow}, + } + command := func(home string, env map[string]string) WindowsSandboxCommandConfig { + return WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + Env: env, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo"}, + } + } + testCases := []struct { + name string + setupOptIn bool + commandEnv map[string]string + wantError string + }{ + { + name: "command opts in, setup provisioned no principal", + setupOptIn: false, + commandEnv: map[string]string{windowsSandboxIdentityEnv: "1"}, + wantError: "asks for a sandbox principal, but setup provisioned none", + }, + { + name: "setup provisioned a principal, command opts out", + setupOptIn: true, + commandEnv: map[string]string{windowsSandboxIdentityEnv: "0"}, + wantError: "setup provisioned a sandbox principal", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + home := t.TempDir() + setupConfig := WindowsSandboxSetupConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + PrincipalOptIn: testCase.setupOptIn, + } + marker, err := WriteWindowsSandboxSetupMarker(setupConfig) + if err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + // Assert the setup half recorded what it was told before trusting what + // the command half makes of it. + if marker.PrincipalOptIn != testCase.setupOptIn { + t.Fatalf("marker PrincipalOptIn = %v, want %v", marker.PrincipalOptIn, testCase.setupOptIn) + } + // An agreeing command still validates, so the refusal below is about the + // disagreement and not about the marker being unusable. + agreeing := command(home, map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(testCase.setupOptIn)}) + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(agreeing)); err != nil { + t.Fatalf("agreeing command must validate against its own setup: %v", err) + } + + err = ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(home, testCase.commandEnv))) + if err == nil { + t.Fatalf("disagreeing command validated the marker, want refusal") + } + if !strings.Contains(err.Error(), testCase.wantError) { + t.Fatalf("validate error = %v, want it to contain %q", err, testCase.wantError) + } + if !strings.Contains(err.Error(), "zero sandbox setup") { + t.Fatalf("validate error = %v, want the remedy to name `zero sandbox setup`", err) + } + }) + } +} + func TestWindowsSandboxSetupConfigFromCommandPreservesProfileInputs(t *testing.T) { command := WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), @@ -181,6 +345,105 @@ func TestWindowsSandboxSetupConfigFromCommandPreservesProfileInputs(t *testing.T } } +// Serializing the opt-in makes it a field every caller of +// BuildWindowsSandboxSetupArgs could get wrong, so the field is a tri-state and +// its unset meaning is load-bearing: "consult the environment", never "opted +// out". The command half still resolves the opt-in from the process environment +// when its own Env carries no explicit entry, so if an unset setup caller +// asserted false instead, the two halves would disagree under a machine-wide +// opt-in and marker validation would refuse every command — safe, but it bricks +// the caller, and it re-creates the very disagreement this flag removes. That is +// exactly what the existing smoke callers +// (runner_windows_integration_test.go:43 and :52) do: they never set the field. +// +// This test runs on every GOOS and pins the unset default in both ambient +// states, so getting it backwards is a test failure here rather than a surprise +// on a real elevated machine. +func TestWindowsSandboxSetupArgsUnsetPrincipalOptInConsultsEnvironment(t *testing.T) { + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkAllow}, + } + // The command half as an ambient caller declares it: no explicit entry in Env, + // so it resolves the opt-in from the environment. + command := func(home string) WindowsSandboxCommandConfig { + return WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo"}, + } + } + // setupMarkerFor runs the full caller path — build args, cross the (simulated) + // UAC boundary by re-parsing them, write the marker — so what is asserted is + // what an elevated helper would actually have provisioned. + setupMarkerFor := func(t *testing.T, home string, optIn *bool) WindowsSandboxSetupMarker { + t.Helper() + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + PrincipalOptIn: optIn, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + config, err := ParseWindowsSandboxSetupArgs(args) + if err != nil { + t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err) + } + marker, err := WriteWindowsSandboxSetupMarker(config) + if err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + return marker + } + + for _, ambient := range []string{"1", ""} { + name := "machine-wide opt-in" + if ambient == "" { + name = "no opt-in" + } + t.Run(name, func(t *testing.T) { + t.Setenv(windowsSandboxIdentityEnv, ambient) + want := ambient == "1" + + // An unset caller must provision what the environment says. Assert the + // setup half recorded that before trusting the agreement below: a marker + // that recorded the wrong thing could still "agree" if the command half + // were broken in the same direction. + home := t.TempDir() + marker := setupMarkerFor(t, home, nil) + if marker.PrincipalOptIn != want { + t.Fatalf("unset caller recorded PrincipalOptIn = %v, want %v (ambient %s=%q decides)", + marker.PrincipalOptIn, want, windowsSandboxIdentityEnv, ambient) + } + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(home))); err != nil { + t.Fatalf("an unset caller must agree with the ambient command half: %v", err) + } + + // And an explicit value still overrides the environment in both + // directions, or the tri-state would have no third state. + override := !want + overrideHome := t.TempDir() + overrideMarker := setupMarkerFor(t, overrideHome, &override) + if overrideMarker.PrincipalOptIn != override { + t.Fatalf("explicit caller recorded PrincipalOptIn = %v, want %v", overrideMarker.PrincipalOptIn, override) + } + err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(overrideHome))) + if err == nil { + t.Fatalf("an explicit opt-in of %v validated against an ambient command half of %v, want refusal", override, want) + } + if !strings.Contains(err.Error(), windowsSandboxIdentityEnv) { + t.Fatalf("validate error = %v, want it to name %s", err, windowsSandboxIdentityEnv) + } + }) + } +} + func TestWindowsACLPlanHashIsStableAcrossEntryOrder(t *testing.T) { left, err := WindowsACLPlanHash(WindowsACLPlan{Entries: []WindowsACLEntry{ {Action: WindowsACLDenyRead, Path: `C:\workspace\secret`, Capability: "S-1-5-21-3", Materialize: true}, From f318d867283d9259a4c78045eb57baa5cf0af0dc Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 3 Aug 2026 17:16:40 +0530 Subject: [PATCH 33/45] fix(sandbox): revoke principal ACEs on roots that left the policy applyWindowsPrincipalACLs revoked the trustee only from the paths of the plan it was about to apply. A root that LEFT the policy is by definition absent from that plan, so its ACE survived the re-setup marker validation forces and the principal kept write access the current policy no longer grants -- the sandbox widened as a result of being tightened. Teardown repeated the same current-plan-only calculation, so retiring the principal cleaned every path except that one, and then deleted the account, leaving the ACE naming a SID nothing could resolve. Nothing on Windows can answer "which paths hold an ACE for this SID" without walking every volume, so the grants are now written down as they are made: a per-principal record beside the secret, keyed the same way because one sandbox home serves every workspace on the machine. Setup revokes over the union of the recorded paths and the new plan's; teardown revokes over the same union. The record is written as that union BEFORE any DACL changes and narrowed to the granted set after, so a crash in between leaves a superset rather than a record missing paths the run granted. A superset is the safe direction: revoking a path that holds no ACE for the trustee is a no-op. The interesting case is a principal from an earlier setup with no record. Proceeding with an empty prior set would be the fail-open, on the one path where the prior set is not empty but unenumerable. Setup retires that account before provisioning instead: Windows never reuses a deleted local account's RID, so whatever ACEs cannot be found end up naming a principal that no longer exists, and the SID minted next is one no DACL on the machine can already carry. It needs no new operator action, which matters -- there is no `zero sandbox teardown` to send anyone to. Every guard is mutation-checked. Reverting the union to the new plan's paths fails the end-to-end test against real DACLs with "the principal kept its grant on a root the narrowed policy removed"; moving the record after the grant, dropping the retirement, reverting teardown, and trusting an unknown schema each turn a different test red. Two existing tests move to the new applyWindowsPrincipalACLs signature. --- .../windows_identity_runtime_windows.go | 142 ++++++++- internal/sandbox/windows_principal_ledger.go | 160 ++++++++++ .../sandbox/windows_principal_ledger_test.go | 174 +++++++++++ .../windows_principal_ledger_windows_test.go | 291 ++++++++++++++++++ .../sandbox/windows_stale_ace_windows_test.go | 4 +- 5 files changed, 758 insertions(+), 13 deletions(-) create mode 100644 internal/sandbox/windows_principal_ledger.go create mode 100644 internal/sandbox/windows_principal_ledger_test.go create mode 100644 internal/sandbox/windows_principal_ledger_windows_test.go diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 5f1bc62d9..e03d0daa0 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -305,6 +305,28 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig // naming a SID that no longer resolves, which is the orphaned-entry residue this // model exists to avoid. func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() error, error) { + username := windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + // Retire a principal whose grants were never recorded, BEFORE provisioning + // adopts it. + // + // This is the one case where the prior grant set is not empty but unknowable: + // an account from an earlier setup exists, and nothing on Windows can + // enumerate the paths whose DACL names its SID. Carrying on would revoke only + // what the new plan happens to name and leave the rest — the fail-open this + // record exists to close, reached on the single path where it cannot be ruled + // out. + // + // Retiring is a real fix rather than a gesture because Windows never reuses a + // deleted local account's RID: whatever ACEs survive name a principal that no + // longer exists and grant access to nobody, and the SID minted below is one + // no DACL on this machine can already carry. It also needs no new operator + // action, which matters — there is no `zero sandbox teardown` to send anyone + // to, so refusing here would strand the workspace instead of fixing it. + if _, recorded := readWindowsPrincipalACLLedger(config.SandboxHome, username); !recorded { + if err := retireUnrecordedWindowsSandboxPrincipal(config); err != nil { + return nil, err + } + } identity, created, err := provisionWindowsSandboxPrincipalForSetup(config) if err != nil { return nil, err @@ -345,7 +367,7 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er } else if runtimeRoot != "" { writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: runtimeRoot}) } - revertACL, err := applyWindowsPrincipalACLs(identity.SID.String(), filesystem, writeRoots) + revertACL, err := applyWindowsPrincipalACLs(config.SandboxHome, username, identity.SID.String(), filesystem, writeRoots) if err != nil { _ = removePrincipal() return nil, err @@ -363,6 +385,23 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er }, nil } +// retireUnrecordedWindowsSandboxPrincipal removes this workspace's principal +// when one exists, and does nothing when one does not. +// +// The absent case is the ordinary one and is not a problem: with no account +// there is nothing that could be holding an ACE, so a missing record is simply +// a machine where setup has not run yet. +func retireUnrecordedWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) error { + _, err := lookupWindowsSandboxIdentityFn(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + if err != nil { + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + return nil + } + return err + } + return removeWindowsSandboxPrincipalForSetupFn(config) +} + // removeWindowsSandboxPrincipalForSetup retires a workspace's principal in the // order that leaves nothing behind: secret, then ACEs, then LSA logon rights, // then the account itself. Everything keyed to the SID has to go while the SID @@ -397,7 +436,7 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // The rollback is discarded here on purpose, unlike at setup: this is // teardown, the account is about to be deleted, and putting its ACEs back // is the opposite of what the caller asked for. - if paths, pathsErr := windowsPrincipalTeardownPaths(config, identity.SID.String()); pathsErr == nil { + if paths, pathsErr := windowsPrincipalRevocationPaths(config, identity.SID.String()); pathsErr == nil { _, _ = revokeWindowsPrincipalACEs(identity.SID.String(), paths) } if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { @@ -406,7 +445,18 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e } else if !errors.Is(err, errWindowsSandboxIdentityUnavailable) { return err } - return removeWindowsSandboxIdentity(username) + if err := removeWindowsSandboxIdentity(username); err != nil { + return err + } + // Last, and only once the account is actually gone, so a failure anywhere + // above leaves the record describing a principal that still exists. + // + // It describes grants for a SID that no longer resolves, and leaving it would + // have the next setup revoke those paths on behalf of a freshly minted SID + // that never held them. That is a harmless no-op rather than a hole — the + // deleted account's RID is never reused — but a record that outlives its + // principal is a lie the next reader has no way to detect. + return removeWindowsPrincipalACLLedger(config.SandboxHome, username) } // setupWindowsSandboxRuntimeRoot resolves this workspace's runtime root and @@ -509,8 +559,8 @@ func revokeWindowsPrincipalACEs(principalSID string, paths []string) (func() err } // applyWindowsPrincipalACLs writes the principal's ACEs for one policy: it -// revokes whatever this trustee already had on the paths the plan touches, then -// applies the plan. +// revokes whatever this trustee already had on the paths the new plan touches +// AND on the paths an earlier setup recorded, then applies the plan. // // The order is the whole point. applyWindowsACLPlan MERGES into the existing // DACL, so without the revocation first a re-run after narrowing a write root @@ -520,12 +570,16 @@ func revokeWindowsPrincipalACEs(principalSID string, paths []string) (func() err // chance to notice: marker validation refuses commands with "permission roots // or deny lists changed" until setup runs again. // +// The recorded paths are what makes that revocation complete. Revoking only the +// new plan's paths could never reach a root that had LEFT the policy, which is +// precisely the root whose ACE has to go: absent from the new plan, it was +// skipped, so the re-setup that was supposed to resolve the widening preserved +// it instead. +// // Revocation is by TRUSTEE, so it drops every ACE naming this principal on -// these paths whatever an older version of Zero granted. Its rollback is -// discarded on purpose: the only failure path from here removes the principal -// outright, and restoring stale ACEs for an account about to be deleted is the -// residue this exists to prevent. -func applyWindowsPrincipalACLs(principalSID string, filesystem FileSystemPolicy, writeRoots []WritableRoot) (func() error, error) { +// these paths whatever an older version of Zero granted, and a recorded path +// that no longer exists or never held an ACE costs nothing. +func applyWindowsPrincipalACLs(sandboxHome string, username string, principalSID string, filesystem FileSystemPolicy, writeRoots []WritableRoot) (func() error, error) { plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ PrincipalSID: principalSID, WriteRoots: writeRoots, @@ -536,6 +590,25 @@ func applyWindowsPrincipalACLs(principalSID string, filesystem FileSystemPolicy, if err != nil { return nil, err } + granted := windowsACLPlanPaths(plan) + // An unreadable record arrives here as an empty prior set, which taken alone + // would be the fail-open. It cannot be reached: setupWindowsSandboxPrincipal + // retires any principal whose record is missing BEFORE provisioning, so by + // this point either the record is trustworthy or principalSID is one that no + // DACL on this machine has ever been able to name. + recorded, _ := readWindowsPrincipalACLLedger(sandboxHome, username) + stale := unionWindowsPrincipalACLPaths(recorded, granted) + + // Recorded BEFORE a single DACL changes, and as the union rather than the new + // set. A crash between the grant below and the narrowing write would otherwise + // leave a record that omits paths this run granted, and the next policy change + // would strand them in exactly the way this fix exists to prevent. A superset + // is the safe direction to be wrong in: revoking a path that holds no ACE for + // this trustee is a no-op. + if err := writeWindowsPrincipalACLLedger(sandboxHome, username, stale); err != nil { + return nil, err + } + // The revocation's own rollback matters, and discarding it was a real bug. // // It was discarded on the reasoning that the only failure path from here @@ -549,7 +622,7 @@ func applyWindowsPrincipalACLs(principalSID string, filesystem FileSystemPolicy, // // Restoring the pre-revocation DACL first, then the grant, unwinds in the // reverse order they were applied. - restoreRevoked, err := revokeWindowsPrincipalACEs(principalSID, windowsACLPlanPaths(plan)) + restoreRevoked, err := revokeWindowsPrincipalACEs(principalSID, stale) if err != nil { return nil, err } @@ -560,6 +633,26 @@ func applyWindowsPrincipalACLs(principalSID string, filesystem FileSystemPolicy, } return nil, err } + // Narrow the record to what is granted now, so it tracks the policy instead + // of accumulating every root the workspace has ever had. + // + // A failure here fails the setup rather than being shrugged off. The same + // file was written successfully moments ago, so failing now means the sandbox + // home has stopped being writable, and reporting a provisioned sandbox on a + // sandbox home that cannot hold its own state is the kind of quiet this + // backend must not have. Unwinding leaves the union recorded, which is the + // safe direction. + if err := writeWindowsPrincipalACLLedger(sandboxHome, username, granted); err != nil { + if revertErr := revertGrant(); revertErr != nil { + err = errors.Join(err, revertErr) + } + if restoreRevoked != nil { + if restoreErr := restoreRevoked(); restoreErr != nil { + err = errors.Join(err, restoreErr) + } + } + return nil, err + } return func() error { grantErr := revertGrant() // Restore the pre-revocation ACEs even when reverting the grant failed: @@ -608,6 +701,24 @@ func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principal return windowsACLPlanPaths(plan), nil } +// windowsPrincipalRevocationPaths is what teardown actually has to revoke: the +// paths the CURRENT policy describes, plus every path an earlier setup recorded. +// +// Teardown used the current policy alone, which reproduced the setup-side bug on +// the way out. A root the user removed from their policy is missing from today's +// plan, so retiring the principal revoked every ACE except the one that was +// widening the sandbox — and then deleted the account, leaving that ACE naming a +// SID nothing could resolve to clean it up later. +func windowsPrincipalRevocationPaths(config WindowsSandboxCommandConfig, principalSID string) ([]string, error) { + current, err := windowsPrincipalTeardownPaths(config, principalSID) + if err != nil { + return nil, err + } + recorded, _ := readWindowsPrincipalACLLedger( + config.SandboxHome, windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots))) + return unionWindowsPrincipalACLPaths(recorded, current), nil +} + // Seams for the two elevated calls the provisioning rollback depends on, so the // stale-secret recovery path is reachable in tests without an elevated machine. var ( @@ -615,3 +726,12 @@ var ( removeWindowsSandboxSecretFn = removeWindowsSandboxSecret writeWindowsSandboxSecretFn = writeWindowsSandboxSecret ) + +// Seams for the two elevated calls the unrecorded-principal retirement depends +// on, so the decision to retire is observable in a test without a provisioned +// machine — on which the lookup declines for its own reasons and would report +// success whether or not the guard existed. +var ( + lookupWindowsSandboxIdentityFn = lookupWindowsSandboxIdentity + removeWindowsSandboxPrincipalForSetupFn = removeWindowsSandboxPrincipalForSetup +) diff --git a/internal/sandbox/windows_principal_ledger.go b/internal/sandbox/windows_principal_ledger.go new file mode 100644 index 000000000..13705c74c --- /dev/null +++ b/internal/sandbox/windows_principal_ledger.go @@ -0,0 +1,160 @@ +package sandbox + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// A record of the paths a sandbox principal was last granted ACEs on. +// +// applyWindowsPrincipalACLs revokes this trustee before it re-applies, but the +// only paths it could name were the ones in the plan it was about to apply. A +// root that LEFT the policy is absent from that plan, so its ACE survived the +// re-setup marker validation forces and the principal kept write access the +// current policy no longer grants — the sandbox widened as a result of being +// tightened. Teardown repeated the same current-plan-only calculation, so it did +// not clean the leftover either. +// +// Nothing on Windows can answer "which paths hold an ACE for this SID" without +// walking every volume, so the grants have to be written down as they are made. +// +// Keyed by principal, beside the secret and for the same reason: one sandbox +// home serves every workspace on the machine, so a single shared file would let +// one workspace's setup overwrite another's record — reproducing exactly the +// stale-ACE bug this exists to close, one level up. + +const windowsPrincipalACLLedgerSchemaVersion = 1 + +const windowsPrincipalACLLedgerDirName = "windows-principal-acl" + +type windowsPrincipalACLLedger struct { + SchemaVersion int `json:"schemaVersion"` + Paths []string `json:"paths"` +} + +func windowsPrincipalACLLedgerPath(sandboxHome string, username string) (string, error) { + if strings.TrimSpace(sandboxHome) == "" { + return "", errors.New("windows principal ACL ledger: empty sandbox home") + } + if strings.TrimSpace(username) == "" { + return "", errors.New("windows principal ACL ledger: empty principal name") + } + // The same guard the secret path applies, against a caller passing something + // windowsSandboxUserName did not produce. + if strings.ContainsAny(username, `\/:`) || strings.Contains(username, "..") { + return "", fmt.Errorf("windows principal ACL ledger: unsafe principal name %q", username) + } + return filepath.Join(sandboxHome, windowsPrincipalACLLedgerDirName, username+".json"), nil +} + +// readWindowsPrincipalACLLedger returns the paths an earlier setup recorded for +// this principal. +// +// recorded is false for every reason the record cannot be trusted — absent, +// unreadable, malformed, or written to a schema this build does not know — and +// not merely for "absent". Collapsing them is deliberate: there is exactly one +// safe response to any of them, and it is the same one. The prior grant set is +// unknown, and a principal whose grants are unknown cannot be reused. Returning +// an error instead would invite a caller to report it and carry on with an empty +// prior set, which is the fail-open this record exists to close — the one case +// where the previous paths are not empty but unenumerable. +func readWindowsPrincipalACLLedger(sandboxHome string, username string) ([]string, bool) { + path, err := windowsPrincipalACLLedgerPath(sandboxHome, username) + if err != nil { + return nil, false + } + contents, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var ledger windowsPrincipalACLLedger + if err := json.Unmarshal(contents, &ledger); err != nil { + return nil, false + } + if ledger.SchemaVersion != windowsPrincipalACLLedgerSchemaVersion { + return nil, false + } + return trimNonEmptyStrings(ledger.Paths), true +} + +// writeWindowsPrincipalACLLedger records paths for this principal, replacing any +// previous record atomically so an interrupted write cannot leave a truncated +// file — which the reader would then treat as "no principal was ever granted +// anything", the very state it must never guess. +func writeWindowsPrincipalACLLedger(sandboxHome string, username string, paths []string) error { + path, err := windowsPrincipalACLLedgerPath(sandboxHome, username) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create windows principal ACL ledger dir: %w", err) + } + contents, err := json.MarshalIndent(windowsPrincipalACLLedger{ + SchemaVersion: windowsPrincipalACLLedgerSchemaVersion, + Paths: trimNonEmptyStrings(paths), + }, "", " ") + if err != nil { + return fmt.Errorf("marshal windows principal ACL ledger: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-principal-acl-*.tmp") + if err != nil { + return fmt.Errorf("create windows principal ACL ledger temp file: %w", err) + } + tmpPath := tmp.Name() + if _, err := tmp.Write(contents); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return fmt.Errorf("write windows principal ACL ledger temp file: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("close windows principal ACL ledger temp file: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("replace windows principal ACL ledger: %w", err) + } + return nil +} + +// removeWindowsPrincipalACLLedger drops the record. An absent one is not an +// error: this runs on the teardown path, where being gone is the goal. +func removeWindowsPrincipalACLLedger(sandboxHome string, username string) error { + path, err := windowsPrincipalACLLedgerPath(sandboxHome, username) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove windows principal ACL ledger: %w", err) + } + return nil +} + +// unionWindowsPrincipalACLPaths merges path sets for revocation, keeping the +// first spelling of each path. +// +// Deduplication uses the same case-insensitive key the ACL plans use, so a root +// recorded as C:\Ws by one setup and re-granted as c:\ws by the next is one path +// to revoke rather than two. +func unionWindowsPrincipalACLPaths(sets ...[]string) []string { + seen := make(map[string]struct{}) + union := make([]string, 0) + for _, set := range sets { + for _, path := range set { + key := windowsCapabilityPathKey(path) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + union = append(union, path) + } + } + return union +} diff --git a/internal/sandbox/windows_principal_ledger_test.go b/internal/sandbox/windows_principal_ledger_test.go new file mode 100644 index 000000000..26fd36065 --- /dev/null +++ b/internal/sandbox/windows_principal_ledger_test.go @@ -0,0 +1,174 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The point of the record is that a LATER setup can name a root the CURRENT +// policy no longer mentions, so the round trip has to survive the process that +// wrote it having no memory of the paths. +func TestPrincipalACLLedgerRoundTripsRecordedPaths(t *testing.T) { + home := t.TempDir() + paths := []string{`C:\ws\alpha`, `C:\ws\beta`, `C:\cache\runtime`} + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", paths); err != nil { + t.Fatalf("write: %v", err) + } + got, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01") + if !recorded { + t.Fatal("a record this process just wrote read back as untrusted") + } + if strings.Join(got, "|") != strings.Join(paths, "|") { + t.Errorf("read back %v, want %v", got, paths) + } +} + +// One sandbox home serves every workspace on the machine. If the record were a +// single shared file, workspace B's setup would overwrite workspace A's, and A's +// dropped roots would then be unnameable at the next re-setup — the same stale +// ACE this record exists to revoke, produced by the record itself. +func TestPrincipalACLLedgerIsPerPrincipal(t *testing.T) { + home := t.TempDir() + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`}); err != nil { + t.Fatalf("write alpha: %v", err) + } + if err := writeWindowsPrincipalACLLedger(home, "zerosbx02", []string{`C:\ws\beta`}); err != nil { + t.Fatalf("write beta: %v", err) + } + alpha, ok := readWindowsPrincipalACLLedger(home, "zerosbx01") + if !ok || len(alpha) != 1 || alpha[0] != `C:\ws\alpha` { + t.Errorf("first principal's record = %v (ok=%v); a second workspace's setup overwrote it", alpha, ok) + } +} + +// Every untrustworthy record has to read as untrustworthy, not as "nothing was +// ever granted". The caller retires the principal on false; treating a corrupt +// file as an empty prior set is the fail-open. +func TestPrincipalACLLedgerRefusesRecordsItCannotTrust(t *testing.T) { + for name, contents := range map[string]string{ + "truncated mid-write": `{"schemaVersion": 1, "pat`, + "not json at all": "\x00\x01garbage", + "a schema from later": `{"schemaVersion": 99, "paths": ["C:\\ws"]}`, + "a schema from before": `{"paths": ["C:\\ws"]}`, + } { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + path, err := windowsPrincipalACLLedgerPath(home, "zerosbx01") + if err != nil { + t.Fatalf("path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if paths, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01"); recorded { + t.Errorf("read a record it cannot interpret as trustworthy (%v); the caller would then revoke nothing", paths) + } + }) + } + // And an absent one, which is the ordinary first-setup case. + if _, recorded := readWindowsPrincipalACLLedger(t.TempDir(), "zerosbx01"); recorded { + t.Error("a missing record read as trusted") + } +} + +// A partial write must not be readable at all, which is why the file is renamed +// into place rather than written in situ: a reader that saw half a record would +// treat the missing half as never granted. +func TestPrincipalACLLedgerWriteIsAtomic(t *testing.T) { + home := t.TempDir() + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`, `C:\ws\beta`}); err != nil { + t.Fatalf("first write: %v", err) + } + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`}); err != nil { + t.Fatalf("second write: %v", err) + } + path, err := windowsPrincipalACLLedgerPath(home, "zerosbx01") + if err != nil { + t.Fatalf("path: %v", err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + var ledger windowsPrincipalACLLedger + if err := json.Unmarshal(contents, &ledger); err != nil { + t.Fatalf("the replaced record did not parse: %v", err) + } + if len(ledger.Paths) != 1 { + t.Errorf("record = %v, want the second write to have replaced the first outright", ledger.Paths) + } + // No temp files left behind to be mistaken for a record later. + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatalf("read dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("ledger directory holds %d entries, want just the record", len(entries)) + } +} + +// The name comes from windowsSandboxUserName, but the path builder is the last +// thing between a caller and the filesystem, so it refuses anything that could +// escape the directory. +func TestPrincipalACLLedgerPathRefusesUnsafeNames(t *testing.T) { + for _, username := range []string{"", " ", `..\..\evil`, "a/b", `a\b`, "c:evil", "..", "zerosbx..01"} { + if _, err := windowsPrincipalACLLedgerPath(`C:\home`, username); err == nil { + t.Errorf("accepted principal name %q", username) + } + } + if _, err := windowsPrincipalACLLedgerPath("", "zerosbx01"); err == nil { + t.Error("accepted an empty sandbox home") + } + if _, err := windowsPrincipalACLLedgerPath(`C:\home`, "zerosbx01"); err != nil { + t.Errorf("rejected a name windowsSandboxUserName would produce: %v", err) + } +} + +// Removal is idempotent because teardown only cares that the record is gone, +// and a setup that failed before writing one must not make teardown fail too. +func TestPrincipalACLLedgerRemovalToleratesAnAbsentRecord(t *testing.T) { + home := t.TempDir() + if err := removeWindowsPrincipalACLLedger(home, "zerosbx01"); err != nil { + t.Fatalf("remove an absent record: %v", err) + } + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws`}); err != nil { + t.Fatalf("write: %v", err) + } + if err := removeWindowsPrincipalACLLedger(home, "zerosbx01"); err != nil { + t.Fatalf("remove: %v", err) + } + if _, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01"); recorded { + t.Error("the record survived removal") + } +} + +// The union is what setup revokes over. A path in both sets must be revoked +// once, and a root respelled between setups — Windows opens a path whatever its +// casing — is one path, not two. +func TestUnionPrincipalACLPathsDedupesTheWayTheACLPlansDo(t *testing.T) { + union := unionWindowsPrincipalACLPaths( + []string{`C:\Ws\Alpha`, `C:\ws\beta`, " "}, + []string{`c:\ws\alpha`, `C:\ws\gamma`, `C:/ws/beta`}, + ) + if len(union) != 3 { + t.Fatalf("union = %v, want three distinct paths", union) + } + // The first spelling wins: revocation needs a real path, and the recorded one + // is the spelling that was actually granted. + if union[0] != `C:\Ws\Alpha` { + t.Errorf("union[0] = %q, want the recorded spelling kept", union[0]) + } + // The recorded set comes first so a dropped root cannot be crowded out. + if union[1] != `C:\ws\beta` || union[2] != `C:\ws\gamma` { + t.Errorf("union = %v, want the recorded paths before the newly granted ones", union) + } + if got := unionWindowsPrincipalACLPaths(nil, nil); len(got) != 0 { + t.Errorf("union of nothing = %v, want empty", got) + } +} diff --git a/internal/sandbox/windows_principal_ledger_windows_test.go b/internal/sandbox/windows_principal_ledger_windows_test.go new file mode 100644 index 000000000..76d8c64b7 --- /dev/null +++ b/internal/sandbox/windows_principal_ledger_windows_test.go @@ -0,0 +1,291 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// The finding this record exists for, end to end and against real DACLs: setup +// grants two roots, the user removes one from their policy, setup runs again, +// and the removed root must not still name the principal. +// +// Both halves go through the PRODUCTION applyWindowsPrincipalACLs rather than +// the revoke helper, because the mechanism already worked — what did not was the +// call site's idea of which paths to revoke. It could only name the paths of the +// plan it was about to apply, and the dropped root is by definition absent from +// that plan, so the re-setup marker validation forces preserved the very ACE it +// was supposed to clear. +func TestReSetupRevokesARootTheNarrowedPolicyDropped(t *testing.T) { + home := t.TempDir() + username := "zerosbxregression" + root := t.TempDir() + kept := filepath.Join(root, "kept") + dropped := filepath.Join(root, "dropped") + for _, dir := range []string{kept, dropped} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + // Guests: a real, resolvable trustee this process is not a member of, so the + // ACEs below are observable without affecting the test process. + principal := "S-1-5-32-546" + + wide := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: kept}, {Root: dropped}}, + } + if _, err := applyWindowsPrincipalACLs(home, username, principal, wide, wide.WriteRoots); err != nil { + t.Fatalf("first setup: %v", err) + } + if !hasACEForTrustee(t, dropped, principal) { + t.Fatal("precondition: the first setup should have granted the root that is about to leave the policy") + } + + // The user narrows their policy and re-runs setup. Nothing in this call + // mentions the dropped root; only the record does. + narrow := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: kept}}, + } + if _, err := applyWindowsPrincipalACLs(home, username, principal, narrow, narrow.WriteRoots); err != nil { + t.Fatalf("re-setup: %v", err) + } + + if hasACEForTrustee(t, dropped, principal) { + t.Error("the principal kept its grant on a root the narrowed policy removed") + } + if !hasACEForTrustee(t, kept, principal) { + t.Error("revoking over the recorded paths also dropped the grant the narrowed policy still wants") + } + // And the record narrows with the policy, or it would accumulate every root + // the workspace has ever had. + recorded, ok := readWindowsPrincipalACLLedger(home, username) + if !ok { + t.Fatal("no record survived the re-setup") + } + if containsPathFold(recorded, dropped) { + t.Errorf("the record still names %q after the policy dropped it", dropped) + } +} + +// The record is written BEFORE any DACL changes and as the union, not after and +// as the new set. A crash between the grant and a post-hoc write would otherwise +// leave a record missing paths this run granted, stranding them at the next +// policy change — the same bug, one interruption away. +func TestPrincipalACLRecordCoversTheGrantBeforeItIsMade(t *testing.T) { + prevApply := applyWindowsACLPlanFn + t.Cleanup(func() { applyWindowsACLPlanFn = prevApply }) + + home := t.TempDir() + username := "zerosbxtwophase" + workspace := t.TempDir() + stale := filepath.Join(t.TempDir(), "left-the-policy") + if err := writeWindowsPrincipalACLLedger(home, username, []string{stale}); err != nil { + t.Fatalf("seed the record: %v", err) + } + + var atGrant []string + applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) { + if len(plan.Entries) > 0 && plan.Entries[0].Action != windowsACLRevoke { + atGrant, _ = readWindowsPrincipalACLLedger(home, username) + } + return func() error { return nil }, nil + } + + filesystem := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + } + if _, err := applyWindowsPrincipalACLs(home, username, "S-1-5-32-546", filesystem, filesystem.WriteRoots); err != nil { + t.Fatalf("applyWindowsPrincipalACLs: %v", err) + } + + if !containsPathFold(atGrant, stale) || !containsPathFold(atGrant, workspace) { + t.Errorf("record at grant time = %v, want the union of the recorded and newly granted paths", atGrant) + } + after, ok := readWindowsPrincipalACLLedger(home, username) + if !ok { + t.Fatal("no record after a successful setup") + } + if containsPathFold(after, stale) { + t.Errorf("record after = %v, want it narrowed to what is granted now", after) + } + if !containsPathFold(after, workspace) { + t.Errorf("record after = %v, want the granted workspace root", after) + } +} + +// A principal from an earlier setup whose grants were never recorded is the one +// case where the prior set is not empty but unenumerable, and carrying on with +// it is the fail-open: revocation would then cover only what the new plan +// happens to name. Retiring the account instead makes every ACE that cannot be +// found name a SID Windows never reuses. +func TestSetupRetiresAPrincipalWithNoRecordOfItsGrants(t *testing.T) { + for name, testCase := range map[string]struct { + seedRecord bool + identityFound bool + wantRetired int + }{ + "no record and a principal from an earlier setup": {identityFound: true, wantRetired: 1}, + "no record and nothing provisioned": {identityFound: false, wantRetired: 0}, + "a record to reconcile against": {seedRecord: true, identityFound: true, wantRetired: 0}, + } { + t.Run(name, func(t *testing.T) { + config := stubWindowsPrincipalSetup(t) + username := windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + if testCase.seedRecord { + if err := writeWindowsPrincipalACLLedger(config.SandboxHome, username, []string{`C:\ws\recorded`}); err != nil { + t.Fatalf("seed the record: %v", err) + } + } + + lookupWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, error) { + if testCase.identityFound { + return windowsSandboxIdentity{Username: username, SID: guestsSID(t)}, nil + } + return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable + } + retired := 0 + removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { + retired++ + return nil + } + + if _, err := setupWindowsSandboxPrincipal(config); err != nil { + t.Fatalf("setupWindowsSandboxPrincipal: %v", err) + } + if retired != testCase.wantRetired { + t.Errorf("retired the principal %d times, want %d", retired, testCase.wantRetired) + } + }) + } +} + +// A failure to retire has to fail the setup. Reporting success would leave the +// operator believing the sandbox is provisioned while a principal whose grants +// nobody can enumerate is still holding them. +func TestSetupFailsWhenAnUnrecordedPrincipalCannotBeRetired(t *testing.T) { + config := stubWindowsPrincipalSetup(t) + lookupWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, error) { + return windowsSandboxIdentity{Username: "zerosbx", SID: guestsSID(t)}, nil + } + removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { + return errors.New("account is in use") + } + if _, err := setupWindowsSandboxPrincipal(config); err == nil { + t.Fatal("setup reported success after failing to retire a principal it cannot reconcile") + } +} + +// Teardown had the same blind spot as setup: it computed the paths to revoke +// from the CURRENT policy, so retiring a principal cleared every ACE except the +// one on the root that had left the policy — and then deleted the account, which +// left that ACE naming a SID nothing could resolve to clean up later. +func TestTeardownRevokesRecordedPathsTheCurrentPolicyNoLongerNames(t *testing.T) { + home := t.TempDir() + workspace := t.TempDir() + dropped := filepath.Join(t.TempDir(), "left-the-policy") + config := WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + }, + } + username := windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + if err := writeWindowsPrincipalACLLedger(home, username, []string{dropped}); err != nil { + t.Fatalf("seed the record: %v", err) + } + + paths, err := windowsPrincipalRevocationPaths(config, "S-1-5-32-546") + if err != nil { + t.Fatalf("windowsPrincipalRevocationPaths: %v", err) + } + if !containsPathFold(paths, dropped) { + t.Errorf("teardown would revoke %v, missing the recorded root %q the policy no longer names", paths, dropped) + } + if !containsPathFold(paths, workspace) { + t.Errorf("teardown would revoke %v, missing the workspace the current policy grants", paths) + } +} + +// stubWindowsPrincipalSetup replaces every elevated call +// setupWindowsSandboxPrincipal makes, so the decision under test is reachable on +// a machine with nothing provisioned. +func stubWindowsPrincipalSetup(t *testing.T) WindowsSandboxCommandConfig { + t.Helper() + home := t.TempDir() + workspace := t.TempDir() + + prevLookup := lookupWindowsSandboxIdentityFn + prevRemove := removeWindowsSandboxPrincipalForSetupFn + prevProvision := provisionWindowsSandboxIdentityFn + prevGrant := grantWindowsSandboxLogonRightsFn + prevReset := resetWindowsSandboxUserPasswordFn + prevSecret := writeWindowsSandboxSecretFn + prevApply := applyWindowsACLPlanFn + prevCache := sandboxUserCacheDir + t.Cleanup(func() { + lookupWindowsSandboxIdentityFn = prevLookup + removeWindowsSandboxPrincipalForSetupFn = prevRemove + provisionWindowsSandboxIdentityFn = prevProvision + grantWindowsSandboxLogonRightsFn = prevGrant + resetWindowsSandboxUserPasswordFn = prevReset + writeWindowsSandboxSecretFn = prevSecret + applyWindowsACLPlanFn = prevApply + sandboxUserCacheDir = prevCache + }) + + provisionWindowsSandboxIdentityFn = func(key string) (windowsSandboxIdentity, string, bool, error) { + return windowsSandboxIdentity{Username: windowsSandboxUserName(key), SID: guestsSID(t)}, "pw", true, nil + } + grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { return nil } + resetWindowsSandboxUserPasswordFn = func(string, string) error { return nil } + writeWindowsSandboxSecretFn = func(string, string) error { return nil } + applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) { + return func() error { return nil }, nil + } + cache := t.TempDir() + sandboxUserCacheDir = func() (string, error) { return cache, nil } + + return WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + }, + } +} + +func guestsSID(t *testing.T) *windows.SID { + t.Helper() + sid, err := windows.StringToSid("S-1-5-32-546") + if err != nil { + t.Fatalf("StringToSid: %v", err) + } + return sid +} + +func containsPathFold(paths []string, want string) bool { + for _, path := range paths { + if strings.EqualFold(filepath.Clean(path), filepath.Clean(want)) { + return true + } + } + return false +} diff --git a/internal/sandbox/windows_stale_ace_windows_test.go b/internal/sandbox/windows_stale_ace_windows_test.go index 21e1013d4..b6920c897 100644 --- a/internal/sandbox/windows_stale_ace_windows_test.go +++ b/internal/sandbox/windows_stale_ace_windows_test.go @@ -135,7 +135,7 @@ func TestApplyPrincipalACLsRevokesBeforeApplying(t *testing.T) { Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: workspace}}, } - if _, err := applyWindowsPrincipalACLs("S-1-5-32-546", filesystem, filesystem.WriteRoots); err != nil { + if _, err := applyWindowsPrincipalACLs(t.TempDir(), "zero-sbx-test", "S-1-5-32-546", filesystem, filesystem.WriteRoots); err != nil { t.Fatalf("applyWindowsPrincipalACLs: %v", err) } @@ -182,7 +182,7 @@ func TestApplyPrincipalACLsRollbackRestoresTheRevokedACEs(t *testing.T) { Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: workspace}}, } - rollback, err := applyWindowsPrincipalACLs("S-1-5-32-546", filesystem, filesystem.WriteRoots) + rollback, err := applyWindowsPrincipalACLs(t.TempDir(), "zero-sbx-test", "S-1-5-32-546", filesystem, filesystem.WriteRoots) if err != nil { t.Fatalf("applyWindowsPrincipalACLs: %v", err) } From c3bfbc17ad984cf3fb8a6e3e825786a114f0c3f6 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 3 Aug 2026 17:57:43 +0530 Subject: [PATCH 34/45] test(sandbox): compare ACL record paths the way the plans do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two new record tests failed on the Windows runner and passed everywhere else. The plan builder runs every root through normalizeProfilePath, whose EvalSymlinks expands the 8.3 short name GitHub's runners hand out for TEMP, so the record held C:\Users\runneradmin\... while t.TempDir() had returned C:\Users\RUNNER~1\... and an EqualFold on the raw spellings called two spellings of one directory different paths. Production was never affected: the recorded paths and the newly planned paths both go through that same normalization, so setup and teardown agree with each other. This was only the test being naive about what "same path" means on Windows, and a developer whose volume has 8.3 name generation disabled cannot reproduce it — which is how it shipped. The comparison now normalizes both sides through normalizeProfilePath and keys them with windowsCapabilityPathKey, which is what the ACL plans themselves use. Re-checked against the mutation that matters: reverting the revocation to the new plan's paths alone still fails TestReSetupRevokesARootTheNarrowedPolicyDropped, so the looser-looking comparison has not defanged the assertion. --- .../windows_principal_ledger_windows_test.go | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/windows_principal_ledger_windows_test.go b/internal/sandbox/windows_principal_ledger_windows_test.go index 76d8c64b7..b71118cec 100644 --- a/internal/sandbox/windows_principal_ledger_windows_test.go +++ b/internal/sandbox/windows_principal_ledger_windows_test.go @@ -6,7 +6,6 @@ import ( "errors" "os" "path/filepath" - "strings" "testing" "golang.org/x/sys/windows" @@ -281,9 +280,26 @@ func guestsSID(t *testing.T) *windows.SID { return sid } +// containsPathFold compares the way the ACL plans do, which is the only +// comparison that means anything here. +// +// Comparing the raw spellings passed CI on nothing and failed on Windows: the +// plan builder runs every root through normalizeProfilePath, whose EvalSymlinks +// expands the 8.3 short name GitHub's runners hand out for TEMP, so the record +// holds C:\Users\runneradmin\... while t.TempDir() returned C:\Users\RUNNER~1\... +// and EqualFold called two spellings of one directory different paths. A +// developer whose TEMP has no short name never sees it. +// +// Production is self-consistent — both the recorded and the newly planned paths +// go through the same normalization — so this was only ever the test being +// naive about what "same path" means on Windows. func containsPathFold(paths []string, want string) bool { + wanted := windowsCapabilityPathKey(normalizeProfilePath(want)) + if wanted == "" { + return false + } for _, path := range paths { - if strings.EqualFold(filepath.Clean(path), filepath.Clean(want)) { + if windowsCapabilityPathKey(normalizeProfilePath(path)) == wanted { return true } } From f0716ace46ac2a01878380022261c478be566e55 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 7 Aug 2026 13:18:00 +0530 Subject: [PATCH 35/45] fix(sandbox): stop a principal replacing .git to shed its carveouts The write-denied carveouts guarding .git are attached to .git/config and .git/hooks as OBJECTS. The workspace allow grant is inheritable and carries DELETE, and nothing denied DELETE on .git itself, so a principal could rename .git aside, recreate it, and create fresh config and hooks that inherit the allow with no deny of their own. That restores credential.helper and core.hooksPath, and with them arbitrary code execution on the next git command. .git could not simply join sandboxFullyProtectedMetadataNames next to .zero and .agents: that list emits DenyWrite, whose mask includes FILE_GENERIC_WRITE, and git has to write index, objects and refs. Its absence from that list was correct and was also the hole. Add WindowsACLDenyDelete: DELETE, WRITE_DAC and WRITE_OWNER only. Renaming a directory needs DELETE on that directory, so denying it is what closes the replacement. WRITE_DAC and WRITE_OWNER come along because a guard the principal can rewrite is not a guard. FILE_GENERIC_WRITE and FILE_DELETE_CHILD stay out so git keeps working. The ACE is applied uninherited, which is why the narrow mask is safe: inherited onto .git's children it would deny DELETE on every file inside and git could not remove a lock file or a ref. windowsExplicitAccessEntries hardcoded SUB_CONTAINERS_AND_OBJECTS_INHERIT for every directory entry, so inheritance is now decided per action. The entry is not materialized. git creates .git, and an empty one made by setup breaks git init. Reported by @jatmn on #808. --- internal/sandbox/profile.go | 11 +++ internal/sandbox/windows_acl.go | 10 ++ internal/sandbox/windows_acl_apply_windows.go | 26 ++++- .../sandbox/windows_git_rename_guard_test.go | 94 +++++++++++++++++++ .../windows_git_rename_guard_windows_test.go | 81 ++++++++++++++++ internal/sandbox/windows_identity_acl.go | 14 +++ 6 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/windows_git_rename_guard_test.go create mode 100644 internal/sandbox/windows_git_rename_guard_windows_test.go diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 02b0742be..21258ac7e 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -58,6 +58,17 @@ var protectedMetadataNames = []string{".git", ".zero", ".agents"} // gitMetadataWriteCarveouts below. var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"} +// sandboxRenameProtectedMetadataName is the metadata directory that cannot be +// fully write-protected but must still not be REPLACEABLE. +// +// It is deliberately not in the list above. That list denies write, and git has +// to write index, objects and refs. But the carveouts guarding it are attached +// to .git/config and .git/hooks as objects, so a principal that renames .git and +// recreates it gets fresh paths inheriting the workspace allow with no denies, +// which restores credential.helper and core.hooksPath. The Windows ACL plan +// therefore denies DELETE on this directory alone, uninherited. +const sandboxRenameProtectedMetadataName = ".git" + // gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied // under the OS-level sandbox even though the rest of .git is writable to git // subprocesses. Nonexistent paths are harmless no-ops in every backend's diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index b59e0659f..23255a711 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -12,6 +12,16 @@ const ( WindowsACLAllowWrite WindowsACLAction = "allow-write" WindowsACLDenyRead WindowsACLAction = "deny-read" WindowsACLDenyWrite WindowsACLAction = "deny-write" + // WindowsACLDenyDelete denies removing or renaming the object it names, + // WITHOUT denying writes to it or inside it, and without inheriting. + // + // It exists for .git. The write-denied carveouts live on .git/config and + // .git/hooks as objects, so replacing the .git directory discards them: the + // recreated config and hooks inherit the workspace allow with no deny, which + // restores credential.helper and core.hooksPath. .git cannot simply join + // sandboxFullyProtectedMetadataNames, because DenyWrite's mask includes + // FILE_GENERIC_WRITE and git must write index, objects and refs. + WindowsACLDenyDelete WindowsACLAction = "deny-delete" ) type WindowsACLEntry struct { diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 002ee9c4e..39c48c6c5 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -215,10 +215,19 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind if err != nil { return nil, err } + entryInheritance := inheritance + // DenyDelete governs the object it names and nothing beneath it. Inherited + // onto .git's children it would deny DELETE on every file inside, so git + // could not remove a lock file, a ref, or anything else it rewrites, and + // the guard would read as a broken repository rather than as a blocked + // rename. + if entry.Action == WindowsACLDenyDelete { + entryInheritance = windows.NO_INHERITANCE + } out = append(out, windows.EXPLICIT_ACCESS{ AccessPermissions: permissions, AccessMode: accessMode, - Inheritance: inheritance, + Inheritance: entryInheritance, Trustee: windows.TRUSTEE{ TrusteeForm: windows.TRUSTEE_IS_SID, TrusteeType: windows.TRUSTEE_IS_GROUP, @@ -270,6 +279,21 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC return windows.DENY_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil case WindowsACLDenyWrite: return windows.DENY_ACCESS, windows.FILE_GENERIC_WRITE | windows.DELETE | windowsFileDeleteChild | windows.WRITE_DAC | windows.WRITE_OWNER, nil + case WindowsACLDenyDelete: + // Deny removing or RENAMING the object itself, nothing more. Renaming a + // directory needs DELETE on that directory, so denying DELETE is what + // stops .git being moved aside and recreated without its carveouts. + // + // WRITE_DAC and WRITE_OWNER come along because a guard the principal can + // rewrite, or take ownership of and then rewrite, is not a guard. + // + // FILE_GENERIC_WRITE is deliberately absent: git writes index, objects and + // refs constantly, and denying it would break every commit rather than the + // rename. FILE_DELETE_CHILD is absent for the same reason one level down, + // since git deletes its own lock files and refs. Neither is needed here: + // this ACE does not inherit (see windowsExplicitAccessEntries), so it + // governs the .git directory object alone. + return windows.DENY_ACCESS, windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER, nil default: return 0, 0, fmt.Errorf("unsupported windows ACL action %q", action) } diff --git a/internal/sandbox/windows_git_rename_guard_test.go b/internal/sandbox/windows_git_rename_guard_test.go new file mode 100644 index 000000000..f85b800e7 --- /dev/null +++ b/internal/sandbox/windows_git_rename_guard_test.go @@ -0,0 +1,94 @@ +package sandbox + +import ( + "path/filepath" + "testing" +) + +// A sandbox principal must not be able to REPLACE .git. +// +// The write-denied carveouts are attached to .git/config and .git/hooks as +// objects. Rename .git aside, recreate it, and those objects are gone: the fresh +// config and hooks inherit the workspace allow with no deny of their own, which +// hands back credential.helper and core.hooksPath, and with them arbitrary code +// execution on the next git command. +// +// .git cannot join sandboxFullyProtectedMetadataNames to fix this, because that +// list emits DenyWrite, whose mask includes FILE_GENERIC_WRITE. Git has to write +// index, objects and refs. So the directory needs DELETE denied on itself while +// staying writable underneath. +func TestThePrincipalCannotRenameTheGitDirectory(t *testing.T) { + root := filepath.Join("C:\\", "work", "repo") + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-1-2-3-1001", + WriteRoots: []WritableRoot{{ + Root: root, + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + ReadOnlySubpaths: gitMetadataWriteCarveouts(root), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + gitDir := filepath.Join(root, ".git") + var denyDelete *WindowsACLEntry + for index := range plan.Entries { + if plan.Entries[index].Action == WindowsACLDenyDelete && plan.Entries[index].Path == gitDir { + denyDelete = &plan.Entries[index] + break + } + } + if denyDelete == nil { + t.Fatalf("no deny-delete entry for %s, so the principal can rename .git and recreate it without the carveouts:\n%#v", gitDir, plan.Entries) + } + if denyDelete.Capability != "S-1-5-21-1-2-3-1001" { + t.Errorf("deny-delete names %q, want the principal SID", denyDelete.Capability) + } +} + +// The guard must not become a write ban. Git writes constantly inside .git, so a +// deny that reached the children would break every commit rather than just the +// rename. It also must not be materialized into existence: .git is git's to +// create, and an empty .git directory made by setup breaks `git init`. +func TestTheGitRenameGuardDoesNotBlockGitsOwnWrites(t *testing.T) { + root := filepath.Join("C:\\", "work", "repo") + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-1-2-3-1001", + WriteRoots: []WritableRoot{{ + Root: root, + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + ReadOnlySubpaths: gitMetadataWriteCarveouts(root), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + gitDir := filepath.Join(root, ".git") + for _, entry := range plan.Entries { + if entry.Path != gitDir { + continue + } + if entry.Action == WindowsACLDenyWrite { + t.Errorf("deny-write on %s would stop git writing index/objects/refs", gitDir) + } + if entry.Action == WindowsACLDenyDelete && entry.Materialize { + t.Errorf("the rename guard materializes %s; git must create it, an empty .git breaks git init", gitDir) + } + } + + // The existing carveouts must survive unchanged. + for _, want := range []string{filepath.Join(gitDir, "config"), filepath.Join(gitDir, "hooks")} { + found := false + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && entry.Path == want { + found = true + break + } + } + if !found { + t.Errorf("the write-deny carveout for %s disappeared", want) + } + } +} diff --git a/internal/sandbox/windows_git_rename_guard_windows_test.go b/internal/sandbox/windows_git_rename_guard_windows_test.go new file mode 100644 index 000000000..0b4a9f841 --- /dev/null +++ b/internal/sandbox/windows_git_rename_guard_windows_test.go @@ -0,0 +1,81 @@ +//go:build windows + +package sandbox + +import ( + "testing" + + "golang.org/x/sys/windows" +) + +// The mask is the whole point of the action, so it is asserted bit by bit. +// +// Renaming a directory needs DELETE on the directory itself, so denying DELETE +// is what stops .git being replaced. Everything else in the mask is there to +// stop the principal removing the guard: WRITE_DAC would let it rewrite the +// DACL, WRITE_OWNER would let it take ownership and then rewrite the DACL. +// +// What must NOT be in it matters just as much. FILE_GENERIC_WRITE would stop git +// writing index, objects and refs. FILE_DELETE_CHILD would stop git deleting its +// own lock files and refs. Either one turns a rename guard into a broken repo. +func TestDenyDeleteMaskStopsRenameWithoutStoppingGit(t *testing.T) { + mode, mask, err := windowsACLAccess(WindowsACLDenyDelete) + if err != nil { + t.Fatalf("windowsACLAccess(deny-delete): %v", err) + } + if mode != windows.DENY_ACCESS { + t.Fatalf("access mode = %v, want DENY_ACCESS", mode) + } + + for _, required := range []struct { + name string + bit windows.ACCESS_MASK + }{ + {"DELETE", windows.DELETE}, + {"WRITE_DAC", windows.WRITE_DAC}, + {"WRITE_OWNER", windows.WRITE_OWNER}, + } { + if mask&required.bit == 0 { + t.Errorf("mask %#x is missing %s, so the guard can be removed or bypassed", mask, required.name) + } + } + for _, forbidden := range []struct { + name string + bit windows.ACCESS_MASK + breaks string + }{ + {"FILE_GENERIC_WRITE", windows.FILE_GENERIC_WRITE, "git writing index/objects/refs"}, + {"FILE_DELETE_CHILD", windowsFileDeleteChild, "git deleting its own lock files and refs"}, + } { + if mask&forbidden.bit != 0 { + t.Errorf("mask %#x includes %s, which breaks %s", mask, forbidden.name, forbidden.breaks) + } + } +} + +// Inheritance is the other half. An inherited deny would reach every file inside +// .git and stop git deleting anything at all, so this ACE has to apply to the +// directory object alone while the other actions keep inheriting as before. +func TestDenyDeleteDoesNotInheritWhileOtherActionsStillDo(t *testing.T) { + entries := []WindowsACLEntry{ + {Action: WindowsACLDenyDelete, Path: `C:\work\repo\.git`, Capability: "S-1-5-32-9999"}, + {Action: WindowsACLDenyWrite, Path: `C:\work\repo\.zero`, Capability: "S-1-5-32-9999"}, + {Action: WindowsACLAllowWrite, Path: `C:\work\repo`, Capability: "S-1-5-32-9999"}, + } + + access, err := windowsExplicitAccessEntries(entries, true) + if err != nil { + t.Fatalf("windowsExplicitAccessEntries: %v", err) + } + if len(access) != len(entries) { + t.Fatalf("got %d access entries, want %d", len(access), len(entries)) + } + if access[0].Inheritance != windows.NO_INHERITANCE { + t.Errorf("deny-delete inheritance = %#x, want NO_INHERITANCE; an inherited deny would stop git deleting inside .git", access[0].Inheritance) + } + for index, entry := range entries[1:] { + if got := access[index+1].Inheritance; got != windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT { + t.Errorf("%s inheritance = %#x, want the directory default to be unchanged", entry.Action, got) + } + } +} diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index d21f50792..e37f0f87d 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -124,6 +124,20 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla Materialize: true, }) } + // .git gets DELETE denied on the directory itself, because the carveouts + // that protect it are attached to .git/config and .git/hooks as OBJECTS. + // Rename .git aside and recreate it and those objects are gone, so the + // fresh config and hooks inherit the workspace allow with no deny of their + // own, handing back credential.helper and core.hooksPath. + // + // Not DenyWrite (git writes index, objects and refs), not materialized + // (git creates .git, and an empty one breaks git init), and not inherited, + // so everything underneath stays writable. + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyDelete, + Path: filepath.Join(cleaned, sandboxRenameProtectedMetadataName), + Capability: input.PrincipalSID, + }) } // Then the grants the principal cannot work without. From 5d1d827dd921008b0b4d8e7a0de12ad51202119a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 7 Aug 2026 23:57:42 +0530 Subject: [PATCH 36/45] feat(sandbox): handle-relative directory create and delete on Windows Groundwork for the two remaining materialization P1s. No call sites yet; the walk and the rollback move onto these next. Every pathname-based call re-resolves the whole path inside the kernel when it runs, so verifying a component and then creating through it are two separate resolutions of the same string. A workspace owner can swap an ancestor for a junction in that gap. Demonstrated, not theorised: with the same swap performed between verify and create, os.Mkdir put the new directory OUTSIDE the approved tree ("landed inside approved tree: false, ESCAPED outside approved tree: true"), and the post-create check cannot un-create it. A handle pins the object rather than the name, so a create resolved against it cannot be redirected however the path is later rearranged. os.Mkdir, os.OpenFile and os.RemoveAll are pathname-based by construction with no relative form on Windows, hence NtCreateFile with OBJECT_ATTRIBUTES.RootDirectory. x/sys/windows already exposes every piece, so this adds no hand-rolled syscall bindings. createWindowsACLChildDirectory reports whether it created or opened, because rollback must delete only what it made; removing a directory that already existed would destroy a user's data over an unrelated failure. deleteWindowsACLChildDirectory is the counterpart that rollback needs: os.RemoveAll on a pathname whose ancestor has since become a junction recurses outside the workspace. The fix also makes the race testable. Against the old code the swap had to be threaded into the middle of a function; here it is three ordinary lines between an open and a create, because the handle is held across them. Both tests perform the real swap and assert the operation stayed inside the verified directory, and the delete test leaves a bystander under the decoy whose survival proves the delete never resolved by path. Refs #808. --- .../sandbox/windows_acl_relative_windows.go | 200 ++++++++++++++++++ .../windows_acl_relative_windows_test.go | 172 +++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 internal/sandbox/windows_acl_relative_windows.go create mode 100644 internal/sandbox/windows_acl_relative_windows_test.go diff --git a/internal/sandbox/windows_acl_relative_windows.go b/internal/sandbox/windows_acl_relative_windows.go new file mode 100644 index 000000000..5d176f898 --- /dev/null +++ b/internal/sandbox/windows_acl_relative_windows.go @@ -0,0 +1,200 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Handle-relative directory operations. +// +// WHY THESE EXIST. Every pathname-based call re-resolves the whole path inside +// the kernel at the moment it runs. So verifying a component and then creating +// through it are two separate resolutions of the same string, and a workspace +// owner can swap an ancestor for a junction in the gap between them: setup +// verifies, the attacker swaps, setup creates, and the object lands outside the +// approved tree. Checking again afterwards is too late, because the thing has +// already been created somewhere it should not be. +// +// A HANDLE pins the object rather than the name. Once a directory is open, that +// handle keeps referring to the same directory however the path is later +// rearranged, so creating a child relative to it cannot be redirected. os.Mkdir, +// os.OpenFile and os.RemoveAll are pathname-based by construction with no +// relative form on Windows, which is why this drops to NtCreateFile with +// OBJECT_ATTRIBUTES.RootDirectory. + +// IO_STATUS_BLOCK.Information values for a create/open, named because "2 means +// it was created" is not something a reader should have to look up. +const ( + windowsFileOpened uintptr = 1 + windowsFileCreated uintptr = 2 +) + +// windowsACLDirectoryShare is the share mode every open here uses. A sandbox +// tree is live, so refusing to share would fail on any directory something else +// happens to have open: a denial of service on ourselves rather than a security +// property. +const windowsACLDirectoryShare = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE + +// openWindowsACLDirectoryNoFollow opens an existing directory by pathname, +// refusing to traverse or land on a reparse point. +// +// This is the ANCHOR for a handle-relative walk: the one pathname resolution +// that has to happen, with everything below it relative to the handle it +// returns. FILE_FLAG_OPEN_REPARSE_POINT stops the final component being +// followed, and verifyWindowsACLTargetNotRedirected then confirms no ancestor +// redirected either, because GetFinalPathNameByHandle answers for the whole +// resolved path. +func openWindowsACLDirectoryNoFollow(path string) (windows.Handle, error) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, fmt.Errorf("encode windows ACL directory %s: %w", path, err) + } + handle, err := windows.CreateFile( + utf16Path, + windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windowsACLDirectoryShare, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + // Errno.Is maps the not-found codes to os.ErrNotExist, so a caller + // walking up to find the deepest existing ancestor keeps working. + return 0, fmt.Errorf("open windows ACL directory %s: %w", path, err) + } + if err := verifyWindowsACLHandleIsCleanDirectory(handle, path); err != nil { + _ = windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +// createWindowsACLChildDirectory creates one directory directly beneath parent, +// or opens it when it already exists, and reports which happened. +// +// name must be a single component. The kernel resolves it relative to the parent +// HANDLE, so nothing above it is consulted and nothing above it can be swapped +// underneath us. FILE_OPEN_REPARSE_POINT means an existing child that is a +// junction is opened AS the junction rather than followed, so the caller's +// verification can reject it. +// +// created is true only when this call made the directory, which the rollback +// needs: removing one that already existed would delete a user's data over a +// failure that had nothing to do with it. +func createWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, created bool, err error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, false, fmt.Errorf("encode windows ACL directory component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var status windows.IO_STATUS_BLOCK + if err := windows.NtCreateFile( + &handle, + windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE|windows.DELETE, + &attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_DIRECTORY, + windowsACLDirectoryShare, + windows.FILE_OPEN_IF, + windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT, + 0, + 0, + ); err != nil { + return 0, false, fmt.Errorf("create windows ACL directory component %s: %w", name, err) + } + return handle, status.Information == windowsFileCreated, nil +} + +// deleteWindowsACLChildDirectory removes one directory directly beneath parent. +// +// The counterpart to the create above, and the reason rollback cannot use +// os.RemoveAll: that takes a pathname, so an ancestor swapped to a junction +// AFTER the object was created sends the recursive delete somewhere else and +// takes unrelated trees with it. Resolving relative to the parent handle makes +// that impossible, and FILE_DIRECTORY_FILE refuses anything that is not a +// directory rather than deleting it. +// +// A missing child is not an error: rollback runs on failure paths where the +// object may never have been created. +func deleteWindowsACLChildDirectory(parent windows.Handle, name string) error { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return fmt.Errorf("encode windows ACL directory component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var status windows.IO_STATUS_BLOCK + if err := windows.NtCreateFile( + &handle, + windows.DELETE|windows.SYNCHRONIZE, + &attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_DIRECTORY, + windowsACLDirectoryShare, + windows.FILE_OPEN, + windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_DELETE_ON_CLOSE, + 0, + 0, + ); err != nil { + if isWindowsNotExist(err) { + return nil + } + return fmt.Errorf("open windows ACL directory component %s for delete: %w", name, err) + } + // FILE_DELETE_ON_CLOSE performs the removal; closing is what commits it. + if err := windows.CloseHandle(handle); err != nil { + return fmt.Errorf("delete windows ACL directory component %s: %w", name, err) + } + return nil +} + +// verifyWindowsACLHandleIsCleanDirectory rejects a handle that landed on a +// reparse point, or on an object other than the path asked for. +func verifyWindowsACLHandleIsCleanDirectory(handle windows.Handle, path string) error { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("inspect windows ACL directory %s: %w", path, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path) + } + return verifyWindowsACLTargetNotRedirected(handle, path) +} + +// isWindowsNotExist reports a missing-object error from either side of the API. +// NtCreateFile returns NTSTATUS values, which do not map to os.ErrNotExist the +// way the Win32 error codes do. +func isWindowsNotExist(err error) bool { + if err == nil { + return false + } + if os.IsNotExist(err) { + return true + } + var status windows.NTStatus + if errors.As(err, &status) { + return status == windows.STATUS_OBJECT_NAME_NOT_FOUND || status == windows.STATUS_OBJECT_PATH_NOT_FOUND + } + return false +} diff --git a/internal/sandbox/windows_acl_relative_windows_test.go b/internal/sandbox/windows_acl_relative_windows_test.go new file mode 100644 index 000000000..0ed777b17 --- /dev/null +++ b/internal/sandbox/windows_acl_relative_windows_test.go @@ -0,0 +1,172 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// THE SWAP THAT PATHNAMES CANNOT SURVIVE. +// +// This is the race behind the materialization P1. A pathname-based create +// resolves the whole path again at the moment it runs, so an ancestor replaced +// between the verification and the create sends the new directory somewhere else +// entirely. Verifying afterwards is too late: the object already exists in the +// wrong place. +// +// A handle pins the OBJECT, not the name. This test performs the swap for real, +// in the window that used to be exploitable, and asserts the child still lands +// in the directory that was verified. +// +// Worth noting for anyone extending this: the fix is what makes the race +// testable at all. Against the old code the swap had to be threaded into the +// middle of a function; here it is three ordinary lines between an open and a +// create, because the handle is held across them. +func TestChildCreationFollowsTheHandleNotThePath(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "approved") + elsewhere := filepath.Join(root, "elsewhere") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + + // Verified once, exactly as setup does before it materializes anything. + parent, err := openWindowsACLDirectoryNoFollow(approved) + if err != nil { + t.Fatalf("open approved directory: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + // THE SWAP, in the window that used to be exploitable: move the verified + // directory aside and leave a junction to somewhere else wearing its name. + moved := filepath.Join(root, "approved-moved") + if err := os.Rename(approved, moved); err != nil { + t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err) + } + makeJunction(t, approved, elsewhere) + + // The create resolves against the handle, so it must ignore the junction now + // sitting at the original pathname. + child, created, err := createWindowsACLChildDirectory(parent, "materialized") + if err != nil { + t.Fatalf("create child relative to the pinned handle: %v", err) + } + defer func() { _ = windows.CloseHandle(child) }() + if !created { + t.Error("created = false for a directory that did not exist") + } + + if _, err := os.Stat(filepath.Join(moved, "materialized")); err != nil { + t.Errorf("the child did not land in the verified directory: %v", err) + } + if _, err := os.Stat(filepath.Join(elsewhere, "materialized")); err == nil { + t.Fatal("ESCAPED: the child was created through the junction, outside the approved tree") + } +} + +// Materialization runs on trees that may already be half-built, so creating an +// existing directory has to be a no-op rather than a failure. created must still +// report the truth, because rollback deletes only what this call made. +func TestCreatingAnExistingChildOpensItInstead(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "already"), 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + handle, created, err := createWindowsACLChildDirectory(parent, "already") + if err != nil { + t.Fatalf("open existing child: %v", err) + } + _ = windows.CloseHandle(handle) + if created { + t.Error("created = true for a directory that already existed; rollback would delete a user's data") + } +} + +// The rollback counterpart. os.RemoveAll on a pathname whose ancestor has since +// become a junction recurses outside the workspace and deletes unrelated trees, +// which is the second P1. Resolving relative to the parent handle cannot. +func TestDeleteFollowsTheHandleNotThePath(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "approved") + elsewhere := filepath.Join(root, "elsewhere") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + // A bystander under the decoy: if the delete ever resolves by pathname it is + // reachable, and its survival is what proves the delete did not. + if err := os.Mkdir(filepath.Join(elsewhere, "victim"), 0o700); err != nil { + t.Fatalf("seed victim: %v", err) + } + if err := os.Mkdir(filepath.Join(approved, "victim"), 0o700); err != nil { + t.Fatalf("seed target: %v", err) + } + + parent, err := openWindowsACLDirectoryNoFollow(approved) + if err != nil { + t.Fatalf("open approved directory: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + moved := filepath.Join(root, "approved-moved") + if err := os.Rename(approved, moved); err != nil { + t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err) + } + makeJunction(t, approved, elsewhere) + + if err := deleteWindowsACLChildDirectory(parent, "victim"); err != nil { + t.Fatalf("delete relative to the pinned handle: %v", err) + } + if _, err := os.Stat(filepath.Join(elsewhere, "victim")); err != nil { + t.Fatal("DESTRUCTIVE: rollback followed the junction and deleted a directory outside the approved tree") + } + if _, err := os.Stat(filepath.Join(moved, "victim")); err == nil { + t.Error("the directory inside the approved tree was not removed") + } +} + +// Rollback runs on failure paths where the object may never have been created, +// so a missing child is success rather than an error to report. +func TestDeletingAMissingChildIsNotAnError(t *testing.T) { + root := t.TempDir() + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + if err := deleteWindowsACLChildDirectory(parent, "never-existed"); err != nil { + t.Fatalf("deleting a missing child reported an error: %v", err) + } +} + +// The anchor open is the one pathname resolution in the walk, so it has to +// refuse a junction itself rather than leaving it to a later check. +func TestOpeningAJunctionAnchorIsRefused(t *testing.T) { + root := t.TempDir() + real := filepath.Join(root, "real") + if err := os.Mkdir(real, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + link := filepath.Join(root, "link") + makeJunction(t, link, real) + + handle, err := openWindowsACLDirectoryNoFollow(link) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatal("opened a junction as the materialization anchor; every create beneath it would land outside the approved tree") + } +} From 30e4e8c904b701e0e8868d51c9ae9aeec28123c8 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 14:43:14 +0530 Subject: [PATCH 37/45] fix(sandbox): keep the git rename guard tests portable The two rename-guard tests built their expectations from a hardcoded C:\work\repo, but buildWindowsPrincipalACLPlan normalizes every write root before it names an ACE. On Windows that root is already absolute, so normalizing is a no-op and both tests passed locally. On Linux and macOS it is not absolute, so the plan named a different path and both tests failed. The plan builder is portable code with no build tag, so these tests run on every platform. Use an OS-neutral root and normalize the expected path the same way the plan does, which is what the other untagged test in this package already does. The Windows-only assertions about the ACE mask and its inheritance stay where they are, in the file that is tagged for it. --- internal/sandbox/windows_git_rename_guard_test.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/windows_git_rename_guard_test.go b/internal/sandbox/windows_git_rename_guard_test.go index f85b800e7..4d82f243b 100644 --- a/internal/sandbox/windows_git_rename_guard_test.go +++ b/internal/sandbox/windows_git_rename_guard_test.go @@ -18,7 +18,7 @@ import ( // index, objects and refs. So the directory needs DELETE denied on itself while // staying writable underneath. func TestThePrincipalCannotRenameTheGitDirectory(t *testing.T) { - root := filepath.Join("C:\\", "work", "repo") + root := filepath.FromSlash("/ws/repo") plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ PrincipalSID: "S-1-5-21-1-2-3-1001", WriteRoots: []WritableRoot{{ @@ -31,7 +31,11 @@ func TestThePrincipalCannotRenameTheGitDirectory(t *testing.T) { t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) } - gitDir := filepath.Join(root, ".git") + // The plan normalizes every write root before it names an ACE, so the + // expected path has to be normalized too or this compares two spellings of + // the same directory. Hardcoding a drive letter instead would pass on + // Windows and fail everywhere else, since the builder is portable code. + gitDir := filepath.Join(normalizeProfilePath(root), ".git") var denyDelete *WindowsACLEntry for index := range plan.Entries { if plan.Entries[index].Action == WindowsACLDenyDelete && plan.Entries[index].Path == gitDir { @@ -52,7 +56,7 @@ func TestThePrincipalCannotRenameTheGitDirectory(t *testing.T) { // rename. It also must not be materialized into existence: .git is git's to // create, and an empty .git directory made by setup breaks `git init`. func TestTheGitRenameGuardDoesNotBlockGitsOwnWrites(t *testing.T) { - root := filepath.Join("C:\\", "work", "repo") + root := filepath.FromSlash("/ws/repo") plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ PrincipalSID: "S-1-5-21-1-2-3-1001", WriteRoots: []WritableRoot{{ @@ -65,7 +69,7 @@ func TestTheGitRenameGuardDoesNotBlockGitsOwnWrites(t *testing.T) { t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) } - gitDir := filepath.Join(root, ".git") + gitDir := filepath.Join(normalizeProfilePath(root), ".git") for _, entry := range plan.Entries { if entry.Path != gitDir { continue From 8a8c19b51b09f0b0e708a5fcbc3409799a7dfa7e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 15:13:58 +0530 Subject: [PATCH 38/45] fix(sandbox): bind windows ACL materialization and rollback to handles Closes the two path-swap findings on this PR. Both had the same cause: an object was addressed by a pathname that the kernel re-resolves at the moment the call runs, so a workspace owner could change what that name meant in the gap between checking it and using it. Junctions need no privilege, so this was reachable by exactly the unprivileged user the sandbox exists to contain. Materialization verified a component, closed the handle, then handed the same string to os.Mkdir. Swapping the verified ancestor for a junction inside that gap got the component created outside the approved tree, as Administrator, and verifying again afterwards cannot un-create it. It now opens the deepest existing ancestor once and creates each missing component relative to the handle of the level above, so the tree it descends is pinned to objects rather than named by strings. Rollback called os.RemoveAll on the target pathname, so an ancestor swapped to a junction after creation sent a recursive elevated delete into an unrelated tree. It now records what it created as single components under an anchor and unwinds them handle-relative, deepest first. It also removes the whole chain rather than only the final component, which the old comment claimed was fixed and was not, and it removes only what the kernel confirmed this run created, so a racing creator no longer gets its directory deleted on teardown. Three things this turned up that were not in the original findings: The delete primitive used FILE_DELETE_ON_CLOSE, which defers the removal to cleanup and reports a non-empty directory to nobody: open succeeded, close succeeded, directory still there. Wired into rollback that would have been worse than the os.RemoveAll it replaced, since rollback would report success while leaving state on disk. It now sets the disposition explicitly, so STATUS_DIRECTORY_NOT_EMPTY comes back to the caller. Residue is preferable to recursing through a path someone else controls; lying about it is not. The child create never inspected the handle it returned, so an existing junction was handed back as the next parent in the walk and every deeper create landed on the far side of it. Component names are now validated as single components too: NtCreateFile resolves a relative name containing separators the ordinary way, which would have walked straight through an intermediate junction. Rollback re-opens the anchor by pathname and checks its volume and file index against what materialization saw, because replacing a directory with another real directory of the same name needs no reparse point at all and passes every no-follow check there is. Holding the handle instead would be stronger, but three call sites deliberately discard the rollback closure and would leak. Testing. makeWindowsACLDirChainNoFollow carries a seam that fires between verifying the anchor and creating anything, because a race nobody can trigger on demand is not a regression test. The existing junction test plants its junction before the walk starts and so never reached this. The new test has a control arm that performs the identical swap and creates by pathname, and asserts the object does escape, so the fixed arm proves the hole was open rather than proving some code ran. Also covers the file target, rollback through the closure callers actually hold, and the non-empty case that the old empty-directory-only delete test hid. Validation now runs before the filesystem is touched, so a malformed entry no longer creates a chain and then fails. --- internal/sandbox/windows_acl_apply_windows.go | 336 ++++++++++--- .../sandbox/windows_acl_apply_windows_test.go | 23 +- ...dows_acl_junction_ancestor_windows_test.go | 4 +- ...ndows_acl_materialize_swap_windows_test.go | 453 ++++++++++++++++++ .../sandbox/windows_acl_relative_windows.go | 325 ++++++++++++- .../windows_acl_relative_windows_test.go | 58 +++ 6 files changed, 1122 insertions(+), 77 deletions(-) create mode 100644 internal/sandbox/windows_acl_materialize_swap_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 39c48c6c5..073a57fc1 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -22,10 +22,54 @@ type windowsACLPathGroup struct { MaterializeFile bool } +// windowsACLChainStep is one directory component beneath the anchor, and +// whether THIS run created it. The flag comes from the kernel rather than from +// "it was missing when we looked", because something else can win the gap +// between the probe and the create, and removing a directory the sandbox did not +// make is how a rollback deletes a user's data. +type windowsACLChainStep struct { + Name string + Made bool +} + +// windowsACLMaterialization is exactly what materialization created, recorded in +// the shape rollback needs to undo it without resolving a single pathname below +// the anchor. +// +// The anchor is the deepest directory that already existed, and it is the ONLY +// pathname rollback re-resolves. Everything under it is a list of single +// components walked one handle at a time, because a name containing a separator +// is resolved the ordinary way by the kernel and would follow an intermediate +// junction straight out of the approved tree. +type windowsACLMaterialization struct { + AnchorPath string + AnchorID windowsFileIdentity + // Chain is every component between the anchor and the target, shallow to + // deep. All of them are needed to descend at rollback time; only the ones + // with Made set are removed. + Chain []windowsACLChainStep + // File is the leaf file component created inside the deepest Chain entry, + // for the .git/config carveout. Empty when the target is a directory. + File string + FileMade bool +} + +func (materialization windowsACLMaterialization) createdAnything() bool { + if materialization.FileMade { + return true + } + for _, step := range materialization.Chain { + if step.Made { + return true + } + } + return false +} + type windowsACLSnapshot struct { - Path string - Descriptor *windows.SECURITY_DESCRIPTOR - Materialized bool + Path string + Descriptor *windows.SECURITY_DESCRIPTOR + Created windowsACLMaterialization } func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { @@ -87,8 +131,40 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // elevated setup a lower-privileged local user could swap the target for a // symlink/junction between operations and redirect the ACL change onto a // system object it never validated (issue #728, a TOCTOU privilege boundary). - materialized := false - handle, isDir, err := openWindowsACLTarget(path) + // Reject a malformed group BEFORE touching the filesystem. This validation + // used to run after materialization, so a bad SID or an unknown action + // created a directory chain and only then failed, leaving the error path to + // unwind work that never needed doing. Nothing here depends on isDir: that + // argument only selects the inheritance flag, while the errors come from the + // action lookup and the SID parse. + if _, err := windowsExplicitAccessEntries(group.Entries, false); err != nil { + return windowsACLSnapshot{}, false, err + } + + var created windowsACLMaterialization + var handle windows.Handle + // Every exit from here goes through one closure, because there are now two + // things to undo rather than one: the open handle, and whatever + // materialization created. Both are captured by reference and both start + // zero, so calling this before either is set is safe and does nothing. + // + // The unwind is handle-relative. It must never fall back to a pathname + // delete: the failure being cleaned up here can BE the path swap, and + // os.RemoveAll on a swapped ancestor is precisely the recursive elevated + // delete outside the workspace that this cleanup is supposed to prevent. + fail := func(err error) (windowsACLSnapshot, bool, error) { + if handle != 0 { + _ = windows.CloseHandle(handle) + } + if unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil { + return windowsACLSnapshot{}, false, fmt.Errorf("%w; cleanup failed: %v", err, unwindErr) + } + return windowsACLSnapshot{}, false, err + } + + var isDir bool + var err error + handle, isDir, err = openWindowsACLTarget(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { return windowsACLSnapshot{}, false, err @@ -99,24 +175,18 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo } return windowsACLSnapshot{}, false, nil } - if err := materializeWindowsACLTarget(path, group.MaterializeFile); err != nil { - return windowsACLSnapshot{}, false, fmt.Errorf("materialize windows ACL target %s: %w", path, err) + // created is assigned even on failure: materialization reports what it + // managed to make before it stopped, and fail() unwinds exactly that. + created, err = materializeWindowsACLTarget(path, group.MaterializeFile) + if err != nil { + return fail(fmt.Errorf("materialize windows ACL target %s: %w", path, err)) } - materialized = true handle, isDir, err = openWindowsACLTarget(path) if err != nil { - _ = os.RemoveAll(path) - return windowsACLSnapshot{}, false, fmt.Errorf("open materialized windows ACL target %s: %w", path, err) - } - } - // From here the handle is open; every early return must close it first (and - // remove a freshly materialized target) so a failure leaks neither. - fail := func(err error) (windowsACLSnapshot, bool, error) { - _ = windows.CloseHandle(handle) - if materialized { - _ = os.RemoveAll(path) + // This is the branch that fires when the post-create verify catches a + // swap, so it is the single most important cleanup in the file. + return fail(fmt.Errorf("open materialized windows ACL target %s: %w", path, err)) } - return windowsACLSnapshot{}, false, err } descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) if err != nil { @@ -142,7 +212,7 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // closed now — rollback re-opens no-follow rather than holding a handle for // the whole sandbox lifetime, since one caller discards the rollback closure. _ = windows.CloseHandle(handle) - return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil + return windowsACLSnapshot{Path: path, Descriptor: descriptor, Created: created}, true, nil } // openWindowsACLTarget opens path for reading and rewriting its DACL without @@ -301,11 +371,18 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { var errs []error + // Reverse order is load-bearing twice over. Groups are sorted ascending by + // path key and an ancestor key is always a proper prefix of its descendants, + // so walking backwards unwinds descendant before ancestor: a materialized + // directory is therefore empty by the time its own removal is attempted. + // Restoring ACLs in the same order is independently right, because + // SetSecurityInfo propagates inheritable ACEs down, so the ancestor must go + // last. TestRollbackUnwindsDescendantsBeforeAncestors pins it. for index := len(snapshots) - 1; index >= 0; index-- { snapshot := snapshots[index] - if snapshot.Materialized { - if err := os.RemoveAll(snapshot.Path); err != nil { - errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err)) + if snapshot.Created.createdAnything() { + if err := rollbackWindowsACLMaterialization(snapshot.Created); err != nil { + errs = append(errs, err) } continue } @@ -336,67 +413,212 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { // owning tool expects. A directory target is created whole; a file target gets // its parent chain created and then an empty file, because creating it as a // directory would break the tool that owns it rather than just mis-ACL it. -func materializeWindowsACLTarget(path string, asFile bool) error { +// The returned record is meaningful even when the error is non-nil: a chain that +// got three levels deep and then failed still has three levels to unwind. +func materializeWindowsACLTarget(path string, asFile bool) (windowsACLMaterialization, error) { + directory := path + leaf := "" + if asFile { + directory = filepath.Dir(path) + leaf = filepath.Base(path) + } + created, parent, err := makeWindowsACLDirChainNoFollow(directory) + if err != nil { + return created, err + } + defer func() { _ = windows.CloseHandle(parent) }() if !asFile { - return makeWindowsACLDirChainNoFollow(path) + return created, nil } - if err := makeWindowsACLDirChainNoFollow(filepath.Dir(path)); err != nil { - return err + // A racing creator winning is still fine: the target exists, which is all + // materialization needed. createWindowsACLChildFile reports that as + // created=false, so rollback will not delete a file the sandbox did not make. + created.File = leaf + madeFile, err := createWindowsACLChildFile(parent, leaf) + created.FileMade = madeFile + return created, err +} + +// rollbackWindowsACLMaterialization removes exactly what materialization +// created, deepest first, without resolving any pathname below the anchor. +// +// This is the other half of the pathname problem. The old cleanup called +// os.RemoveAll on the target pathname, which re-resolves every ancestor at the +// moment it runs, so an ancestor swapped to a junction after the object was +// created sent a recursive elevated delete into an unrelated tree. It also only +// ever removed the final component, leaving every intermediate directory the +// chain had created behind. +// +// The anchor is the one pathname that has to be resolved again, and it is +// checked by file identity rather than by name: replacing a directory with +// another REAL directory of the same name needs no reparse point at all and +// would otherwise pass every no-follow check there is. +// +// Residue is preferable to over-deletion throughout. When something cannot be +// removed safely this reports it and leaves it, and never falls back to a +// pathname delete. +func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization) error { + if !materialization.createdAnything() { + return nil } - handle, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + anchor, err := reopenWindowsACLDirectoryAsIdentity(materialization.AnchorPath, materialization.AnchorID) if err != nil { - // A racing creator winning is fine — the target exists, which is all - // materialization needed. Anything else is a real failure. - if errors.Is(err, os.ErrExist) { + if errors.Is(err, os.ErrNotExist) { + // The anchor is gone, so everything created beneath it is gone too. + // Nothing to undo, and no way to undo it if there were. return nil } - return err + return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err) } - return handle.Close() + + // One handle per level: handles[i] is the parent of Chain[i], which is what + // deleting Chain[i] relative to a pinned parent requires. + handles := []windows.Handle{anchor} + defer func() { + for _, handle := range handles { + if handle != 0 { + _ = windows.CloseHandle(handle) + } + } + }() + + // Descend only as far as is actually required. Removing a directory needs its + // PARENT's handle, not its own, so the deepest component is opened only when + // a file leaf lives inside it. This is not just economy: the deepest + // component is usually the ACL target itself, so it may already carry the + // deny-read ACE this rollback is undoing, and opening it would be refused by + // the very ACL being unwound. + needed := len(materialization.Chain) + if !materialization.FileMade && needed > 0 { + needed-- + } + depth := 0 + for ; depth < needed; depth++ { + child, err := openWindowsACLChildDirectory(handles[depth], materialization.Chain[depth].Name) + if err != nil { + // Already removed by something else. Stop descending; whatever is + // below it is gone with it. + if isWindowsNotExist(err) { + break + } + return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err) + } + handles = append(handles, child) + } + + var errs []error + // The file leaf lives inside the deepest chain directory, so it goes first + // and only if the descent actually reached that far. + if materialization.FileMade && depth == needed { + if err := deleteWindowsACLChildFile(handles[len(handles)-1], materialization.File); err != nil { + errs = append(errs, fmt.Errorf("remove materialized windows ACL file %s: %w", materialization.File, err)) + } + } + // Chain[i] is removed through handles[i], so the deepest one that can be + // removed is bounded by how far the descent actually got. + deepest := len(handles) - 1 + if last := len(materialization.Chain) - 1; deepest > last { + deepest = last + } + for index := deepest; index >= 0; index-- { + // Close the child's own handle, if the descent opened one, before asking + // its parent to remove it. A directory with a live handle open still + // counts as present, so the parent's delete would come back as non-empty. + if index+1 < len(handles) && handles[index+1] != 0 { + _ = windows.CloseHandle(handles[index+1]) + handles[index+1] = 0 + } + if !materialization.Chain[index].Made { + continue + } + if err := deleteWindowsACLChildDirectory(handles[index], materialization.Chain[index].Name); err != nil { + errs = append(errs, fmt.Errorf("remove materialized windows ACL directory %s: %w", materialization.Chain[index].Name, err)) + } + } + return errors.Join(errs...) } -// makeWindowsACLDirChainNoFollow is a reparse-safe os.MkdirAll. It walks up to -// the deepest ancestor that already exists and verifies it no-follow; because -// GetFinalPathNameByHandle answers for the whole resolved path, that one check -// clears every ancestor above it too. Only then does it create the missing -// components, one level at a time, re-verifying each immediately after creating -// it so a component swapped for a junction mid-walk is caught before anything is -// created underneath it. +// windowsACLMaterializeSwapHook is a test seam and nothing else. It fires inside +// makeWindowsACLDirChainNoFollow at the exact instant the race used to be +// exploitable: the anchor is verified and pinned, and nothing has been created +// yet. A race reproducible only by luck is not a regression test, so the instant +// is made addressable rather than hoped for. Always nil in production. +var windowsACLMaterializeSwapHook func(anchor string) + +// makeWindowsACLDirChainNoFollow is an os.MkdirAll that never resolves a +// pathname below its anchor. +// +// It walks UP to the deepest ancestor that already exists and opens it +// no-follow. Because GetFinalPathNameByHandle answers for the whole resolved +// path, that single check clears every ancestor above it too. Then it walks back +// DOWN, creating one component at a time relative to the HANDLE of the level +// above, so the tree it descends is pinned to objects rather than named by +// strings. +// +// That is the difference that matters. This used to verify a component by +// pathname, close the handle, and then hand the same string to os.Mkdir: two +// independent kernel resolutions with a gap between them. A workspace owner who +// swapped the verified ancestor for a junction inside that gap got the component +// created outside the approved tree, as Administrator, and verifying again +// afterwards cannot un-create it. Junctions need no privilege, so this was +// reachable by exactly the unprivileged user the sandbox exists to contain. // -// os.MkdirAll cannot be used here: it resolves ancestors, so a workspace owner -// who turned .git into a junction before elevated setup ran got the target -// CREATED outside the approved tree, and openWindowsACLTarget's reparse check -// only rejected it afterwards — too late to un-create it, and the error path -// removes only the final component, leaving every intermediate directory behind. -func makeWindowsACLDirChainNoFollow(dir string) error { +// It deliberately does NOT re-verify each created component by pathname. A child +// created relative to a pinned parent is in the right place by construction, so +// comparing pathnames afterwards would add nothing and would reject correct +// creates whenever the tree was legitimately renamed mid-setup. +// +// Returns what it created, plus an open handle to the deepest directory which +// the caller must close. +func makeWindowsACLDirChainNoFollow(dir string) (windowsACLMaterialization, windows.Handle, error) { cleaned := filepath.Clean(strings.TrimSpace(dir)) if cleaned == "" || cleaned == "." { - return fmt.Errorf("materialize windows ACL target: empty directory path %q", dir) + return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target: empty directory path %q", dir) } + + // Walk up to the deepest ancestor that exists, collecting the component + // NAMES that are missing. Names, not paths: everything below the anchor is + // addressed relative to a handle from here on. var missing []string current := cleaned + var anchor windows.Handle + var anchorID windowsFileIdentity for { - err := verifyWindowsACLPathComponentNotRedirected(current) + handle, identity, err := openWindowsACLDirectoryNoFollowWithIdentity(current) if err == nil { + anchor, anchorID = handle, identity break } if !errors.Is(err, os.ErrNotExist) { - return err + return windowsACLMaterialization{}, 0, err } - missing = append(missing, current) parent := filepath.Dir(current) if parent == current { - return fmt.Errorf("materialize windows ACL target %s: no existing ancestor to anchor on", dir) + return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target %s: no existing ancestor to anchor on", dir) } + missing = append(missing, filepath.Base(current)) current = parent } + + created := windowsACLMaterialization{AnchorPath: current, AnchorID: anchorID} + + if hook := windowsACLMaterializeSwapHook; hook != nil { + hook(current) + } + + // Walk back down, one component per handle. The anchor handle is released as + // soon as its child is open, so at most two levels are held at once. + parent := anchor for index := len(missing) - 1; index >= 0; index-- { - if err := os.Mkdir(missing[index], 0o700); err != nil && !errors.Is(err, os.ErrExist) { - return err - } - if err := verifyWindowsACLPathComponentNotRedirected(missing[index]); err != nil { - return err + name := missing[index] + child, madeNow, err := createWindowsACLChildDirectory(parent, name) + if err != nil { + _ = windows.CloseHandle(parent) + return created, 0, err } + created.Chain = append(created.Chain, windowsACLChainStep{Name: name, Made: madeNow}) + _ = windows.CloseHandle(parent) + parent = child } - return nil + return created, parent, nil } diff --git a/internal/sandbox/windows_acl_apply_windows_test.go b/internal/sandbox/windows_acl_apply_windows_test.go index f0b7675d0..2df9cca0a 100644 --- a/internal/sandbox/windows_acl_apply_windows_test.go +++ b/internal/sandbox/windows_acl_apply_windows_test.go @@ -37,8 +37,15 @@ func TestApplyWindowsACLPathGroupHandleBasedRoundTrip(t *testing.T) { if !applied { t.Fatal("applied = false, want true for an existing directory target") } - if snapshot.Path != dir || snapshot.Materialized { - t.Fatalf("snapshot = %#v, want Path=%q Materialized=false", snapshot, dir) + if snapshot.Path != dir { + t.Fatalf("snapshot.Path = %q, want %q", snapshot.Path, dir) + } + // The target already existed, so nothing was created and rollback must have + // nothing to remove. Asserting the chain rather than a bool matters: a + // rewiring that recorded the walked components instead of only the created + // ones would make rollback delete a directory the sandbox never made. + if snapshot.Created.createdAnything() { + t.Fatalf("snapshot recorded %#v as created for a target that already existed", snapshot.Created) } if snapshot.Descriptor == nil { t.Fatal("snapshot has no captured descriptor to roll back to") @@ -67,8 +74,16 @@ func TestApplyWindowsACLPathGroupMaterializes(t *testing.T) { if err != nil { t.Fatalf("applyWindowsACLPathGroup: %v", err) } - if !applied || !snapshot.Materialized { - t.Fatalf("applied=%v materialized=%v, want both true", applied, snapshot.Materialized) + if !applied { + t.Fatal("applied = false, want true for a materialized target") + } + // Exactly one component was missing, so exactly one must be recorded as + // created, and it must be the leaf's own name rather than a path. + if len(snapshot.Created.Chain) != 1 || snapshot.Created.Chain[0] != (windowsACLChainStep{Name: "created", Made: true}) { + t.Fatalf("created chain = %#v, want one step {created true}", snapshot.Created.Chain) + } + if snapshot.Created.AnchorPath != filepath.Dir(target) { + t.Fatalf("anchor = %q, want the existing parent %q", snapshot.Created.AnchorPath, filepath.Dir(target)) } if _, err := os.Stat(target); err != nil { t.Fatalf("materialized target not created: %v", err) diff --git a/internal/sandbox/windows_acl_junction_ancestor_windows_test.go b/internal/sandbox/windows_acl_junction_ancestor_windows_test.go index 63efdf946..24817ee94 100644 --- a/internal/sandbox/windows_acl_junction_ancestor_windows_test.go +++ b/internal/sandbox/windows_acl_junction_ancestor_windows_test.go @@ -49,7 +49,7 @@ func TestMaterializeRefusesAncestorJunctionBeforeCreating(t *testing.T) { makeJunction(t, gitDir, external) target := filepath.Join(gitDir, "hooks", "config") - err := materializeWindowsACLTarget(target, asFile) + _, err := materializeWindowsACLTarget(target, asFile) if err == nil { t.Fatalf("materialized %s through a junction ancestor instead of refusing", target) } @@ -79,7 +79,7 @@ func TestMaterializeStillCreatesOrdinaryTargets(t *testing.T) { for name, asFile := range map[string]bool{"file target": true, "directory target": false} { t.Run(name, func(t *testing.T) { target := filepath.Join(root, name, "nested", "deeper", "target") - if err := materializeWindowsACLTarget(target, asFile); err != nil { + if _, err := materializeWindowsACLTarget(target, asFile); err != nil { t.Fatalf("materializeWindowsACLTarget: %v", err) } info, err := os.Stat(target) diff --git a/internal/sandbox/windows_acl_materialize_swap_windows_test.go b/internal/sandbox/windows_acl_materialize_swap_windows_test.go new file mode 100644 index 000000000..8776b3427 --- /dev/null +++ b/internal/sandbox/windows_acl_materialize_swap_windows_test.go @@ -0,0 +1,453 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// swapAncestorAside moves approved out of the way and leaves a junction wearing +// its name, pointing at elsewhere. This is the whole attack in three lines, and +// it needs no privilege: junctions are creatable by any user, which is exactly +// why an unprivileged workspace owner can aim elevated setup wherever they like. +// +// Returns the path the real directory now lives at. +func swapAncestorAside(t *testing.T, approved, elsewhere string) string { + t.Helper() + moved := approved + "-moved" + if err := os.Rename(approved, moved); err != nil { + t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err) + } + makeJunction(t, approved, elsewhere) + return moved +} + +// requireNothingEscaped fails when anything at all was created on the far side +// of the junction. +func requireNothingEscaped(t *testing.T, elsewhere string) { + t.Helper() + leaked, err := os.ReadDir(elsewhere) + if err != nil { + t.Fatalf("read the decoy directory: %v", err) + } + if len(leaked) == 0 { + return + } + names := make([]string, 0, len(leaked)) + for _, entry := range leaked { + names = append(names, entry.Name()) + } + t.Fatalf("ESCAPED: created %v outside the approved tree, as Administrator", names) +} + +// THE MATERIALIZATION RACE, ON THE PRODUCTION CALL PATH. +// +// The existing junction test plants its junction before the walk even starts, so +// the very first check sees it and refuses. That proves the easy half. The half +// the reviewer filed is the swap that happens AFTER a component has been +// verified and BEFORE it is used, and no test reached it: a race nobody can +// trigger on demand is not a regression test, so makeWindowsACLDirChainNoFollow +// carries a seam that fires at exactly that instant. +// +// The control arm matters as much as the fixed one. It performs the identical +// swap and then does what this code used to do, creating by pathname, and +// asserts that the object DOES escape. Without it, the fixed arm passing proves +// only that some code ran, not that the hole it closes was ever open. +func TestMaterializeSurvivesAnAncestorSwappedMidWalk(t *testing.T) { + t.Run("control: creating by pathname escapes", func(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + elsewhere := filepath.Join(root, "OUTSIDE") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + // Verified, exactly as the walk verifies its anchor. + if err := verifyWindowsACLPathComponentNotRedirected(approved); err != nil { + t.Fatalf("anchor did not verify before the swap: %v", err) + } + moved := swapAncestorAside(t, approved, elsewhere) + + // The old create: a pathname, re-resolved by the kernel right now. + if err := os.MkdirAll(filepath.Join(approved, "a", "b"), 0o700); err != nil { + t.Fatalf("pathname create: %v", err) + } + if _, err := os.Stat(filepath.Join(elsewhere, "a", "b")); err != nil { + t.Fatalf("the control arm did not reproduce the escape, so the fixed arm below proves nothing: %v", err) + } + if _, err := os.Stat(filepath.Join(moved, "a", "b")); err == nil { + t.Error("the control arm created inside the verified directory, which is not the behaviour being contrasted") + } + }) + + t.Run("fixed: creating through the pinned handle stays put", func(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + elsewhere := filepath.Join(root, "OUTSIDE") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + + var moved string + swapped := false + windowsACLMaterializeSwapHook = func(anchor string) { + if swapped { + return + } + swapped = true + if anchor != approved { + t.Errorf("anchored on %q, want the deepest existing ancestor %q", anchor, approved) + } + moved = swapAncestorAside(t, approved, elsewhere) + } + t.Cleanup(func() { windowsACLMaterializeSwapHook = nil }) + + created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + if !swapped { + t.Fatal("the seam never fired, so the swap never happened and this test proved nothing") + } + requireNothingEscaped(t, elsewhere) + if _, err := os.Stat(filepath.Join(moved, "a", "b")); err != nil { + t.Errorf("the target did not land in the directory that was verified: %v", err) + } + // And the record must describe what to unwind, in components rather than + // paths, shallowest first. + if len(created.Chain) != 2 || created.Chain[0].Name != "a" || created.Chain[1].Name != "b" { + t.Fatalf("created chain = %#v, want [a b] shallow to deep", created.Chain) + } + for _, step := range created.Chain { + if !step.Made { + t.Errorf("component %q was not recorded as created, so rollback would leave it behind", step.Name) + } + } + }) +} + +// The FILE target has the same race, and it is the one that matters most: +// .git/config is materialized as a file on every stock setup, and it is the file +// whose credential.helper is worth stealing. +func TestMaterializeFileSurvivesAnAncestorSwappedMidWalk(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + elsewhere := filepath.Join(root, "OUTSIDE") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + + var moved string + swapped := false + windowsACLMaterializeSwapHook = func(string) { + if swapped { + return + } + swapped = true + moved = swapAncestorAside(t, approved, elsewhere) + } + t.Cleanup(func() { windowsACLMaterializeSwapHook = nil }) + + created, err := materializeWindowsACLTarget(filepath.Join(approved, ".git", "config"), true) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + if !swapped { + t.Fatal("the seam never fired, so this test proved nothing") + } + requireNothingEscaped(t, elsewhere) + + landed := filepath.Join(moved, ".git", "config") + info, err := os.Stat(landed) + if err != nil { + t.Fatalf("the file did not land in the directory that was verified: %v", err) + } + if info.IsDir() { + t.Error("materialized .git/config as a directory, which breaks git init") + } + if !created.FileMade || created.File != "config" { + t.Errorf("file record = %q made=%v, want config/true", created.File, created.FileMade) + } +} + +// THE ROLLBACK RACE. The ancestor is swapped AFTER the target was created, which +// is the window the teardown path lives in: minutes or hours, not microseconds. +// +// The bystander is the point. If the unwind resolves by pathname it walks into +// the decoy and deletes what it finds there, recursively and elevated. Its +// survival is the only thing that proves the unwind did not. +func TestRollbackDoesNotFollowAnAncestorSwappedAfterCreation(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + elsewhere := filepath.Join(root, "OUTSIDE") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + + created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + + // A bystander tree under the decoy, shaped exactly like what we created, so a + // pathname unwind would find something to destroy at every level. + bystander := filepath.Join(elsewhere, "a", "b") + if err := os.MkdirAll(bystander, 0o700); err != nil { + t.Fatalf("seed bystander: %v", err) + } + witness := filepath.Join(bystander, "irreplaceable.txt") + if err := os.WriteFile(witness, []byte("not yours to delete"), 0o600); err != nil { + t.Fatalf("seed witness: %v", err) + } + + moved := swapAncestorAside(t, approved, elsewhere) + + // The anchor pathname now names the decoy, and the decoy is a junction, so + // the unwind must refuse rather than proceed. Either way it must not delete. + err = rollbackWindowsACLMaterialization(created) + + if _, statErr := os.Stat(witness); statErr != nil { + t.Fatalf("DESTRUCTIVE: rollback followed the junction and deleted a tree outside the approved directory: %v", statErr) + } + if _, statErr := os.Stat(bystander); statErr != nil { + t.Fatalf("DESTRUCTIVE: rollback removed the bystander directory outside the approved directory: %v", statErr) + } + if err == nil { + t.Error("rollback reported success while unwinding through a swapped ancestor; it must say it could not") + } + // Residue inside the real tree is the accepted price: leaving it is strictly + // better than a recursive delete through a path someone else controls. + if _, statErr := os.Stat(filepath.Join(moved, "a", "b")); statErr != nil { + t.Logf("note: the real tree was also unwound (%v); leaving it would be acceptable too", statErr) + } +} + +// A real directory wearing the anchor's name is a swap with NO reparse point +// anywhere, so every no-follow check in this package passes it. Only the file +// identity notices. +func TestRollbackRefusesAnAnchorReplacedByARealDirectory(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + if err := os.Mkdir(approved, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + created, err := materializeWindowsACLTarget(filepath.Join(approved, "a"), false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + + if err := os.Rename(approved, approved+"-moved"); err != nil { + t.Skipf("cannot rename here: %v", err) + } + // An ordinary directory. Nothing is a link; nothing is a reparse point. + if err := os.Mkdir(approved, 0o700); err != nil { + t.Fatalf("plant the replacement: %v", err) + } + decoy := filepath.Join(approved, "a") + if err := os.Mkdir(decoy, 0o700); err != nil { + t.Fatalf("plant the decoy child: %v", err) + } + + err = rollbackWindowsACLMaterialization(created) + if err == nil { + t.Error("rollback accepted a different directory wearing the anchor's name") + } else if !strings.Contains(err.Error(), "no longer the directory") { + t.Errorf("refused for the wrong reason: %v", err) + } + if _, statErr := os.Stat(decoy); statErr != nil { + t.Errorf("rollback deleted a directory it never created: %v", statErr) + } +} + +// Rollback removes ONLY what this run created. A pre-existing ancestor is walked +// through and left alone. +func TestRollbackLeavesDirectoriesItDidNotCreate(t *testing.T) { + root := t.TempDir() + existing := filepath.Join(root, "ws", "already-here") + if err := os.MkdirAll(existing, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + target := filepath.Join(existing, "made", "deeper") + + created, err := materializeWindowsACLTarget(target, false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + if created.AnchorPath != existing { + t.Fatalf("anchor = %q, want the deepest pre-existing directory %q", created.AnchorPath, existing) + } + if err := rollbackWindowsACLMaterialization(created); err != nil { + t.Fatalf("rollbackWindowsACLMaterialization: %v", err) + } + if _, err := os.Stat(filepath.Join(existing, "made")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("created directory survived rollback: stat err = %v", err) + } + if _, err := os.Stat(existing); err != nil { + t.Errorf("rollback removed a directory that already existed: %v", err) + } +} + +// The whole apply path, through the closure callers actually hold, rather than +// through rollbackWindowsACLSnapshots directly. Every other rollback test in +// this package calls the unwind by hand, which cannot catch applyWindowsACLPlan +// failing to carry the created record into the snapshots it hands over. +func TestApplyWindowsACLPlanClosureRemovesWhatItMaterialized(t *testing.T) { + root := t.TempDir() + directoryTarget := filepath.Join(root, "ws", "hooks") + fileTarget := filepath.Join(root, "ws", "config") + + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLDenyWrite, Path: directoryTarget, Capability: "S-1-1-0", Materialize: true}, + {Action: WindowsACLDenyWrite, Path: fileTarget, Capability: "S-1-1-0", Materialize: true, MaterializeFile: true}, + }} + + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + for _, path := range []string{directoryTarget, fileTarget} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("%s was not materialized: %v", path, err) + } + } + if err := rollback(); err != nil { + t.Fatalf("rollback closure: %v", err) + } + for _, path := range []string{directoryTarget, fileTarget} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%s survived the rollback closure: stat err = %v", path, err) + } + } + // The shared prefix both targets needed must go too, and it is created by + // whichever group runs first rather than being owned by both. + if _, err := os.Stat(filepath.Join(root, "ws")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("the shared parent survived: stat err = %v", err) + } +} + +// A rollback that cannot remove something must SAY so. This is the regression +// guard for the trap a naive handle-relative port walks straight into: +// FILE_DELETE_ON_CLOSE reports success on a non-empty directory and leaves it +// there, which turns a loud failure into a silent lie. The directory being +// populated is not adversarial; .git/hooks fills up the moment git runs. +func TestRollbackReportsWhatItCouldNotRemove(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "ws", "hooks") + created, err := materializeWindowsACLTarget(target, false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + if err := os.WriteFile(filepath.Join(target, "pre-commit"), []byte("#!/bin/sh\n"), 0o600); err != nil { + t.Fatalf("populate: %v", err) + } + + err = rollbackWindowsACLMaterialization(created) + if err == nil { + t.Fatal("rollback reported success on a directory it could not empty, so callers cannot tell teardown failed") + } + if !strings.Contains(strings.ToLower(err.Error()), "hooks") { + t.Errorf("the error does not name what was left behind: %v", err) + } + // Left in place deliberately. Removing it would mean recursing, and recursion + // through a path the workspace owner controls is the thing being avoided. + if _, statErr := os.Stat(target); statErr != nil { + t.Errorf("rollback recursed into a populated directory instead of reporting it: %v", statErr) + } +} + +// The primitives take a single component and the walk relies on that. A joined +// name is resolved the ordinary way by the kernel, so an intermediate junction +// inside it is followed and the object lands outside the anchor: the pinned +// parent buys nothing if the name itself walks. +func TestChildOperationsRefuseNamesThatAreNotSingleComponents(t *testing.T) { + root := t.TempDir() + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + for _, name := range []string{`sub\child`, "sub/child", "..", ".", "", `C:\absolute`, "stream:name"} { + t.Run("create dir "+name, func(t *testing.T) { + handle, _, err := createWindowsACLChildDirectory(parent, name) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatalf("accepted %q, which the kernel would resolve through intermediate directories", name) + } + }) + t.Run("delete dir "+name, func(t *testing.T) { + if err := deleteWindowsACLChildDirectory(parent, name); err == nil { + t.Fatalf("accepted %q for deletion", name) + } + }) + t.Run("create file "+name, func(t *testing.T) { + if _, err := createWindowsACLChildFile(parent, name); err == nil { + t.Fatalf("accepted %q for file creation", name) + } + }) + } +} + +// A junction sitting where a chain component should be must be refused when it +// is OPENED, not merely when it is created. FILE_OPEN_REPARSE_POINT hands back a +// handle to the junction itself, and using that as the next parent puts every +// deeper create on the far side of it. +func TestChildOperationsRefuseAnExistingJunction(t *testing.T) { + root := t.TempDir() + elsewhere := t.TempDir() + makeJunction(t, filepath.Join(root, "hop"), elsewhere) + + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + handle, _, err := createWindowsACLChildDirectory(parent, "hop") + if err == nil { + _ = windows.CloseHandle(handle) + t.Error("createWindowsACLChildDirectory returned a junction as the next parent in the walk") + } + opened, err := openWindowsACLChildDirectory(parent, "hop") + if err == nil { + _ = windows.CloseHandle(opened) + t.Error("openWindowsACLChildDirectory returned a junction to descend through") + } +} + +// Rollback descends; it must never create. If a component was removed by +// something else in the meantime, re-making it and then deleting it would remove +// a directory the sandbox never made. +func TestRollbackDescentNeverCreatesAMissingComponent(t *testing.T) { + root := t.TempDir() + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + handle, err := openWindowsACLChildDirectory(parent, "never-existed") + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatal("the descent open created a directory that did not exist") + } + if !isWindowsNotExist(err) { + t.Errorf("a missing component reported %v, which rollback cannot distinguish from a real failure", err) + } + if _, statErr := os.Stat(filepath.Join(root, "never-existed")); statErr == nil { + t.Error("a directory appeared on disk from an open that should never create") + } +} diff --git a/internal/sandbox/windows_acl_relative_windows.go b/internal/sandbox/windows_acl_relative_windows.go index 5d176f898..08400b325 100644 --- a/internal/sandbox/windows_acl_relative_windows.go +++ b/internal/sandbox/windows_acl_relative_windows.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "strings" "unsafe" "golang.org/x/sys/windows" @@ -41,6 +42,66 @@ const ( // property. const windowsACLDirectoryShare = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE +// windowsFileIdentity is the kernel's own answer to "is this the same object", +// independent of what it is currently called. +// +// It exists because rollback cannot hold the anchor handle open for the whole +// sandbox lifetime (see rollbackWindowsACLSnapshots), so it has to re-open the +// anchor by pathname, and a pathname can be made to name a different object. +// Crucially that substitution needs NO reparse point: rename the real directory +// aside and create an ordinary directory wearing its name, and every no-follow +// check still passes because nothing anywhere is a link. Comparing the volume +// and file index catches it, because those identify the object the kernel +// actually opened. +type windowsFileIdentity struct { + Volume uint32 + IndexHigh uint32 + IndexLow uint32 +} + +func (identity windowsFileIdentity) empty() bool { + return identity == windowsFileIdentity{} +} + +// windowsIdentityOfHandle reads the identity of an already-open object. +func windowsIdentityOfHandle(handle windows.Handle) (windowsFileIdentity, error) { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return windowsFileIdentity{}, fmt.Errorf("read windows file identity: %w", err) + } + return windowsFileIdentity{ + Volume: info.VolumeSerialNumber, + IndexHigh: info.FileIndexHigh, + IndexLow: info.FileIndexLow, + }, nil +} + +// validateWindowsACLComponent rejects anything that is not a single path +// component. +// +// This is load-bearing, not defensive tidiness. NtCreateFile happily resolves a +// RELATIVE name containing separators, and it resolves it the ordinary way, +// which means an intermediate junction inside that name is followed and the +// object lands outside the anchor. A name with a separator therefore reopens +// exactly the hole the parent handle exists to close, so the shape is checked +// rather than assumed. +// +// A colon is rejected too: it introduces an alternate data stream, or a drive +// qualifier, neither of which is a child of the parent handle. +func validateWindowsACLComponent(name string) error { + switch { + case name == "": + return errors.New("windows ACL path component is empty") + case name == "." || name == "..": + return fmt.Errorf("windows ACL path component %q is a relative reference, not a child", name) + case strings.ContainsAny(name, `\/`): + return fmt.Errorf("windows ACL path component %q contains a separator, so the kernel would resolve it through intermediate directories instead of the parent handle", name) + case strings.Contains(name, ":"): + return fmt.Errorf("windows ACL path component %q contains a colon, which names a stream or a drive rather than a child", name) + } + return nil +} + // openWindowsACLDirectoryNoFollow opens an existing directory by pathname, // refusing to traverse or land on a reparse point. // @@ -76,19 +137,63 @@ func openWindowsACLDirectoryNoFollow(path string) (windows.Handle, error) { return handle, nil } +// openWindowsACLDirectoryNoFollowWithIdentity is the anchor open plus the +// identity a later rollback needs in order to prove it re-opened the same +// object. +func openWindowsACLDirectoryNoFollowWithIdentity(path string) (windows.Handle, windowsFileIdentity, error) { + handle, err := openWindowsACLDirectoryNoFollow(path) + if err != nil { + return 0, windowsFileIdentity{}, err + } + identity, err := windowsIdentityOfHandle(handle) + if err != nil { + _ = windows.CloseHandle(handle) + return 0, windowsFileIdentity{}, fmt.Errorf("identify windows ACL directory %s: %w", path, err) + } + return handle, identity, nil +} + +// reopenWindowsACLDirectoryAsIdentity re-opens an anchor by pathname and refuses +// it unless the kernel says it is the object that was opened before. +// +// Used only by rollback. See windowsFileIdentity for why the pathname alone is +// not enough, and rollbackWindowsACLSnapshots for why a handle cannot simply be +// held instead. +func reopenWindowsACLDirectoryAsIdentity(path string, want windowsFileIdentity) (windows.Handle, error) { + handle, err := openWindowsACLDirectoryNoFollow(path) + if err != nil { + return 0, err + } + if want.empty() { + return handle, nil + } + got, err := windowsIdentityOfHandle(handle) + if err != nil { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("identify windows ACL directory %s: %w", path, err) + } + if got != want { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("refusing to unwind under %s: it is no longer the directory setup created into, so something replaced it since (possible path swap during elevated setup)", path) + } + return handle, nil +} + // createWindowsACLChildDirectory creates one directory directly beneath parent, // or opens it when it already exists, and reports which happened. // -// name must be a single component. The kernel resolves it relative to the parent +// name must be a single component; see validateWindowsACLComponent for why that +// is enforced rather than assumed. The kernel resolves it relative to the parent // HANDLE, so nothing above it is consulted and nothing above it can be swapped -// underneath us. FILE_OPEN_REPARSE_POINT means an existing child that is a -// junction is opened AS the junction rather than followed, so the caller's -// verification can reject it. +// underneath us. // // created is true only when this call made the directory, which the rollback // needs: removing one that already existed would delete a user's data over a // failure that had nothing to do with it. func createWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, created bool, err error) { + if err := validateWindowsACLComponent(name); err != nil { + return 0, false, err + } objectName, err := windows.NewNTUnicodeString(name) if err != nil { return 0, false, fmt.Errorf("encode windows ACL directory component %s: %w", name, err) @@ -116,24 +221,174 @@ func createWindowsACLChildDirectory(parent windows.Handle, name string) (handle ); err != nil { return 0, false, fmt.Errorf("create windows ACL directory component %s: %w", name, err) } + // FILE_OPEN_IF means an EXISTING child is opened rather than created, and + // FILE_OPEN_REPARSE_POINT means a junction is opened AS the junction. Without + // this check that junction becomes the parent of the next level and every + // create beneath it lands wherever it points, which is the mid-walk swap this + // whole file exists to stop. + if err := rejectWindowsACLReparseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return 0, false, err + } return handle, status.Information == windowsFileCreated, nil } +// openWindowsACLChildDirectory opens an EXISTING directory beneath parent and +// never creates one. +// +// Rollback walks back down the chain it created, and it must not conjure a +// component that has since been removed: FILE_OPEN_IF would recreate it, and +// then the unwind would delete a directory setup never made. FILE_OPEN is the +// whole difference from createWindowsACLChildDirectory. +func openWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, err error) { + if err := validateWindowsACLComponent(name); err != nil { + return 0, err + } + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, fmt.Errorf("encode windows ACL directory component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + // Ask for the least that still allows passing through and checking the + // reparse attribute. This runs during rollback, on directories whose ACEs + // have already been applied, so every extra right is another way for the + // unwind to be refused by the very ACL it is unwinding. Notably absent: + // SYNCHRONIZE, and with it FILE_SYNCHRONOUS_IO_NONALERT, since nothing is + // read or written through this handle. + var status windows.IO_STATUS_BLOCK + if err := windows.NtCreateFile( + &handle, + windows.FILE_TRAVERSE|windows.FILE_READ_ATTRIBUTES, + &attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_DIRECTORY, + windowsACLDirectoryShare, + windows.FILE_OPEN, + windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ); err != nil { + return 0, fmt.Errorf("open windows ACL directory component %s: %w", name, err) + } + if err := rejectWindowsACLReparseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +// createWindowsACLChildFile creates an empty file directly beneath parent, or +// opens it when it already exists, and reports which happened. +// +// The counterpart to createWindowsACLChildDirectory for the one materialized +// target that must be a FILE: .git/config, where creating a directory instead +// would break `git init` outright rather than merely mis-ACL it. +// +// FILE_OPEN_IF rather than FILE_CREATE deliberately. The pathname version this +// replaces used O_CREATE|O_EXCL and then tolerated os.ErrExist, so a racing +// creator winning was fine. FILE_CREATE's collision status is +// STATUS_OBJECT_NAME_COLLISION, which errors.Is(err, os.ErrExist) does NOT +// match, so porting it literally would have turned that tolerated race into a +// hard failure. FILE_OPEN_IF keeps the old behaviour and reports the truth in +// created, which rollback needs so it never deletes a file it did not make. +func createWindowsACLChildFile(parent windows.Handle, name string) (created bool, err error) { + if err := validateWindowsACLComponent(name); err != nil { + return false, err + } + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return false, fmt.Errorf("encode windows ACL file component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var status windows.IO_STATUS_BLOCK + if err := windows.NtCreateFile( + &handle, + windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + &attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windowsACLDirectoryShare, + windows.FILE_OPEN_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT, + 0, + 0, + ); err != nil { + return false, fmt.Errorf("create windows ACL file component %s: %w", name, err) + } + if err := rejectWindowsACLReparseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return false, err + } + createdNow := status.Information == windowsFileCreated + if err := windows.CloseHandle(handle); err != nil { + return createdNow, fmt.Errorf("close windows ACL file component %s: %w", name, err) + } + return createdNow, nil +} + // deleteWindowsACLChildDirectory removes one directory directly beneath parent. // // The counterpart to the create above, and the reason rollback cannot use // os.RemoveAll: that takes a pathname, so an ancestor swapped to a junction // AFTER the object was created sends the recursive delete somewhere else and // takes unrelated trees with it. Resolving relative to the parent handle makes -// that impossible, and FILE_DIRECTORY_FILE refuses anything that is not a -// directory rather than deleting it. +// that impossible. // // A missing child is not an error: rollback runs on failure paths where the // object may never have been created. func deleteWindowsACLChildDirectory(parent windows.Handle, name string) error { + return deleteWindowsACLChild(parent, name, true) +} + +// deleteWindowsACLChildFile removes one file directly beneath parent, for the +// materialized .git/config carveout. Rollback picks between this and the +// directory form from the shape recorded at materialization time rather than by +// stat-ing the pathname, because a stat is another pathname resolution and this +// whole file exists to avoid those. +func deleteWindowsACLChildFile(parent windows.Handle, name string) error { + return deleteWindowsACLChild(parent, name, false) +} + +// deleteWindowsACLChild opens one child relative to parent and deletes it by +// SETTING ITS DISPOSITION, not with FILE_DELETE_ON_CLOSE. +// +// That distinction is the whole point, and it was measured rather than assumed. +// FILE_DELETE_ON_CLOSE defers the removal to cleanup, where a non-empty +// directory makes it fail with nothing to report it to: NtCreateFile returns +// success, CloseHandle returns success, and the directory is still there. A +// rollback built on it would report success while leaving materialized state on +// disk, which is strictly worse than the os.RemoveAll it replaces, because +// os.RemoveAll at least removed it. +// +// NtSetInformationFile answers synchronously and to the caller, so a non-empty +// directory comes back as STATUS_DIRECTORY_NOT_EMPTY. Leaving residue is +// acceptable, since the alternative is a recursive delete through a pathname an +// attacker may control; lying about having removed it is not. +// +// FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE also make the open refuse an +// object of the wrong shape rather than deleting it. +func deleteWindowsACLChild(parent windows.Handle, name string, directory bool) error { + if err := validateWindowsACLComponent(name); err != nil { + return err + } objectName, err := windows.NewNTUnicodeString(name) if err != nil { - return fmt.Errorf("encode windows ACL directory component %s: %w", name, err) + return fmt.Errorf("encode windows ACL component %s: %w", name, err) } attributes := windows.OBJECT_ATTRIBUTES{ RootDirectory: parent, @@ -142,29 +397,71 @@ func deleteWindowsACLChildDirectory(parent windows.Handle, name string) error { } attributes.Length = uint32(unsafe.Sizeof(attributes)) + shapeOption := uint32(windows.FILE_NON_DIRECTORY_FILE) + shapeAttribute := uint32(windows.FILE_ATTRIBUTE_NORMAL) + if directory { + shapeOption = windows.FILE_DIRECTORY_FILE + shapeAttribute = windows.FILE_ATTRIBUTE_DIRECTORY + } + + // DELETE alone. Asking for SYNCHRONIZE as well would make this fail on any + // object already carrying a deny-read ACE, because FILE_GENERIC_READ and + // FILE_GENERIC_EXECUTE both include SYNCHRONIZE, and rollback exists + // precisely to undo objects that have just been ACL'd. Nothing is read or + // written through this handle, so synchronous IO is not needed either. var handle windows.Handle var status windows.IO_STATUS_BLOCK if err := windows.NtCreateFile( &handle, - windows.DELETE|windows.SYNCHRONIZE, + windows.DELETE, &attributes, &status, nil, - windows.FILE_ATTRIBUTE_DIRECTORY, + shapeAttribute, windowsACLDirectoryShare, windows.FILE_OPEN, - windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_DELETE_ON_CLOSE, + shapeOption|windows.FILE_OPEN_REPARSE_POINT, 0, 0, ); err != nil { if isWindowsNotExist(err) { return nil } - return fmt.Errorf("open windows ACL directory component %s for delete: %w", name, err) + return fmt.Errorf("open windows ACL component %s for delete: %w", name, err) } - // FILE_DELETE_ON_CLOSE performs the removal; closing is what commits it. - if err := windows.CloseHandle(handle); err != nil { - return fmt.Errorf("delete windows ACL directory component %s: %w", name, err) + defer func() { _ = windows.CloseHandle(handle) }() + + // One BOOLEAN: FILE_DISPOSITION_INFORMATION.DeleteFile = TRUE. + disposition := byte(1) + var setStatus windows.IO_STATUS_BLOCK + if err := windows.NtSetInformationFile( + handle, + &setStatus, + &disposition, + uint32(unsafe.Sizeof(disposition)), + windows.FileDispositionInformation, + ); err != nil { + return fmt.Errorf("delete windows ACL component %s: %w", name, err) + } + return nil +} + +// rejectWindowsACLReparseHandle refuses a handle that landed on a reparse point. +// +// Deliberately NOT verifyWindowsACLTargetNotRedirected: that one compares the +// handle's resolved path against an expected pathname, which is exactly the +// pathname dependency the handle-relative walk removes. A child opened relative +// to a pinned parent is in the right place by construction even when the +// pathname no longer leads there, so comparing paths would reject correct, safe +// creates whenever the tree was legitimately renamed. The attribute is the only +// thing worth checking here. +func rejectWindowsACLReparseHandle(handle windows.Handle, name string) error { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("inspect windows ACL component %s: %w", name, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("refusing to work through reparse-point component %s: possible path swap during elevated setup", name) } return nil } diff --git a/internal/sandbox/windows_acl_relative_windows_test.go b/internal/sandbox/windows_acl_relative_windows_test.go index 0ed777b17..4f52a83d5 100644 --- a/internal/sandbox/windows_acl_relative_windows_test.go +++ b/internal/sandbox/windows_acl_relative_windows_test.go @@ -138,6 +138,64 @@ func TestDeleteFollowsTheHandleNotThePath(t *testing.T) { } } +// A delete that cannot happen must SAY so. +// +// This is the gap that let a silent bug ship in the first version of this file. +// It deleted with FILE_DELETE_ON_CLOSE, which defers the removal to cleanup, +// where a non-empty directory makes it fail with nowhere to report it: the open +// returned success, the close returned success, and the directory was still +// there. The only test covering deletion used an EMPTY directory, so it passed +// throughout. Rollback built on that would have reported success while leaving +// materialized state on disk, which is worse than the os.RemoveAll it replaced, +// because os.RemoveAll actually removed it. +func TestDeletingANonEmptyDirectoryIsReported(t *testing.T) { + root := t.TempDir() + populated := filepath.Join(root, "populated") + if err := os.Mkdir(populated, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.WriteFile(filepath.Join(populated, "occupant"), []byte("x"), 0o600); err != nil { + t.Fatalf("seed occupant: %v", err) + } + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + err = deleteWindowsACLChildDirectory(parent, "populated") + if _, statErr := os.Stat(populated); statErr != nil { + t.Fatalf("the directory was removed with its contents, which this delete must never do: %v", statErr) + } + if err == nil { + t.Fatal("reported success while leaving the directory in place") + } +} + +// The directory form must refuse a file rather than delete it, and the file form +// must handle the one materialized target that is a file. +func TestDeleteDistinguishesFilesFromDirectories(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "config"), []byte("x"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + if err := deleteWindowsACLChildDirectory(parent, "config"); err == nil { + t.Error("the directory delete accepted a file") + } + if err := deleteWindowsACLChildFile(parent, "config"); err != nil { + t.Fatalf("deleteWindowsACLChildFile: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "config")); err == nil { + t.Error("the file survived its delete") + } +} + // Rollback runs on failure paths where the object may never have been created, // so a missing child is success rather than an error to report. func TestDeletingAMissingChildIsNotAnError(t *testing.T) { From 209898dfbeda5776236d23e761c349f6b6c249b0 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 15:50:09 +0530 Subject: [PATCH 39/45] fix(sandbox): reject protected metadata names that escape the write root ProtectedMetadataNames is documented as names, but it is joined onto the write root to place a deny ACE and to materialize the directory that ACE protects. A value containing ".." or a separator puts both outside the workspace: the deny lands on a directory the sandbox does not own, and elevated setup creates it there. Raised by CodeRabbit on this PR. Not reachable today, since the only caller passes a package constant. It is guarded so that stays true if a future caller sources these from config, which is the kind of change that would not obviously be a security decision. The component check moves to the portable file so both users share it. The handle-relative primitives need it because NtCreateFile resolves a relative name containing separators the ordinary way, following any junction inside it; the plan builder needs it for the escape above. Separators are matched explicitly rather than through filepath.Base, because these are Windows paths whatever the build host is, and on Linux filepath.Base leaves a backslash-joined name untouched and would wave it through. Rejection tests are in the portable test file, so they run on all three platforms alongside the rest of the plan-builder coverage. --- internal/sandbox/windows_acl.go | 36 ++++++++++++++++++ .../sandbox/windows_acl_relative_windows.go | 27 ------------- internal/sandbox/windows_identity_acl.go | 9 +++++ internal/sandbox/windows_identity_acl_test.go | 38 +++++++++++++++++++ 4 files changed, 83 insertions(+), 27 deletions(-) diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 23255a711..bdf58d1b4 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -2,10 +2,46 @@ package sandbox import ( "errors" + "fmt" "path/filepath" "strings" ) +// validateWindowsACLComponent rejects anything that is not a single path +// component. +// +// This is load-bearing in two places, which is why it lives in the portable file +// rather than beside either of them. +// +// At apply time NtCreateFile happily resolves a RELATIVE name containing +// separators, and it resolves it the ordinary way, so an intermediate junction +// inside that name is followed and the object lands outside the pinned parent. +// A name with a separator reopens exactly the hole the parent handle exists to +// close. +// +// At plan time the same shape escapes the write root: a name is joined onto the +// root to place a deny ACE, so ".." or a separator puts that ACE on a directory +// outside the workspace entirely. +// +// The separators are checked explicitly rather than via filepath.Base, because +// these are Windows paths whatever the build host is, and on Linux +// filepath.Base leaves a backslash-joined name untouched and would wave it +// through. A colon is rejected too: it names an alternate data stream or a +// drive, neither of which is a child. +func validateWindowsACLComponent(name string) error { + switch { + case name == "": + return errors.New("windows ACL path component is empty") + case name == "." || name == "..": + return fmt.Errorf("windows ACL path component %q is a relative reference, not a child", name) + case strings.ContainsAny(name, `\/`): + return fmt.Errorf("windows ACL path component %q contains a separator, so it would resolve through intermediate directories instead of staying a child", name) + case strings.Contains(name, ":"): + return fmt.Errorf("windows ACL path component %q contains a colon, which names a stream or a drive rather than a child", name) + } + return nil +} + type WindowsACLAction string const ( diff --git a/internal/sandbox/windows_acl_relative_windows.go b/internal/sandbox/windows_acl_relative_windows.go index 08400b325..1fc4d3231 100644 --- a/internal/sandbox/windows_acl_relative_windows.go +++ b/internal/sandbox/windows_acl_relative_windows.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "os" - "strings" "unsafe" "golang.org/x/sys/windows" @@ -76,32 +75,6 @@ func windowsIdentityOfHandle(handle windows.Handle) (windowsFileIdentity, error) }, nil } -// validateWindowsACLComponent rejects anything that is not a single path -// component. -// -// This is load-bearing, not defensive tidiness. NtCreateFile happily resolves a -// RELATIVE name containing separators, and it resolves it the ordinary way, -// which means an intermediate junction inside that name is followed and the -// object lands outside the anchor. A name with a separator therefore reopens -// exactly the hole the parent handle exists to close, so the shape is checked -// rather than assumed. -// -// A colon is rejected too: it introduces an alternate data stream, or a drive -// qualifier, neither of which is a child of the parent handle. -func validateWindowsACLComponent(name string) error { - switch { - case name == "": - return errors.New("windows ACL path component is empty") - case name == "." || name == "..": - return fmt.Errorf("windows ACL path component %q is a relative reference, not a child", name) - case strings.ContainsAny(name, `\/`): - return fmt.Errorf("windows ACL path component %q contains a separator, so the kernel would resolve it through intermediate directories instead of the parent handle", name) - case strings.Contains(name, ":"): - return fmt.Errorf("windows ACL path component %q contains a colon, which names a stream or a drive rather than a child", name) - } - return nil -} - // openWindowsACLDirectoryNoFollow opens an existing directory by pathname, // refusing to traverse or land on a reparse point. // diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index e37f0f87d..884e44b19 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -117,6 +117,15 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla }) } for _, name := range root.ProtectedMetadataNames { + // These are documented as NAMES, and the join below is what makes that + // documentation load-bearing rather than descriptive: ".." or a + // separator would place this deny ACE, and the directory it + // materializes, outside the write root entirely. Today every caller + // passes a package constant, so this is unreachable; it is here so that + // stays true when a future caller sources these from config. + if err := validateWindowsACLComponent(name); err != nil { + return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: protected metadata name: %w", err) + } entries = append(entries, WindowsACLEntry{ Action: WindowsACLDenyWrite, Path: filepath.Join(cleaned, name), diff --git a/internal/sandbox/windows_identity_acl_test.go b/internal/sandbox/windows_identity_acl_test.go index b9026337a..ef2cd796e 100644 --- a/internal/sandbox/windows_identity_acl_test.go +++ b/internal/sandbox/windows_identity_acl_test.go @@ -180,3 +180,41 @@ func TestPrincipalACLActionsAreDistinct(t *testing.T) { seen[action] = true } } + +// ProtectedMetadataNames is joined onto the write root to place a deny ACE and to +// materialize the directory it names. A value that is not a single component +// therefore puts both OUTSIDE the workspace: ".." walks up out of it, and a +// separator reaches through whatever sits in between. Every caller passes a +// package constant today, so this is the guard that keeps it true if one ever +// sources these from config. +func TestPrincipalACLPlanRefusesProtectedNamesThatEscapeTheWriteRoot(t *testing.T) { + root := filepath.FromSlash("/ws/project") + for _, name := range []string{"..", ".", "", `..\..\Windows\System32`, "nested/child", `nested\child`, "C:", "stream:name"} { + t.Run(name, func(t *testing.T) { + input := testPrincipalInput() + input.WriteRoots = []WritableRoot{{ + Root: root, + ProtectedMetadataNames: []string{name}, + }} + plan, err := buildWindowsPrincipalACLPlan(input) + if err == nil { + t.Fatalf("accepted protected metadata name %q, which would place a deny ACE outside %s:\n%#v", name, root, plan.Entries) + } + if len(plan.Entries) != 0 { + t.Errorf("returned %d entries alongside the error, so a caller ignoring err would still apply them", len(plan.Entries)) + } + }) + } +} + +// The ordinary names must keep working, or the guard above is just a break. +func TestPrincipalACLPlanStillAcceptsTheRealProtectedNames(t *testing.T) { + input := testPrincipalInput() + input.WriteRoots = []WritableRoot{{ + Root: filepath.FromSlash("/ws/project"), + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + }} + if _, err := buildWindowsPrincipalACLPlan(input); err != nil { + t.Fatalf("the shipped protected names were rejected: %v", err) + } +} From e8c7e2903a7f2477bfc9267548d8352d6f5e7934 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 19:19:24 +0530 Subject: [PATCH 40/45] fix(sandbox): apply the .git rename guard on a workspace that had no .git Found by gnanam1990 and anandh8x independently, and confirmed on disk before fixing: the deny-delete ACE that stops a principal replacing .git was silently absent on exactly the workspace shape that matters most. .git deliberately carries no Materialize, because creating an empty one breaks `git init`. Groups are applied in ascending path order, so the .git group ran while .git did not yet exist, was skipped as a non-materializing missing target, and nothing revisited it when the .git\config carveout created .git as its parent chain moments later. windowsACLGroupRequiresExistingTarget did not catch it either, since it only treats AllowWrite as requiring an existing target. Net effect: the principal could rename .git aside and recreate it without the config and hooks carveouts, which is the escape the guard exists to stop. Groups skipped for a missing target are now retried once after the pass, since a later group can create what an earlier one needed. Groups that are genuinely absent simply skip again. Ordering is otherwise untouched, so the reverse-order unwind that rollback depends on still holds. Fixing the gate to demand an existing target for deny-delete would have been wrong: it turns a fresh clone into a setup failure. Materializing .git is likewise ruled out by git init. The four existing guard tests assert the mask and what the planner emits, and all four pass with the guard absent from disk, which is why a green suite said nothing. The new tests apply the plan and read the actual DACL back through SDDL. Verified the fresh-workspace case fails without this change and passes with it, while the already-has-.git case passes either way, so the new coverage is pinned to the defect rather than to the code. Also moves windowsACLPlanPaths behind the Windows build tag. Every caller is Windows-tagged, so defining it in the portable file made it dead code on Linux and macOS and failed make lint-static, as anandh8x reported. --- internal/sandbox/windows_acl_apply_windows.go | 39 ++++- ...ows_git_rename_guard_apply_windows_test.go | 145 ++++++++++++++++++ internal/sandbox/windows_identity_acl.go | 16 +- .../windows_identity_runtime_windows.go | 17 ++ 4 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 internal/sandbox/windows_git_rename_guard_apply_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 073a57fc1..ef02ea0b4 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -75,14 +75,43 @@ type windowsACLSnapshot struct { func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { groups := groupWindowsACLPlanByPath(plan) snapshots := make([]windowsACLSnapshot, 0, len(groups)) + abort := func(err error) (func() error, error) { + if rollbackErr := rollbackWindowsACLSnapshots(snapshots); rollbackErr != nil { + return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr) + } + return nil, err + } + + var deferred []windowsACLPathGroup for _, group := range groups { snapshot, applied, err := applyWindowsACLPathGroup(group) if err != nil { - rollbackErr := rollbackWindowsACLSnapshots(snapshots) - if rollbackErr != nil { - return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr) - } - return nil, err + return abort(err) + } + if applied { + snapshots = append(snapshots, snapshot) + continue + } + deferred = append(deferred, group) + } + + // A group that applied to nothing was skipped because its target did not + // exist and the group does not materialize one. A LATER group can still + // create it, so the skip has to be retried rather than treated as final. + // + // The .git rename guard is exactly this shape and was silently absent + // because of it. .git must NOT be materialized, since an empty one breaks + // `git init`, so its deny-delete group carries no Materialize. But .git does + // get created, as the parent chain of the .git\config carveout, and that + // group sorts AFTER it. So on every workspace that did not already have a + // .git, the guard was skipped, the directory appeared moments later, and + // nothing went back for it: the principal could then rename .git aside and + // shed the config and hooks carveouts, which is the escape the guard exists + // to stop. Groups that are genuinely absent simply skip again here. + for _, group := range deferred { + snapshot, applied, err := applyWindowsACLPathGroup(group) + if err != nil { + return abort(err) } if applied { snapshots = append(snapshots, snapshot) diff --git a/internal/sandbox/windows_git_rename_guard_apply_windows_test.go b/internal/sandbox/windows_git_rename_guard_apply_windows_test.go new file mode 100644 index 000000000..3dcfd6202 --- /dev/null +++ b/internal/sandbox/windows_git_rename_guard_apply_windows_test.go @@ -0,0 +1,145 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// windowsPathDeniesDelete reports whether the object's real DACL carries a deny +// ACE covering DELETE for the given SID. +// +// The plan-shape tests assert what the planner emits. This reads what actually +// landed on disk, which is the gap that let the guard go missing: the plan was +// right the whole time and the apply silently dropped it. +func windowsPathDeniesDelete(t *testing.T, path, sid string) bool { + t.Helper() + handle, _, err := openWindowsACLTarget(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer func() { _ = windows.CloseHandle(handle) }() + + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read DACL for %s: %v", path, err) + } + // SDDL rather than walking the ACE buffer by hand: this x/sys does not export + // an ACE enumerator, and unsafe pointer arithmetic in a test that exists to + // catch a security regression is its own hazard. + // + // An ACE renders as (type;flags;rights;guid;inherit_guid;sid), so the deny + // entries are the ones whose type field is D. Rights come back as SDDL + // abbreviations when they fit and as a hex mask when they do not, so both are + // accepted; SD is the abbreviation for DELETE. + for _, ace := range strings.Split(descriptor.String(), "(") { + fields := strings.Split(strings.TrimSuffix(strings.TrimSpace(ace), ")"), ";") + if len(fields) != 6 || fields[0] != "D" || !strings.EqualFold(fields[5], sid) { + continue + } + rights := fields[2] + if strings.Contains(rights, "SD") { + return true + } + if mask, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(rights), "0x"), 16, 32); err == nil { + if uint32(mask)&uint32(windows.DELETE) != 0 { + return true + } + } + } + return false +} + +// THE GUARD MUST REACH DISK ON A WORKSPACE THAT HAD NO .git. +// +// This is the case the whole plan is built around and it was the one case where +// the guard was absent. .git deliberately carries no Materialize, because an +// empty .git breaks `git init`. Groups are applied in ascending path order, so +// the .git group ran while .git did not yet exist, was skipped as a +// non-materializing missing target, and nothing revisited it once the +// .git\config carveout created .git as its parent chain moments later. +// +// Four tests already covered the deny-delete mask and the plan emitting it. +// None of them applied the plan and looked at the object, which is exactly why +// a green suite said nothing. +func TestGitRenameGuardReachesDiskOnAWorkspaceWithoutGit(t *testing.T) { + const principal = testPrincipalSID // Unaliased, so it renders literally in SDDL. + workspace := t.TempDir() + gitDir := filepath.Join(workspace, ".git") + if _, err := os.Stat(gitDir); err == nil { + t.Fatal("the workspace already has .git, so this proves nothing") + } + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principal, + WriteRoots: []WritableRoot{{ + Root: workspace, + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + t.Cleanup(func() { + if rollback != nil { + _ = rollback() + } + }) + + // The carveouts create .git on the way to .git\config. + if _, err := os.Stat(gitDir); err != nil { + t.Fatalf(".git was never created by the carveout materialization: %v", err) + } + if !windowsPathDeniesDelete(t, gitDir, principal) { + t.Fatal("no deny-delete ACE on .git after applying the plan: the principal can rename it aside and recreate it without the config and hooks carveouts") + } +} + +// The same guard on a workspace that already had .git must keep working. This +// case was already correct, and it is kept so a fix aimed at the fresh +// workspace cannot quietly trade one for the other. +func TestGitRenameGuardStillReachesDiskWhenGitAlreadyExists(t *testing.T) { + const principal = testPrincipalSID + workspace := t.TempDir() + gitDir := filepath.Join(workspace, ".git") + if err := os.Mkdir(gitDir, 0o700); err != nil { + t.Fatalf("seed .git: %v", err) + } + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principal, + WriteRoots: []WritableRoot{{ + Root: workspace, + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + t.Cleanup(func() { + if rollback != nil { + _ = rollback() + } + }) + + if !windowsPathDeniesDelete(t, gitDir, principal) { + t.Fatal("no deny-delete ACE on a .git that already existed") + } +} diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index 884e44b19..40356e1cd 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -200,16 +200,6 @@ func windowsPrincipalRevokePlan(principalSID string, paths []string) (WindowsACL // access it granted or denied. const windowsACLRevoke WindowsACLAction = "revoke" -// windowsACLPlanPaths returns each distinct path a plan touches, in plan order. -func windowsACLPlanPaths(plan WindowsACLPlan) []string { - seen := make(map[string]struct{}, len(plan.Entries)) - paths := make([]string, 0, len(plan.Entries)) - for _, entry := range plan.Entries { - if _, ok := seen[entry.Path]; ok { - continue - } - seen[entry.Path] = struct{}{} - paths = append(paths, entry.Path) - } - return paths -} +// windowsACLPlanPaths lives in windows_identity_runtime_windows.go, beside its +// only callers. It was here, in the portable file, which made it dead code on +// every non-Windows build and failed the static analysis gate. diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index e03d0daa0..c26933ca2 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -735,3 +735,20 @@ var ( lookupWindowsSandboxIdentityFn = lookupWindowsSandboxIdentity removeWindowsSandboxPrincipalForSetupFn = removeWindowsSandboxPrincipalForSetup ) + +// windowsACLPlanPaths returns each distinct path a plan touches, in plan order. +// +// Windows-tagged deliberately: every caller is, so defining it in the portable +// file made it unused on Linux and macOS builds and failed static analysis. +func windowsACLPlanPaths(plan WindowsACLPlan) []string { + seen := make(map[string]struct{}, len(plan.Entries)) + paths := make([]string, 0, len(plan.Entries)) + for _, entry := range plan.Entries { + if _, ok := seen[entry.Path]; ok { + continue + } + seen[entry.Path] = struct{}{} + paths = append(paths, entry.Path) + } + return paths +} From 31b5ad342eaaaf3dcb404eef775e17889edf8450 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 19:26:01 +0530 Subject: [PATCH 41/45] fix(sandbox): stop granting a principal read at the volume root, and report failed ACE revocation Two of anandh8x's findings on this PR. The read grant. permissionProfileReadRoots seeds its list with the bare separator, because the workspace-write posture is read-all with a write jail. That costs nothing for the capability backend, whose child runs as the caller and could read those paths anyway. For a principal it became a real, persistent, inheritable allow-read ACE for a separate local account at the root of the current drive, reaching every directory that does not block inheritance. Verified against the shipped profile rather than argued: with the filter removed the plan emits AllowRead on the drive root. Dropping it does not remove the reads a principal needs. It is a member of Users, and the machine's own ACLs already grant Users read on the system and program directories. What the grant added was read access to the places Users are deliberately kept out of, which is the opposite of what a sandbox is for. The volume root is detected structurally, since filepath.Dir of a root is that same root, so drive-qualified paths, a bare separator and UNC roots are all covered on either build host. The teardown. removeWindowsSandboxPrincipalForSetup discarded the result of revokeWindowsPrincipalACEs, deleted the account, deleted the ledger and returned success. A failed revocation therefore left ACEs naming that SID on the user's tree while the only record of which paths they sat on was removed moments later: residue nothing could find again. The existing reasoning for not failing hard was right and is kept, since refusing to remove the account would strand the principal and its logon rights permanently. So teardown still completes, but the error is now remembered, the ledger is kept when revocation failed, and the returned error says the ledger was kept. A record that outlives its principal is a smaller problem than unfindable residue. The new read-root test is built from the production profile, and skips loudly rather than passing silently if that profile ever stops carrying a volume root. It also asserts the workspace itself stays reachable, so the fix cannot trade a real grant for a broken sandbox. --- internal/sandbox/windows_acl.go | 15 +++++ internal/sandbox/windows_identity_acl.go | 20 +++++++ internal/sandbox/windows_identity_acl_test.go | 55 +++++++++++++++++++ .../windows_identity_runtime_windows.go | 28 +++++++++- 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index bdf58d1b4..170001da5 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -7,6 +7,21 @@ import ( "strings" ) +// isWindowsVolumeRoot reports whether a cleaned path is the top of a volume, +// with nothing above it: `C:\`, a bare separator, or a UNC share root. +// +// Detected structurally rather than by pattern matching drive letters, because +// filepath.Dir of a root is that same root and of anything else is strictly +// shorter. That holds for drive-qualified paths, for the separator alone, and +// for UNC roots, on either build host. +func isWindowsVolumeRoot(path string) bool { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "" || cleaned == "." { + return false + } + return filepath.Dir(cleaned) == cleaned +} + // validateWindowsACLComponent rejects anything that is not a single path // component. // diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index 40356e1cd..9fe3d0b4f 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -162,6 +162,26 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla }) } for _, path := range normalizeProfilePaths(input.ReadRoots) { + // NEVER grant at a volume root. + // + // permissionProfileReadRoots seeds its list with profileRootPath(), which + // is the separator alone, because the workspace-write posture is + // read-all/write-jail. That is harmless for the capability backend, whose + // child runs as the CALLER and therefore reads what the caller could read + // anyway. It is not harmless here: a principal is a separate local + // account, so this loop turns that synthetic entry into a real, + // persistent, inheritable allow-read ACE for that account at the root of + // the current drive, reaching every directory that does not block + // inheritance. + // + // Dropping it does not take away the reads the principal needs to run + // commands. It is a member of Users, and the machine's own ACLs already + // grant Users read on the system and program directories. What the grant + // added on top was read access to places Users are deliberately kept out + // of, which is the opposite of what a sandbox is for. + if isWindowsVolumeRoot(path) { + continue + } entries = append(entries, WindowsACLEntry{ Action: WindowsACLAllowRead, Path: path, diff --git a/internal/sandbox/windows_identity_acl_test.go b/internal/sandbox/windows_identity_acl_test.go index ef2cd796e..79b5ff85b 100644 --- a/internal/sandbox/windows_identity_acl_test.go +++ b/internal/sandbox/windows_identity_acl_test.go @@ -207,6 +207,61 @@ func TestPrincipalACLPlanRefusesProtectedNamesThatEscapeTheWriteRoot(t *testing. } } +// A PRINCIPAL MUST NEVER BE GRANTED READ AT A VOLUME ROOT. +// +// permissionProfileReadRoots seeds its list with the bare separator, because +// the workspace-write posture is read-all with a write jail. That costs nothing +// for the capability backend, whose child runs as the caller and could read +// those paths anyway. For a principal it is a real, persistent, inheritable +// allow-read ACE for a separate local account at the root of the drive. +// +// Built from the production profile rather than a synthetic fixture, because +// the whole point is that the shipped configuration produced it. +func TestPrincipalACLPlanNeverGrantsReadAtAVolumeRoot(t *testing.T) { + workspace := filepath.FromSlash("/ws/project") + profile := DefaultPermissionProfile(workspace) + + // If the profile ever stops carrying a volume root, this test proves nothing + // and should be retired rather than left passing vacuously. + seeded := false + for _, root := range profile.FileSystem.ReadRoots { + if isWindowsVolumeRoot(normalizeProfilePath(root)) { + seeded = true + break + } + } + if !seeded { + t.Skipf("the production profile no longer contains a volume read root: %v", profile.FileSystem.ReadRoots) + } + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: testPrincipalSID, + ReadRoots: profile.FileSystem.ReadRoots, + WriteRoots: profile.FileSystem.WriteRoots, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowRead && isWindowsVolumeRoot(entry.Path) { + t.Errorf("plan grants the principal read at the volume root %q, which inherits into every directory on the drive", entry.Path) + } + } + // And the workspace itself must still be reachable, or this traded a real + // grant for a broken sandbox. + wantWorkspace := normalizeProfilePath(workspace) + reachable := false + for _, entry := range plan.Entries { + if entry.Path == wantWorkspace && (entry.Action == WindowsACLAllowRead || entry.Action == WindowsACLAllowWrite) { + reachable = true + break + } + } + if !reachable { + t.Errorf("no grant for the workspace root %q, so the principal could not read its own workspace", wantWorkspace) + } +} + // The ordinary names must keep working, or the guard above is just a break. func TestPrincipalACLPlanStillAcceptsTheRealProtectedNames(t *testing.T) { input := testPrincipalInput() diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index c26933ca2..b1942a87c 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -416,6 +416,10 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e if err := removeWindowsSandboxSecret(secretPath); err != nil { return err } + // Set when ACE revocation could not complete. Teardown continues regardless, + // but the ledger is kept and the error surfaced, so the residue stays + // findable instead of being silently orphaned. + var revokeErr error // Drop the LSA account rights before the account itself. Deleting the account // first would leave its rights behind keyed to a SID that no longer resolves, // which is the same orphaned residue the trustee-keyed ACE revocation exists @@ -436,8 +440,18 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // The rollback is discarded here on purpose, unlike at setup: this is // teardown, the account is about to be deleted, and putting its ACEs back // is the opposite of what the caller asked for. - if paths, pathsErr := windowsPrincipalRevocationPaths(config, identity.SID.String()); pathsErr == nil { - _, _ = revokeWindowsPrincipalACEs(identity.SID.String(), paths) + // + // The ERROR is not discarded, though it used to be. Teardown carried on + // and reported success, which meant a failed revocation left ACEs naming + // this SID on the user's tree while the ledger recording which paths they + // sat on was deleted moments later: residue nothing could find again. + // Remembered below rather than returned here, so removing the account + // still happens and the principal is not stranded. + paths, pathsErr := windowsPrincipalRevocationPaths(config, identity.SID.String()) + if pathsErr != nil { + revokeErr = fmt.Errorf("resolve the paths holding ACEs for sandbox principal %s: %w", username, pathsErr) + } else if _, err := revokeWindowsPrincipalACEs(identity.SID.String(), paths); err != nil { + revokeErr = fmt.Errorf("revoke ACEs for sandbox principal %s: %w", username, err) } if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { return err @@ -454,8 +468,16 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // It describes grants for a SID that no longer resolves, and leaving it would // have the next setup revoke those paths on behalf of a freshly minted SID // that never held them. That is a harmless no-op rather than a hole — the - // deleted account's RID is never reused — but a record that outlives its + // deleted account's RID is never reused, but a record that outlives its // principal is a lie the next reader has no way to detect. + // + // Unless revocation failed. Then the ledger is the ONLY surviving record of + // which paths still carry ACEs for this SID, and deleting it turns a + // reportable leftover into permanent unfindable residue. Keeping a record + // that outlives its principal is the lesser problem, and the error says so. + if revokeErr != nil { + return fmt.Errorf("%w; the principal ACL ledger has been kept so the remaining ACEs can still be found", revokeErr) + } return removeWindowsPrincipalACLLedger(config.SandboxHome, username) } From 24f8b96e3abb05011de79d04e316e5a0ea531bec Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 19:41:30 +0530 Subject: [PATCH 42/45] docs(sandbox): attribute the Users-group premise the volume-root fix relies on Cross-checking against another Windows sandbox implementation showed it joins its account to the built-in Users group explicitly, and then verifies at apply time whether Users already hold read before granting. Ours gets that membership implicitly from NetUserAdd with USER_PRIV_USER and asserts the consequence in a comment. Same conclusion, weaker footing, so the comment now says where the membership comes from and that a hardened image which strips Users read would need an explicit bounded read set instead. --- internal/sandbox/windows_identity_acl.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index 9fe3d0b4f..7547f1caf 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -175,10 +175,16 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla // inheritance. // // Dropping it does not take away the reads the principal needs to run - // commands. It is a member of Users, and the machine's own ACLs already - // grant Users read on the system and program directories. What the grant - // added on top was read access to places Users are deliberately kept out - // of, which is the opposite of what a sandbox is for. + // commands. NetUserAdd with USER_PRIV_USER puts the account in the + // built-in Users group (see usrPrivUser in windows_identity_windows.go), + // and the machine's own ACLs already grant Users read on the system and + // program directories. What the grant added on top was read access to the + // places Users are deliberately kept out of, which is the opposite of what + // a sandbox is for. + // + // Note this inherits rather than asserts: nothing here checks that those + // default ACLs are actually in place, so a hardened image that strips + // Users read would need an explicit bounded read set instead. if isWindowsVolumeRoot(path) { continue } From b1020ab349f64c35990e6d86a4a76843dd62bef5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 20:18:55 +0530 Subject: [PATCH 43/45] fix(sandbox): name the root that blocks unelevated ACL setup Found while testing the default sandbox path, not the principal path this PR is about. One write root that the current user cannot re-DACL fails the entire unelevated ACL plan, and the success marker is only recorded on success, so the identical failure repeats on every later command until that root leaves the plan. The workspace is effectively unusable in the meantime. Refusing to run is correct and is left alone: without those ACEs there is no write jail, so continuing would run a command that believes it is sandboxed and is not. The defect was the diagnosis. Every failure got the same guess, "the workspace may be on a filesystem the current user does not own", and recommended elevated setup, which does nothing when the real problem is a system directory sitting in the root set. An access denial now names the exact path, says the sandbox cannot enforce a boundary there, points at TEMP and TMP as the usual way such a path gets in, and states plainly that elevated setup will NOT help. Other failures keep the old message. The extractor reads the path back out of an error string produced two functions away, which the compiler cannot check, so a test drives the real producer rather than hand-writing the message: if openWindowsACLTarget rewords its error the test fails instead of the diagnostic quietly going blank. That test earned itself immediately by catching the first version splitting on the first colon and returning "C", since on Windows the path opens with a drive colon. It splits on colon-space now, which a drive colon can never be. The helper sits behind the Windows build tag beside its only caller, for the same reason windowsACLPlanPaths was moved earlier in this branch. --- .../sandbox/windows_command_runner_windows.go | 51 ++++++++++++++++++ internal/sandbox/windows_unelevated.go | 4 ++ .../windows_unelevated_denied_windows_test.go | 53 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 internal/sandbox/windows_unelevated_denied_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index e42b85385..378239f02 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -3,8 +3,11 @@ package sandbox import ( + "errors" "fmt" "io" + "os" + "strings" ) func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int { @@ -158,8 +161,56 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { return nil } if _, err := applyWindowsACLPlan(plan); err != nil { + // Refusing to run is right: without these ACEs the write jail does not + // exist, so continuing would run the command believing it is sandboxed + // when it is not. What was wrong was the diagnosis. Every failure got the + // same "the workspace may be on a filesystem you do not own" guess, and + // the suggested remedy was elevated setup, which does not help at all + // when the real problem is one root in the plan that nobody can ACL. + // + // Being precise matters because this failure repeats: the success marker + // is only recorded on success, so the same plan fails identically on + // every later command until the offending root leaves it. A reader who + // cannot tell which root is at fault has no way out of that. + if denied := windowsACLPlanDeniedPath(err); denied != "" { + return fmt.Errorf("apply unelevated workspace ACLs: %w; %s cannot have its permissions changed by this user, "+ + "so the sandbox cannot enforce a write boundary there and will not run the command. "+ + "That path is one of this workspace's sandbox roots, usually a system directory that arrived via TEMP or TMP. "+ + "Check those, or re-run with `--sandbox forbid` to skip OS sandboxing. "+ + "Running `zero sandbox setup` elevated will NOT fix this", err, denied) + } return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+ "run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err) } return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied) } + +// windowsACLPlanDeniedPath pulls the target path out of an apply failure that +// was an access denial, and returns "" for anything else. +// +// applyWindowsACLPathGroup already wraps the path into its error, so this reads +// the message rather than threading a typed error through four layers for one +// diagnostic. The string it matches is produced in the same package by +// openWindowsACLTarget, and a test pins the pairing so the two cannot drift +// apart silently. +func windowsACLPlanDeniedPath(err error) string { + if err == nil || !errors.Is(err, os.ErrPermission) { + return "" + } + const marker = "open windows ACL target " + message := err.Error() + start := strings.Index(message, marker) + if start < 0 { + return "" + } + // Colon-SPACE, not colon. The wrapper is "...target %s: %w", and on Windows + // the path itself starts with a drive colon, so splitting on the first colon + // returns "C". A drive colon is always followed by a separator, never a + // space, which makes ": " the only unambiguous boundary here. + rest := message[start+len(marker):] + end := strings.Index(rest, ": ") + if end <= 0 { + return "" + } + return strings.TrimSpace(rest[:end]) +} diff --git a/internal/sandbox/windows_unelevated.go b/internal/sandbox/windows_unelevated.go index 980664d6a..53eadc825 100644 --- a/internal/sandbox/windows_unelevated.go +++ b/internal/sandbox/windows_unelevated.go @@ -146,3 +146,7 @@ func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnele } return nil } + +// windowsACLPlanDeniedPath lives in windows_command_runner_windows.go, beside +// its only caller. Defining it here, in the portable file, would make it dead +// code on every non-Windows build and fail the static analysis gate. diff --git a/internal/sandbox/windows_unelevated_denied_windows_test.go b/internal/sandbox/windows_unelevated_denied_windows_test.go new file mode 100644 index 000000000..60b710279 --- /dev/null +++ b/internal/sandbox/windows_unelevated_denied_windows_test.go @@ -0,0 +1,53 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "testing" +) + +// The diagnostic reads a path back out of an error string produced two +// functions away. That coupling is invisible to the compiler, so it is pinned +// here by driving the REAL producer rather than by hand-writing the message: +// if openWindowsACLTarget ever rewords its error, this fails instead of the +// diagnostic silently going quiet and users losing the one clue they had. +func TestDeniedPathIsRecoveredFromARealApplyFailure(t *testing.T) { + // A directory no ordinary user can re-DACL. Exactly the shape that bricked a + // workspace: present, in the plan, and impossible to apply. + const target = `C:\Windows\System32` + + _, _, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: target, + Entries: []WindowsACLEntry{{ + Action: WindowsACLAllowWrite, + Path: target, + Capability: testPrincipalSID, + }}, + }) + if err == nil { + t.Skip("this process can re-DACL System32, so it is elevated and cannot exercise the denial path") + } + if !errors.Is(err, os.ErrPermission) { + t.Skipf("failed for a reason other than access denial, nothing to extract here: %v", err) + } + + got := windowsACLPlanDeniedPath(err) + if got == "" { + t.Fatalf("no path recovered from a real access-denied apply failure, so the operator is told only that something was denied: %v", err) + } + if got != target { + t.Errorf("recovered %q, want %q", got, target) + } +} + +// Anything that is not an access denial must return empty, so the caller falls +// back to the generic message rather than naming an innocent path. +func TestDeniedPathIgnoresUnrelatedErrors(t *testing.T) { + for _, err := range []error{nil, errors.New(`open windows ACL target C:\somewhere: disk full`), os.ErrNotExist} { + if got := windowsACLPlanDeniedPath(err); got != "" { + t.Errorf("recovered %q from %v, want empty", got, err) + } + } +} From e6091732bab8c8af901ebe68deb153046c9bb790 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 20:54:52 +0530 Subject: [PATCH 44/45] feat(sandbox): add `zero sandbox exec` to run one command through the sandbox Until now the sandbox could only be exercised through a full agent turn with a model in the loop. `zero sandbox policy` reports the posture it would take and `zero sandbox check` evaluates a hypothetical decision, but nothing ran a command and let anyone look at what happened on disk afterwards. That is the root of the verification gap on this branch. Enforcement is covered almost entirely by tests asserting the shape of an ACL plan, and almost not at all by tests asserting that a write was refused. The two are not the same thing, and this branch has already produced the proof: the .git rename guard was emitted correctly by the planner and silently dropped by the applier, and four tests covering the plan passed while the ACE was absent from the directory. It goes through SandboxManager.BuildCommandPlan, the same path a shell tool takes, so what it demonstrates is what users get rather than what a test double does. Backend, enforcement level and workspace are written to stderr before the command runs, and a downgrade is printed explicitly, so a harness can assert the sandbox was actually engaged instead of passing because it quietly stood down. Exit status is the command's own, which a harness asserting a refusal needs. Everything after `--` is the command, so its flags are never parsed as ours. Verified by hand on Windows: a write inside the workspace succeeds and the file exists; the same write to C:\Windows\Temp fails with UnauthorizedAccessException and the file is not created. Both halves matter, since a command can fail for an unrelated reason while the write still lands. This is the prerequisite for a real smoke harness, which is the actual goal. --- internal/cli/sandbox.go | 5 +- internal/cli/sandbox_exec.go | 165 +++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 internal/cli/sandbox_exec.go diff --git a/internal/cli/sandbox.go b/internal/cli/sandbox.go index 7c850a99b..7647f4d1a 100644 --- a/internal/cli/sandbox.go +++ b/internal/cli/sandbox.go @@ -20,7 +20,7 @@ type sandboxCommandOptions struct { func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if len(args) == 0 { - return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.") + return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy`, `zero sandbox exec`, or `zero sandbox grants list`.") } switch args[0] { case "-h", "--help", "help": @@ -34,6 +34,8 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) return runSandboxSetup(args[1:], stdout, stderr, deps) case "check": return runSandboxCheck(args[1:], stdout, stderr, deps) + case "exec": + return runSandboxExec(args[1:], stdout, stderr, deps) case "grants": return runSandboxGrants(args[1:], stdout, stderr, deps) default: @@ -639,6 +641,7 @@ Commands: policy Inspect active sandbox policy and platform backend setup Run native platform sandbox setup check Evaluate the sandbox decision for a hypothetical tool action + exec Run one command through the real sandbox grants Manage persistent sandbox grants `) diff --git a/internal/cli/sandbox_exec.go b/internal/cli/sandbox_exec.go new file mode 100644 index 000000000..460216552 --- /dev/null +++ b/internal/cli/sandbox_exec.go @@ -0,0 +1,165 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/Gitlawb/zero/internal/config" + zeroSandbox "github.com/Gitlawb/zero/internal/sandbox" +) + +// runSandboxExec runs ONE command through the real sandbox and exits with its +// status. +// +// This exists because until now the sandbox could only be exercised through a +// full agent turn with a model in the loop. `zero sandbox policy` reports what +// the posture would be and `zero sandbox check` evaluates a hypothetical +// decision, but nothing actually ran a command and let you look at what +// happened on disk afterwards. The practical result is that enforcement is +// covered almost entirely by tests asserting the shape of an ACL plan, and +// almost not at all by tests asserting a write was refused. +// +// A plan can be perfectly correct and never reach the filesystem. That is not +// hypothetical here: the .git rename guard was emitted correctly by the planner +// and silently skipped by the applier, and four tests covering the plan all +// passed while the ACE was absent from disk. Something that runs the real +// binary and then stats the file is the only thing that catches that class. +// +// Deliberately NOT a debug curiosity: it takes the same path a shell tool +// takes, through SandboxManager.BuildCommandPlan, so what it proves is what +// users get. It prints the resolved backend and enforcement level to stderr +// before running, so a harness can assert the sandbox was actually engaged +// rather than quietly downgraded. +func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + command, err := parseSandboxExecArgs(args) + if err != nil { + if errors.Is(err, errSandboxExecHelp) { + if writeErr := writeSandboxExecHelp(stdout); writeErr != nil { + return exitCrash + } + return exitSuccess + } + return writeExecUsageError(stderr, err.Error()) + } + + workspaceRoot, err := resolveWorkspaceRoot("", deps) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{}) + if err != nil { + return writeAppError(stderr, err.Error(), exitProvider) + } + policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox) + + scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots) + if err != nil { + return writeAppError(stderr, fmt.Sprintf("resolve sandbox write roots: %v", err), exitCrash) + } + + manager := zeroSandbox.NewSandboxManager(zeroSandbox.SandboxManagerOptions{ + Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}), + }) + plan, err := manager.BuildCommandPlan(zeroSandbox.SandboxManagerRequest{ + WorkspaceRoot: workspaceRoot, + Command: zeroSandbox.CommandSpec{ + Name: command[0], + Args: command[1:], + Dir: workspaceRoot, + Env: os.Environ(), + }, + Policy: policy, + Scope: scope, + // Ask for validation rather than a best-effort plan: a harness asserting + // that a write was refused needs to know the sandbox was really there. + ValidateExecution: true, + }) + if err != nil { + return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash) + } + + // Printed before the command runs and on stderr, so it survives a command + // that writes to stdout and stays greppable by a test harness. A downgrade + // is reported loudly for the same reason: a smoke test that passes because + // the sandbox quietly stood down is worse than no smoke test. + fmt.Fprintf(stderr, "sandbox: backend=%s enforcement=%s wrapped=%t workspace=%s\n", + plan.Backend.Name, plan.EnforcementLevel, plan.Wrapped, plan.WorkspaceRoot) + if strings.TrimSpace(plan.DowngradeReason) != "" { + fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason) + } + + return runSandboxPlannedCommand(plan, stdout, stderr) +} + +func runSandboxPlannedCommand(plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int { + process := exec.Command(plan.Name, plan.Args...) + process.Dir = plan.Dir + if process.Dir == "" { + process.Dir = plan.WorkspaceRoot + } + if len(plan.Env) > 0 { + process.Env = plan.Env + } + process.Stdin = os.Stdin + process.Stdout = stdout + process.Stderr = stderr + + if err := process.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // The command's own status, not ours. A harness asserting "the write + // was refused" needs the refusal's exit code, not a wrapper's. + return exitErr.ExitCode() + } + fmt.Fprintf(stderr, "sandbox exec: %v\n", err) + return exitCrash + } + return exitSuccess +} + +var errSandboxExecHelp = errors.New("help requested") + +// parseSandboxExecArgs takes everything after `--` as the command, so the +// command's own flags are never mistaken for ours. +func parseSandboxExecArgs(args []string) ([]string, error) { + for index, arg := range args { + switch arg { + case "-h", "--help", "help": + return nil, errSandboxExecHelp + case "--": + command := args[index+1:] + if len(command) == 0 { + return nil, errors.New("usage: zero sandbox exec -- [args...]") + } + return command, nil + } + } + if len(args) == 0 { + return nil, errors.New("usage: zero sandbox exec -- [args...]") + } + // Tolerated without the separator for interactive use, but the separator is + // what the help shows, because anything with a leading dash needs it. + return args, nil +} + +func writeSandboxExecHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero sandbox exec -- [args...] + +Runs one command through the real sandbox and exits with its status. + +Everything after the -- separator is the command, so its own flags are not +parsed as Zero's. The resolved backend and enforcement level are written to +stderr before the command runs, and a downgrade is reported there explicitly. + +Examples: + zero sandbox exec -- cmd /c echo hello + zero sandbox exec -- powershell -Command "Set-Content out.txt x" + +`) + return err +} From 912a873c1e817abcfa7750a2cafbd9fc444bc74e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 21:19:27 +0530 Subject: [PATCH 45/45] fix(sandbox): fingerprint principal grants in the setup marker anandh8x's finding #4. The marker hashed BuildWindowsACLPlan, the capability-SID plan, while principal grants are built separately by buildWindowsPrincipalACLPlan from the same profile. So the marker was blind to them: narrowing or removing a principal read root left setup looking current and the old AllowRead ACE sitting on disk. Narrowing a policy has to be able to take access away, and this was the one path where it could not. The fingerprint hashes the plan with a fixed placeholder trustee rather than the real principal SID. The account is recreated with a fresh SID whenever it is reprovisioned, so hashing the real one would move the fingerprint on every rebuild even when the granted paths were identical, and every command would then rerun setup. What must invalidate the marker is the set of paths and actions, which is what this captures. Empty when the principal backend is not opted into, so the default install's marker does not churn. Schema version goes 5 to 6 so existing markers are treated as stale rather than read as having no principal grants. Validation refuses a mismatch with the action to take, since the remedy is re-running elevated setup so the stale grants are actually revoked rather than merely re-planned. --- internal/sandbox/windows_setup.go | 73 ++++++++++-- ...indows_setup_principal_fingerprint_test.go | 112 ++++++++++++++++++ 2 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 internal/sandbox/windows_setup_principal_fingerprint_test.go diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 269730457..e0e2de65f 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -15,7 +15,7 @@ import ( const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe" -const windowsSandboxSetupMarkerSchemaVersion = 5 +const windowsSandboxSetupMarkerSchemaVersion = 6 // windowsSandboxIdentityEnv opts a machine into the principal backend while it // is still experimental. Provisioning is inert without it, so an existing @@ -114,6 +114,16 @@ type WindowsSandboxSetupMarker struct { // environment and could disagree silently — see // ValidateWindowsSandboxSetupMarker. PrincipalOptIn bool `json:"principalOptIn"` + // PrincipalPlanHash fingerprints the PRINCIPAL ACL plan, which ACLPlanHash + // above does not cover: that one hashes BuildWindowsACLPlan, the + // capability-SID plan, while principal grants are built separately by + // buildWindowsPrincipalACLPlan from the same profile. + // + // Without it, narrowing or removing a principal read root left setup looking + // current, so the old AllowRead ACEs stayed on disk with nothing to notice + // they no longer matched the policy. Empty when the principal backend is not + // opted into, which keeps the marker stable for the default install. + PrincipalPlanHash string `json:"principalPlanHash,omitempty"` } func WindowsSandboxSetupMarkerPath(sandboxHome string) string { @@ -295,17 +305,55 @@ func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa if len(infraPlan.IdentitySIDs) > 0 { offlineSID = infraPlan.IdentitySIDs[0] } + principalHash, err := windowsPrincipalPlanFingerprint(config) + if err != nil { + return WindowsSandboxSetupMarker{}, err + } return WindowsSandboxSetupMarker{ - SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, - ACLPlanHash: hash, - ACLPlanEntries: len(plan.Entries), - NetworkInfraHash: infraHash, - OfflineFilterSID: offlineSID, - NetworkFilters: len(infraPlan.Filters), - PrincipalOptIn: config.PrincipalOptIn, + SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, + ACLPlanHash: hash, + ACLPlanEntries: len(plan.Entries), + NetworkInfraHash: infraHash, + OfflineFilterSID: offlineSID, + NetworkFilters: len(infraPlan.Filters), + PrincipalOptIn: config.PrincipalOptIn, + PrincipalPlanHash: principalHash, }, nil } +// windowsPrincipalPlanFingerprint hashes the principal ACL plan so a change to +// principal read or write roots invalidates setup. +// +// The SID is a fixed placeholder rather than the real principal's, deliberately. +// The account is recreated with a fresh SID on reprovision, so hashing the real +// one would make the fingerprint change every time the account is rebuilt even +// though the GRANTED PATHS are identical, and every command would then rerun +// setup. What must invalidate the marker is the set of paths and actions, which +// is exactly what this captures. +// +// Returns empty when the principal backend is not opted into, so the default +// install's marker is unchanged. +func windowsPrincipalPlanFingerprint(config WindowsSandboxSetupConfig) (string, error) { + if !config.PrincipalOptIn { + return "", nil + } + filesystem := config.commandConfig().PermissionProfile.FileSystem + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: windowsPrincipalFingerprintSID, + WriteRoots: filesystem.WriteRoots, + ReadRoots: filesystem.ReadRoots, + DenyRead: filesystem.DenyRead, + }) + if err != nil { + return "", fmt.Errorf("fingerprint windows principal ACL plan: %w", err) + } + return WindowsACLPlanHash(plan) +} + +// windowsPrincipalFingerprintSID is a placeholder trustee used only for hashing. +// It never reaches an ACE. +const windowsPrincipalFingerprintSID = "S-1-0-0" + func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) { marker, err := BuildWindowsSandboxSetupMarker(config) if err != nil { @@ -389,6 +437,15 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { if actual.ACLPlanHash != expected.ACLPlanHash || actual.ACLPlanEntries != expected.ACLPlanEntries { return errors.New("windows sandbox setup is out of date: permission roots or deny lists changed") } + // The capability-SID plan above and the principal plan are built separately + // from the same profile, so the hash above does not cover principal grants. + // Without this check, removing a principal read root left setup looking + // current and the stale AllowRead ACE in place, which is the opposite of + // what narrowing a policy is supposed to do. + if actual.PrincipalPlanHash != expected.PrincipalPlanHash { + return errors.New("windows sandbox setup is out of date: sandbox principal grants changed — " + + "re-run `zero sandbox setup` from an elevated (Administrator) terminal so the old grants are revoked") + } // Mode-agnostic: validate the provisioned infrastructure, never the // per-command network mode — so an approved (allow) network command and an // ordinary (deny) command both validate against this one setup. diff --git a/internal/sandbox/windows_setup_principal_fingerprint_test.go b/internal/sandbox/windows_setup_principal_fingerprint_test.go new file mode 100644 index 000000000..3f724955f --- /dev/null +++ b/internal/sandbox/windows_setup_principal_fingerprint_test.go @@ -0,0 +1,112 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func principalFingerprintConfig(sandboxHome string, readRoots []string) WindowsSandboxSetupConfig { + workspace := filepath.FromSlash("/ws/project") + return WindowsSandboxSetupConfig{ + SandboxHome: sandboxHome, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PrincipalOptIn: true, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + ReadRoots: readRoots, + }, + }, + } +} + +// Narrowing a principal's read roots MUST invalidate setup. +// +// ACLPlanHash covers BuildWindowsACLPlan, the capability-SID plan. Principal +// grants are built separately by buildWindowsPrincipalACLPlan from the same +// profile, so before this the marker was blind to them: remove a read root and +// setup still looked current while the AllowRead ACE stayed on disk. Narrowing +// a policy has to be able to take access away. +func TestSetupMarkerInvalidatesWhenPrincipalReadRootsShrink(t *testing.T) { + home := t.TempDir() + wide, err := BuildWindowsSandboxSetupMarker(principalFingerprintConfig(home, []string{ + filepath.FromSlash("/ws/project"), + filepath.FromSlash("/ws/extra-read"), + })) + if err != nil { + t.Fatalf("build wide marker: %v", err) + } + narrow, err := BuildWindowsSandboxSetupMarker(principalFingerprintConfig(home, []string{ + filepath.FromSlash("/ws/project"), + })) + if err != nil { + t.Fatalf("build narrow marker: %v", err) + } + + if wide.PrincipalPlanHash == "" { + t.Fatal("no principal fingerprint recorded while opted in, so principal grants are unfingerprinted") + } + if wide.PrincipalPlanHash == narrow.PrincipalPlanHash { + t.Error("dropping a principal read root did not change the fingerprint, so stale AllowRead ACEs survive a narrowed policy") + } +} + +// A stale principal fingerprint must be refused through the real validator, +// which reads the marker off disk, with a message that says what to do. +func TestValidateRefusesAChangedPrincipalFingerprint(t *testing.T) { + home := t.TempDir() + config := principalFingerprintConfig(home, []string{filepath.FromSlash("/ws/project")}) + if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("write marker: %v", err) + } + if err := ValidateWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("a freshly written marker did not validate, so this test cannot isolate the fingerprint: %v", err) + } + + // Rewrite only the principal fingerprint, the way a policy change would. + path := WindowsSandboxSetupMarkerPath(home) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read marker: %v", err) + } + var marker map[string]any + if err := json.Unmarshal(raw, &marker); err != nil { + t.Fatalf("parse marker: %v", err) + } + marker["principalPlanHash"] = "stale-hash-from-an-earlier-policy" + rewritten, err := json.Marshal(marker) + if err != nil { + t.Fatalf("marshal marker: %v", err) + } + if err := os.WriteFile(path, rewritten, 0o600); err != nil { + t.Fatalf("rewrite marker: %v", err) + } + + err = ValidateWindowsSandboxSetupMarker(config) + if err == nil { + t.Fatal("a marker whose principal grants no longer match the policy was accepted") + } + if !strings.Contains(err.Error(), "principal grants changed") { + t.Errorf("refused for the wrong reason: %v", err) + } +} + +// The default install must be untouched: opted out means no fingerprint, so the +// marker does not churn for the overwhelming majority of users. +func TestSetupMarkerHasNoPrincipalFingerprintWhenOptedOut(t *testing.T) { + config := principalFingerprintConfig(t.TempDir(), []string{filepath.FromSlash("/ws/project")}) + config.PrincipalOptIn = false + + marker, err := BuildWindowsSandboxSetupMarker(config) + if err != nil { + t.Fatalf("build marker: %v", err) + } + if marker.PrincipalPlanHash != "" { + t.Errorf("principal fingerprint %q recorded while opted out", marker.PrincipalPlanHash) + } +}