From 63221266d0faa84cc1d528e13c8373c337bea63f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:07:09 +0530 Subject: [PATCH 01/96] 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 f3eeb0bbee0961c154712c1d307dd33bca102bc6 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:13:04 +0530 Subject: [PATCH 02/96] 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 41a15265996bdc3fd79d2529eba240e7d5bba4ef Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:24:58 +0530 Subject: [PATCH 03/96] 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 34a6e218bb1606db471070874507f5f9a7bee5df Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:33:08 +0530 Subject: [PATCH 04/96] 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 30944ff7e526c049cd2c2764d2ec56dd7a33dd41 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:35:41 +0530 Subject: [PATCH 05/96] 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 6053126e7f901b8f28ad130b112d4681de740322 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:43:35 +0530 Subject: [PATCH 06/96] 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 2ab568d63ed97bbc8dc48d841795b5ce73082fc6 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 26 Jul 2026 23:43:43 +0530 Subject: [PATCH 07/96] 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 521e9f5b838565ac67e7c5e08124632c527c34c2 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 09:03:13 +0530 Subject: [PATCH 08/96] 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 5c624e0a55b23c03df172a63d86c0fbac94bc6a0 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 09:16:24 +0530 Subject: [PATCH 09/96] 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 c2ceb0dc8282078c50c2d5b7d1db279e7a9c946b Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 09:29:28 +0530 Subject: [PATCH 10/96] 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 d40dce00db32bcf5dd05a9b09d36b9699d16ea1d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 13:35:30 +0530 Subject: [PATCH 11/96] 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 a54a001c78f779b9ec71e69d4d9d35b47ae0c17d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 27 Jul 2026 14:24:30 +0530 Subject: [PATCH 12/96] 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 a5c2ab6c56b55a19984741a849a35608c6d24422 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 28 Jul 2026 12:24:24 +0530 Subject: [PATCH 13/96] 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 d5d2b09b82281323b3b0852e37a0f2cf75802f74 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 28 Jul 2026 21:31:50 +0530 Subject: [PATCH 14/96] 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 60257df7e71235b14697541b6e46b22907485845 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 29 Jul 2026 13:50:34 +0530 Subject: [PATCH 15/96] 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 194e68772486cca3cb82696270e2d7971acdb254 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 29 Jul 2026 20:05:17 +0530 Subject: [PATCH 16/96] 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 700336787d1d461fe7fed91f6db5ba94a5cc8b70 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 11:08:58 +0530 Subject: [PATCH 17/96] 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 1462592061cf09c145b8dcd9ffe60dd217673d1c Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 11:24:49 +0530 Subject: [PATCH 18/96] 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 aa1a18746df9e88c6f9a4109e783c35cde1c90d2 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 22:04:32 +0530 Subject: [PATCH 19/96] 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 349e2b1c6..46c61f1e2 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -101,9 +101,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 bdb5bc5754831a0afc917945b15537fec1bca2f3 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 22:38:18 +0530 Subject: [PATCH 20/96] 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 87180abebb9471d40d9aeea4221f64010fb85654 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 22:44:37 +0530 Subject: [PATCH 21/96] 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 c12738ab0f4e935c590679512d4ed3fd8dc741d5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 22:51:11 +0530 Subject: [PATCH 22/96] 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 6d82e7a2041b24d55f49bd36a588cd8bf87ec9da Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 23:13:32 +0530 Subject: [PATCH 23/96] 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 46c61f1e2..14143499d 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -129,6 +129,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 { return permissionProfileFromPolicy(workspaceRoot, policy, scope, "", nil) } 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 97ff106217b41218d11028663c0810e9b971ac92 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 23:27:19 +0530 Subject: [PATCH 24/96] 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 d44ac1720a685ac9f77b1f3ff5efa95c99b42246 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 23:33:56 +0530 Subject: [PATCH 25/96] 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 5a7f6306b619db142d0ace1e13ef2fc612b42fe7 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 30 Jul 2026 23:45:10 +0530 Subject: [PATCH 26/96] 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 dc8b7bbc220342062ebf9d6e5e850230f731cfe5 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 12:14:14 +0530 Subject: [PATCH 27/96] 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 e42cace3d529bc3013f8c6200d6a6d617c256799 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 12:18:23 +0530 Subject: [PATCH 28/96] 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 27849df2025672126d359adf7ac2acc8f418dda1 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 31 Jul 2026 23:14:54 +0530 Subject: [PATCH 29/96] 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 f2a8e71ed2b42463e068efd606157eee262b99de Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 1 Aug 2026 17:49:33 +0530 Subject: [PATCH 30/96] 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 374f28dad1951af49bdd2a4ddb776c8d56addce2 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 1 Aug 2026 21:01:26 +0530 Subject: [PATCH 31/96] 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 1cf360948..77c8863bb 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 0aac1fa285a89791dbbec7b2b80437f70eedfd5d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 3 Aug 2026 11:36:23 +0530 Subject: [PATCH 32/96] 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 bb49fa8e1..5ebc876da 100644 --- a/internal/cli/sandbox.go +++ b/internal/cli/sandbox.go @@ -174,10 +174,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 fb98f1287..4e01c5639 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -352,6 +352,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 9dc1880f9c3a3256f8662082fe952dda3a0293a1 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 3 Aug 2026 17:16:40 +0530 Subject: [PATCH 33/96] 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 f3e6bc0f5b7c74c7b7541c33728461c3665d3567 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 3 Aug 2026 17:57:43 +0530 Subject: [PATCH 34/96] 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 62976aa4f6814389acbc6eccf73bddae574ba9b7 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 7 Aug 2026 13:18:00 +0530 Subject: [PATCH 35/96] 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 14143499d..5beb042a3 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -96,6 +96,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 7ea940540be7dda7bf4028396d06ea94a8e07cd6 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 7 Aug 2026 23:57:42 +0530 Subject: [PATCH 36/96] 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 fd6e1d8a2278b16643283c0a3f46412d526687e4 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 14:43:14 +0530 Subject: [PATCH 37/96] 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 0ac2e729479814bc30b38829364d504e458be369 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 15:13:58 +0530 Subject: [PATCH 38/96] 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 f4fd1c9006f50b1bdd4ae1126c3a7e274d549555 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 15:50:09 +0530 Subject: [PATCH 39/96] 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 c834f883e70dab50bf299b256eeae1c7f11e9b6f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 19:19:24 +0530 Subject: [PATCH 40/96] 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 95521758029506f5ed2d52e6384c0fabac7e671a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 19:26:01 +0530 Subject: [PATCH 41/96] 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 60da04e78e3950f0c11bafe565b4093a43fe2f0f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 19:41:30 +0530 Subject: [PATCH 42/96] 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 366b9c3911b035b0fd9de9fdc5e5b84e4e2dffed Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 20:18:55 +0530 Subject: [PATCH 43/96] 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 9b3376def3df58db5927458a2cdfd12a908d2fda Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 20:54:52 +0530 Subject: [PATCH 44/96] 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 5ebc876da..892638362 100644 --- a/internal/cli/sandbox.go +++ b/internal/cli/sandbox.go @@ -22,7 +22,7 @@ const permissionProfileScopeNote = "permissionProfile is derived from this proce 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": @@ -36,6 +36,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: @@ -653,6 +655,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 bbda356b34ab7d2d64c6d3169fbabe85ea0004af Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 8 Aug 2026 21:19:27 +0530 Subject: [PATCH 45/96] 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) + } +} From e2b8b70834335dced8e919f07c394ed574b47645 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 9 Aug 2026 13:12:13 +0530 Subject: [PATCH 46/96] fix(sandbox): report an inactive principal in doctor, and finish the comment upgrade anandh8x's findings #7 and #10. #7. windowsSandboxPrincipalEligible stands the principal down under network-deny, which is the DEFAULT policy, so an operator who set the opt-in to confine reads silently gets the same-user restricted token and no read confinement at all. Nothing told them. The runner cannot: it is re-exec'd per command, so the notice would land on the stderr of essentially every tool call, and it is not per-command actionable anyway. The setup marker validates happily because setup really did provision the account. The runtime comment already said this belonged on `zero doctor`, and doctor never carried it. Doctor now warns, and the rule has one definition rather than two. The eligibility check asks the same exported predicate doctor does, so the two cannot drift into disagreeing about whether a principal is in play. Drift here would mean doctor telling someone reads are confined while commands run on the restricted token, which is worse than saying nothing. A warning rather than a failure: commands work and the network is still enforced. What is wrong is the operator's picture of what they have. #10. windowsSandboxUserIsManaged has always claimed a legacy account is "adopted and its comment rewritten on the way through", and it never was: provisioning only ever set the password (USER_INFO_1003 is documented in this file as the password-only form). So a pre-key account stayed permanently unattributable, and every later run had to keep accepting the bare comment to avoid orphaning it. Adoption now stamps the workspace key via USER_INFO_1007, which ends that and lets the bare form eventually be retired rather than accepted forever. Best effort: the account is adopted and working either way, and failing provisioning over a cosmetic stamp would strand a usable sandbox for bookkeeping. Reported rather than swallowed, so a run that keeps re-adopting the same legacy account is visible. The legacy-comment probe is a separate function rather than folded into windowsSandboxUserIsManaged, so that keeps its (bool, error) shape and the test seam around it does not have to change. --- internal/doctor/hardening.go | 21 ++++ .../windows_identity_runtime_windows.go | 7 +- internal/sandbox/windows_identity_windows.go | 115 ++++++++++++++++-- .../windows_principal_inactive_test.go | 56 +++++++++ internal/sandbox/windows_setup.go | 27 ++++ 5 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 internal/sandbox/windows_principal_inactive_test.go diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index 90ffb7d23..4eda050f9 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -119,6 +119,27 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo }) return &result } + // Setup is valid, but the principal can still be opted into and stand down. + // Under the DEFAULT network-deny policy it always does, so an operator who + // set the opt-in to confine reads gets the same-user restricted token and no + // read confinement at all. Nothing else tells them: the runner cannot warn + // per command without spamming every tool call, and the marker validates + // happily because setup really did provision the account. + // + // A warning rather than a failure. Commands run correctly and the network is + // still enforced; what is wrong is the operator's picture of what they have. + if reason := sandbox.WindowsSandboxPrincipalInactiveReason(setupConfig.PrincipalOptIn, profile.Network.Mode); reason != "" { + result := check("sandbox.principal", "Sandbox principal", StatusWarn, + fmt.Sprintf("Sandbox principal is opted in but inactive: %s.", reason), map[string]any{ + "backend": string(backend.Name), + "platform": goos, + "optIn": true, + "active": false, + "networkMode": string(profile.Network.Mode), + "remedy": "allow network for this workspace to use the principal, or unset the opt-in to stop expecting read confinement", + }) + return &result + } return nil } diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index b1942a87c..1d16b89af 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -65,7 +65,12 @@ func windowsSandboxPrincipalEligible(config WindowsSandboxCommandConfig) bool { // 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 + // + // Asked of the shared predicate rather than re-tested here, so `zero doctor` + // reports exactly the rule this path applies. Two copies would drift, and the + // failure mode of drift is doctor telling an operator reads are confined + // while commands quietly run on the restricted token. + return WindowsSandboxPrincipalInactiveReason(true, config.PermissionProfile.Network.Mode) == "" } // windowsSandboxPrincipalToken returns a token for this workspace's sandbox diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index 03504b135..137e89294 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -27,6 +27,7 @@ import ( "encoding/base32" "errors" "fmt" + "os" "runtime" "strings" "unsafe" @@ -108,6 +109,12 @@ type userInfo1003 struct { Password *uint16 } +// userInfo1007 mirrors USER_INFO_1007, the comment-only form. Used to finish +// the legacy-comment upgrade windowsSandboxUserIsManaged promises. +type userInfo1007 struct { + Comment *uint16 +} + // localGroupInfo1 mirrors LOCALGROUP_INFO_1. type localGroupInfo1 struct { Name *uint16 @@ -378,13 +385,85 @@ func windowsSandboxUserIsManaged(username string, workspaceKey string) (bool, er 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. + // adopted and its comment rewritten on the way through. The rewrite is + // upgradeWindowsSandboxUserComment, called from the adoption path. if comment == windowsSandboxUserComment { return true, nil } return comment == windowsSandboxUserCommentFor(workspaceKey), nil } +// windowsSandboxUserHasLegacyComment reports an account carrying the OLD bare +// ownership comment, the one with no workspace key. +// +// Separate from windowsSandboxUserIsManaged rather than folded into it so that +// function keeps its (bool, error) shape and the test seam around it does not +// have to change. Both read the same field; this one runs on the adoption path +// only, at setup time, so the second lookup costs nothing that matters. +func windowsSandboxUserHasLegacyComment(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 +} + +// upgradeWindowsSandboxUserComment stamps the workspace-keyed ownership comment +// onto an account still carrying the legacy bare one. +// +// windowsSandboxUserIsManaged has always said the comment is "rewritten on the +// way through", and it never was: provisioning only ever set the password. So a +// pre-key account stayed permanently unattributable, and every later run had to +// keep accepting the bare comment to avoid orphaning it. Writing the key ends +// that, and lets the bare form eventually be retired. +// +// Not fatal on failure. The account is adopted and usable either way; refusing +// to provision because a cosmetic stamp did not take would strand a working +// sandbox over bookkeeping. +func upgradeWindowsSandboxUserComment(username string, workspaceKey string) error { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return err + } + comment, err := windows.UTF16PtrFromString(windowsSandboxUserCommentFor(workspaceKey)) + if err != nil { + return err + } + info := userInfo1007{Comment: comment} + status, _, _ := procNetUserSetInfo.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 1007, // level: USER_INFO_1007, comment only + uintptr(unsafe.Pointer(&info)), + 0, // no parameter-error index + ) + runtime.KeepAlive(name) + runtime.KeepAlive(comment) + return netAPIStatus("NetUserSetInfo", status) +} + // localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0: one group name pointer. type localGroupUsersInfo0 struct { Name *uint16 @@ -501,15 +580,16 @@ 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 - resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword - windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged - windowsSandboxUserIsPrivilegedFn = windowsSandboxUserIsPrivileged - grantWindowsSandboxLogonRightsFn = grantWindowsSandboxLogonRights - revokeWindowsSandboxLogonRightsFn = revokeWindowsSandboxLogonRights + ensureWindowsSandboxGroupFn = ensureWindowsSandboxGroup + ensureWindowsSandboxUserFn = ensureWindowsSandboxUser + addWindowsSandboxUserToGroupFn = addWindowsSandboxUserToGroup + resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID + resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword + windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged + windowsSandboxUserHasLegacyCommentFn = windowsSandboxUserHasLegacyComment + 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 @@ -567,6 +647,21 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit if privileged { return windowsSandboxIdentity{}, "", false, fmt.Errorf("%w: %q", errWindowsSandboxPrivilegedAccount, username) } + // Finish the upgrade windowsSandboxUserIsManaged has always promised. An + // account carrying the legacy bare comment gets the workspace key stamped + // on now, so it stops being unattributable and the bare form can + // eventually be retired rather than accepted forever. + // + // Best effort on purpose: the account is adopted and working either way, + // and failing provisioning over a comment would strand a usable sandbox + // for bookkeeping. Reported rather than swallowed so a run that keeps + // re-adopting the same legacy account is visible. + if legacy, err := windowsSandboxUserHasLegacyCommentFn(username); err == nil && legacy { + if err := upgradeWindowsSandboxUserComment(username, workspaceKey); err != nil { + fmt.Fprintf(os.Stderr, "%s: could not stamp the workspace key onto sandbox principal %s: %v\n", + WindowsSandboxSetupName, username, err) + } + } // Deliberately NOT resetting the password here. // // NetUserAdd left an existing account untouched, so the password above is diff --git a/internal/sandbox/windows_principal_inactive_test.go b/internal/sandbox/windows_principal_inactive_test.go new file mode 100644 index 000000000..bb8ec3a7a --- /dev/null +++ b/internal/sandbox/windows_principal_inactive_test.go @@ -0,0 +1,56 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// An opted-in principal that stands down must be reportable. +// +// This is the whole point of the predicate: under the DEFAULT network-deny +// policy the principal never runs, so an operator who set the opt-in to confine +// reads gets the same-user restricted token and no read confinement, with +// nothing anywhere saying so. The runner cannot warn per command, and the setup +// marker validates happily because the account really was provisioned. +func TestPrincipalReportsInactiveUnderTheDefaultDenyPolicy(t *testing.T) { + reason := WindowsSandboxPrincipalInactiveReason(true, NetworkDeny) + if reason == "" { + t.Fatal("opted in under network-deny reported as active, so the standdown is invisible to doctor") + } + // The message has to say WHY, not just that something is off. An operator + // reading it needs to know reads are not confined. + for _, want := range []string{"restricted token", "reads"} { + if !strings.Contains(reason, want) { + t.Errorf("reason %q does not mention %q", reason, want) + } + } +} + +// With the network allowed the principal genuinely runs, so there is nothing to +// report and doctor must stay quiet. +func TestPrincipalReportsActiveWhenNetworkIsAllowed(t *testing.T) { + if reason := WindowsSandboxPrincipalInactiveReason(true, NetworkAllow); reason != "" { + t.Errorf("opted in with network allowed reported inactive: %q", reason) + } +} + +// Not opting in is not a standdown. Warning every default install that a +// backend it never asked for is inactive would be noise, and noise that repeats +// gets filtered rather than acted on. +func TestNotOptingInIsNotReportedAsInactive(t *testing.T) { + for _, mode := range []NetworkMode{NetworkDeny, NetworkAllow, ""} { + if reason := WindowsSandboxPrincipalInactiveReason(false, mode); reason != "" { + t.Errorf("opt-out with network %q reported inactive: %q", mode, reason) + } + } +} + +// An unset network mode must normalize the same way the rest of the package +// treats it, so doctor and the runtime agree on a config that omits it. +func TestPrincipalInactiveHandlesAnUnsetNetworkMode(t *testing.T) { + unset := WindowsSandboxPrincipalInactiveReason(true, "") + normalized := WindowsSandboxPrincipalInactiveReason(true, NormalizeNetworkMode("")) + if unset != normalized { + t.Errorf("unset mode reported %q but its normalized form reported %q", unset, normalized) + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index e0e2de65f..2591a7c93 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -44,6 +44,33 @@ func WindowsSandboxPrincipalOptIn(env map[string]string) bool { return windowsSandboxIdentityEnabled(env) } +// WindowsSandboxPrincipalInactiveReason explains why the sandbox principal will +// NOT be used even though it is opted into, or returns empty when it will be. +// +// This is the single source of truth for that rule: windowsSandboxPrincipalEligible +// asks it too, so the runtime and `zero doctor` cannot drift into disagreeing +// about whether a principal is in play. +// +// It exists because the standdown is otherwise invisible. The runner cannot +// announce it, being re-exec'd per command so the notice would land on the +// stderr of essentially every tool call, and it is not per-command actionable +// anyway. But an operator who set the opt-in and believes reads are confined, +// when they are not, is holding a false picture of their own machine. Doctor is +// read once, which is where a standing configuration fact belongs. +// +// Returns empty when the opt-in is off: that is not a standdown, it is simply +// not asking for the backend. +func WindowsSandboxPrincipalInactiveReason(optIn bool, network NetworkMode) string { + if !optIn { + return "" + } + if NormalizeNetworkMode(network) != NetworkDeny { + return "" + } + return "network denial is enforced by WFP filters keyed to the offline-marker SID, which a principal token cannot carry, " + + "so commands run on the restricted token instead and reads are not confined to the principal" +} + func windowsSandboxPrincipalOptInValue(optIn bool) string { if optIn { return "1" From 7b0ed7eb00b15c8cc608c700cdd90f0f7c0b52e0 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 9 Aug 2026 13:19:49 +0530 Subject: [PATCH 47/96] fix(sandbox): retire the principal on opt-out, and finish teardown past a foreign secret anandh8x's findings #6 and #8. They are one commit because #6 calls straight into the path #8 breaks, so fixing #6 alone would have shipped an opt-out that aborts on exactly the machines #8 describes. #8 first. readWindowsSandboxSecret already treats permission-denied as unavailability rather than breakage, and documents why: an operator who elevated with a separate administrative account ends up with a secret their ordinary account cannot open. Removal did not agree. os.Remove returning a permission error aborted teardown at its very first step, so the account, its logon rights, its ACEs and its ledger all stayed installed because one file could not be unlinked. The operator asked for removal, got an error, and kept a working principal. Removal now classifies that case distinctly and teardown carries on, reporting the leftover secret at the end rather than swallowing it. An ordinary removal failure is still fatal, and there is a test for the difference: misclassifying one as the other would turn a real failure into a shrug. #6. Opting out has to actually retire the principal, because that is what we tell people it does. ValidateWindowsSandboxSetupMarker sends an operator here in as many words. There was no opt-out branch at all, so the marker flipped to opted-out while everything stayed exactly where it was: the instruction was a lie, and the leftovers were invisible, because nothing afterwards looks for a principal it believes was never provisioned. Setup now runs teardown on the opt-out path. Not fatal if it does not complete: teardown is idempotent and a machine that never had a principal passes straight through, so a failure means genuine residue rather than a missing account. Say so and finish a setup whose sandbox is otherwise fine. --- .../windows_identity_runtime_windows.go | 21 ++++++- .../windows_identity_secret_windows.go | 25 +++++++- .../windows_secret_removal_windows_test.go | 63 +++++++++++++++++++ internal/sandbox/windows_setup_windows.go | 17 +++++ 4 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 internal/sandbox/windows_secret_removal_windows_test.go diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 1d16b89af..84f7630bc 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -418,8 +418,20 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e if err != nil { return err } + // A secret owned by another administrator's setup is not a reason to abandon + // the rest of teardown. Aborting here left the account, its logon rights, its + // ACEs and its ledger all installed because one file could not be unlinked, + // which is the worst of both outcomes: the operator asked for removal, got a + // failure, and kept a working principal. + // + // Remembered and reported at the end rather than swallowed, so the leftover + // secret is visible to whoever has to clean it up. + var secretErr error if err := removeWindowsSandboxSecret(secretPath); err != nil { - return err + if !errors.Is(err, errWindowsSandboxSecretNotOurs) { + return err + } + secretErr = err } // Set when ACE revocation could not complete. Teardown continues regardless, // but the ledger is kept and the error surfaced, so the residue stays @@ -481,9 +493,12 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // 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 errors.Join(secretErr, fmt.Errorf("%w; the principal ACL ledger has been kept so the remaining ACEs can still be found", revokeErr)) } - return removeWindowsPrincipalACLLedger(config.SandboxHome, username) + // The account and its ACEs are gone either way. A secret left behind is + // residue worth naming, not a reason to keep the ledger: there are no ACEs + // left for it to describe. + return errors.Join(secretErr, removeWindowsPrincipalACLLedger(config.SandboxHome, username)) } // setupWindowsSandboxRuntimeRoot resolves this workspace's runtime root and diff --git a/internal/sandbox/windows_identity_secret_windows.go b/internal/sandbox/windows_identity_secret_windows.go index 87463940b..f331241ea 100644 --- a/internal/sandbox/windows_identity_secret_windows.go +++ b/internal/sandbox/windows_identity_secret_windows.go @@ -229,9 +229,28 @@ func readWindowsSandboxSecret(path string) (string, error) { // removeWindowsSandboxSecret deletes a stored password. Called before the // account itself is removed so a secret never outlives the principal it // authenticates. +// errWindowsSandboxSecretNotOurs reports a secret this account cannot delete +// because another administrator's setup owns its DACL. +// +// Distinguishable so teardown can carry on. The alternative is what it used to +// do: abort before removing the account, its logon rights, its ACEs and its +// ledger, leaving a fully provisioned principal on the machine because one file +// could not be unlinked. +var errWindowsSandboxSecretNotOurs = errors.New("the sandbox secret belongs to a different administrator's setup") + func removeWindowsSandboxSecret(path string) error { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove sandbox secret: %w", err) + err := os.Remove(path) + if err == nil || os.IsNotExist(err) { + return nil } - return nil + // The read path already treats permission-denied as unavailability rather + // than breakage, for the reason documented on readWindowsSandboxSecret: an + // operator who elevated with a separate administrative account gets a secret + // their ordinary account cannot open. Removal has to agree. It did not, so on + // exactly those machines teardown stopped at the first file and stranded + // everything after it. + if os.IsPermission(err) { + return fmt.Errorf("%w: %s", errWindowsSandboxSecretNotOurs, path) + } + return fmt.Errorf("remove sandbox secret: %w", err) } diff --git a/internal/sandbox/windows_secret_removal_windows_test.go b/internal/sandbox/windows_secret_removal_windows_test.go new file mode 100644 index 000000000..fd0ca9dc6 --- /dev/null +++ b/internal/sandbox/windows_secret_removal_windows_test.go @@ -0,0 +1,63 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// A secret this account cannot delete must be distinguishable from one that +// simply would not delete. +// +// Teardown carries on past the first and aborts on the second. Before the +// distinction existed, a secret owned by another administrator's setup stopped +// teardown at its very first step, leaving the account, its logon rights, its +// ACEs and its ledger all installed. The operator asked for removal, got an +// error, and kept a working principal. +func TestRemovingAnUndeletableSecretIsDistinguishable(t *testing.T) { + // A non-empty directory standing in for a secret: os.Remove refuses it, which + // gives a real removal failure without needing a second administrator. + path := filepath.Join(t.TempDir(), "secret") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.WriteFile(filepath.Join(path, "occupant"), []byte("x"), 0o600); err != nil { + t.Fatalf("seed occupant: %v", err) + } + + err := removeWindowsSandboxSecret(path) + if err == nil { + t.Fatal("a secret that could not be removed reported success") + } + // This is NOT the foreign-owner case, so teardown must still treat it as + // fatal rather than shrugging and carrying on. + if errors.Is(err, errWindowsSandboxSecretNotOurs) { + t.Errorf("an ordinary removal failure was classified as a foreign owner: %v", err) + } +} + +// Absence is success. Teardown runs on machines that never provisioned a +// principal, and re-running it must converge rather than fail. +func TestRemovingAMissingSecretIsNotAnError(t *testing.T) { + if err := removeWindowsSandboxSecret(filepath.Join(t.TempDir(), "never-existed")); err != nil { + t.Fatalf("removing a missing secret reported an error: %v", err) + } +} + +// The ordinary case still works, so the tolerance above did not turn removal +// into a no-op. +func TestRemovingOurOwnSecretDeletesIt(t *testing.T) { + path := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if err := removeWindowsSandboxSecret(path); err != nil { + t.Fatalf("removeWindowsSandboxSecret: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("the secret survived removal: stat err = %v", err) + } +} diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index ac8dc1243..3cb934894 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -61,6 +61,23 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) } return aclErr } + } else if err := removeWindowsSandboxPrincipalForSetup(config.commandConfig()); err != nil { + // Opting out has to actually retire the principal, because that is what + // we tell people it does. ValidateWindowsSandboxSetupMarker sends an + // operator here in as many words: re-run setup from an elevated terminal + // without the opt-in to retire the principal. Until now this branch did + // not exist, so the marker flipped to opted-out while the account, its + // secret, its logon rights, its ACEs and its ledger all stayed exactly + // where they were. The instruction was a lie, and the leftovers were + // invisible, because nothing afterwards looks for a principal it believes + // was never provisioned. + // + // Not fatal. Teardown is idempotent and a machine that never had a + // principal passes straight through, so a failure here means genuine + // residue rather than a missing account: say so and carry on rather than + // refusing to complete a setup whose sandbox is otherwise fine. + fmt.Fprintf(stderr, "%s: opted out, but retiring the existing sandbox principal did not complete: %v\n", + WindowsSandboxSetupName, err) } if err := applyWindowsNetworkPlan(networkPlan); err != nil { if rollbackErr := rollback(); rollbackErr != nil { From 54af05ea744b94059626ac42f67176a967a92b7e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 9 Aug 2026 14:51:56 +0530 Subject: [PATCH 48/96] fix(sandbox): serialize elevated setup with a per-workspace lock anandh8x's finding #3, the last of the twelve. Setup is a transaction, not a write. It rotates the principal's password, stores the secret, read-modify-writes the ACL ledger, installs WFP filters and writes the marker. Two setups for the same workspace at once interleave those steps: the account ends up with one process's password while the stored secret is the other's, so every later command fails to log on, and the ledger's read-modify-write silently loses a path set so ACEs that should have been revoked stay on disk with nothing recording them. Atomic individual writes do not address this. Each step being all-or-nothing is orthogonal to two processes interleaving BETWEEN steps, which is the actual failure, so the lock spans from the elevation check through the marker write. Nothing to port here: the reference implementation has the identical bug. Its password rotation and secret write hold no lock, its named mutex guards only an unrelated read-ACL-only mode and is machine-singleton, and the singleflight that looks like a fix is process-local. Decisions worth the reviewer's attention. Global rather than Local namespace, because the protected state is not session-scoped: the local account, its LSA logon rights and the WFP filters are machine state, so setups in different logon sessions must still exclude each other. Per-workspace rather than machine-wide, because every artifact the lock protects is per-workspace and a global lock would serialize unrelated setups. The DACL is Administrators and SYSTEM only, protected. A sandbox principal able to open this object could hold it and stall every future setup, or create it first and squat the name so real setup believes it holds a lock it does not. The OS thread is pinned. Win32 mutex ownership is thread-affine, so a goroutine that migrated between the wait and the release would call ReleaseMutex from a thread that does not own the object, leaving the mutex held until process exit, which is a deadlock for every later setup on the machine. WAIT_ABANDONED is treated as acquired and reported. The previous holder died inside the transaction, so the machine may be half set up; setup is idempotent and re-running it is the repair, whereas refusing would leave no way forward. A bounded wait rather than skip-and-succeed, because skipping here means not setting the sandbox up at all. On the tests: the production DACL means an unelevated process cannot open an existing lock, so the contention test could not reach the mutex wait anywhere that matters, including CI. The SDDL is therefore a var the wait tests relax for themselves, and a separate test pins the production value so loosening it takes an explicit change. Writing the test first is what surfaced this at all. --- .../sandbox/windows_setup_lock_windows.go | 193 ++++++++++++++++ .../windows_setup_lock_windows_test.go | 214 ++++++++++++++++++ internal/sandbox/windows_setup_windows.go | 28 +++ 3 files changed, 435 insertions(+) create mode 100644 internal/sandbox/windows_setup_lock_windows.go create mode 100644 internal/sandbox/windows_setup_lock_windows_test.go diff --git a/internal/sandbox/windows_setup_lock_windows.go b/internal/sandbox/windows_setup_lock_windows.go new file mode 100644 index 000000000..1cbce8b28 --- /dev/null +++ b/internal/sandbox/windows_setup_lock_windows.go @@ -0,0 +1,193 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "runtime" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Serializing elevated setup across processes. +// +// WHY THIS EXISTS. Setup is not one write, it is a transaction: it rotates the +// principal's password, stores the secret, read-modify-writes the ACL ledger, +// installs WFP filters and finally writes the marker. Two setups for the same +// workspace running at once interleave those steps, and the interleavings are +// not merely untidy. The account ends up with process B's password while the +// stored secret is process A's, so every later command fails to log on. The +// ledger's read-modify-write loses one process's path set, so ACEs that should +// have been revoked stay on disk with nothing recording them. +// +// Atomic individual writes do not help. Each step being all-or-nothing is +// orthogonal to two processes interleaving BETWEEN steps, which is the actual +// failure. The lock has to span the whole transaction. +// +// Worth recording: the reference implementation this was compared against has +// the identical bug. Its password rotation and secret write hold no lock at +// all, its named mutex guards only an unrelated read-ACL-only mode, and the +// singleflight that looks like a fix is process-local and gives no cross-process +// guarantee. There was nothing to port, so this is built. + +// windowsSetupLockTimeout bounds the wait. Setup is interactive and elevated, +// so a minute is generous for a legitimate concurrent run and short enough that +// a stuck holder gets reported rather than hung on. A var rather than a const +// only so a contention test does not have to wait a real minute to prove the +// two setups exclude each other. +var windowsSetupLockTimeout = 60 * time.Second + +// windowsSetupLockSDDL grants full control to Administrators and SYSTEM and +// nobody else, with the DACL protected so no inherited ACE widens it. +// +// Load-bearing rather than tidy. A sandbox principal able to open this object +// could hold it and stall every future setup, or create it first and squat the +// name so real setup believes it holds a lock it does not. Only accounts that +// could already run setup may touch it. +// +// A var so a test can exercise the mutex wait path without elevation. Setup +// itself always runs elevated, so unelevated callers cannot even open the +// object under this DACL, which would make the contention test unrunnable +// anywhere it matters. TestSetupLockIsAdministratorsOnly pins the production +// value so loosening it needs an explicit change here. +var windowsSetupLockSDDL = "D:P(A;;GA;;;BA)(A;;GA;;;SY)" + +const ( + // windowsWaitTimeout is WAIT_TIMEOUT. x/sys exports WAIT_ABANDONED, + // WAIT_OBJECT_0 and WAIT_FAILED at this version but not this one. + windowsWaitTimeout = 0x00000102 + + // windowsSetupLockKeyChars keeps the object name short while staying + // collision-free in practice: the key is already a hash, so a prefix of it + // distinguishes workspaces. + windowsSetupLockKeyChars = 32 +) + +// windowsSandboxSetupLock is a held cross-process setup lock. Release it once. +type windowsSandboxSetupLock struct { + handle windows.Handle + released bool + // abandoned records that the previous holder died without releasing, which + // means it stopped somewhere inside the transaction. + abandoned bool +} + +// windowsSandboxSetupLockName derives the object name for a workspace. +// +// Per-workspace rather than machine-wide because every artifact the lock +// protects is per-workspace: this account, this secret, this ledger. A global +// lock would serialize unrelated workspaces for no reason. +// +// Global rather than Local because the protected state is not session-scoped. +// The local account, its LSA logon rights and the WFP filters are machine +// state, so two setups in different logon sessions must still exclude each +// other. Creating a Global object needs SeCreateGlobalPrivilege, which the +// elevated setup helper has and which is the only caller. +func windowsSandboxSetupLockName(workspaceKey string) string { + key := 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 key == "" { + key = "default" + } + if len(key) > windowsSetupLockKeyChars { + key = key[:windowsSetupLockKeyChars] + } + return `Global\ZeroSandboxSetup-` + key +} + +// acquireWindowsSandboxSetupLock blocks until this process owns the setup lock +// for a workspace, or fails with an actionable error. +// +// The OS thread is pinned for the lock's lifetime. Win32 mutex ownership is +// thread-affine, so a goroutine that migrated between the wait and the release +// would call ReleaseMutex from a thread that does not own the object, leaving +// the mutex held until the process exits. That is a deadlock for every later +// setup on the machine, so the pinning is not optional. +func acquireWindowsSandboxSetupLock(workspaceKey string) (*windowsSandboxSetupLock, error) { + descriptor, err := windows.SecurityDescriptorFromString(windowsSetupLockSDDL) + if err != nil { + return nil, fmt.Errorf("build the sandbox setup lock security descriptor: %w", err) + } + attributes := windows.SecurityAttributes{SecurityDescriptor: descriptor} + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + name := windowsSandboxSetupLockName(workspaceKey) + namePtr, err := windows.UTF16PtrFromString(name) + if err != nil { + return nil, fmt.Errorf("encode the sandbox setup lock name: %w", err) + } + // initialOwner false, then wait explicitly. Asking for ownership at creation + // would take the lock only when this process created the object, so the + // create and open paths would differ and only one of them would ever wait. + handle, err := windows.CreateMutex(&attributes, false, namePtr) + if handle == 0 { + return nil, fmt.Errorf("open the sandbox setup lock %s: %w", name, err) + } + runtime.LockOSThread() + + lock := &windowsSandboxSetupLock{handle: handle} + state, waitErr := windows.WaitForSingleObject(handle, uint32(windowsSetupLockTimeout/time.Millisecond)) + switch state { + case windows.WAIT_OBJECT_0: + return lock, nil + case windows.WAIT_ABANDONED: + // The previous holder died inside the transaction. We own the mutex now. + // Proceeding is right rather than refusing: setup is idempotent and + // re-running it is exactly how a half-finished transaction is repaired, + // whereas refusing would leave the machine stuck in that half state with + // no way forward. + lock.abandoned = true + return lock, nil + case windowsWaitTimeout: + lock.releaseThreadAndHandle() + return nil, fmt.Errorf("another `zero sandbox setup` is already running for this workspace and did not finish within %s; "+ + "wait for it to finish, or look for a stuck elevated setup process before re-running", windowsSetupLockTimeout) + default: + lock.releaseThreadAndHandle() + if waitErr != nil { + return nil, fmt.Errorf("wait for the sandbox setup lock %s: %w", name, waitErr) + } + return nil, fmt.Errorf("wait for the sandbox setup lock %s returned an unexpected state %#x", name, state) + } +} + +// Abandoned reports that the previous holder crashed mid-transaction, so this +// run is repairing a half-finished setup rather than starting a clean one. +func (lock *windowsSandboxSetupLock) Abandoned() bool { + return lock != nil && lock.abandoned +} + +// release drops ownership and unpins the thread. Safe to call twice, so a defer +// and an explicit call cannot double-release. +func (lock *windowsSandboxSetupLock) release() { + if lock == nil || lock.released { + return + } + lock.released = true + // Release before closing. Closing a still-held mutex leaves it abandoned for + // the next waiter, which is recoverable but reports a crash that never + // happened. + _ = windows.ReleaseMutex(lock.handle) + lock.releaseThreadAndHandle() +} + +// releaseThreadAndHandle unpins the thread and drops the handle without +// releasing ownership, for the paths that never acquired it. +func (lock *windowsSandboxSetupLock) releaseThreadAndHandle() { + if lock.handle != 0 { + _ = windows.CloseHandle(lock.handle) + lock.handle = 0 + } + runtime.UnlockOSThread() +} diff --git a/internal/sandbox/windows_setup_lock_windows_test.go b/internal/sandbox/windows_setup_lock_windows_test.go new file mode 100644 index 000000000..89d7fe6fd --- /dev/null +++ b/internal/sandbox/windows_setup_lock_windows_test.go @@ -0,0 +1,214 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// A unique key per test run, so a leftover object from an earlier run cannot +// make these pass or fail for the wrong reason. +func lockTestKey(t *testing.T) string { + t.Helper() + return fmt.Sprintf("t%dx%d", time.Now().UnixNano(), len(t.Name())) +} + +// The production DACL is Administrators only, so an unelevated test process +// cannot OPEN an existing lock and never reaches the mutex wait at all. These +// tests are about the wait, so they relax the DACL for themselves. +// TestSetupLockIsAdministratorsOnly pins the real value. +func withOpenableLock(t *testing.T) { + t.Helper() + previous := windowsSetupLockSDDL + windowsSetupLockSDDL = "D:P(A;;GA;;;WD)" + t.Cleanup(func() { windowsSetupLockSDDL = previous }) +} + +func withShortLockTimeout(t *testing.T, d time.Duration) { + t.Helper() + previous := windowsSetupLockTimeout + windowsSetupLockTimeout = d + t.Cleanup(func() { windowsSetupLockTimeout = previous }) +} + +// THE POINT OF THE WHOLE FILE. A second setup for the same workspace must not +// get in while the first holds the lock. +// +// Without this, two elevated setups interleave: the account ends up with one +// process's password while the stored secret is the other's, and every later +// command fails to log on. The second acquisition runs on its own goroutine +// because Win32 mutex ownership is per THREAD and acquire pins the thread, so a +// same-thread re-entry would succeed recursively and prove nothing. +func TestSecondSetupIsExcludedWhileTheFirstHoldsTheLock(t *testing.T) { + withShortLockTimeout(t, 300*time.Millisecond) + withOpenableLock(t) + key := lockTestKey(t) + + first, err := acquireWindowsSandboxSetupLock(key) + if err != nil { + t.Fatalf("first acquisition: %v", err) + } + defer first.release() + + result := make(chan error, 1) + go func() { + second, err := acquireWindowsSandboxSetupLock(key) + if err == nil { + second.release() + } + result <- err + }() + + select { + case err := <-result: + if err == nil { + t.Fatal("a second setup acquired the lock while the first still held it, so the transaction is not serialized") + } + // It has to say what to do, since the operator's next question is always + // whether to wait or to go looking for a stuck process. + if !strings.Contains(err.Error(), "already running") { + t.Errorf("contention error does not explain itself: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("the second acquisition neither succeeded nor timed out, so the bounded wait is not bounded") + } +} + +// Releasing must actually hand the lock on. A lock that excludes forever is a +// worse failure than one that never excluded: the machine could never be set up +// again without a reboot. +func TestReleasingTheLockLetsTheNextSetupIn(t *testing.T) { + withShortLockTimeout(t, 5*time.Second) + withOpenableLock(t) + key := lockTestKey(t) + + first, err := acquireWindowsSandboxSetupLock(key) + if err != nil { + t.Fatalf("first acquisition: %v", err) + } + first.release() + + result := make(chan error, 1) + go func() { + second, err := acquireWindowsSandboxSetupLock(key) + if err == nil { + second.release() + } + result <- err + }() + + select { + case err := <-result: + if err != nil { + t.Fatalf("the lock was not released, so no later setup could ever run: %v", err) + } + case <-time.After(15 * time.Second): + t.Fatal("acquiring a released lock hung") + } +} + +// Different workspaces must not block each other. A machine-wide lock would +// serialize unrelated setups for no reason. +func TestDifferentWorkspacesDoNotExcludeEachOther(t *testing.T) { + withShortLockTimeout(t, 5*time.Second) + withOpenableLock(t) + base := lockTestKey(t) + + first, err := acquireWindowsSandboxSetupLock(base + "alpha") + if err != nil { + t.Fatalf("first workspace: %v", err) + } + defer first.release() + + result := make(chan error, 1) + go func() { + second, err := acquireWindowsSandboxSetupLock(base + "beta") + if err == nil { + second.release() + } + result <- err + }() + + select { + case err := <-result: + if err != nil { + t.Fatalf("a second workspace was blocked by an unrelated one: %v", err) + } + case <-time.After(15 * time.Second): + t.Fatal("a second workspace hung behind an unrelated lock") + } +} + +// Release must be safe to call twice, since it is both deferred and reachable +// explicitly. A double ReleaseMutex would fail and, worse, a double +// UnlockOSThread panics the runtime. +func TestReleasingTwiceIsSafe(t *testing.T) { + withShortLockTimeout(t, 5*time.Second) + withOpenableLock(t) + lock, err := acquireWindowsSandboxSetupLock(lockTestKey(t)) + if err != nil { + t.Fatalf("acquire: %v", err) + } + lock.release() + lock.release() +} + +// The object name has to be per workspace and stable, since that is what makes +// the exclusion scoped correctly rather than machine-wide or per-run. +func TestLockNameIsPerWorkspaceAndStable(t *testing.T) { + first := windowsSandboxSetupLockName("workspace-one") + again := windowsSandboxSetupLockName("workspace-one") + other := windowsSandboxSetupLockName("workspace-two") + + if first != again { + t.Errorf("the same workspace derived two names, %q then %q", first, again) + } + if first == other { + t.Errorf("two workspaces share the lock name %q, so unrelated setups would serialize", first) + } + // Global rather than Local: the account, its logon rights and the WFP + // filters are machine state, so setups in different logon sessions must + // still exclude each other. + if !strings.HasPrefix(first, `Global\`) { + t.Errorf("lock name %q is not in the Global namespace, so it would not span logon sessions", first) + } + // A backslash past the namespace prefix is rejected by the object manager, + // so a key that smuggled one in would fail at CreateMutex. + if strings.Contains(strings.TrimPrefix(first, `Global\`), `\`) { + t.Errorf("lock name %q contains a separator past its namespace prefix", first) + } +} + +// The production lock must stay reachable only by accounts that could already +// run setup. A sandbox principal able to open it could hold it and stall every +// future setup, or squat the name so real setup believes it holds a lock it +// does not. +func TestSetupLockIsAdministratorsOnly(t *testing.T) { + // BA is Administrators, SY is SYSTEM, and the leading P protects the DACL so + // no inherited ACE can widen it. + for _, want := range []string{"D:P", "(A;;GA;;;BA)", "(A;;GA;;;SY)"} { + if !strings.Contains(windowsSetupLockSDDL, want) { + t.Errorf("lock SDDL %q is missing %q", windowsSetupLockSDDL, want) + } + } + // WD is Everyone. Granting it here would let any sandboxed process stall + // setup for the whole machine. + if strings.Contains(windowsSetupLockSDDL, ";WD)") { + t.Errorf("lock SDDL %q grants Everyone, so any process could hold the setup lock", windowsSetupLockSDDL) + } +} + +// An empty or unusable key must still produce a valid name rather than a bare +// prefix, which every such workspace would then share. +func TestLockNameHandlesAnEmptyKey(t *testing.T) { + name := windowsSandboxSetupLockName("") + if name == `Global\ZeroSandboxSetup-` { + t.Fatal("an empty key produced a bare prefix, so every such workspace would share one lock") + } + if !strings.HasPrefix(name, `Global\ZeroSandboxSetup-`) { + t.Errorf("unexpected name %q", name) + } +} diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 3cb934894..0511385c6 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -17,6 +17,34 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": Administrator rights are required. Re-run `zero sandbox setup` from an elevated (Run as administrator) terminal.") return 1 } + // Serialize the WHOLE transaction, not the individual writes. + // + // Setup rotates the principal's password, stores the secret, + // read-modify-writes the ACL ledger, installs WFP filters and writes the + // marker. Two setups for this workspace at once interleave those steps: the + // account ends up with one process's password while the stored secret is the + // other's, so every later command fails to log on, and the ledger's + // read-modify-write silently loses a path set. Each write being individually + // atomic does nothing about interleaving BETWEEN writes, which is the actual + // failure, so the lock is taken here and held to the end. + // + // Immediately after the elevation check, because acquiring a Global object + // needs the rights that check just confirmed. + lock, err := acquireWindowsSandboxSetupLock(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + if err != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + return 1 + } + defer lock.release() + if lock.Abandoned() { + // The previous holder died somewhere inside the transaction, so this + // machine may be half set up. Setup is idempotent and re-running it is + // the repair, but say so: a silent recovery hides that something crashed + // while holding the machine's sandbox state open. + fmt.Fprintf(stderr, "%s: a previous setup for this workspace exited without finishing; re-running to repair it.\n", + WindowsSandboxSetupName) + } + plan, err := BuildWindowsACLPlan(config.commandConfig()) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) From ad2d03b23c00679a72318418b4baf77ece14edb3 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 10 Aug 2026 09:03:59 +0530 Subject: [PATCH 49/96] fix(sandbox): address jatmn's review on the principal backend and sandbox exec Four of the five findings were real. Taking them in order of what they cost. Cross-user principal collision (P1). The account name was keyed on the workspace alone while its DPAPI secret and ACL ledger both live under the invoking user's sandbox home, so two Windows users sharing one workspace derived the same account with private bookkeeping. The second user found no ledger of their own and either retired the first user's working principal or adopted it and rotated its password while storing only their own secret; either way the first user's marker still validated and their next command could not log on. windowsSandboxPrincipalKey now folds the invoking user's SID into the key. The setup LOCK deliberately keeps the workspace-only key: two users setting up one shared workspace still write DACLs on the same paths, so they must keep serializing even though they now provision separate accounts. Splitting the two keys is the point, and there is a test asserting they differ. Ledger not restored on rollback (P1). The narrowed ledger is written only after everything succeeds, but the rollback closure restored the old ACEs and left it narrowed. A later setup or teardown reads the ledger to decide what to revoke, so those restored paths would never be revisited: access outside the current policy held by an account nothing knows to clean up. The closure now writes back the union recorded before any DACL changed. Over-recording is the safe direction. sandbox exec skipped the production planning path (P2). It built a SandboxManager directly, so it never reached prepareSandboxRuntime and ran without the runtime write root that a real sandboxed command gets. It now goes through Engine.BuildCommandPlan. This matters beyond tidiness: the command exists to prove enforcement, and it was proving a path no tool call takes. Plan resources leaked (P2). No plan.Cleanup(), so every invocation left a /tmp/zero-sandbox-report-* behind on Linux. Deferred immediately after the build. The `--` separator (P2). The reported case, `exec -- cmd --help`, already worked: the loop hits the separator first and returns. The real break was the tolerated separator-less form, where `exec mycmd --help` printed Zero's help and never ran mycmd. The separator is now located before any help flag is interpreted, and help is only read from the wrapper's own arguments. Two existing ledger tests seeded state under a username derived from the workspace key and had to move to the principal key. Worth noting for anyone running this branch: an account provisioned before this commit will not be found by the new key, and setup will retire it as unrecorded. That is correct behaviour for an unmerged branch with no released accounts, but it is not a no-op locally. --- internal/cli/sandbox_exec.go | 68 +++++++++++++------ .../windows_identity_runtime_windows.go | 62 +++++++++++++++-- .../windows_identity_runtime_windows_test.go | 39 +++++++++++ .../windows_principal_ledger_windows_test.go | 4 +- 4 files changed, 142 insertions(+), 31 deletions(-) diff --git a/internal/cli/sandbox_exec.go b/internal/cli/sandbox_exec.go index 460216552..016fb6271 100644 --- a/internal/cli/sandbox_exec.go +++ b/internal/cli/sandbox_exec.go @@ -61,26 +61,35 @@ func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appD 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{ + // Through the ENGINE, not a SandboxManager built here. + // + // The engine is what a real tool call goes through, and it does more than + // hand the manager a request: it resolves the permission profile, calls + // prepareSandboxRuntime, and folds the runtime state into that profile + // before planning. Building a manager directly skipped all of it, so the + // command ran without the runtime write root, and cache or temp writes could + // pass or fail differently from the sandboxed command this exists to imitate. + // A harness that exercises the wrong path is worse than no harness, because + // its result still reads as evidence. + engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{ 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, + Policy: policy, + Scope: scope, + Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}), + }) + plan, err := engine.BuildCommandPlan(zeroSandbox.CommandSpec{ + Name: command[0], + Args: command[1:], + Dir: workspaceRoot, + Env: os.Environ(), }) if err != nil { return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash) } + // The plan owns resources beyond its construction: on Linux it allocates a + // policy-report file and registers the removal here, so without this every + // invocation leaves a /tmp/zero-sandbox-report-* behind. + defer plan.Cleanup() // 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 @@ -126,20 +135,35 @@ 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) { + if len(args) == 0 { + return nil, errors.New("usage: zero sandbox exec -- [args...]") + } + // The separator is located FIRST, before any help flag is interpreted. + // + // Everything after `--` belongs to the child, help flags included, so a + // single pass that treated `-h`/`--help`/`help` as ours wherever it found + // them would answer for a command it was supposed to be running. Scanning + // for the separator up front keeps that promise structural rather than + // dependent on which token happens to come first. for index, arg := range args { - switch arg { - case "-h", "--help", "help": - return nil, errSandboxExecHelp - case "--": + if arg == "--" { 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...]") + // Only the wrapper's own arguments, meaning those before the separator, + // can ask for the wrapper's help. + switch arg { + case "-h", "--help", "help": + return nil, errSandboxExecHelp + } + // The first non-flag token starts the command in the tolerated + // separator-less form, so nothing after it is ours to read. Without this + // `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd, + // which is the same contract break as reading past `--`. + return args, nil } // Tolerated without the separator for interactive use, but the separator is // what the help shows, because anything with a leading dash needs it. diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 84f7630bc..3e77ec718 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -100,7 +100,7 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T // surface read once — `zero doctor`, which carries the opt-in now. return 0, false, nil } - key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + key := windowsSandboxPrincipalKey(config.WorkspaceRoots) identity, err := lookupWindowsSandboxPrincipalForCommand(key) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { @@ -195,7 +195,7 @@ var windowsSandboxPrincipalNotUsedWarnOnce sync.Once // 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, bool, error) { - key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + key := windowsSandboxPrincipalKey(config.WorkspaceRoots) identity, password, created, err := provisionWindowsSandboxIdentityFn(key) // Undo whatever this run actually did, in reverse, on any failure after the @@ -310,7 +310,7 @@ 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)) + username := windowsSandboxUserName(windowsSandboxPrincipalKey(config.WorkspaceRoots)) // Retire a principal whose grants were never recorded, BEFORE provisioning // adopts it. // @@ -397,7 +397,7 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er // 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)) + _, err := lookupWindowsSandboxIdentityFn(windowsSandboxPrincipalKey(config.WorkspaceRoots)) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { return nil @@ -412,7 +412,7 @@ func retireUnrecordedWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) // 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) + key := windowsSandboxPrincipalKey(config.WorkspaceRoots) username := windowsSandboxUserName(key) secretPath, err := windowsSandboxSecretPath(config.SandboxHome, username) if err != nil { @@ -442,7 +442,7 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // 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 identity, err := lookupWindowsSandboxIdentity(windowsSandboxPrincipalKey(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 @@ -704,6 +704,23 @@ func applyWindowsPrincipalACLs(sandboxHome string, username string, principalSID if restoreRevoked != nil { restoreErr = restoreRevoked() } + // THE LEDGER HAS TO COME BACK TOO. + // + // The narrowed set was written above only once everything had succeeded. + // This closure puts the old ACEs back, so leaving that narrowed ledger in + // place describes a principal holding ACEs the ledger does not name, and + // a later setup or teardown reads the ledger to decide what to revoke. + // The restored paths would never be revisited: access outside the current + // policy, held by an account nothing knows to clean up. + // + // The union is what was recorded before any DACL changed, so writing it + // back returns the ledger to the state the restored ACEs belong to. + // Over-recording is the safe direction: a path named but no longer held + // costs one redundant revoke, while a path held but unnamed is residue + // nothing can find. + if ledgerErr := writeWindowsPrincipalACLLedger(sandboxHome, username, stale); ledgerErr != nil { + restoreErr = errors.Join(restoreErr, ledgerErr) + } if grantErr != nil { return grantErr } @@ -757,7 +774,7 @@ func windowsPrincipalRevocationPaths(config WindowsSandboxCommandConfig, princip return nil, err } recorded, _ := readWindowsPrincipalACLLedger( - config.SandboxHome, windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots))) + config.SandboxHome, windowsSandboxUserName(windowsSandboxPrincipalKey(config.WorkspaceRoots))) return unionWindowsPrincipalACLPaths(recorded, current), nil } @@ -794,3 +811,34 @@ func windowsACLPlanPaths(plan WindowsACLPlan) []string { } return paths } + +// windowsSandboxPrincipalKey derives the key a principal ACCOUNT is named after, +// scoped to the invoking user as well as the workspace. +// +// The workspace alone is not enough. The account name is machine-wide, but its +// DPAPI secret and its ACL ledger both live under the invoking user's sandbox +// home, so two Windows users sharing one workspace path derive the same account +// while keeping private bookkeeping for it. The second user finds an account +// with no ledger of their own and either retires the first user's working +// principal or adopts it and rotates its password while storing only their own +// secret. Either way the first user's marker still validates and their next +// command cannot log on. +// +// Deliberately NOT used for the setup lock, which stays keyed to the workspace +// alone. Two users setting up one shared workspace still write DACLs on the same +// paths, so they must continue to serialize against each other even though they +// now provision separate accounts. +// +// Falls back to the workspace-only key when the token user cannot be read. That +// is the pre-existing behaviour rather than a new failure mode, and refusing to +// derive a name at all would break setup on a machine whose token query fails +// for unrelated reasons. +func windowsSandboxPrincipalKey(workspaceRoots []string) string { + workspace := windowsSandboxWorkspaceKey(workspaceRoots) + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil || user == nil || user.User.Sid == nil { + return workspace + } + digest := sha256.Sum256([]byte(workspace + "\x00" + user.User.Sid.String())) + return hex.EncodeToString(digest[:]) +} diff --git a/internal/sandbox/windows_identity_runtime_windows_test.go b/internal/sandbox/windows_identity_runtime_windows_test.go index 9b7bc5ae5..e0ce4a0cc 100644 --- a/internal/sandbox/windows_identity_runtime_windows_test.go +++ b/internal/sandbox/windows_identity_runtime_windows_test.go @@ -232,3 +232,42 @@ func TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied(t *testing.T) t.Fatal("principal backend eligible without the opt-in") } } + +// The account key has to separate USERS, not only workspaces. +// +// The name is machine-wide while the DPAPI secret and the ACL ledger both live +// under the invoking user's sandbox home. Keyed on the workspace alone, two +// Windows users sharing one path derive the same account with private +// bookkeeping: the second finds no ledger of its own and either retires the +// first user's working principal or adopts it and rotates the password while +// storing only its own secret. The first user's marker still validates and its +// next command cannot log on. +// +// Asserting the principal key DIFFERS from the workspace key is what pins that. +// It is the cheapest observable difference; a test that only checked the key was +// stable would pass against the broken version. +func TestPrincipalKeyIsScopedToTheUserNotOnlyTheWorkspace(t *testing.T) { + roots := []string{`C:\ws\shared`} + workspace := windowsSandboxWorkspaceKey(roots) + principal := windowsSandboxPrincipalKey(roots) + + if principal == workspace { + t.Fatal("the principal key equals the workspace key, so two users sharing this workspace would derive one account with separate secrets and ledgers") + } + if principal != windowsSandboxPrincipalKey(roots) { + t.Error("the principal key is not stable for one user, so a second setup would not find its own account") + } + if other := windowsSandboxPrincipalKey([]string{`C:\ws\other`}); other == principal { + t.Error("two workspaces derived the same principal key, so they would share one account") + } +} + +// The setup lock must stay keyed to the WORKSPACE alone. Two users setting up +// one shared workspace now provision separate accounts, but they still write +// DACLs on the same paths, so they have to keep serializing against each other. +func TestSetupLockStaysWorkspaceScoped(t *testing.T) { + roots := []string{`C:\ws\shared`} + if windowsSandboxWorkspaceKey(roots) == windowsSandboxPrincipalKey(roots) { + t.Fatal("the lock key and the principal key are the same, so making the account per-user also made the lock per-user") + } +} diff --git a/internal/sandbox/windows_principal_ledger_windows_test.go b/internal/sandbox/windows_principal_ledger_windows_test.go index b71118cec..2075719d7 100644 --- a/internal/sandbox/windows_principal_ledger_windows_test.go +++ b/internal/sandbox/windows_principal_ledger_windows_test.go @@ -138,7 +138,7 @@ func TestSetupRetiresAPrincipalWithNoRecordOfItsGrants(t *testing.T) { } { t.Run(name, func(t *testing.T) { config := stubWindowsPrincipalSetup(t) - username := windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + username := windowsSandboxUserName(windowsSandboxPrincipalKey(config.WorkspaceRoots)) if testCase.seedRecord { if err := writeWindowsPrincipalACLLedger(config.SandboxHome, username, []string{`C:\ws\recorded`}); err != nil { t.Fatalf("seed the record: %v", err) @@ -202,7 +202,7 @@ func TestTeardownRevokesRecordedPathsTheCurrentPolicyNoLongerNames(t *testing.T) }, }, } - username := windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + username := windowsSandboxUserName(windowsSandboxPrincipalKey(config.WorkspaceRoots)) if err := writeWindowsPrincipalACLLedger(home, username, []string{dropped}); err != nil { t.Fatalf("seed the record: %v", err) } From 1ef660495ea6d6f708e5fee0ff2c3fd548ac617e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 10 Aug 2026 10:54:21 +0530 Subject: [PATCH 50/96] fix(sandbox): keep elevated Windows setup tied to the caller's identity Two failures on the elevated setup path, both reported by anandh8x. The first is caller identity. windowsSandboxPrincipalKey sampled the CURRENT process token, and elevated setup is not the caller's process: under over-the-shoulder UAC or `runas` it belongs to whichever administrator typed the credentials. Setup therefore provisioned an account, a secret, an ACL ledger and a set of grants named after that administrator, while every later command derived the ordinary user's key, found nothing, and fell back to the restricted token. The admin-keyed leftovers were unreachable and nothing would ever reclaim them. The caller's SID now crosses the UAC boundary in the setup args, exactly as the principal opt-in already does and for exactly the same reason, and the key is derived from it. That fixes everything merely NAMED after the invoking user. It cannot fix the secret. CryptProtectData derives its key from the user that stores the blob, so a password sealed by another administrator could not be unsealed by the caller no matter how the account were named. Provisioning is refused up front in that case, before an account or a grant exists, with a message that says how to clear it. The refusal is scoped to the principal opt-in: the default restricted-token sandbox stores no secret and works fine across that boundary. The second is the opt-out path. Retiring an existing principal could fail, and setup printed the error and carried on to write an opted-out marker and exit 0. Teardown being idempotent was the argument for continuing, but it is the reason this must fail instead: a machine with nothing to retire passes straight through, so an error here means a real account, secret, logon right, ACE set and ledger are still installed, and the marker now claims none of them exist. Setup returns non-zero and rolls back its ACL work. runWindowsSandboxSetup had no coverage at all, since every step wants an elevated token, a machine-global mutex, real DACLs, the WFP engine or a local account. The external effects are now seams in the idiom the package already used for two of them, and the tests drive the step order and each failure path, including the marker never being written. --- .../windows_identity_runtime_windows.go | 57 +++-- .../windows_identity_runtime_windows_test.go | 9 +- .../windows_principal_ledger_windows_test.go | 4 +- internal/sandbox/windows_runner.go | 6 + internal/sandbox/windows_setup.go | 48 ++++ .../windows_setup_caller_windows_test.go | 226 ++++++++++++++++++ internal/sandbox/windows_setup_other.go | 6 + internal/sandbox/windows_setup_test.go | 75 ++++++ internal/sandbox/windows_setup_windows.go | 109 ++++++++- 9 files changed, 506 insertions(+), 34 deletions(-) create mode 100644 internal/sandbox/windows_setup_caller_windows_test.go diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 3e77ec718..2b3e18966 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -100,7 +100,7 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T // surface read once — `zero doctor`, which carries the opt-in now. return 0, false, nil } - key := windowsSandboxPrincipalKey(config.WorkspaceRoots) + key := windowsSandboxPrincipalKey(config) identity, err := lookupWindowsSandboxPrincipalForCommand(key) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { @@ -195,7 +195,7 @@ var windowsSandboxPrincipalNotUsedWarnOnce sync.Once // 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, bool, error) { - key := windowsSandboxPrincipalKey(config.WorkspaceRoots) + key := windowsSandboxPrincipalKey(config) identity, password, created, err := provisionWindowsSandboxIdentityFn(key) // Undo whatever this run actually did, in reverse, on any failure after the @@ -310,7 +310,7 @@ 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(windowsSandboxPrincipalKey(config.WorkspaceRoots)) + username := windowsSandboxUserName(windowsSandboxPrincipalKey(config)) // Retire a principal whose grants were never recorded, BEFORE provisioning // adopts it. // @@ -397,7 +397,7 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er // 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(windowsSandboxPrincipalKey(config.WorkspaceRoots)) + _, err := lookupWindowsSandboxIdentityFn(windowsSandboxPrincipalKey(config)) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { return nil @@ -412,7 +412,7 @@ func retireUnrecordedWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) // then the account itself. Everything keyed to the SID has to go while the SID // still resolves. func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) error { - key := windowsSandboxPrincipalKey(config.WorkspaceRoots) + key := windowsSandboxPrincipalKey(config) username := windowsSandboxUserName(key) secretPath, err := windowsSandboxSecretPath(config.SandboxHome, username) if err != nil { @@ -442,7 +442,7 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // 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(windowsSandboxPrincipalKey(config.WorkspaceRoots)); err == nil { + if identity, err := lookupWindowsSandboxIdentity(windowsSandboxPrincipalKey(config)); 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 @@ -774,7 +774,7 @@ func windowsPrincipalRevocationPaths(config WindowsSandboxCommandConfig, princip return nil, err } recorded, _ := readWindowsPrincipalACLLedger( - config.SandboxHome, windowsSandboxUserName(windowsSandboxPrincipalKey(config.WorkspaceRoots))) + config.SandboxHome, windowsSandboxUserName(windowsSandboxPrincipalKey(config))) return unionWindowsPrincipalACLPaths(recorded, current), nil } @@ -812,6 +812,19 @@ func windowsACLPlanPaths(plan WindowsACLPlan) []string { return paths } +// windowsCurrentUserSID returns the SID of the user this process runs as, or +// empty when the token cannot be read. Elevation does not change it: a UAC +// consent prompt splits the caller's token but keeps the user, so this differs +// from the caller's SID only when a DIFFERENT account supplied the credentials +// (over-the-shoulder elevation, or `runas /user:`). +func windowsCurrentUserSID() string { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil || user == nil || user.User.Sid == nil { + return "" + } + return user.User.Sid.String() +} + // windowsSandboxPrincipalKey derives the key a principal ACCOUNT is named after, // scoped to the invoking user as well as the workspace. // @@ -829,16 +842,28 @@ func windowsACLPlanPaths(plan WindowsACLPlan) []string { // paths, so they must continue to serialize against each other even though they // now provision separate accounts. // -// Falls back to the workspace-only key when the token user cannot be read. That -// is the pre-existing behaviour rather than a new failure mode, and refusing to -// derive a name at all would break setup on a machine whose token query fails -// for unrelated reasons. -func windowsSandboxPrincipalKey(workspaceRoots []string) string { - workspace := windowsSandboxWorkspaceKey(workspaceRoots) - user, err := windows.GetCurrentProcessToken().GetTokenUser() - if err != nil || user == nil || user.User.Sid == nil { +// The invoking user is config.CallerSID when the caller supplied one, and only +// then the current process token. Those are the same identity for an ordinary +// command, which runs as the caller. They are NOT the same inside elevated +// setup: over-the-shoulder UAC runs the helper as whichever administrator typed +// the credentials, so sampling its own token here named the account after the +// administrator while every later command derived the ordinary user's name, +// found nothing, and fell back to the restricted token — leaving an admin-keyed +// account, secret and ledger that nothing afterwards would ever reclaim. +// +// Falls back to the workspace-only key when neither is available. That is the +// pre-existing behaviour rather than a new failure mode, and refusing to derive +// a name at all would break setup on a machine whose token query fails for +// unrelated reasons. +func windowsSandboxPrincipalKey(config WindowsSandboxCommandConfig) string { + workspace := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + sid := strings.TrimSpace(config.CallerSID) + if sid == "" { + sid = windowsCurrentUserSID() + } + if sid == "" { return workspace } - digest := sha256.Sum256([]byte(workspace + "\x00" + user.User.Sid.String())) + digest := sha256.Sum256([]byte(workspace + "\x00" + sid)) return hex.EncodeToString(digest[:]) } diff --git a/internal/sandbox/windows_identity_runtime_windows_test.go b/internal/sandbox/windows_identity_runtime_windows_test.go index e0ce4a0cc..0332c0bdb 100644 --- a/internal/sandbox/windows_identity_runtime_windows_test.go +++ b/internal/sandbox/windows_identity_runtime_windows_test.go @@ -249,15 +249,16 @@ func TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied(t *testing.T) func TestPrincipalKeyIsScopedToTheUserNotOnlyTheWorkspace(t *testing.T) { roots := []string{`C:\ws\shared`} workspace := windowsSandboxWorkspaceKey(roots) - principal := windowsSandboxPrincipalKey(roots) + config := WindowsSandboxCommandConfig{WorkspaceRoots: roots} + principal := windowsSandboxPrincipalKey(config) if principal == workspace { t.Fatal("the principal key equals the workspace key, so two users sharing this workspace would derive one account with separate secrets and ledgers") } - if principal != windowsSandboxPrincipalKey(roots) { + if principal != windowsSandboxPrincipalKey(config) { t.Error("the principal key is not stable for one user, so a second setup would not find its own account") } - if other := windowsSandboxPrincipalKey([]string{`C:\ws\other`}); other == principal { + if other := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: []string{`C:\ws\other`}}); other == principal { t.Error("two workspaces derived the same principal key, so they would share one account") } } @@ -267,7 +268,7 @@ func TestPrincipalKeyIsScopedToTheUserNotOnlyTheWorkspace(t *testing.T) { // DACLs on the same paths, so they have to keep serializing against each other. func TestSetupLockStaysWorkspaceScoped(t *testing.T) { roots := []string{`C:\ws\shared`} - if windowsSandboxWorkspaceKey(roots) == windowsSandboxPrincipalKey(roots) { + if windowsSandboxWorkspaceKey(roots) == windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots}) { t.Fatal("the lock key and the principal key are the same, so making the account per-user also made the lock per-user") } } diff --git a/internal/sandbox/windows_principal_ledger_windows_test.go b/internal/sandbox/windows_principal_ledger_windows_test.go index 2075719d7..651506f1a 100644 --- a/internal/sandbox/windows_principal_ledger_windows_test.go +++ b/internal/sandbox/windows_principal_ledger_windows_test.go @@ -138,7 +138,7 @@ func TestSetupRetiresAPrincipalWithNoRecordOfItsGrants(t *testing.T) { } { t.Run(name, func(t *testing.T) { config := stubWindowsPrincipalSetup(t) - username := windowsSandboxUserName(windowsSandboxPrincipalKey(config.WorkspaceRoots)) + username := windowsSandboxUserName(windowsSandboxPrincipalKey(config)) if testCase.seedRecord { if err := writeWindowsPrincipalACLLedger(config.SandboxHome, username, []string{`C:\ws\recorded`}); err != nil { t.Fatalf("seed the record: %v", err) @@ -202,7 +202,7 @@ func TestTeardownRevokesRecordedPathsTheCurrentPolicyNoLongerNames(t *testing.T) }, }, } - username := windowsSandboxUserName(windowsSandboxPrincipalKey(config.WorkspaceRoots)) + username := windowsSandboxUserName(windowsSandboxPrincipalKey(config)) if err := writeWindowsPrincipalACLLedger(home, username, []string{dropped}); err != nil { t.Fatalf("seed the record: %v", err) } diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 032f2a844..3b7092220 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -173,6 +173,12 @@ type WindowsSandboxCommandConfig struct { Env map[string]string SandboxLevel WindowsSandboxLevel Command []string + // CallerSID names the Windows user whose sandbox principal this config plans + // against. Empty — the case for every ordinary command — means the process + // running it, which is already the invoking user. It is set only by the + // elevated setup half, which runs as somebody else under over-the-shoulder UAC + // and would otherwise provision an account the caller can never find. + CallerSID string } func BuildWindowsSandboxCommandArgs(options WindowsSandboxCommandArgsOptions) ([]string, error) { diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 2591a7c93..8235cb7f5 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -103,6 +103,32 @@ type WindowsSandboxSetupArgsOptions struct { // 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 + // CallerSID identifies the Windows user setup was invoked BY, serialized into + // the args for the same reason PrincipalOptIn is: elevated setup runs in its + // own process, and under over-the-shoulder UAC (or `runas`) that process + // belongs to a DIFFERENT administrator than the caller. + // + // Everything a principal needs is scoped to the invoking user — the account + // name, its ACL ledger and its DPAPI secret all key off that identity — so a + // helper that sampled its own token would provision an account for the + // administrator who typed the credentials while every later command looked for + // one named after the ordinary user, found nothing, fell back to the restricted + // token, and left an admin-keyed account nothing would ever reclaim. + // + // Empty means "resolve from the process building the args", which IS the + // caller: these args are built before the UAC boundary is crossed. + CallerSID string +} + +// callerSID resolves the invoking user, in the caller's own process, before the +// args cross the UAC boundary. Empty when the token cannot be read (and on +// non-Windows builds), which leaves the elevated helper on its pre-existing +// behaviour of sampling its own token rather than inventing an identity. +func (options WindowsSandboxSetupArgsOptions) callerSID() string { + if sid := strings.TrimSpace(options.CallerSID); sid != "" { + return sid + } + return windowsCurrentUserSID() } // principalOptIn resolves the tri-state. It runs inside @@ -122,6 +148,10 @@ type WindowsSandboxSetupConfig struct { WorkspaceRoots []string PermissionProfile PermissionProfile PrincipalOptIn bool + // CallerSID is the invoking user this setup is provisioning FOR. See + // WindowsSandboxSetupArgsOptions.CallerSID. Empty means the caller did not + // say, and the helper falls back to its own token. + CallerSID string } type WindowsSandboxSetupMarker struct { @@ -187,6 +217,12 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str // nothing", and only the first of those is safe to run silently. "--sandbox-principal", windowsSandboxPrincipalOptInValue(options.principalOptIn()), } + // Omitted rather than sent empty when the caller's identity cannot be read: + // absence means "this caller did not say", which the helper answers by + // sampling its own token exactly as it did before this flag existed. + if callerSID := options.callerSID(); callerSID != "" { + args = append(args, "--caller-sid", callerSID) + } for _, root := range workspaceRoots { args = append(args, "--workspace-root", root) } @@ -247,6 +283,13 @@ func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, err return WindowsSandboxSetupConfig{}, fmt.Errorf("invalid --sandbox-principal %q, want 0 or 1", value) } index = next + case "--caller-sid": + value, next, err := nextWindowsSandboxFlagValue(args, index) + if err != nil { + return WindowsSandboxSetupConfig{}, err + } + config.CallerSID = strings.TrimSpace(value) + index = next default: return WindowsSandboxSetupConfig{}, fmt.Errorf("unknown windows sandbox setup flag %q", arg) } @@ -292,6 +335,10 @@ func (config WindowsSandboxSetupConfig) commandConfig() WindowsSandboxCommandCon PermissionProfile: config.PermissionProfile, Env: map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(config.PrincipalOptIn)}, SandboxLevel: WindowsSandboxLevelRestrictedToken, + // Carried through for the same reason as the opt-in above: every principal + // name, ledger and secret path the setup half plans is derived from the + // invoking user, and that is the caller's identity, not this process's. + CallerSID: config.CallerSID, } } @@ -305,6 +352,7 @@ func WindowsSandboxSetupConfigFromCommand(config WindowsSandboxCommandConfig) Wi WorkspaceRoots: cloneStrings(config.WorkspaceRoots), PermissionProfile: config.PermissionProfile, PrincipalOptIn: windowsSandboxIdentityEnabled(config.Env), + CallerSID: config.CallerSID, } } diff --git a/internal/sandbox/windows_setup_caller_windows_test.go b/internal/sandbox/windows_setup_caller_windows_test.go new file mode 100644 index 000000000..3d5411f08 --- /dev/null +++ b/internal/sandbox/windows_setup_caller_windows_test.go @@ -0,0 +1,226 @@ +//go:build windows + +package sandbox + +import ( + "bytes" + "errors" + "os" + "strings" + "testing" +) + +// windowsSetupSeams stubs every external effect of the setup transaction so a +// test can drive its control flow on an ordinary machine. Each seam defaults to +// a benign success, and a test overrides only the one it is about. +type windowsSetupSeams struct { + elevated bool + aclRollback func() error + retireErr error + provisionErr error + networkErr error + markerErr error + rollbackCalled *bool + markerWritten *bool +} + +func withWindowsSetupSeams(t *testing.T, seams windowsSetupSeams) { + t.Helper() + + originalElevated := windowsProcessIsElevatedFn + originalLock := acquireWindowsSandboxSetupLockFn + originalACL := applyWindowsACLPlanFn + originalPrincipal := setupWindowsSandboxPrincipalFn + originalRetire := removeWindowsSandboxPrincipalForSetupFn + originalNetwork := applyWindowsNetworkPlanFn + originalMarker := writeWindowsSandboxSetupMarkerFn + t.Cleanup(func() { + windowsProcessIsElevatedFn = originalElevated + acquireWindowsSandboxSetupLockFn = originalLock + applyWindowsACLPlanFn = originalACL + setupWindowsSandboxPrincipalFn = originalPrincipal + removeWindowsSandboxPrincipalForSetupFn = originalRetire + applyWindowsNetworkPlanFn = originalNetwork + writeWindowsSandboxSetupMarkerFn = originalMarker + }) + + windowsProcessIsElevatedFn = func() bool { return seams.elevated } + // A zero lock is safe to release: release() tolerates a nil handle and + // runtime.UnlockOSThread is a no-op when the thread was never pinned. + acquireWindowsSandboxSetupLockFn = func(string) (*windowsSandboxSetupLock, error) { + return &windowsSandboxSetupLock{}, nil + } + applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) { + return func() error { + if seams.rollbackCalled != nil { + *seams.rollbackCalled = true + } + if seams.aclRollback != nil { + return seams.aclRollback() + } + return nil + }, nil + } + setupWindowsSandboxPrincipalFn = func(WindowsSandboxCommandConfig) (func() error, error) { + if seams.provisionErr != nil { + return nil, seams.provisionErr + } + return func() error { return nil }, nil + } + removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { + return seams.retireErr + } + applyWindowsNetworkPlanFn = func(WindowsNetworkPlan) error { return seams.networkErr } + writeWindowsSandboxSetupMarkerFn = func(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) { + if seams.markerWritten != nil { + *seams.markerWritten = true + } + if seams.markerErr != nil { + return WindowsSandboxSetupMarker{}, seams.markerErr + } + return BuildWindowsSandboxSetupMarker(config) + } +} + +func windowsSetupTestConfig(t *testing.T, optIn bool) WindowsSandboxSetupConfig { + t.Helper() + root := t.TempDir() + return WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + CommandCWD: root, + WorkspaceRoots: []string{root}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: root}}}, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + PrincipalOptIn: optIn, + } +} + +// windowsForeignSID is a well-formed account SID that no test machine's own +// process can be running as. +const windowsForeignSID = "S-1-5-21-1111111111-2222222222-3333333333-1001" + +// The finding: opting out printed the retirement failure and carried on to write +// an opted-out marker and exit 0. +// +// That is the one outcome this branch must never produce. Teardown is +// idempotent, so a failure here means a real account, secret, logon right, ACE +// set and ledger are still installed — and the marker now says none of them +// exist, so nothing afterwards will ever look for them again. +// +// Asserting on the marker as well as the exit code is deliberate: an exit code +// alone would still pass if the marker had already been written, and the marker +// is what makes the residue permanent. +func TestOptOutRetirementFailureFailsSetupAndWritesNoMarker(t *testing.T) { + markerWritten := false + rollbackCalled := false + withWindowsSetupSeams(t, windowsSetupSeams{ + elevated: true, + retireErr: errors.New("sandbox principal zero-sbx-abc could not be deleted"), + markerWritten: &markerWritten, + rollbackCalled: &rollbackCalled, + }) + + var stderr bytes.Buffer + config := windowsSetupTestConfig(t, false) + if code := runWindowsSandboxSetup(config, &stderr); code == 0 { + t.Fatal("setup reported success after failing to retire an existing sandbox principal, so the operator is told the machine is clean while the account is still installed") + } + if markerWritten { + t.Error("setup wrote an opted-out marker after the retirement failed, which is what makes the leftover principal permanently invisible") + } + if !rollbackCalled { + t.Error("setup failed without undoing the ACL grants it had already applied") + } + if !strings.Contains(stderr.String(), "could not be deleted") { + t.Errorf("the underlying retirement error must reach the operator, got %q", stderr.String()) + } + if _, err := os.Stat(WindowsSandboxSetupMarkerPath(config.SandboxHome)); !os.IsNotExist(err) { + t.Errorf("no marker file should exist on disk after a failed setup, stat error = %v", err) + } +} + +// The opt-out path still succeeds when retirement succeeds. Without this the +// test above would pass against a branch that always failed. +func TestOptOutSucceedsWhenRetirementSucceeds(t *testing.T) { + markerWritten := false + withWindowsSetupSeams(t, windowsSetupSeams{elevated: true, markerWritten: &markerWritten}) + + var stderr bytes.Buffer + if code := runWindowsSandboxSetup(windowsSetupTestConfig(t, false), &stderr); code != 0 { + t.Fatalf("opting out with nothing to retire must succeed, got %d: %s", code, stderr.String()) + } + if !markerWritten { + t.Error("a successful setup must write its marker") + } +} + +// The other half of the caller-identity finding: a principal provisioned by an +// administrator who is not the caller can never be used, because its password is +// sealed to the user that stores it. Refuse before anything is created rather +// than leave an account, a secret and a grant nobody can reach. +func TestPrincipalSetupRefusesWhenElevatedAsAnotherUser(t *testing.T) { + markerWritten := false + withWindowsSetupSeams(t, windowsSetupSeams{elevated: true, markerWritten: &markerWritten}) + + config := windowsSetupTestConfig(t, true) + config.CallerSID = windowsForeignSID + if current := windowsCurrentUserSID(); current == "" || strings.EqualFold(current, config.CallerSID) { + t.Skipf("cannot distinguish the caller from this process (current SID %q)", current) + } + + var stderr bytes.Buffer + if code := runWindowsSandboxSetup(config, &stderr); code == 0 { + t.Fatal("setup provisioned a sandbox principal for a caller whose secret it cannot seal, so every later command would find the account and fail to unseal its password") + } + if markerWritten { + t.Error("setup wrote a marker for a principal it refused to provision") + } + if !strings.Contains(stderr.String(), "different Windows user") { + t.Errorf("the refusal must say what is wrong and how to clear it, got %q", stderr.String()) + } +} + +// The refusal is scoped to the principal opt-in. The default restricted-token +// sandbox stores no secret and names no account after the user, so it works +// perfectly well across this boundary and must keep doing so. +func TestRestrictedTokenSetupIsUnaffectedByACrossUserElevation(t *testing.T) { + withWindowsSetupSeams(t, windowsSetupSeams{elevated: true}) + + config := windowsSetupTestConfig(t, false) + config.CallerSID = windowsForeignSID + + var stderr bytes.Buffer + if code := runWindowsSandboxSetup(config, &stderr); code != 0 { + t.Fatalf("the restricted-token sandbox does not depend on the invoking user and must still set up, got %d: %s", code, stderr.String()) + } +} + +// An unknown caller SID is treated as a match. That is the pre-existing +// behaviour for an older caller or a token query that failed, and refusing on +// absence would break setup on machines where nothing is wrong. +func TestPrincipalSetupProceedsWhenTheCallerIsUnknown(t *testing.T) { + withWindowsSetupSeams(t, windowsSetupSeams{elevated: true}) + + var stderr bytes.Buffer + if code := runWindowsSandboxSetup(windowsSetupTestConfig(t, true), &stderr); code != 0 { + t.Fatalf("an unstated caller must not block setup, got %d: %s", code, stderr.String()) + } +} + +// The account name, its ledger and its ACEs are all derived from this key, and +// under over-the-shoulder UAC the elevated helper is a different user than the +// caller. Keying off the caller is what makes setup provision the account the +// caller's next command will actually look for. +func TestPrincipalKeyFollowsTheCallerNotTheCurrentProcess(t *testing.T) { + roots := []string{`C:\ws\shared`} + self := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots}) + caller := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots, CallerSID: windowsForeignSID}) + if self == caller { + t.Fatal("the principal key ignored the caller SID, so elevated setup would provision an account named after the administrator who typed the credentials") + } + if again := windowsSandboxPrincipalKey(WindowsSandboxCommandConfig{WorkspaceRoots: roots, CallerSID: windowsForeignSID}); again != caller { + t.Error("the caller-scoped key is not stable, so setup and the command half would derive different accounts") + } +} diff --git a/internal/sandbox/windows_setup_other.go b/internal/sandbox/windows_setup_other.go index a18eb2c9b..4a794cbaf 100644 --- a/internal/sandbox/windows_setup_other.go +++ b/internal/sandbox/windows_setup_other.go @@ -11,3 +11,9 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": Windows sandbox setup is only available on Windows") return 1 } + +// windowsCurrentUserSID has no meaning off Windows. Returning empty keeps +// BuildWindowsSandboxSetupArgs from emitting a --caller-sid nobody could +// interpret, which is also what a Windows caller does when its own token cannot +// be read. +func windowsCurrentUserSID() string { return "" } diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 94170e255..4ec374d1f 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -465,3 +466,77 @@ func TestWindowsACLPlanHashIsStableAcrossEntryOrder(t *testing.T) { t.Fatalf("ACL plan hashes differ: %q vs %q", left, right) } } + +// The caller's identity has to survive the UAC boundary for the same reason the +// opt-in does: elevated setup is a separate process, and under over-the-shoulder +// elevation it belongs to a different administrator. Every principal name, its +// ACL ledger and its DPAPI secret are scoped to the invoking user, so a helper +// left to sample its own token provisions for the wrong one. +// +// Runs on every GOOS. The identity is resolved before the boundary, so what is +// pinned here is the transport, which is where it would silently go missing. +func TestWindowsSandboxSetupArgsCarryTheCallerIdentity(t *testing.T) { + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + const callerSID = "S-1-5-21-1111111111-2222222222-3333333333-1001" + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: `C:\home`, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + CallerSID: callerSID, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + config, err := ParseWindowsSandboxSetupArgs(args) + if err != nil { + t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err) + } + if config.CallerSID != callerSID { + t.Fatalf("the caller identity did not survive the setup args: got %q, want %q", config.CallerSID, callerSID) + } + // And on to the command-shaped view the setup half actually plans against — + // the only place it changes what gets provisioned. + if got := config.commandConfig().CallerSID; got != callerSID { + t.Errorf("commandConfig dropped the caller identity: got %q, want %q", got, callerSID) + } +} + +// A caller that cannot state its identity must not send an empty flag. Absence +// means "this caller did not say", which the helper answers by sampling its own +// token exactly as it did before the flag existed; an empty value would have to +// be guessed at instead. +func TestWindowsSandboxSetupArgsOmitAnUnknownCallerIdentity(t *testing.T) { + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: `C:\home`, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted}, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + // Left unset. On non-Windows builds windowsCurrentUserSID also returns + // empty, which is the same case a Windows caller hits when its token query + // fails. + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + if runtime.GOOS != "windows" { + for _, arg := range args { + if arg == "--caller-sid" { + t.Fatalf("an unresolvable caller identity was still serialized: %v", args) + } + } + } + config, err := ParseWindowsSandboxSetupArgs(args) + if err != nil { + t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err) + } + if runtime.GOOS != "windows" && config.CallerSID != "" { + t.Errorf("CallerSID = %q, want empty so the helper falls back to its own token", config.CallerSID) + } +} diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 0511385c6..24449f2d0 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -5,15 +5,35 @@ package sandbox import ( "fmt" "io" + "strings" "golang.org/x/sys/windows" ) +// Seams for the setup transaction's external effects. +// +// Every step below needs something a test machine cannot be asked for: an +// elevated token, a machine-global mutex, real DACLs, the WFP engine, a local +// account. That left the ORDER of those steps and the handling of each failure +// — which is where this function's bugs actually live — with no coverage at all. +// Each var defaults to its production function and is only ever reassigned by a +// test. removeWindowsSandboxPrincipalForSetupFn and applyWindowsACLPlanFn +// already existed in windows_identity_windows.go for the same reason; the rest +// follow them, and the ACL call below now goes through that one so a single stub +// covers both plans. +var ( + windowsProcessIsElevatedFn = windowsProcessIsElevated + acquireWindowsSandboxSetupLockFn = acquireWindowsSandboxSetupLock + applyWindowsNetworkPlanFn = applyWindowsNetworkPlan + setupWindowsSandboxPrincipalFn = setupWindowsSandboxPrincipal + writeWindowsSandboxSetupMarkerFn = WriteWindowsSandboxSetupMarker +) + func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) int { // Applying the WFP network filters and workspace ACLs requires Administrator // rights; without them WFP fails deep inside with a raw ACCESS_DENIED (0x5). // Check up front and return an actionable message instead. - if !windowsProcessIsElevated() { + if !windowsProcessIsElevatedFn() { fmt.Fprintln(stderr, WindowsSandboxSetupName+": Administrator rights are required. Re-run `zero sandbox setup` from an elevated (Run as administrator) terminal.") return 1 } @@ -30,7 +50,7 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // // Immediately after the elevation check, because acquiring a Global object // needs the rights that check just confirmed. - lock, err := acquireWindowsSandboxSetupLock(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + lock, err := acquireWindowsSandboxSetupLockFn(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 @@ -45,6 +65,15 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) WindowsSandboxSetupName) } + // Before anything is provisioned: a principal this helper could not hand back + // to the caller must not be created at all. + if config.PrincipalOptIn { + if err := assertWindowsSetupRunsAsCaller(config.CallerSID); err != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + return 1 + } + } + plan, err := BuildWindowsACLPlan(config.commandConfig()) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) @@ -58,7 +87,7 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 } - rollback, err := applyWindowsACLPlan(plan) + rollback, err := applyWindowsACLPlanFn(plan) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 @@ -69,7 +98,7 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // 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()) + principalRollback, err := setupWindowsSandboxPrincipalFn(config.commandConfig()) if err != nil { if rollbackErr := rollback(); rollbackErr != nil { fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) @@ -89,7 +118,7 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) } return aclErr } - } else if err := removeWindowsSandboxPrincipalForSetup(config.commandConfig()); err != nil { + } else if err := removeWindowsSandboxPrincipalForSetupFn(config.commandConfig()); err != nil { // Opting out has to actually retire the principal, because that is what // we tell people it does. ValidateWindowsSandboxSetupMarker sends an // operator here in as many words: re-run setup from an elevated terminal @@ -100,14 +129,29 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // invisible, because nothing afterwards looks for a principal it believes // was never provisioned. // - // Not fatal. Teardown is idempotent and a machine that never had a - // principal passes straight through, so a failure here means genuine - // residue rather than a missing account: say so and carry on rather than - // refusing to complete a setup whose sandbox is otherwise fine. - fmt.Fprintf(stderr, "%s: opted out, but retiring the existing sandbox principal did not complete: %v\n", + // Fatal, and the first cut of this branch had it wrong: it printed and + // carried on to write an opted-out marker and exit 0. + // + // The reasoning was that teardown is idempotent, so a machine that never + // had a principal passes straight through and a failure here means real + // residue rather than a missing account. Both halves are true; the + // conclusion does not follow. Real residue is precisely the case that must + // not be reported as success, because the marker then says "no principal" + // while the account, its secret, its logon right, its ACEs and its ledger + // are all still there — and nothing afterwards looks for a principal it + // believes was never provisioned, so the leftovers are permanent. Exiting + // non-zero is the only thing that keeps the operator in the loop, and + // re-running setup is the repair. + if rollbackErr := rollback(); rollbackErr != nil { + fmt.Fprintf(stderr, "%s: opted out, but retiring the existing sandbox principal failed: %v; rollback failed: %v\n", + WindowsSandboxSetupName, err, rollbackErr) + return 1 + } + fmt.Fprintf(stderr, "%s: opted out, but retiring the existing sandbox principal failed: %v\n", WindowsSandboxSetupName, err) + return 1 } - if err := applyWindowsNetworkPlan(networkPlan); err != nil { + if err := applyWindowsNetworkPlanFn(networkPlan); err != nil { if rollbackErr := rollback(); rollbackErr != nil { fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) return 1 @@ -115,7 +159,7 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 } - if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { + if _, err := writeWindowsSandboxSetupMarkerFn(config); err != nil { if rollbackErr := rollback(); rollbackErr != nil { fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) return 1 @@ -126,6 +170,47 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) return 0 } +// assertWindowsSetupRunsAsCaller refuses to provision a sandbox principal when +// this elevated helper belongs to a different Windows user than the caller that +// launched it. +// +// A principal is unusable across that boundary, and not for a reason more +// plumbing can fix. Its password is sealed with CryptProtectData, which derives +// its key from the CALLING user, so a blob written here is decryptable only by +// this administrator; the caller's next command would find the account, find the +// secret file, and fail to unseal it. Threading the caller's SID through the +// setup args fixes the account NAME, the ledger and the ACEs — everything that +// is merely named after the invoking user — but it cannot re-key DPAPI for a +// user whose token this process does not hold. +// +// So the answer is to say so before anything exists, rather than provision an +// account, a secret and a grant that resolve to a sandbox nobody can log into. +// +// Same-account elevation is unaffected: a UAC consent prompt splits the caller's +// token but leaves the user SID identical, which is the ordinary path. Only +// over-the-shoulder elevation and `runas /user:` land here. An unknown caller +// SID (an older caller, or a token query that failed) is treated as a match: +// that is the pre-existing behaviour, and refusing on absence would break setup +// on machines where nothing is wrong. +// +// Scoped to the opt-in by its caller, deliberately. The default restricted-token +// sandbox stores no secret and names no account after the user, so it works +// perfectly well across this boundary and must keep doing so. +func assertWindowsSetupRunsAsCaller(callerSID string) error { + caller := strings.TrimSpace(callerSID) + if caller == "" { + return nil + } + current := windowsCurrentUserSID() + if current == "" || strings.EqualFold(current, caller) { + return nil + } + return fmt.Errorf("this elevated setup is running as a different Windows user (%s) than the one that started it (%s), "+ + "and a sandbox principal's password is sealed to the user that stores it, so the account provisioned here could never be used. "+ + "Re-run `zero sandbox setup` from a terminal elevated as your own account, or unset %s to use the restricted-token sandbox", + current, caller, windowsSandboxIdentityEnv) +} + // windowsProcessIsElevated reports whether the current process runs with an // elevated (Administrator) token. On any error obtaining the token it returns // true so the setup proceeds and surfaces the real WFP/ACL error rather than a From 63bda96252600a818f4efcd7f83a2513efc599f7 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 10 Aug 2026 11:38:28 +0530 Subject: [PATCH 51/96] fix(sandbox): fail opt-out only when the principal actually survived Three corrections, the first to my own previous commit. Making the opt-out retirement failure fatal was too broad. removeWindowsSandboxPrincipalForSetup deliberately continues past a secret file owned by another administrator's setup, reporting it at the end through errors.Join, so that one unlinkable file cannot strand the account, its logon rights and its ACEs. The account is already gone when that error is returned. Treating it as fatal made `zero sandbox setup` fail permanently on such a machine and rolled back its ACL work, taking the DEFAULT restricted-token sandbox down over inert residue. The rule is now the narrower one that was always meant: fatal when the ACCOUNT survived, because that is exactly what makes an opted-out marker a lie. Asked of the account rather than inferred from the error, since the error is a join whose parts are not separable at the call site, and the lookup fails closed so an undeterminable state counts as still installed. A retirement that left something inert behind still completes, and still names what it left. Second, assertWindowsSetupRunsAsCaller cannot fire on any path Zero takes, and said otherwise. Zero never elevates the helper: runSandboxSetupHelper is a plain exec.Command and runWindowsSandboxSetup refuses unless already elevated, so the operator supplies elevation by opening an elevated terminal and BOTH halves run there. The caller SID is resolved from the process that becomes the helper, so the two are equal by construction. The guard is kept, because it is a correctness assertion that becomes load-bearing the moment a ShellExecute "runas" path is added and already covers a helper invoked directly, but its doc now says plainly what it does not do. The residual hazard has a different shape and is loud: elevating as another administrator provisions into that account's sandbox home, and the operator's own session finds no marker and is told to run setup. Third, parseSandboxExecArgs claimed to scan for the separator up front. Every branch of its loop body returned, so it only ever examined index 0 and the scan did not exist. Rewritten as the straight-line decision it actually was, which is also the rule we want: only the first token is the wrapper's. sandbox exec also built its engine without SensitiveEnvKeys, so a key named by apiKeyEnv in the user's config was scrubbed for every sandboxed tool call and handed to the one command whose purpose is to reproduce that environment. Tests: the opt-out branch now covers both sides of the rule, and parseSandboxExecArgs gets the coverage it never had, including the reported `-- cmd --help` case and an invariant that the returned command is always a trailing slice of the input. --- internal/cli/sandbox_exec.go | 59 +++++----- internal/cli/sandbox_exec_test.go | 108 ++++++++++++++++++ .../windows_setup_caller_windows_test.go | 77 +++++++++++-- internal/sandbox/windows_setup_windows.go | 93 +++++++++++---- 4 files changed, 277 insertions(+), 60 deletions(-) create mode 100644 internal/cli/sandbox_exec_test.go diff --git a/internal/cli/sandbox_exec.go b/internal/cli/sandbox_exec.go index 016fb6271..77ce2e9b8 100644 --- a/internal/cli/sandbox_exec.go +++ b/internal/cli/sandbox_exec.go @@ -76,6 +76,14 @@ func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appD Policy: policy, Scope: scope, Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}), + // The same scrub list a real tool call gets. Without it the engine keeps an + // empty set, and only the hardcoded names plus the provider catalog's own + // AuthEnvVars are removed from the child's environment: a key named by + // `apiKeyEnv` in the user's config would be scrubbed for every sandboxed + // tool call and handed to this one. Reproducing the production environment + // is the entire point of the command, and a credential is the last part of + // it that may differ. + SensitiveEnvKeys: providerSensitiveEnvKeys(resolved), }) plan, err := engine.BuildCommandPlan(zeroSandbox.CommandSpec{ Name: command[0], @@ -138,35 +146,34 @@ func parseSandboxExecArgs(args []string) ([]string, error) { if len(args) == 0 { return nil, errors.New("usage: zero sandbox exec -- [args...]") } - // The separator is located FIRST, before any help flag is interpreted. + // Only the FIRST token is ours to interpret. Everything from the second + // onwards belongs to the child, help flags included. // - // Everything after `--` belongs to the child, help flags included, so a - // single pass that treated `-h`/`--help`/`help` as ours wherever it found - // them would answer for a command it was supposed to be running. Scanning - // for the separator up front keeps that promise structural rather than - // dependent on which token happens to come first. - for index, arg := range args { - if arg == "--" { - command := args[index+1:] - if len(command) == 0 { - return nil, errors.New("usage: zero sandbox exec -- [args...]") - } - return command, nil - } - // Only the wrapper's own arguments, meaning those before the separator, - // can ask for the wrapper's help. - switch arg { - case "-h", "--help", "help": - return nil, errSandboxExecHelp + // Written as a straight-line decision on args[0] rather than a loop, because + // a loop here is a lie. An earlier version scanned for `--` "up front" and + // said so in its comment, but every branch of its body returned, so it only + // ever examined index 0 and the promised scan did not exist. The rule below + // is what that code actually implemented, and it is the rule we want: it is + // the wrapper's own prefix that can ask for the wrapper's help, and the + // prefix is at most one token long. + switch args[0] { + case "--": + // Everything after the separator is the command, verbatim, including + // `--help`. That is the documented contract: `zero sandbox exec -- cmd + // --help` must run cmd's help, not ours. + command := args[1:] + if len(command) == 0 { + return nil, errors.New("usage: zero sandbox exec -- [args...]") } - // The first non-flag token starts the command in the tolerated - // separator-less form, so nothing after it is ours to read. Without this - // `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd, - // which is the same contract break as reading past `--`. - return args, nil + return command, nil + case "-h", "--help", "help": + return nil, errSandboxExecHelp } - // Tolerated without the separator for interactive use, but the separator is - // what the help shows, because anything with a leading dash needs it. + // The separator-less form, tolerated for interactive use: the first token is + // the command, so nothing after it is ours to read. Without this, + // `zero sandbox exec mycmd --help` printed OUR help and never ran mycmd, + // which is the same contract break as reading past `--`. The help text still + // shows the separator, because anything with a leading dash needs it. return args, nil } diff --git a/internal/cli/sandbox_exec_test.go b/internal/cli/sandbox_exec_test.go new file mode 100644 index 000000000..5eca61304 --- /dev/null +++ b/internal/cli/sandbox_exec_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "errors" + "strings" + "testing" +) + +// parseSandboxExecArgs sits on the boundary between Zero's flags and a child +// command's, and it had no tests at all: not for the reported defect, not even +// for the happy case. The contract it has to keep is that everything after the +// separator belongs to the child, help flags included, so the wrapper can never +// answer for a command it was supposed to be running. +func TestParseSandboxExecArgs(t *testing.T) { + for _, testCase := range []struct { + name string + args []string + command []string + help bool + usage bool + }{ + { + // The reported defect: --help after the separator is the CHILD's. + name: "help after the separator belongs to the child", + args: []string{"--", "cmd", "--help"}, + command: []string{"cmd", "--help"}, + }, + { + name: "a second separator is the child's too", + args: []string{"--", "cmd", "--", "inner"}, + command: []string{"cmd", "--", "inner"}, + }, + { + name: "a flag-looking command survives the separator", + args: []string{"--", "--weird-binary"}, + command: []string{"--weird-binary"}, + }, + { + // The separator-less form. The first token is the command, so its own + // flags are not ours to read either. + name: "help after a separatorless command belongs to the child", + args: []string{"cmd", "--help"}, + command: []string{"cmd", "--help"}, + }, + { + // A separator arriving AFTER the command has already started is part of + // the child's argv, not a delimiter we get a second go at. + name: "a separator after the command is the child's argument", + args: []string{"ls", "--", "foo"}, + command: []string{"ls", "--", "foo"}, + }, + {name: "bare help", args: []string{"--help"}, help: true}, + {name: "short help", args: []string{"-h"}, help: true}, + {name: "help subcommand", args: []string{"help"}, help: true}, + {name: "help in the prefix wins", args: []string{"--help", "--", "cmd"}, help: true}, + {name: "no arguments", args: nil, usage: true}, + {name: "separator with nothing after it", args: []string{"--"}, usage: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + command, err := parseSandboxExecArgs(testCase.args) + switch { + case testCase.help: + if !errors.Is(err, errSandboxExecHelp) { + t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want the help sentinel", testCase.args, command, err) + } + case testCase.usage: + if err == nil || errors.Is(err, errSandboxExecHelp) { + t.Fatalf("parseSandboxExecArgs(%q) = %q, %v; want a usage error", testCase.args, command, err) + } + if !strings.Contains(err.Error(), "usage:") { + t.Errorf("a usage error must show the usage, got %q", err.Error()) + } + default: + if err != nil { + t.Fatalf("parseSandboxExecArgs(%q) returned %v", testCase.args, err) + } + if strings.Join(command, "\x00") != strings.Join(testCase.command, "\x00") { + t.Errorf("parseSandboxExecArgs(%q) = %q, want %q", testCase.args, command, testCase.command) + } + } + }) + } +} + +// The wrapper must never consume a token it then fails to hand over. Whatever it +// returns as the command has to be a trailing slice of what it was given, with +// at most a leading separator removed, or an argument has gone missing on its +// way to the child. +func TestParseSandboxExecArgsNeverDropsAChildArgument(t *testing.T) { + for _, args := range [][]string{ + {"--", "cmd", "--help", "-x", "--"}, + {"cmd", "--help"}, + {"ls", "--", "foo"}, + {"--", "--weird-binary", "arg"}, + } { + command, err := parseSandboxExecArgs(args) + if err != nil { + t.Fatalf("parseSandboxExecArgs(%q) returned %v", args, err) + } + if len(command) == 0 { + t.Fatalf("parseSandboxExecArgs(%q) returned an empty command", args) + } + suffix := strings.Join(args[len(args)-len(command):], "\x00") + if strings.Join(command, "\x00") != suffix { + t.Errorf("parseSandboxExecArgs(%q) = %q, which is not a trailing slice of the input; an argument was dropped or reordered", args, command) + } + } +} diff --git a/internal/sandbox/windows_setup_caller_windows_test.go b/internal/sandbox/windows_setup_caller_windows_test.go index 3d5411f08..d36491c91 100644 --- a/internal/sandbox/windows_setup_caller_windows_test.go +++ b/internal/sandbox/windows_setup_caller_windows_test.go @@ -14,14 +14,18 @@ import ( // test can drive its control flow on an ordinary machine. Each seam defaults to // a benign success, and a test overrides only the one it is about. type windowsSetupSeams struct { - elevated bool - aclRollback func() error - retireErr error - provisionErr error - networkErr error - markerErr error - rollbackCalled *bool - markerWritten *bool + elevated bool + // principalStillInstalled is what the account lookup reports AFTER retirement + // ran. It is the whole of the opt-out branch's fatality rule, so it is stated + // separately from retireErr rather than inferred from it. + principalStillInstalled bool + aclRollback func() error + retireErr error + provisionErr error + networkErr error + markerErr error + rollbackCalled *bool + markerWritten *bool } func withWindowsSetupSeams(t *testing.T, seams windowsSetupSeams) { @@ -32,9 +36,11 @@ func withWindowsSetupSeams(t *testing.T, seams windowsSetupSeams) { originalACL := applyWindowsACLPlanFn originalPrincipal := setupWindowsSandboxPrincipalFn originalRetire := removeWindowsSandboxPrincipalForSetupFn + originalLookup := lookupWindowsSandboxIdentityFn originalNetwork := applyWindowsNetworkPlanFn originalMarker := writeWindowsSandboxSetupMarkerFn t.Cleanup(func() { + lookupWindowsSandboxIdentityFn = originalLookup windowsProcessIsElevatedFn = originalElevated acquireWindowsSandboxSetupLockFn = originalLock applyWindowsACLPlanFn = originalACL @@ -70,6 +76,12 @@ func withWindowsSetupSeams(t *testing.T, seams windowsSetupSeams) { removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { return seams.retireErr } + lookupWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, error) { + if seams.principalStillInstalled { + return windowsSandboxIdentity{Username: "zero-sbx-stub"}, nil + } + return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable + } applyWindowsNetworkPlanFn = func(WindowsNetworkPlan) error { return seams.networkErr } writeWindowsSandboxSetupMarkerFn = func(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) { if seams.markerWritten != nil { @@ -116,10 +128,11 @@ func TestOptOutRetirementFailureFailsSetupAndWritesNoMarker(t *testing.T) { markerWritten := false rollbackCalled := false withWindowsSetupSeams(t, windowsSetupSeams{ - elevated: true, - retireErr: errors.New("sandbox principal zero-sbx-abc could not be deleted"), - markerWritten: &markerWritten, - rollbackCalled: &rollbackCalled, + elevated: true, + retireErr: errors.New("sandbox principal zero-sbx-abc could not be deleted"), + principalStillInstalled: true, + markerWritten: &markerWritten, + rollbackCalled: &rollbackCalled, }) var stderr bytes.Buffer @@ -141,6 +154,46 @@ func TestOptOutRetirementFailureFailsSetupAndWritesNoMarker(t *testing.T) { } } +// The other side of that rule, and a regression I introduced fixing the first +// one: failing on ANY retirement error is wrong. +// +// removeWindowsSandboxPrincipalForSetup deliberately carries on past a secret +// file owned by another administrator's setup (errWindowsSandboxSecretNotOurs +// goes into secretErr and is reported at the end via errors.Join) so one +// unlinkable file cannot strand the account, its logon rights and its ACEs. The +// account is therefore already gone when that error is returned, and treating it +// as fatal made `zero sandbox setup` fail permanently on such a machine, taking +// the DEFAULT restricted-token sandbox down with it over inert residue. +// +// Setup must complete, and must still say what was left behind. +func TestOptOutSucceedsWhenOnlyInertResidueSurvivesRetirement(t *testing.T) { + markerWritten := false + rollbackCalled := false + withWindowsSetupSeams(t, windowsSetupSeams{ + elevated: true, + // What teardown reports when the only leftover is another administrator's + // secret file: the account, its rights, its ACEs and its ledger are gone. + retireErr: errWindowsSandboxSecretNotOurs, + principalStillInstalled: false, + markerWritten: &markerWritten, + rollbackCalled: &rollbackCalled, + }) + + var stderr bytes.Buffer + if code := runWindowsSandboxSetup(windowsSetupTestConfig(t, false), &stderr); code != 0 { + t.Fatalf("setup failed over residue left behind by an already-retired principal, which makes the default sandbox unsetupable on this machine: %s", stderr.String()) + } + if !markerWritten { + t.Error("setup that completed must write its marker") + } + if rollbackCalled { + t.Error("setup rolled back its ACL work over residue it had already reported as survivable") + } + if !strings.Contains(stderr.String(), "residue") { + t.Errorf("the leftover must still be named for whoever cleans it up, got %q", stderr.String()) + } +} + // The opt-out path still succeeds when retirement succeeds. Without this the // test above would pass against a branch that always failed. func TestOptOutSucceedsWhenRetirementSucceeds(t *testing.T) { diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 24449f2d0..07b28a208 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -3,6 +3,7 @@ package sandbox import ( + "errors" "fmt" "io" "strings" @@ -129,27 +130,41 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // invisible, because nothing afterwards looks for a principal it believes // was never provisioned. // - // Fatal, and the first cut of this branch had it wrong: it printed and - // carried on to write an opted-out marker and exit 0. + // Fatal only when the ACCOUNT survived, which is a narrower rule than + // either of the two this branch has had. // - // The reasoning was that teardown is idempotent, so a machine that never - // had a principal passes straight through and a failure here means real - // residue rather than a missing account. Both halves are true; the - // conclusion does not follow. Real residue is precisely the case that must - // not be reported as success, because the marker then says "no principal" - // while the account, its secret, its logon right, its ACEs and its ledger - // are all still there — and nothing afterwards looks for a principal it - // believes was never provisioned, so the leftovers are permanent. Exiting - // non-zero is the only thing that keeps the operator in the loop, and - // re-running setup is the repair. - if rollbackErr := rollback(); rollbackErr != nil { - fmt.Fprintf(stderr, "%s: opted out, but retiring the existing sandbox principal failed: %v; rollback failed: %v\n", - WindowsSandboxSetupName, err, rollbackErr) + // It first printed and carried on, which was wrong: a marker that says "no + // principal" while the account is still installed is a lie nothing + // afterwards can detect, because nothing looks for a principal it believes + // was never provisioned. + // + // Then it failed on any error at all, which was wrong in the other + // direction. removeWindowsSandboxPrincipalForSetup deliberately continues + // past a secret file owned by ANOTHER administrator's setup and reports it + // at the end, so that one unlinkable file does not strand the account, its + // logon rights and its ACEs. Treating that report as fatal made the whole + // default sandbox unsetupable on such a machine, permanently, over inert + // residue: the account is already gone and the leftover is a file with a + // DACL we do not own. + // + // So the question is not whether teardown reported a problem, it is + // whether the thing the marker is about to deny is still there. Asked of + // the account rather than inferred from the error, because the error is + // a join and its parts are not separable at this distance. + if windowsSandboxPrincipalIsInstalled(config.commandConfig()) { + if rollbackErr := rollback(); rollbackErr != nil { + fmt.Fprintf(stderr, "%s: opted out, but the existing sandbox principal is still installed: %v; rollback failed: %v\n", + WindowsSandboxSetupName, err, rollbackErr) + return 1 + } + fmt.Fprintf(stderr, "%s: opted out, but the existing sandbox principal is still installed: %v\n", + WindowsSandboxSetupName, err) return 1 } - fmt.Fprintf(stderr, "%s: opted out, but retiring the existing sandbox principal failed: %v\n", + // Retired, with something inert left over. Named rather than swallowed: + // whoever has to clean it up needs to know it is there. + fmt.Fprintf(stderr, "%s: opted out; the sandbox principal was retired, but some of its residue could not be removed: %v\n", WindowsSandboxSetupName, err) - return 1 } if err := applyWindowsNetworkPlanFn(networkPlan); err != nil { if rollbackErr := rollback(); rollbackErr != nil { @@ -170,6 +185,19 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) return 0 } +// windowsSandboxPrincipalIsInstalled reports whether this workspace's principal +// account still resolves. +// +// Fails CLOSED. Only an outright "not provisioned" counts as gone; a name that +// resolves to something unexpected, or a lookup that fails for its own reasons, +// is treated as still installed. The consumer uses this to decide whether an +// opted-out marker would be a lie, and the expensive mistake there is claiming a +// principal is gone when nobody actually checked. +func windowsSandboxPrincipalIsInstalled(config WindowsSandboxCommandConfig) bool { + _, err := lookupWindowsSandboxIdentityFn(windowsSandboxPrincipalKey(config)) + return !errors.Is(err, errWindowsSandboxIdentityUnavailable) +} + // assertWindowsSetupRunsAsCaller refuses to provision a sandbox principal when // this elevated helper belongs to a different Windows user than the caller that // launched it. @@ -187,11 +215,32 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // account, a secret and a grant that resolve to a sandbox nobody can log into. // // Same-account elevation is unaffected: a UAC consent prompt splits the caller's -// token but leaves the user SID identical, which is the ordinary path. Only -// over-the-shoulder elevation and `runas /user:` land here. An unknown caller -// SID (an older caller, or a token query that failed) is treated as a match: -// that is the pre-existing behaviour, and refusing on absence would break setup -// on machines where nothing is wrong. +// token but leaves the user SID identical, which is the ordinary path. An +// unknown caller SID (an older caller, or a token query that failed) is treated +// as a match: that is the pre-existing behaviour, and refusing on absence would +// break setup on machines where nothing is wrong. +// +// What this does NOT do, stated plainly because the name suggests otherwise: +// it cannot fire on any path Zero itself takes. Zero never elevates the helper. +// runSandboxSetupHelper (internal/cli/app.go) is a plain exec.Command with no +// token work, and runWindowsSandboxSetup refuses unless the process is ALREADY +// elevated, so the operator supplies elevation by opening an elevated terminal. +// Both halves then run in that terminal: BuildWindowsSandboxSetupArgs resolves +// the caller SID from the very process that later becomes the helper, so the two +// are equal by construction and this returns nil every time. +// +// It is kept rather than deleted because it is a correctness assertion, not a +// no-op. It becomes load-bearing the moment anyone adds a ShellExecute "runas" +// path, which is the natural way this command grows, and it already covers a +// helper .exe invoked directly with hand-written args. What it must not do is be +// mistaken for a live defence against over-the-shoulder elevation today. +// +// The residual hazard that boundary was supposed to describe is real but has a +// different shape: an operator who elevates a terminal as a DIFFERENT +// administrator runs both halves as that administrator, so setup provisions into +// THAT account's sandbox home. Their own unelevated session resolves its own +// home, finds no marker, and is told to run setup. Loud, not silent, and not a +// weakened sandbox. // // Scoped to the opt-in by its caller, deliberately. The default restricted-token // sandbox stores no secret and names no account after the user, so it works From 2170635cf20bb2062115f9deff63c061c5bdb20e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 10 Aug 2026 21:19:41 +0530 Subject: [PATCH 52/96] fix(sandbox): refuse to adopt a ZeroSandboxUsers group Zero did not create Reported by anandh8x as P2 #3. ensureWindowsSandboxGroup treated NERR_GroupExists and ERROR_ALIAS_EXISTS as plain success, so any local group that happened to carry our name was adopted. Its members, and every grant already keyed to it, silently became part of the sandbox's identity. A name is not proof of provenance, which is the same reasoning windowsSandboxUserIsManaged already applies to an ACCOUNT of our name. The group half was missing. An unprivileged user cannot create a local group, but an administrator, an installer or an earlier build can, and the principal would then inherit whatever it grants. An existing group is now adopted only when it carries the managed comment, read back with NetLocalGroupGetInfo. Anything else is refused by name rather than adopted, renamed around or deleted: removing somebody else's group would be destructive, and provisioning into it is the hole being closed. The decision is split from the syscall into resolveWindowsSandboxGroupAdd so it can be tested without Administrator and without leaving a real local group on the machine running the suite. Tests cover both "already exists" statuses, refusal for a foreign group, adoption of our own so re-running setup still converges, no ownership probe when we just created it ourselves, an unreadable probe surfacing rather than being guessed either way, and a real API failure still failing. Also rebased onto main, 17 commits behind, under the fresh-base rule. All 51 commits replayed with no conflicts. --- .../windows_group_adoption_windows_test.go | 75 ++++++++++++++++++ internal/sandbox/windows_identity_windows.go | 79 ++++++++++++++++++- 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 internal/sandbox/windows_group_adoption_windows_test.go diff --git a/internal/sandbox/windows_group_adoption_windows_test.go b/internal/sandbox/windows_group_adoption_windows_test.go new file mode 100644 index 000000000..6f18a6dcf --- /dev/null +++ b/internal/sandbox/windows_group_adoption_windows_test.go @@ -0,0 +1,75 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +// A NAME IS NOT PROOF OF PROVENANCE. +// +// "Already exists" was treated as plain success, so any local group that +// happened to be called ZeroSandboxUsers was adopted: its members, and every +// grant already keyed to it, silently became part of the sandbox's identity. +// An unprivileged user cannot create a local group, but an administrator, an +// installer or an earlier tool can, and the sandbox would then inherit it. +// +// Tested through the extracted decision rather than the syscall, so it needs no +// Administrator and leaves no group behind on the machine running the suite. +func TestAdoptingAForeignGroupOfOurNameIsRefused(t *testing.T) { + for _, status := range []uintptr{nerrGroupExists, errorAliasExists} { + err := resolveWindowsSandboxGroupAdd(status, func() (bool, error) { return false, nil }) + if err == nil { + t.Fatalf("status %d adopted a group Zero did not create, handing the sandbox whatever it already grants", status) + } + if !strings.Contains(err.Error(), windowsSandboxGroupName) { + t.Errorf("the refusal must name the group that is in the way, got %q", err) + } + } +} + +// Our own group is still adopted, or re-running setup would fail on the group +// it created a moment ago and provisioning would never converge. +func TestAdoptingOurOwnGroupStillSucceeds(t *testing.T) { + for _, status := range []uintptr{nerrGroupExists, errorAliasExists} { + if err := resolveWindowsSandboxGroupAdd(status, func() (bool, error) { return true, nil }); err != nil { + t.Errorf("status %d refused a group carrying Zero's own comment: %v", status, err) + } + } +} + +// A freshly created group is ours by construction, so no ownership probe should +// run at all. Without this the check could be satisfied by an implementation +// that interrogates the group it just made, which would be a wasted syscall and +// a needless failure mode. +func TestCreatingTheGroupDoesNotProbeOwnership(t *testing.T) { + probed := false + if err := resolveWindowsSandboxGroupAdd(nerrSuccess, func() (bool, error) { + probed = true + return false, nil + }); err != nil { + t.Fatalf("a successful create was rejected: %v", err) + } + if probed { + t.Error("ownership was probed for a group we had just created ourselves") + } +} + +// A failed ownership probe must not be read as "not ours" and must not be read +// as "ours" either. Neither guess is safe, so the error surfaces. +func TestAnUnreadableGroupIsNotGuessedEitherWay(t *testing.T) { + sentinel := errors.New("NetLocalGroupGetInfo: status 5") + err := resolveWindowsSandboxGroupAdd(nerrGroupExists, func() (bool, error) { return false, sentinel }) + if !errors.Is(err, sentinel) { + t.Fatalf("a failed ownership probe was swallowed, got %v", err) + } +} + +// A real API failure is still a failure; the new branch must not mask it. +func TestARealGroupAddFailureStillFails(t *testing.T) { + if err := resolveWindowsSandboxGroupAdd(errorAccessDenied32, func() (bool, error) { return true, nil }); err == nil { + t.Fatal("access denied was reported as success") + } +} diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index 137e89294..36817095c 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -67,6 +67,8 @@ const ( errorMemberInAlias = 1378 errorAccessDenied32 = 5 nerrUserNotFound = 2221 + // NERR_GroupNotFound, the group half of nerrUserNotFound above. + nerrGroupNotFound = 2220 ) // USER_INFO_1 privilege and flag values. @@ -88,6 +90,7 @@ var ( procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo") procNetApiBufferFree = netapi32.NewProc("NetApiBufferFree") procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups") + procNetLocalGroupGetInfo = netapi32.NewProc("NetLocalGroupGetInfo") ) // userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32 @@ -212,8 +215,77 @@ func netAPIStatus(call string, status uintptr, okStatuses ...uintptr) error { return fmt.Errorf("%s: status %d", call, status) } -// ensureWindowsSandboxGroup creates the managed local group, or leaves it alone -// when it already exists. +// windowsSandboxGroupIsOwned reports whether an EXISTING group of our name +// carries Zero's managed comment. +// +// Mirrors windowsSandboxUserIsManaged, which asks the same question of an +// account, and for the same reason: a name is not proof of provenance. +func windowsSandboxGroupIsOwned() (bool, error) { + name, err := windows.UTF16PtrFromString(windowsSandboxGroupName) + if err != nil { + return false, err + } + var buffer *byte + status, _, _ := procNetLocalGroupGetInfo.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 1, // level: LOCALGROUP_INFO_1 + uintptr(unsafe.Pointer(&buffer)), + ) + runtime.KeepAlive(name) + if status == nerrGroupNotFound { + return false, nil + } + if err := netAPIStatus("NetLocalGroupGetInfo", status); err != nil { + return false, err + } + if buffer == nil { + return false, nil + } + defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer))) + info := (*localGroupInfo1)(unsafe.Pointer(buffer)) + if info.Comment == nil { + return false, nil + } + return windows.UTF16PtrToString(info.Comment) == windowsSandboxGroupComment, nil +} + +// resolveWindowsSandboxGroupAdd turns NetLocalGroupAdd's status into a verdict. +// +// Split out from the syscall so the DECISION can be tested without creating a +// real local group, which needs Administrator and would leave machine state +// behind. +// +// "Already exists" used to be treated as plain success, so any local group that +// happened to be called ZeroSandboxUsers was adopted: its members, and every +// grant already keyed to it, silently became part of the sandbox's identity. +// A name is not proof of provenance. Creating the group ourselves needs no +// check, since we just made it. Adopting one does. +func resolveWindowsSandboxGroupAdd(status uintptr, owned func() (bool, error)) error { + if err := netAPIStatus("NetLocalGroupAdd", status, nerrGroupExists, errorAliasExists); err != nil { + return err + } + if status != nerrGroupExists && status != errorAliasExists { + return nil + } + isOwned, err := owned() + if err != nil { + return err + } + if !isOwned { + // Refused rather than adopted, renamed around, or deleted. Removing + // somebody else's group would be destructive, and provisioning into it + // would hand the sandbox whatever that group already grants, so the only + // safe move is to stop and name what is in the way. + return fmt.Errorf("a local group named %s already exists but was not created by Zero (its comment is not %q); "+ + "rename or remove it, or the sandbox principal would inherit whatever that group already grants", + windowsSandboxGroupName, windowsSandboxGroupComment) + } + return nil +} + +// ensureWindowsSandboxGroup creates the managed local group, or adopts it when +// it already exists AND carries our ownership comment. func ensureWindowsSandboxGroup() error { name, err := windows.UTF16PtrFromString(windowsSandboxGroupName) if err != nil { @@ -235,7 +307,7 @@ func ensureWindowsSandboxGroup() error { runtime.KeepAlive(info) runtime.KeepAlive(name) runtime.KeepAlive(comment) - return netAPIStatus("NetLocalGroupAdd", status, nerrGroupExists, errorAliasExists) + return resolveWindowsSandboxGroupAdd(status, windowsSandboxGroupIsOwnedFn) } // ensureWindowsSandboxUser creates a sandbox account with the supplied password. @@ -581,6 +653,7 @@ func resolveWindowsSandboxSID(username string) (*windows.SID, error) { // ordinary machine and would pass without reaching the code it names. var ( ensureWindowsSandboxGroupFn = ensureWindowsSandboxGroup + windowsSandboxGroupIsOwnedFn = windowsSandboxGroupIsOwned ensureWindowsSandboxUserFn = ensureWindowsSandboxUser addWindowsSandboxUserToGroupFn = addWindowsSandboxUserToGroup resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID From 97251dd6cc9cbddff34fc23b79a40f1259dec194 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 12:37:17 +0530 Subject: [PATCH 53/96] fix(sandbox): stop the principal's own SID from defeating its write jail Reported by jatmn. The principal path appended the account's own SID to the restricting-SID list before building a WRITE_RESTRICTED token. That token allows a write only when BOTH the normal token and the restricting list allow it. The account SID is already enabled in the normal token, so listing it as a restricting SID makes the second check a formality for anything granted to that account: every path carrying a direct principal ACE passes both halves and is writable wherever it sits. The principal's own profile directory, which Windows creates on first logon with exactly such an ACE, is outside every configured write root and was writable for that reason. It is the same defect as the World SID in the restricted list (#865), with the account SID in place of Everyone. A SID already carried by the normal token cannot also restrict it. The previous reasoning, recorded in the comment this replaces, was that the ACL plan grants the workspace to the principal SID so removing it would jail the principal out of its own tree. That is not so, because setup applies BuildWindowsACLPlan on every path, not only the restricted-token one, so each configured write root already carries a capability ACE as well as the principal ACE. Confining to the capability SIDs therefore restores the intersection the jail is supposed to be: a write root satisfies the normal token through its principal ACE and the restriction through its capability ACE, while a path holding only a principal ACE now fails the restricted check. The account SID is passed to windowsPrincipalJailSIDs and excluded there rather than simply not passed. Naming it makes the exclusion the function's contract instead of an omission a later edit could undo silently, and it also strips the SID should it ever arrive through the capability list, which is the route the World SID took. On the test jatmn asked for: the existing jail test grants WinBuiltinGuestsSid, a GROUP, so it exercised a configuration the product never ships and could not have caught this. The new test plants the account SID INSIDE the capability list and asserts it is gone, which is deliberately falsifiable: a list that never contained it would pass against any implementation, including one that appends the SID straight back. Verified by mutation, disabling the filter fails it with the reported symptom. A second test pins that the capability SIDs survive, so the fix cannot degenerate into jailing the principal out of its workspace. Still outstanding from the same review and not addressed here: the runtime-root fallback that hands setup and each command runner a different directory, and the elevated end-to-end evidence. --- .../sandbox/windows_command_runner_windows.go | 52 ++++++++++++-- ...indows_principal_jail_sids_windows_test.go | 70 +++++++++++++++++++ probe-inside.txt | 1 + 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 internal/sandbox/windows_principal_jail_sids_windows_test.go create mode 100644 probe-inside.txt diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 378239f02..b82825671 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -101,15 +101,35 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // 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. + // The principal's own SID is deliberately NOT a restricting SID, and that + // is the whole of the write jail on this path. + // + // WRITE_RESTRICTED allows a write only when BOTH the normal token and the + // restricting-SID list allow it. The account SID is already enabled in the + // normal token, so listing it as a restricting SID makes the second check + // a formality for anything granted to that account: every path carrying a + // direct principal ACE passes both halves and is writable wherever it + // sits. The principal's own profile directory, which Windows creates on + // first logon with exactly such an ACE, is outside every configured write + // root and was writable for that reason. + // + // Same defect as the World SID in the restricted list (#865), with the + // account SID in place of Everyone: a SID already carried by the normal + // token cannot also serve as the restriction on it. + // + // Confining to the capability SIDs alone restores the intersection the + // jail is supposed to be. A configured write root carries BOTH a principal + // ACE, from the principal plan, satisfying the normal token, AND a + // capability ACE, from BuildWindowsACLPlan which setup applies on every + // path, satisfying the restriction. So the principal keeps the tree it + // owns, while anything holding only a principal ACE now fails the + // restricted check. 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()) + jailSIDs := windowsPrincipalJailSIDs(tokenSIDs, principalUser.User.Sid.String()) jailedToken, err := restrictWindowsTokenForCapabilitySIDs(principalToken, jailSIDs, writeRestricted) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) @@ -214,3 +234,27 @@ func windowsACLPlanDeniedPath(err error) string { } return strings.TrimSpace(rest[:end]) } + +// windowsPrincipalJailSIDs returns the restricting SIDs for a principal command. +// +// A named function with its own test rather than an inline slice literal, +// because the defect it encodes was invisible: the production composition added +// the account's own SID while the only jail test granted a GROUP, so the +// configuration that shipped was never the configuration under test. +// +// accountSID is taken and DELIBERATELY EXCLUDED rather than simply not passed. +// Naming it here makes the exclusion the function's stated contract instead of an +// omission a later edit could undo without noticing, and it also removes the SID +// should it ever arrive through capabilitySIDs, which is the same route the World +// SID took in #865. +func windowsPrincipalJailSIDs(capabilitySIDs []string, accountSID string) []string { + account := strings.TrimSpace(accountSID) + jail := make([]string, 0, len(capabilitySIDs)) + for _, sid := range capabilitySIDs { + if account != "" && strings.EqualFold(strings.TrimSpace(sid), account) { + continue + } + jail = append(jail, sid) + } + return jail +} diff --git a/internal/sandbox/windows_principal_jail_sids_windows_test.go b/internal/sandbox/windows_principal_jail_sids_windows_test.go new file mode 100644 index 000000000..af3666b11 --- /dev/null +++ b/internal/sandbox/windows_principal_jail_sids_windows_test.go @@ -0,0 +1,70 @@ +//go:build windows + +package sandbox + +import ( + "strings" + "testing" +) + +// THE ACCOUNT SID MUST NOT BE A RESTRICTING SID. +// +// WRITE_RESTRICTED allows a write only when the normal token AND the +// restricting-SID list both allow it. The principal's account SID is already +// enabled in its normal token, so listing it as a restricting SID makes the +// second check a formality for anything granted to that account: every path +// carrying a direct principal ACE passes both halves and is writable wherever it +// sits, including the profile directory Windows creates on first logon outside +// every configured write root. +// +// Same shape as the World SID in the restricted list (#865). A SID already +// carried by the normal token cannot also restrict it. +// +// The pre-existing jail test could not catch this: it grants a GROUP +// (WinBuiltinGuestsSid), so it exercises a configuration the product never +// ships. This asserts the composition that actually ships. +func TestPrincipalJailExcludesTheAccountsOwnSID(t *testing.T) { + accountSID := "S-1-5-21-9-9-9-1500" + // The account SID is planted IN the capability list on purpose. Passing a + // list that never contained it would make this test unfalsifiable: it would + // pass against any implementation, including one that appends the account SID + // straight back. This is the route the World SID took in #865. + capabilities := []string{"S-1-5-21-1-2-3-1001", accountSID, "S-1-5-21-1-2-3-1002"} + + jail := windowsPrincipalJailSIDs(capabilities, accountSID) + + for _, sid := range jail { + if strings.EqualFold(sid, accountSID) { + t.Fatalf("the principal's own account SID is a restricting SID, so any path with a direct principal ACE escapes the write jail: %v", jail) + } + } + // ...and the capability SIDs are still all present, or the principal would be + // jailed out of the workspace it owns and the fix would be a denial of service + // rather than a fix. + for _, want := range []string{"S-1-5-21-1-2-3-1001", "S-1-5-21-1-2-3-1002"} { + if !containsString(jail, want) { + t.Errorf("capability SID %q missing from the jail, so the principal loses write access to its own workspace: %v", want, jail) + } + } +} + +// The jail must not alias the caller's slice. Appending to a shared backing +// array would let a later append mutate the restricting set of a token already +// being built. +func TestPrincipalJailDoesNotAliasTheCapabilitySlice(t *testing.T) { + capabilities := make([]string, 2, 8) + capabilities[0] = "S-1-5-21-1-2-3-1001" + capabilities[1] = "S-1-5-21-1-2-3-1002" + + jail := windowsPrincipalJailSIDs(capabilities, "S-1-5-21-9-9-9-1500") + jail = append(jail, "S-1-1-0") + + for _, sid := range capabilities { + if sid == "S-1-1-0" { + t.Fatal("appending to the jail wrote through into the caller's capability slice") + } + } + if len(capabilities) != 2 { + t.Errorf("caller's slice grew to %d", len(capabilities)) + } +} diff --git a/probe-inside.txt b/probe-inside.txt new file mode 100644 index 000000000..076e7ce76 --- /dev/null +++ b/probe-inside.txt @@ -0,0 +1 @@ +inside From af97b886ec95692642be88b258fef8cb4db2b140 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 12:43:32 +0530 Subject: [PATCH 54/96] fix(sandbox): derive the fallback runtime root instead of minting one per process Reported by jatmn. sandboxRuntimeRootFor falls back to a private tree when the cache-derived runtime root would land inside the workspace, and that fallback called os.MkdirTemp and cached the answer in a process-global map. Separate processes therefore got separate answers. Elevated setup granted the sandbox principal write access to the directory it created, and every later __windows-command-runner process created a different one and pointed TMP, GOCACHE, npm and the rest at it. Those directories are made by the calling user and carry no ACE for the principal, so ordinary cache and temp writes failed with a bare ACCESS_DENIED and nothing naming the sandbox. sandboxRuntimeRootFor already documented that both callers must agree exactly; the fallback was the branch where that could not hold. It now hashes the workspace under os.TempDir, the same shape as the cache-derived root, so every process reaches the same path with no shared state. When even that lands inside the workspace it returns an error rather than picking somewhere arbitrary: a runtime tree governed by the workspace's own policy makes the sandbox's cache writes indistinguishable from the work it is confining. Two consequences worth naming. It creates nothing now, so the split between deterministicSandboxRuntimeRoot and the resolver is no longer about avoiding a side effect. The comments that justified that split on those grounds were rewritten rather than left describing behaviour the code no longer has. Teardown can name the fallback tree for the first time. windowsPrincipalTeardown Paths used the deterministic resolver precisely because the fallback was random and unnameable, which meant an opted-out machine kept principal ACEs on whatever tree the fallback had produced. It now goes through the shared resolver and revokes what commands actually used. Tests pin the property the defect turned on: a repeated call, which is what a second process looks like with no shared state, must return the same root. The old implementation could not have passed that, since only the in-process map made repeat calls agree. Also pinned: per-workspace separation, that naming the tree creates nothing so teardown leaves no directory behind, and that the root stays outside the workspace. --- internal/sandbox/runtime_fallback_test.go | 85 +++++++++++++++++++ internal/sandbox/runtime_state.go | 59 +++++++------ .../windows_identity_runtime_windows.go | 20 ++--- 3 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 internal/sandbox/runtime_fallback_test.go diff --git a/internal/sandbox/runtime_fallback_test.go b/internal/sandbox/runtime_fallback_test.go new file mode 100644 index 000000000..2c5852d3f --- /dev/null +++ b/internal/sandbox/runtime_fallback_test.go @@ -0,0 +1,85 @@ +package sandbox + +import ( + "path/filepath" + "strings" + "testing" +) + +// SETUP AND EVERY COMMAND RUNNER MUST DERIVE THE SAME FALLBACK ROOT. +// +// They are separate processes. The fallback used to call os.MkdirTemp and cache +// the answer in a process-global map, so elevated setup granted the sandbox +// principal write access to the directory IT made, and each later +// __windows-command-runner made a different one and pointed TMP, GOCACHE and npm +// there. Those directories are created by the calling user and carry no ACE for +// the principal, so ordinary cache writes failed with a bare ACCESS_DENIED. +// +// A repeated call is what a second process looks like from here: no shared +// state, same inputs. The old implementation could not pass this, because the +// only thing that made repeat calls agree was the in-process map. +func TestFallbackRuntimeRootIsTheSameForEveryProcess(t *testing.T) { + workspace := filepath.Join(t.TempDir(), "ws") + + first, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot: %v", err) + } + second, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot (second process): %v", err) + } + if first != second { + t.Fatalf("two processes derived different runtime roots, so setup grants one and commands write to another:\n %s\n %s", first, second) + } + if first == "" { + t.Fatal("no runtime root derived") + } +} + +// Different workspaces must not share a runtime tree, or one workspace's +// principal would hold an ACE on another's caches. +func TestFallbackRuntimeRootIsPerWorkspace(t *testing.T) { + base := t.TempDir() + first, err := fallbackSandboxRuntimeRoot(filepath.Join(base, "alpha")) + if err != nil { + t.Fatal(err) + } + second, err := fallbackSandboxRuntimeRoot(filepath.Join(base, "beta")) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatalf("two workspaces share the runtime root %q", first) + } +} + +// It must NAME the tree without creating it. Teardown asks for this path on its +// way out, and materializing a directory there would leave one behind on every +// teardown. +func TestFallbackRuntimeRootCreatesNothing(t *testing.T) { + workspace := filepath.Join(t.TempDir(), "ws") + root, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatal(err) + } + if pathExists(root) { + t.Errorf("naming the runtime root created %s", root) + } +} + +// The root must land outside the workspace, which is the entire reason this +// branch exists. +func TestFallbackRuntimeRootStaysOutsideTheWorkspace(t *testing.T) { + workspace := filepath.Join(t.TempDir(), "ws") + root, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatal(err) + } + if pathWithinRoot(workspace, root) { + t.Errorf("runtime root %q is inside workspace %q", root, workspace) + } + if !strings.Contains(root, "zero") { + t.Errorf("runtime root %q does not look like a zero-owned tree", root) + } +} diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 061bde8ea..0bde14441 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -21,11 +21,6 @@ const ( sandboxRuntimeMaxRoots = 64 ) -var fallbackSandboxRuntimes = struct { - sync.Mutex - roots map[string]string -}{roots: make(map[string]string)} - type SandboxRuntime struct { Root string `json:"root,omitempty"` Cache string `json:"cache,omitempty"` @@ -50,15 +45,12 @@ func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, erro } // 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. +// whether it is usable, meaning it lands outside the workspace. // -// 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. +// Neither this nor the fallback creates anything now, so a caller that only +// needs to NAME the tree can safely go through sandboxRuntimeRootFor and get the +// answer commands will actually use. This remains separate for callers that need +// to distinguish the cache-derived root from the temp-derived one. func deterministicSandboxRuntimeRoot(workspaceRoot string, cacheRoot string) (string, bool) { digest := sha256.Sum256([]byte(workspaceRoot)) root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) @@ -209,22 +201,37 @@ func combineSandboxCleanups(cleanups ...func()) func() { } } +// fallbackSandboxRuntimeRoot returns the runtime root for a workspace whose +// cache-derived root would land inside itself. +// +// DERIVED, not minted, and that is the whole of the fix. It used to call +// os.MkdirTemp and remember the answer in a process-global map, which made the +// result private to whichever process asked first. Elevated Windows setup +// granted the sandbox principal write access to the directory IT created, then +// every later __windows-command-runner process created a DIFFERENT one and +// pointed TMP, GOCACHE, npm and the rest at it. Those directories are created by +// the calling user and carry no ACE for the principal, so ordinary cache and +// temp writes failed with a bare ACCESS_DENIED and nothing naming the sandbox. +// +// sandboxRuntimeRootFor already documents that both callers must agree exactly. +// Hashing the workspace, the same way the cache-derived root does, is what makes +// that true for this branch too: every process reaches the same path without +// having to share any state. +// +// It creates nothing, so deterministicSandboxRuntimeRoot's promise about naming +// a tree without materializing it now holds for the fallback as well. func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) { - fallbackSandboxRuntimes.Lock() - defer fallbackSandboxRuntimes.Unlock() - if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" { - return root, nil - } - parent, err := os.MkdirTemp("", "zero-runtime-") - if err != nil { - return "", fmt.Errorf("create fallback sandbox runtime: %w", err) - } - root := filepath.Join(parent, "runtime") + digest := sha256.Sum256([]byte(workspaceRoot)) + root := filepath.Join(os.TempDir(), "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) if pathWithinRoot(workspaceRoot, root) { - _ = os.RemoveAll(parent) - return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot) + // Both candidates land inside the workspace, so there is nowhere left to + // put a runtime tree the workspace's own policy does not govern. Refused + // rather than pointed somewhere arbitrary: a runtime root inside the + // workspace makes the sandbox's own cache writes indistinguishable from + // the work it is meant to be confining. + return "", fmt.Errorf("sandbox runtime root %q would fall inside workspace %q; "+ + "open the workspace somewhere other than the cache or temp directory", root, workspaceRoot) } - fallbackSandboxRuntimes.roots[workspaceRoot] = root return root, nil } diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 2b3e18966..176f870d7 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -541,9 +541,9 @@ func windowsSandboxRuntimeRootPath(config WindowsSandboxCommandConfig) (string, // 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. +// Kept distinct from windowsSandboxRuntimeRootPath so a caller can tell the +// cache-derived tree from the temp-derived fallback. Neither creates anything, +// so naming either is free. func windowsSandboxDeterministicRuntimeRootPath(config WindowsSandboxCommandConfig) (string, error) { workspaceRoot := "" for _, candidate := range config.WorkspaceRoots { @@ -731,16 +731,16 @@ func applyWindowsPrincipalACLs(sandboxHome string, username string, principalSID // windowsPrincipalTeardownPaths names every path this principal could hold an // 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. +// The runtime root is derived through the same resolver setup uses, so teardown +// revokes ACEs on the tree commands actually wrote to. That was not possible +// while the fallback minted a random per-process directory: nothing here could +// name it, so an opted-out machine kept principal ACEs on whatever tree the +// fallback had produced. Both branches are creation-free now, so teardown can +// ask for the real answer without materializing anything on its way out. func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principalSID string) ([]string, error) { filesystem := config.PermissionProfile.FileSystem writeRoots := filesystem.WriteRoots - runtimeRoot, err := windowsSandboxDeterministicRuntimeRootPath(config) + runtimeRoot, err := windowsSandboxRuntimeRootPath(config) if err != nil { return nil, err } From 0496196917c83fd93e7e239dd1144b2d39065002 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 12:53:27 +0530 Subject: [PATCH 55/96] chore: drop a stray probe artifact from the branch probe-inside.txt is a leftover from the elevated end-to-end probe and was never meant to be committed. A `git add -A` in the write-jail commit swept it in. It is what the automated review's diff-hygiene check was failing on: the file carries trailing whitespace, so `git diff --check` reported a blocker while tests, build and smoke all passed. --- probe-inside.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 probe-inside.txt diff --git a/probe-inside.txt b/probe-inside.txt deleted file mode 100644 index 076e7ce76..000000000 --- a/probe-inside.txt +++ /dev/null @@ -1 +0,0 @@ -inside From 3a1d7fffdeaefcb722cf2fbc101ddd4cb132e294 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 20:14:21 +0530 Subject: [PATCH 56/96] fix(sandbox): cover both runtime roots in the setup contract A fresh setup marker rejected the very command it had just been written for. Engine.run augments the profile with the selected runtime root before the Windows runner sees it, but setup fingerprinted the bare profile, so the extra write root changed the ACL plan hash and every command on a restricted filesystem failed with "permission roots or deny lists changed". The runtime root also reached the principal ACL plan only. A principal command runs on a WRITE_RESTRICTED token, where a write needs the normal check and the restricting-SID check to both pass, so a root carrying just the account ACE was still unwritable once the marker agreed. Derive the runtime candidates once and present them on both sides of the setup protocol. Setup covers the cache-derived root and the temp-derived fallback rather than whichever one it happened to select, since selection is per process and a command that fell back would otherwise land on an unprovisioned tree. Both are pure functions of the workspace root, so setup can cover the set and a later process can select from it. Regressions fail without this: the marker one reproduces the exact rejection, the capability-plan one shows the missing restricting-SID grant. --- internal/sandbox/windows_setup.go | 76 +++++++++- .../windows_setup_runtime_root_test.go | 142 ++++++++++++++++++ 2 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 internal/sandbox/windows_setup_runtime_root_test.go diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 8235cb7f5..86c2d2aef 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -332,7 +332,7 @@ func (config WindowsSandboxSetupConfig) commandConfig() WindowsSandboxCommandCon SandboxHome: config.SandboxHome, CommandCWD: config.CommandCWD, WorkspaceRoots: cloneStrings(config.WorkspaceRoots), - PermissionProfile: config.PermissionProfile, + PermissionProfile: windowsSandboxProfileWithRuntime(config.PermissionProfile, config.WorkspaceRoots), Env: map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(config.PrincipalOptIn)}, SandboxLevel: WindowsSandboxLevelRestrictedToken, // Carried through for the same reason as the opt-in above: every principal @@ -350,7 +350,7 @@ func WindowsSandboxSetupConfigFromCommand(config WindowsSandboxCommandConfig) Wi SandboxHome: config.SandboxHome, CommandCWD: config.CommandCWD, WorkspaceRoots: cloneStrings(config.WorkspaceRoots), - PermissionProfile: config.PermissionProfile, + PermissionProfile: windowsSandboxProfileWithRuntime(config.PermissionProfile, config.WorkspaceRoots), PrincipalOptIn: windowsSandboxIdentityEnabled(config.Env), CallerSID: config.CallerSID, } @@ -569,3 +569,75 @@ func canonicalWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry { }) return out } + +// windowsSandboxRuntimeCandidates returns every runtime root setup provisions. +// +// BOTH candidates, not the one this process would select. sandboxRuntimeRootFor +// prefers the cache-derived root and falls back to the temp-derived one when the +// first would land inside the workspace or its lease cannot be taken, and that +// choice is made per process. Setup that granted only its own choice left the +// other unprovisioned, so a command that fell back wrote to a tree with no ACE +// on it. Both are deterministic now, so setup can cover both and command +// selection lands on a provisioned root either way. +func windowsSandboxRuntimeCandidates(workspaceRoots []string) []string { + workspaceRoot := "" + for _, candidate := range workspaceRoots { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + workspaceRoot = canonicalSandboxWorkspaceRoot(trimmed) + break + } + } + if workspaceRoot == "" || workspaceRoot == "." { + return nil + } + var roots []string + if cacheRoot, err := sandboxUserCacheDir(); err == nil { + if cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot); cacheRoot != "" && cacheRoot != "." { + if root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot); ok { + roots = append(roots, root) + } + } + } + if root, err := fallbackSandboxRuntimeRoot(workspaceRoot); err == nil { + roots = append(roots, root) + } + return roots +} + +// windowsSandboxProfileWithRuntime adds the runtime candidates as write roots. +// +// Applied on BOTH sides of the setup protocol, which is the whole point. The +// marker fingerprints the capability ACL plan built from this profile, while +// every command reaches the Windows runner having already had +// permissionProfileWithRuntime append the root it selected. Setup fingerprinted +// the bare profile and the command presented an augmented one, so a marker +// written seconds earlier was rejected with "permission roots or deny lists +// changed" and no command could run at all. +// +// Adding the full candidate set on both sides makes the two hashes agree without +// the command having to know which root setup happened to pick, and it puts the +// runtime roots into the CAPABILITY plan as well. That second part matters since +// the principal command runs on a WRITE_RESTRICTED token restricted to the +// capability SIDs: a runtime root carrying only the principal ACE satisfies the +// normal token and fails the restricted check, so cache and temp writes were +// denied even once the marker agreed. +func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots []string) PermissionProfile { + candidates := windowsSandboxRuntimeCandidates(workspaceRoots) + if len(candidates) == 0 { + return profile + } + existing := make(map[string]struct{}, len(profile.FileSystem.WriteRoots)) + for _, root := range profile.FileSystem.WriteRoots { + existing[windowsCapabilityPathKey(root.Root)] = struct{}{} + } + writeRoots := append([]WritableRoot{}, profile.FileSystem.WriteRoots...) + for _, candidate := range candidates { + if _, ok := existing[windowsCapabilityPathKey(candidate)]; ok { + continue + } + existing[windowsCapabilityPathKey(candidate)] = struct{}{} + writeRoots = append(writeRoots, WritableRoot{Root: candidate}) + } + profile.FileSystem.WriteRoots = writeRoots + return profile +} diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go new file mode 100644 index 000000000..88731caa6 --- /dev/null +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -0,0 +1,142 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// runtimeRootTestConfig is the shape every command reaches the Windows runner +// with: a restricted filesystem rooted at the workspace, which is what makes the +// runtime root necessary in the first place. +func runtimeRootTestConfig(t *testing.T) WindowsSandboxCommandConfig { + t.Helper() + workspace := t.TempDir() + return WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + } +} + +// A marker written by a fresh setup must accept the ordinary command, and the +// ordinary command is the RUNTIME-AUGMENTED one: Engine.run calls +// permissionProfileWithRuntime before the Windows runner ever sees the profile, +// so the profile presented at validation always carries the selected runtime +// root as an extra write root. +// +// Setup used to fingerprint the bare profile. The extra write root changed the +// ACL plan, the plan hash changed with it, and validation rejected a marker +// written seconds earlier with "permission roots or deny lists changed" — so on +// a restricted filesystem no command could run at all, including the very +// command that had just been set up for. +// +// Asserted for BOTH candidates because which one a process selects is not fixed: +// sandboxRuntimeRootFor prefers the cache-derived root and falls back to the +// temp-derived one, and a marker that only accepts the preferred root bricks +// every machine that falls back. +func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { + config := runtimeRootTestConfig(t) + setup := WindowsSandboxSetupConfigFromCommand(config) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + candidates := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + if len(candidates) == 0 { + t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") + } + for _, candidate := range candidates { + augmented := config + augmented.PermissionProfile = permissionProfileWithRuntime( + config.PermissionProfile, + SandboxRuntime{Root: candidate}, + ) + err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(augmented)) + if err != nil { + t.Fatalf("ValidateWindowsSandboxSetupMarker with runtime root %s: %v", candidate, err) + } + } + + // The guard has to still bite, or the test above passes for the wrong reason + // — a validator that accepts everything would satisfy it too. + changed := config + changed.PermissionProfile.FileSystem.DenyRead = []string{`C:\workspace\secret`} + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(changed)); err == nil { + t.Fatal("ValidateWindowsSandboxSetupMarker accepted a changed deny list, so it no longer detects drift") + } else if !strings.Contains(err.Error(), "out of date") { + t.Fatalf("ValidateWindowsSandboxSetupMarker changed error = %v, want out of date", err) + } +} + +// The runtime root needs BOTH sides of the write-restricted grant. +// +// A principal command runs on a token restricted to the capability SIDs, and a +// WRITE_RESTRICTED token grants a write only when the normal token check AND the +// restricting-SID check both pass. The runtime root used to be appended to the +// principal plan alone, so it carried the account ACE and no capability ACE: the +// normal check passed, the restricted check found nothing, and every cache and +// temp write was denied even once the marker agreed. +// +// This asserts the capability side, which is the side that was missing. Both +// candidates again, for the same reason as above. +func TestWindowsSandboxRuntimeRootsAreInTheCapabilityPlan(t *testing.T) { + config := runtimeRootTestConfig(t) + candidates := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + if len(candidates) == 0 { + t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") + } + + setup := WindowsSandboxSetupConfigFromCommand(config) + plan, err := BuildWindowsACLPlan(setup.commandConfig()) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + granted := make(map[string]struct{}, len(plan.Entries)) + for _, entry := range plan.Entries { + granted[windowsCapabilityPathKey(entry.Path)] = struct{}{} + } + for _, candidate := range candidates { + if _, ok := granted[windowsCapabilityPathKey(candidate)]; !ok { + t.Fatalf("capability ACL plan has no entry for runtime root %s; writes there fail the restricting-SID check", candidate) + } + } +} + +// Both candidates are pure functions of the workspace root. Setup provisions the +// set and a later command selects from it in a different process, so a candidate +// that varied per process (a random or time-seeded fallback) would be granted by +// setup and never selected, or selected and never granted. +func TestWindowsSandboxRuntimeCandidatesAreDeterministic(t *testing.T) { + config := runtimeRootTestConfig(t) + first := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + if len(first) == 0 { + t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") + } + second := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + if len(first) != len(second) { + t.Fatalf("candidate count = %d then %d, want stable", len(first), len(second)) + } + for i := range first { + if first[i] != second[i] { + t.Fatalf("candidate %d = %q then %q, want stable", i, first[i], second[i]) + } + } + + other := runtimeRootTestConfig(t) + otherCandidates := windowsSandboxRuntimeCandidates(other.WorkspaceRoots) + for _, candidate := range otherCandidates { + for _, mine := range first { + if candidate == mine { + t.Fatalf("workspaces %s and %s share runtime root %s, so one workspace's grant covers the other", + config.CommandCWD, other.CommandCWD, candidate) + } + } + } +} From 0f6469f5fe6564a59337a371efcb7bc6b2ee7331 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 20:36:09 +0530 Subject: [PATCH 57/96] test(sandbox): make the jail aliasing assertion able to fail The aliasing test appended a sentinel to the returned jail and then ranged over the caller's slice looking for it. That can never fail: append returns a new header, so the caller's length never grows and the range never reaches the slot the write landed in. It passed against an implementation that returns the caller's slice verbatim, which is the exact thing it claims to rule out. Lint noticed the symptom as an ineffectual assignment. Assert through the backing array instead: element storage must not be shared, and an append must not write into the caller's spare capacity. Both fail against an aliasing implementation. Also drop windowsSandboxDeterministicRuntimeRootPath, which lost its last caller to the shared runtime-candidate helper and duplicated its derivation. --- .../windows_identity_runtime_windows.go | 33 ------------------- ...indows_principal_jail_sids_windows_test.go | 29 ++++++++++++---- 2 files changed, 23 insertions(+), 39 deletions(-) diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 176f870d7..79dce513d 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -537,39 +537,6 @@ 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. -// -// Kept distinct from windowsSandboxRuntimeRootPath so a caller can tell the -// cache-derived tree from the temp-derived fallback. Neither creates anything, -// so naming either is free. -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. diff --git a/internal/sandbox/windows_principal_jail_sids_windows_test.go b/internal/sandbox/windows_principal_jail_sids_windows_test.go index af3666b11..9d6bbd6c9 100644 --- a/internal/sandbox/windows_principal_jail_sids_windows_test.go +++ b/internal/sandbox/windows_principal_jail_sids_windows_test.go @@ -51,18 +51,35 @@ func TestPrincipalJailExcludesTheAccountsOwnSID(t *testing.T) { // The jail must not alias the caller's slice. Appending to a shared backing // array would let a later append mutate the restricting set of a token already // being built. +// +// Both assertions read through to the BACKING ARRAY, which is the only place +// aliasing is observable. An earlier version of this test appended a sentinel and +// then ranged over the caller's slice looking for it, which can never fail: +// append returns a new header, so the caller's length never grows and the range +// never reaches the slot the write landed in. It passed against every +// implementation, including one that returns the caller's slice verbatim. func TestPrincipalJailDoesNotAliasTheCapabilitySlice(t *testing.T) { capabilities := make([]string, 2, 8) capabilities[0] = "S-1-5-21-1-2-3-1001" capabilities[1] = "S-1-5-21-1-2-3-1002" jail := windowsPrincipalJailSIDs(capabilities, "S-1-5-21-9-9-9-1500") - jail = append(jail, "S-1-1-0") - - for _, sid := range capabilities { - if sid == "S-1-1-0" { - t.Fatal("appending to the jail wrote through into the caller's capability slice") - } + if len(jail) == 0 { + t.Fatal("jail is empty, so neither assertion below proves anything") + } + // Shared element storage: a mutation through jail[0] would rewrite a SID in + // the restricting set of a token already being built. + if &jail[0] == &capabilities[0] { + t.Fatal("jail shares element storage with the caller's capability slice") + } + // Spare capacity: appending to an aliased jail writes into the caller's + // backing array past its length, which is invisible through the caller's own + // header but very visible to anything else holding a longer view of it. + grown := append(jail, "S-1-1-0") + shared := capabilities[:cap(capabilities)] + if shared[len(capabilities)] == "S-1-1-0" { + t.Fatalf("appending to the jail wrote through into the caller's backing array: jail=%v shared=%v", + grown, shared[:len(capabilities)+1]) } if len(capabilities) != 2 { t.Errorf("caller's slice grew to %d", len(capabilities)) From 207e21867dfa0c035bce9f4a49ba502e8ca6a97d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 21:22:23 +0530 Subject: [PATCH 58/96] fix(sandbox): create the runtime roots setup grants Elevated setup stopped working entirely: zero-windows-sandbox-setup.exe: windows ACL target does not exist: ...\AppData\Local\Temp\zero\runtime\v1\67b1b01412f588b9 Putting both runtime candidates into the capability plan added a write root that nothing creates. The capability plan deliberately refuses to materialize a write root, since an absent path is a typo or a stale config and inventing the tree would grant write on a directory nobody asked for, so the whole run fails on a path that is merely missing. Only the selected root was ever created, and only on the principal path. Create every candidate on the setup side, before the plan that grants them is built. The regression walks the plan and fails on any granted write root that does not exist, so the two halves cannot drift apart again: today one function chooses the candidates and another creates them, and nothing else couples them. Found by the elevated end-to-end run, which is the only thing that executes this path. No unit test reached it and CI does not run it. --- internal/sandbox/windows_setup.go | 20 ++++++++ .../windows_setup_runtime_root_test.go | 49 +++++++++++++++++++ internal/sandbox/windows_setup_windows.go | 17 +++++++ 3 files changed, 86 insertions(+) diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 86c2d2aef..1e05dd21a 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -641,3 +641,23 @@ func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots profile.FileSystem.WriteRoots = writeRoots return profile } + +// ensureWindowsSandboxRuntimeCandidates creates every runtime root setup grants. +// +// Paired with windowsSandboxProfileWithRuntime: that function puts the candidates +// into the ACL plan, and this one makes them exist. Splitting the two is what +// broke elevated setup once already, because the capability plan refuses to +// materialize a write root and fails the whole run on a path that is merely +// absent. Whenever one of these grows a candidate, so must the other. +// +// Called on the setup side only. A command must never create these: setup is the +// gate that decides which trees the sandbox may write to, and a command that +// created its own root would be granting itself one. +func ensureWindowsSandboxRuntimeCandidates(workspaceRoots []string) error { + for _, root := range windowsSandboxRuntimeCandidates(workspaceRoots) { + if err := os.MkdirAll(root, 0o700); err != nil { + return fmt.Errorf("create sandbox runtime root %s: %w", root, err) + } + } + return nil +} diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 88731caa6..144bf9492 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -1,6 +1,7 @@ package sandbox import ( + "os" "strings" "testing" ) @@ -109,6 +110,54 @@ func TestWindowsSandboxRuntimeRootsAreInTheCapabilityPlan(t *testing.T) { } } +// EVERY write root the capability plan grants must exist by the time setup +// applies it. +// +// The capability plan deliberately refuses to materialize a write root, so a +// granted path that is merely absent fails the entire setup run with +// +// windows ACL target does not exist: ...\zero\runtime\v1\ +// +// which is exactly what an elevated run hit: the runtime candidates were added +// to the plan while only the selected one was ever created, and `zero sandbox +// setup` stopped working altogether. The two halves are separate functions, so +// nothing but this test stops one from growing a candidate without the other. +func TestWindowsSandboxSetupProvisionsEveryGrantedWriteRoot(t *testing.T) { + config := runtimeRootTestConfig(t) + candidates := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + if len(candidates) == 0 { + t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") + } + // The workspace is unique per run, and the roots are derived from it, so these + // cannot pre-exist. Assert that rather than trust it: if they did, the test + // would pass with the provisioning step deleted. + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + t.Fatalf("runtime root %s already exists before provisioning, so this test proves nothing", candidate) + } + t.Cleanup(func() { _ = os.RemoveAll(candidate) }) + } + + if err := ensureWindowsSandboxRuntimeCandidates(config.WorkspaceRoots); err != nil { + t.Fatalf("ensureWindowsSandboxRuntimeCandidates: %v", err) + } + + setup := WindowsSandboxSetupConfigFromCommand(config) + plan, err := BuildWindowsACLPlan(setup.commandConfig()) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + for _, entry := range plan.Entries { + if entry.Action != WindowsACLAllowWrite || entry.Materialize { + continue + } + if _, err := os.Stat(entry.Path); err != nil { + t.Errorf("capability plan grants write on %s but nothing created it, so setup fails with "+ + "\"windows ACL target does not exist\": %v", entry.Path, err) + } + } +} + // Both candidates are pure functions of the workspace root. Setup provisions the // set and a later command selects from it in a different process, so a candidate // that varied per process (a random or time-seeded fallback) would be granted by diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 07b28a208..1c6f242c9 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -75,6 +75,23 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) } } + // Create the runtime roots BEFORE building the plan that grants them. + // + // The capability plan deliberately does not materialize a write root: an + // entry whose path is absent is a typo or a stale config, and inventing the + // tree would silently grant write on a directory nobody asked for. So every + // write root in the plan has to exist by the time it is applied, and the + // runtime roots are the only ones setup owns rather than receives. + // + // Both of them, not the one this process would select. Setup grants the whole + // candidate set so a later command that falls back still lands on a + // provisioned tree, and granting a path setup never created fails the entire + // run with "windows ACL target does not exist". + if err := ensureWindowsSandboxRuntimeCandidates(config.WorkspaceRoots); err != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + return 1 + } + plan, err := BuildWindowsACLPlan(config.commandConfig()) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) From bd64ab713cfaf4f9f47eda8702b5651026aa1ae6 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 21:32:49 +0530 Subject: [PATCH 59/96] fix(sandbox): say what changed when the setup marker is rejected "permission roots or deny lists changed" told an operator that every sandboxed command would now refuse to run, and nothing else. Not which side is stale, not by how much, not even whether the marker belongs to this workspace. Debugging it meant reading the source and guessing, which is what happened. Report the marker path, both entry counts and both hashes. The counts separate the two shapes this takes: equal counts mean the same roots spelled differently, unequal counts mean one side has roots the other has never heard of. --- internal/sandbox/windows_setup.go | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 1e05dd21a..80c2d03da 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -510,7 +510,17 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { 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") + // Say WHAT disagrees. The bare "permission roots or deny lists changed" + // left an operator with a command that refuses to run and no way to tell + // which side is wrong, or even whether the marker belongs to this + // workspace at all. The entry counts separate the two shapes this takes: + // equal counts mean the same roots spelled differently, unequal counts + // mean one side has roots the other has never heard of. + return fmt.Errorf("windows sandbox setup is out of date: permission roots or deny lists changed "+ + "(marker %s has %d entries, hash %s; this command expects %d entries, hash %s) — "+ + "re-run `zero sandbox setup` from an elevated (Administrator) terminal", + path, actual.ACLPlanEntries, shortWindowsACLPlanHash(actual.ACLPlanHash), + expected.ACLPlanEntries, shortWindowsACLPlanHash(expected.ACLPlanHash)) } // 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. @@ -661,3 +671,17 @@ func ensureWindowsSandboxRuntimeCandidates(workspaceRoots []string) error { } return nil } + +// shortWindowsACLPlanHash trims a plan hash for a human-facing error. Twelve hex +// characters is plenty to tell two plans apart by eye, and the full 64 buries the +// rest of the message. +func shortWindowsACLPlanHash(hash string) string { + hash = strings.TrimSpace(hash) + if hash == "" { + return "(none)" + } + if len(hash) > 12 { + return hash[:12] + } + return hash +} From 1ef250f2a0411cf86190d680c123582b8fdb569d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 21:51:55 +0530 Subject: [PATCH 60/96] fix(sandbox): derive the runtime roots where TEMP is the operator's Every sandboxed command failed marker validation with two plans of the same size and different hashes: marker ...\windows-setup.json has 12 entries, hash 76fda2032e66; this command expects 12 entries, hash 4be1dbf8b642 The temp-derived runtime candidate reads os.TempDir(), and the sandbox points TMPDIR, TMP and TEMP at its own runtime temp for everything it launches. The command runner inherits that env, so when it derived the candidate set it produced a root under the runtime tree while setup, whose TEMP is untouched, produced one under the real temp. Same count, different path, and no command could run. A fingerprint both halves compare cannot be a function of the caller's environment. Derive it only where TEMP is still the operator's: the setup args builder, the Windows command plan, and doctor. The runner now takes the profile it is handed, and commandConfig no longer re-derives on behalf of whoever happens to call it. The regression settles the profile first and redirects TEMP afterwards, in that order, so it reproduces the mismatch against the old behaviour. --- internal/doctor/hardening.go | 11 ++-- internal/sandbox/windows_runner.go | 28 ++++++--- internal/sandbox/windows_setup.go | 43 ++++++++++--- .../windows_setup_runtime_root_test.go | 40 ++++++++++++- internal/sandbox/windows_setup_test.go | 60 +++++++++++-------- 5 files changed, 136 insertions(+), 46 deletions(-) diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index 4eda050f9..f6c3165df 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -100,10 +100,13 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo } profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope) setupConfig := sandbox.WindowsSandboxSetupConfig{ - SandboxHome: sandboxHome, - CommandCWD: workspaceRoot, - WorkspaceRoots: []string{workspaceRoot}, - PermissionProfile: profile, + SandboxHome: sandboxHome, + CommandCWD: workspaceRoot, + WorkspaceRoots: []string{workspaceRoot}, + // Same augmentation the setup args and the command plan apply, so doctor + // fingerprints what a real command fingerprints. Safe here: doctor runs in + // the operator's shell, not behind the sandbox's TEMP redirection. + PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}), // 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. diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 3b7092220..dc0d4909a 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -342,13 +342,27 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli level = WindowsSandboxLevelUnelevated } args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{ - SandboxHome: sandboxHome, - CommandCWD: spec.Dir, - WorkspaceRoots: []string{execRequest.WorkspaceRoot}, - PermissionProfile: execRequest.PermissionProfile, - Env: childEnv, - SandboxLevel: level, - Command: append([]string{spec.Name}, spec.Args...), + SandboxHome: sandboxHome, + CommandCWD: spec.Dir, + WorkspaceRoots: []string{execRequest.WorkspaceRoot}, + // Augmented HERE, in the parent, because this is the last process whose + // TEMP is the operator's. + // + // The runner cannot derive the runtime candidates itself: the child env it + // runs with has TEMP and TMP pointed at the sandbox runtime temp, so + // os.TempDir() inside it returns the redirected value and the temp-derived + // candidate comes out rooted under the runtime tree instead of under the + // real temp. Setup, whose TEMP is untouched, derived the other spelling — + // same number of roots, different paths — and every command died on + // "permission roots or deny lists changed" with two 12-entry plans that + // disagreed. + PermissionProfile: windowsSandboxProfileWithRuntime( + execRequest.PermissionProfile, + []string{execRequest.WorkspaceRoot}, + ), + Env: childEnv, + SandboxLevel: level, + Command: append([]string{spec.Name}, spec.Args...), }) if err != nil { return CommandPlan{}, err diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 80c2d03da..d583b9488 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -204,6 +204,10 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str if len(workspaceRoots) == 0 { workspaceRoots = []string{commandCWD} } + // Augmented here, in the caller's shell, before the args cross into the + // elevated helper. Same reason the opt-in and caller SID are: the value has to + // be resolved where the environment is the operator's. + options.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(options.PermissionProfile, workspaceRoots) profileJSON, err := json.Marshal(options.PermissionProfile) if err != nil { return nil, fmt.Errorf("marshal windows sandbox setup permission profile: %w", err) @@ -329,10 +333,16 @@ func RunWindowsSandboxSetup(args []string, stderr io.Writer) int { // 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: windowsSandboxProfileWithRuntime(config.PermissionProfile, config.WorkspaceRoots), + SandboxHome: config.SandboxHome, + CommandCWD: config.CommandCWD, + WorkspaceRoots: cloneStrings(config.WorkspaceRoots), + // Taken as given. This computes the hash BOTH halves compare, and it runs + // in whichever process is asking — including the command runner, whose TEMP + // points at the sandbox runtime temp. Deriving the candidates here made the + // answer depend on the caller's environment, which is the one thing a + // shared fingerprint cannot afford. WindowsSandboxProfileWithRuntimeRoots + // is applied by the callers whose TEMP is still the operator's. + PermissionProfile: config.PermissionProfile, Env: map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(config.PrincipalOptIn)}, SandboxLevel: WindowsSandboxLevelRestrictedToken, // Carried through for the same reason as the opt-in above: every principal @@ -347,10 +357,15 @@ func (config WindowsSandboxSetupConfig) commandConfig() WindowsSandboxCommandCon // 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: windowsSandboxProfileWithRuntime(config.PermissionProfile, config.WorkspaceRoots), + SandboxHome: config.SandboxHome, + CommandCWD: config.CommandCWD, + WorkspaceRoots: cloneStrings(config.WorkspaceRoots), + // NOT augmented here. This runs inside the command runner, whose TEMP and + // TMP are the sandbox runtime's, so deriving the temp-based candidate here + // yields a path no other process agrees on. The parent already folded the + // candidates into the profile it serialized, back when TEMP was still the + // operator's, so take the profile as given. + PermissionProfile: config.PermissionProfile, PrincipalOptIn: windowsSandboxIdentityEnabled(config.Env), CallerSID: config.CallerSID, } @@ -685,3 +700,15 @@ func shortWindowsACLPlanHash(hash string) string { } return hash } + +// WindowsSandboxProfileWithRuntimeRoots folds the sandbox runtime roots into a +// permission profile, for callers that build a setup config outside this package. +// +// Call it ONLY from a process whose TEMP and TMP are the operator's. The +// temp-derived candidate reads os.TempDir(), and the sandbox points those +// variables at its own runtime temp for anything it launches, so a process on the +// far side of that redirection derives a path no other process agrees on. The +// command runner is exactly such a process: it takes the profile it is handed. +func WindowsSandboxProfileWithRuntimeRoots(profile PermissionProfile, workspaceRoots []string) PermissionProfile { + return windowsSandboxProfileWithRuntime(profile, workspaceRoots) +} diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 144bf9492..9a7cd22d1 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -44,7 +44,10 @@ func runtimeRootTestConfig(t *testing.T) WindowsSandboxCommandConfig { // every machine that falls back. func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { config := runtimeRootTestConfig(t) + // The setup half, as BuildWindowsSandboxSetupArgs prepares it in the + // operator's shell before the elevated helper ever runs. setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots) if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) } @@ -55,9 +58,12 @@ func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { } for _, candidate := range candidates { augmented := config - augmented.PermissionProfile = permissionProfileWithRuntime( - config.PermissionProfile, - SandboxRuntime{Root: candidate}, + // The command half, in the same order the real path builds it: the engine + // appends the SELECTED root, then the Windows plan folds in the candidate + // set before serializing the profile to the runner. + augmented.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: candidate}), + config.WorkspaceRoots, ) err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(augmented)) if err != nil { @@ -65,6 +71,32 @@ func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { } } + // THE RUNNER'S TEMP IS NOT THE OPERATOR'S. + // + // sandboxRuntimeEnvironment points TMPDIR/TMP/TEMP at the sandbox runtime + // temp for everything the sandbox launches, and the command runner inherits + // that env. While the runner derived the candidate set itself, os.TempDir() + // there returned the redirected value, so it produced a temp-derived root + // under the runtime tree while setup produced one under the real temp: two + // plans with the SAME entry count and different hashes, and every sandboxed + // command refused to run with "permission roots or deny lists changed". + // + // Validation must not move when that variable does. + // Augmented FIRST, standing in for the parent, whose TEMP is still real. + runner := config + runner.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: candidates[0]}), + config.WorkspaceRoots, + ) + // Only THEN does the environment become the runner's. Anything downstream of + // this line that re-derives a runtime root gets the redirected answer, which + // is precisely the defect: validation has to be settled before here. + t.Setenv("TEMP", t.TempDir()) + t.Setenv("TMP", os.Getenv("TEMP")) + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(runner)); err != nil { + t.Fatalf("ValidateWindowsSandboxSetupMarker with a redirected TEMP: %v", err) + } + // The guard has to still bite, or the test above passes for the wrong reason // — a validator that accepts everything would satisfy it too. changed := config @@ -95,6 +127,7 @@ func TestWindowsSandboxRuntimeRootsAreInTheCapabilityPlan(t *testing.T) { } setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots) plan, err := BuildWindowsACLPlan(setup.commandConfig()) if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) @@ -143,6 +176,7 @@ func TestWindowsSandboxSetupProvisionsEveryGrantedWriteRoot(t *testing.T) { } setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots) plan, err := BuildWindowsACLPlan(setup.commandConfig()) if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 4ec374d1f..b9703242a 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -185,10 +185,12 @@ func TestWindowsSandboxSetupPrincipalOptInSurvivesElevatedEnvironment(t *testing t.Setenv(windowsSandboxIdentityEnv, testCase.ambientEnv) optIn := testCase.optIn args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ - SandboxHome: t.TempDir(), - CommandCWD: `C:\workspace`, - WorkspaceRoots: []string{`C:\workspace`}, - PermissionProfile: profile, + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + // The parent folds in the runtime roots before the runner sees the profile, + // so a command half that skips it no longer matches the setup half. + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), PrincipalOptIn: &optIn, }) if err != nil { @@ -253,10 +255,12 @@ func TestWindowsSandboxSetupMarkerRejectsPrincipalOptInMismatch(t *testing.T) { } command := func(home string, env map[string]string) WindowsSandboxCommandConfig { return WindowsSandboxCommandConfig{ - SandboxHome: home, - CommandCWD: `C:\workspace`, - WorkspaceRoots: []string{`C:\workspace`}, - PermissionProfile: profile, + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + // The parent folds in the runtime roots before the runner sees the profile, + // so a command half that skips it no longer matches the setup half. + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), Env: env, SandboxLevel: WindowsSandboxLevelRestrictedToken, Command: []string{"cmd.exe", "/c", "echo"}, @@ -285,10 +289,12 @@ func TestWindowsSandboxSetupMarkerRejectsPrincipalOptInMismatch(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { home := t.TempDir() setupConfig := WindowsSandboxSetupConfig{ - SandboxHome: home, - CommandCWD: `C:\workspace`, - WorkspaceRoots: []string{`C:\workspace`}, - PermissionProfile: profile, + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + // The parent folds in the runtime roots before the runner sees the profile, + // so a command half that skips it no longer matches the setup half. + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), PrincipalOptIn: testCase.setupOptIn, } marker, err := WriteWindowsSandboxSetupMarker(setupConfig) @@ -369,10 +375,12 @@ func TestWindowsSandboxSetupArgsUnsetPrincipalOptInConsultsEnvironment(t *testin // 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, + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + // The parent folds in the runtime roots before the runner sees the profile, + // so a command half that skips it no longer matches the setup half. + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), SandboxLevel: WindowsSandboxLevelRestrictedToken, Command: []string{"cmd.exe", "/c", "echo"}, } @@ -383,10 +391,12 @@ func TestWindowsSandboxSetupArgsUnsetPrincipalOptInConsultsEnvironment(t *testin 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, + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + // The parent folds in the runtime roots before the runner sees the profile, + // so a command half that skips it no longer matches the setup half. + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), PrincipalOptIn: optIn, }) if err != nil { @@ -482,10 +492,12 @@ func TestWindowsSandboxSetupArgsCarryTheCallerIdentity(t *testing.T) { } const callerSID = "S-1-5-21-1111111111-2222222222-3333333333-1001" args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ - SandboxHome: `C:\home`, - CommandCWD: `C:\workspace`, - WorkspaceRoots: []string{`C:\workspace`}, - PermissionProfile: profile, + SandboxHome: `C:\home`, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + // The parent folds in the runtime roots before the runner sees the profile, + // so a command half that skips it no longer matches the setup half. + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), CallerSID: callerSID, }) if err != nil { From 80ac2cdfa3089fe2ec4b125095a42e658c9859db Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 22:08:30 +0530 Subject: [PATCH 61/96] fix(sandbox): give the principal's read roots a restricting SID A profile with DenyRead drops WRITE_RESTRICTED, so Windows runs the restricted-SID check over reads as well as writes. Read roots were granted only to the principal's account SID, and the write jail removes that SID from the restricting set on purpose, so reads passed the normal check and matched nothing on the restricted one. The result was not a narrower sandbox but an unusable one: no read root readable, including the executable the command was trying to start. Mint a read capability SID, grant it on every read root in the capability plan, and carry it in the strict token's restricting set, so the read allow-list and the restriction come from one value instead of two that can drift. Deny it on every DenyRead path too. The read roots begin at the filesystem root, so without that the carveouts stay readable through the new grant and the deny list stops meaning anything, which is the whole reason the strict token is chosen. The no-write-roots case keeps its ReadOnly deny as well: both SIDs the token can carry must be denied, not only the newest. Gated on DenyRead, which is what selects the strict token. Elsewhere reads never reach the restricted check and the grant would be ACEs on the filesystem root that buy nothing. Both halves decide from the profile alone, so they cannot disagree about whether the entries exist. --- internal/sandbox/windows_acl.go | 66 ++++++++- internal/sandbox/windows_acl_test.go | 13 +- .../sandbox/windows_command_runner_windows.go | 14 ++ .../sandbox/windows_read_capability_test.go | 135 ++++++++++++++++++ internal/sandbox/windows_runner.go | 40 +++++- 5 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 internal/sandbox/windows_read_capability_test.go diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 170001da5..03196cb09 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -114,6 +114,31 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er }) } } + // Read roots, granted to the read-capability SID. + // + // A profile carrying DenyRead runs the command on a strict token rather than a + // WRITE_RESTRICTED one, and the strict token applies the restricted-SID check + // to reads. Read roots reached that check granted only to the principal's own + // account SID, which the write jail keeps out of the restricting set on + // purpose, so every read failed it — including opening the executable. The + // principal plan still grants the account SID; this is the other half of the + // same grant, so the allow-list and the restriction now come from one place. + readSID, err := windowsReadAllowCapabilitySID(config) + if err != nil { + return WindowsACLPlan{}, err + } + if readSID != "" { + for _, path := range config.PermissionProfile.FileSystem.ReadRoots { + if path = strings.TrimSpace(path); path == "" { + continue + } + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLAllowRead, + Path: path, + Capability: readSID, + }) + } + } writeSIDs := windowsWriteCapabilitySIDs(writeCapabilities) for _, path := range config.PermissionProfile.FileSystem.DenyWrite { path = strings.TrimSpace(path) @@ -206,17 +231,32 @@ func windowsWriteCapabilitySIDs(capabilities []windowsWriteRootCapability) []str } func windowsReadDenyCapabilitySIDs(config WindowsSandboxCommandConfig, writeSIDs []string) ([]string, error) { - if len(writeSIDs) > 0 { - return writeSIDs, nil - } if len(config.PermissionProfile.FileSystem.DenyRead) == 0 { return nil, nil } - caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) + // The read-capability SID must be denied here as well as granted above. It + // holds read on the read roots, and those start at the filesystem root, so a + // carveout sitting inside one of them would stay readable through that grant + // and the deny list would quietly stop meaning anything. + // Every SID the token can carry has to be denied, not just the newest one. + // With no write roots the token's only capability is ReadOnly, so dropping it + // here would leave the deny naming a SID the command never holds. + denySIDs := append([]string{}, writeSIDs...) + if len(denySIDs) == 0 { + caps, err := LoadOrCreateWindowsCapabilitySIDs(config.SandboxHome) + if err != nil { + return nil, err + } + denySIDs = append(denySIDs, caps.ReadOnly) + } + readSID, err := windowsReadAllowCapabilitySID(config) if err != nil { return nil, err } - return []string{caps.ReadOnly}, nil + if readSID != "" { + denySIDs = append(denySIDs, readSID) + } + return denySIDs, nil } func planWindowsDenyReadPaths(paths []string) []string { @@ -259,3 +299,19 @@ func dedupeWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry { } return out } + +// windowsReadAllowCapabilitySID returns the read-capability SID when this profile +// will run on a strict token, and "" otherwise. +// +// Gated on DenyRead because that is exactly what selects the strict token in the +// runner: with no DenyRead the token stays WRITE_RESTRICTED, reads skip the +// restricted-SID check entirely, and granting read on roots as broad as the +// filesystem root would add ACEs that buy nothing. Derived purely from the +// profile, so the setup half and the command half reach the same answer without +// consulting anything ambient. +func windowsReadAllowCapabilitySID(config WindowsSandboxCommandConfig) (string, error) { + if len(config.PermissionProfile.FileSystem.DenyRead) == 0 { + return "", nil + } + return WindowsReadAllowSID(config.SandboxHome) +} diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index 1925bd8a9..9343db2a3 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -73,10 +73,19 @@ func TestBuildWindowsACLPlanUsesReadOnlySIDWithoutWriteRoots(t *testing.T) { if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } - if len(plan.Entries) != 1 { - t.Fatalf("ACL entries = %#v, want one deny-read entry", plan.Entries) + // Two deny entries, one per SID the token can carry here. ReadOnly is the + // only capability a profile with no write roots gets, and ReadAllow is the + // read grant a DenyRead profile's strict token carries: denying just one of + // them leaves the carveout readable through the other. + if len(plan.Entries) != 2 { + t.Fatalf("ACL entries = %#v, want a deny-read entry per capability SID", plan.Entries) } assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, caps.ReadOnly, true) + readSID, err := WindowsReadAllowSID(home) + if err != nil { + t.Fatalf("WindowsReadAllowSID: %v", err) + } + assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, readSID, true) } func TestBuildWindowsACLPlanRejectsUnrestrictedProfiles(t *testing.T) { diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index b82825671..e5f94a246 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -130,6 +130,20 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ return 1 } jailSIDs := windowsPrincipalJailSIDs(tokenSIDs, principalUser.User.Sid.String()) + // A strict token restricts READS too, and the account SID that carries the + // read roots was just removed above. Without the read capability the jail + // stops being a write jail and becomes a total one: no read root, no + // executable, nothing. BuildWindowsACLPlan grants this same SID on every + // read root and denies it on every DenyRead path, so the allow-list and the + // restriction stay one decision. + if !writeRestricted { + readSID, err := WindowsReadAllowSID(config.SandboxHome) + if err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": resolve sandbox read capability: "+err.Error()) + return 1 + } + jailSIDs = append(jailSIDs, readSID) + } jailedToken, err := restrictWindowsTokenForCapabilitySIDs(principalToken, jailSIDs, writeRestricted) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) diff --git a/internal/sandbox/windows_read_capability_test.go b/internal/sandbox/windows_read_capability_test.go new file mode 100644 index 000000000..a3e9f513d --- /dev/null +++ b/internal/sandbox/windows_read_capability_test.go @@ -0,0 +1,135 @@ +package sandbox + +import "testing" + +// readCapabilityProfile is the shape that selects the strict token: a restricted +// filesystem with DenyRead set. Without DenyRead the token stays +// WRITE_RESTRICTED and reads never reach the restricted-SID check at all, so +// every assertion below would hold vacuously. +func readCapabilityProfile(readRoot string) PermissionProfile { + return PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + ReadRoots: []string{`C:\`, readRoot}, + DenyRead: []string{`C:\Users\someone\.aws\credentials`}, + }, + Network: NetworkPolicy{Mode: NetworkAllow}, + } +} + +// A READ ROOT MUST CARRY A RESTRICTING SID. +// +// On the active-principal path the command runs on a strict token, because +// DenyRead drops WRITE_RESTRICTED and Windows then applies the restricted-SID +// check to reads as well as writes. Read roots were granted only to the +// principal's account SID, which windowsPrincipalJailSIDs removes from the +// restricting set on purpose, so the normal check passed and the restricted +// check matched nothing. The effect was not a narrower sandbox but an unusable +// one: no read root readable, up to and including the executable being launched. +// +// The custom read root is the case a workspace-only assertion would miss: an SDK +// or toolchain directory outside the workspace that the command must read. +func TestCapabilityPlanGrantsReadRootsToARestrictingSID(t *testing.T) { + home := t.TempDir() + custom := `C:\sdk\toolchain` + config := WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: readCapabilityProfile(custom), + } + plan, err := BuildWindowsACLPlan(config) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + readSID, err := WindowsReadAllowSID(home) + if err != nil { + t.Fatalf("WindowsReadAllowSID: %v", err) + } + if readSID == "" { + t.Fatal("no read capability SID, so this test proves nothing") + } + + for _, want := range []string{`C:\`, custom} { + found := false + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowRead && + windowsCapabilityPathKey(entry.Path) == windowsCapabilityPathKey(want) && + entry.Capability == readSID { + found = true + break + } + } + if !found { + t.Errorf("read root %s has no allow-read entry for the read capability SID, so a strict token cannot read it", want) + } + } +} + +// The read grant must not reopen the deny list. +// +// The read roots start at the filesystem root, so the capability that makes them +// readable also covers every DenyRead carveout underneath. If the deny entries +// name only the write capabilities, the carveout stays readable through the read +// capability and DenyRead silently stops meaning anything — which is the whole +// reason the strict token is selected in the first place. +func TestDenyReadCoversTheReadCapabilitySID(t *testing.T) { + home := t.TempDir() + config := WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: readCapabilityProfile(`C:\sdk\toolchain`), + } + plan, err := BuildWindowsACLPlan(config) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + readSID, err := WindowsReadAllowSID(home) + if err != nil { + t.Fatalf("WindowsReadAllowSID: %v", err) + } + + denied := false + sawDeny := false + for _, entry := range plan.Entries { + if entry.Action != WindowsACLDenyRead { + continue + } + sawDeny = true + if entry.Capability == readSID { + denied = true + } + } + if !sawDeny { + t.Fatal("plan has no deny-read entries at all, so this test proves nothing") + } + if !denied { + t.Error("no deny-read entry names the read capability SID, so every DenyRead path stays readable through the read grant") + } +} + +// Without DenyRead the token stays WRITE_RESTRICTED, reads skip the restricted +// check, and the read grant would be ACEs that buy nothing — on roots as broad +// as the filesystem root. Both halves of the protocol decide this from the +// profile alone, so setup and the command cannot disagree about whether the +// entries exist. +func TestReadCapabilityIsAbsentWithoutDenyRead(t *testing.T) { + profile := readCapabilityProfile(`C:\sdk\toolchain`) + profile.FileSystem.DenyRead = nil + plan, err := BuildWindowsACLPlan(WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + }) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowRead { + t.Fatalf("allow-read entry %s present without DenyRead, where reads are never restricted", entry.Path) + } + } +} diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index dc0d4909a..6b1ca42fe 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -462,6 +462,20 @@ type WindowsCapabilitySIDs struct { // commands include it and are blocked; online (approved) commands omit it and // reach the network — both still write-jailed by the capability SIDs. Offline string `json:"offline,omitempty"` + // ReadAllow carries the principal's READ grants, and exists because a strict + // (non-WRITE_RESTRICTED) token runs the restricted-SID check over reads too. + // + // A profile with DenyRead drops WRITE_RESTRICTED, so every read has to satisfy + // the restricting set as well as the normal token. Read roots were granted + // only to the principal's own account SID, which the write jail deliberately + // keeps OUT of that set, so the normal check passed and the restricted check + // matched nothing: the whole disk became unreadable, down to the executable + // the command was trying to start. + // + // Synthetic and held by nobody. An ACE naming it grants access only to a token + // the sandbox itself composed, and restricting SIDs can only narrow a token, + // never widen it, so publishing this grant on the read roots hands out nothing. + ReadAllow string `json:"readAllow,omitempty"` } func ResolveWindowsSandboxHome(env map[string]string) (string, error) { @@ -498,8 +512,13 @@ func LoadOrCreateWindowsCapabilitySIDs(sandboxHome string) (WindowsCapabilitySID // Back-compat: an older (schema 1) file has no offline-marker SID. // Mint one and persist so the setup helper and the runner agree on a // single value for the WFP filter scope across processes. - if caps.Offline == "" { - caps.Offline = randomWindowsCapabilitySID() + if caps.Offline == "" || caps.ReadAllow == "" { + if caps.Offline == "" { + caps.Offline = randomWindowsCapabilitySID() + } + if caps.ReadAllow == "" { + caps.ReadAllow = randomWindowsCapabilitySID() + } caps.SchemaVersion = windowsCapabilitySIDSchemaVersion if err := saveWindowsCapabilitySIDs(path, caps); err != nil { return WindowsCapabilitySIDs{}, err @@ -514,6 +533,7 @@ func LoadOrCreateWindowsCapabilitySIDs(sandboxHome string) (WindowsCapabilitySID SchemaVersion: windowsCapabilitySIDSchemaVersion, ReadOnly: randomWindowsCapabilitySID(), Offline: randomWindowsCapabilitySID(), + ReadAllow: randomWindowsCapabilitySID(), WorkspaceByRoot: map[string]string{}, WritableRootByPath: map[string]string{}, } @@ -706,3 +726,19 @@ func randomWindowsCapabilitySID() string { } return fmt.Sprintf("S-1-5-21-%d-%d-%d-%d", words[0], words[1], words[2], words[3]) } + +// WindowsReadAllowSID returns the sandbox home's read-capability SID, minting and +// persisting it on first use. Both halves of the setup protocol ask for it: the +// capability ACL plan grants it on every read root, and the principal's strict +// token carries it as a restricting SID, so the read allow-list and the +// restriction come from one value instead of two that can drift. +func WindowsReadAllowSID(sandboxHome string) (string, error) { + caps, err := LoadOrCreateWindowsCapabilitySIDs(sandboxHome) + if err != nil { + return "", err + } + if strings.TrimSpace(caps.ReadAllow) == "" { + return "", errors.New("windows sandbox read-capability SID is missing") + } + return caps.ReadAllow, nil +} From ed84ed3eacdf508437f5791129b91b45d6ac6e53 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 22:19:39 +0530 Subject: [PATCH 62/96] test(sandbox): fix the workspace-root literal in the setup-args tests A bulk edit dropped the separator, leaving the drive-relative `C:workspace` where `C:\workspace` was meant. Windows resolves the first well enough that the two halves still agreed; on Linux a backslash is an ordinary character, so the command half derived no runtime candidates at all and the marker carried two the command never saw. The setup half no longer pre-augments either: BuildWindowsSandboxSetupArgs folds the runtime roots in itself, and passing them in hid that from the one test that covers it. --- internal/sandbox/windows_setup_test.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index b9703242a..3294ec139 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -190,7 +190,7 @@ func TestWindowsSandboxSetupPrincipalOptInSurvivesElevatedEnvironment(t *testing WorkspaceRoots: []string{`C:\workspace`}, // The parent folds in the runtime roots before the runner sees the profile, // so a command half that skips it no longer matches the setup half. - PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}), PrincipalOptIn: &optIn, }) if err != nil { @@ -260,7 +260,7 @@ func TestWindowsSandboxSetupMarkerRejectsPrincipalOptInMismatch(t *testing.T) { WorkspaceRoots: []string{`C:\workspace`}, // The parent folds in the runtime roots before the runner sees the profile, // so a command half that skips it no longer matches the setup half. - PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}), Env: env, SandboxLevel: WindowsSandboxLevelRestrictedToken, Command: []string{"cmd.exe", "/c", "echo"}, @@ -294,7 +294,7 @@ func TestWindowsSandboxSetupMarkerRejectsPrincipalOptInMismatch(t *testing.T) { WorkspaceRoots: []string{`C:\workspace`}, // The parent folds in the runtime roots before the runner sees the profile, // so a command half that skips it no longer matches the setup half. - PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}), PrincipalOptIn: testCase.setupOptIn, } marker, err := WriteWindowsSandboxSetupMarker(setupConfig) @@ -380,7 +380,7 @@ func TestWindowsSandboxSetupArgsUnsetPrincipalOptInConsultsEnvironment(t *testin WorkspaceRoots: []string{`C:\workspace`}, // The parent folds in the runtime roots before the runner sees the profile, // so a command half that skips it no longer matches the setup half. - PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}), SandboxLevel: WindowsSandboxLevelRestrictedToken, Command: []string{"cmd.exe", "/c", "echo"}, } @@ -394,9 +394,10 @@ func TestWindowsSandboxSetupArgsUnsetPrincipalOptInConsultsEnvironment(t *testin SandboxHome: home, CommandCWD: `C:\workspace`, WorkspaceRoots: []string{`C:\workspace`}, - // The parent folds in the runtime roots before the runner sees the profile, - // so a command half that skips it no longer matches the setup half. - PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), + // Deliberately NOT pre-augmented. BuildWindowsSandboxSetupArgs folds the + // runtime roots in itself, and passing them in here would hide it from + // this test if it ever stopped. + PermissionProfile: profile, PrincipalOptIn: optIn, }) if err != nil { @@ -497,7 +498,7 @@ func TestWindowsSandboxSetupArgsCarryTheCallerIdentity(t *testing.T) { WorkspaceRoots: []string{`C:\workspace`}, // The parent folds in the runtime roots before the runner sees the profile, // so a command half that skips it no longer matches the setup half. - PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:workspace`}), + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots(profile, []string{`C:\workspace`}), CallerSID: callerSID, }) if err != nil { From 7a062a611ebb57c965aeceb9daebed7f95c1e510 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Tue, 11 Aug 2026 23:52:47 +0530 Subject: [PATCH 63/96] fix(sandbox): protect the gitdir pointer in a linked worktree A linked worktree or submodule has .git as a FILE holding a `gitdir:` pointer, not a directory. The principal plan names .git/config and .git/hooks and materializes both, and the Windows materializer gets there by descending through .git as a directory. A regular file cannot have children, so opted-in elevated setup aborted and the sandbox could not be used in a worktree at all. Zero's own development worktrees are this shape, which is how it went unnoticed. Deny the pointer file itself there instead. That is the stronger protection rather than a fallback: a principal able to rewrite `gitdir:` repoints the repository at a control directory of its choosing, which subsumes editing config or planting a hook. The real control directory sits outside the write root, so nothing is inherited there and no carveout is needed. Decided by an Lstat rather than a lexical guess, since the layout is a property of the checkout. An absent .git keeps the directory-shaped carveouts so they are still created before git first runs. --- internal/sandbox/profile.go | 23 +++++++ internal/sandbox/profile_gitfile_test.go | 79 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 internal/sandbox/profile_gitfile_test.go diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 5beb042a3..8983cf538 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -134,6 +134,29 @@ type gitMetadataCarveout struct { // 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 { + gitPath := filepath.Join(root, ".git") + // A LINKED WORKTREE OR SUBMODULE HAS .git AS A FILE, NOT A DIRECTORY. + // + // It holds a `gitdir:` pointer to the real control directory, which lives + // outside this write root. Naming .git/config and .git/hooks there asks the + // Windows plan to materialize them by descending through .git as a directory, + // which cannot open a child beneath a regular file, so opted-in elevated setup + // aborts and the sandbox is unusable in any worktree. Zero's own development + // worktrees are exactly this shape. + // + // Deny the pointer file itself instead. It is the right protection rather than + // a lesser one: a principal that can rewrite `gitdir:` repoints the whole + // repository at a control directory it chooses, which subsumes editing config + // or dropping a hook. The real control directory is outside the write root, so + // the principal has no inherited access to it and needs no carveout there. + // + // Stat, not a lexical guess. Whether .git is a file is a property of the + // checkout, and the two layouts want different ACEs. A missing .git (git has + // not run yet) keeps the directory-shaped carveouts, which is what makes them + // materialize before git first runs. + if info, err := os.Lstat(gitPath); err == nil && !info.IsDir() { + return []gitMetadataCarveout{{Path: gitPath, IsFile: true}} + } return []gitMetadataCarveout{ {Path: filepath.Join(root, ".git", "hooks")}, {Path: filepath.Join(root, ".git", "config"), IsFile: true}, diff --git a/internal/sandbox/profile_gitfile_test.go b/internal/sandbox/profile_gitfile_test.go new file mode 100644 index 000000000..439df9063 --- /dev/null +++ b/internal/sandbox/profile_gitfile_test.go @@ -0,0 +1,79 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A LINKED WORKTREE HAS .git AS A FILE. +// +// The Windows principal plan materializes every carveout, and it does so by +// descending through .git as a directory. Against a `gitdir:` pointer file that +// walk cannot open a child, so opted-in elevated setup aborts and the sandbox +// cannot be used in a worktree at all. Zero's own development worktrees have +// exactly this layout, so this is the common case for anyone working on the +// sandbox itself. +func TestGitCarveoutsHandleAWorktreeGitfile(t *testing.T) { + root := t.TempDir() + gitFile := filepath.Join(root, ".git") + // The real shape git writes for a linked worktree. + if err := os.WriteFile(gitFile, []byte("gitdir: "+filepath.Join(root, "..", "main", ".git", "worktrees", "wt")+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + specs := gitMetadataWriteCarveoutSpecs(root) + if len(specs) != 1 { + t.Fatalf("carveout specs = %#v, want only the .git pointer file", specs) + } + if filepath.Clean(specs[0].Path) != filepath.Clean(gitFile) { + t.Fatalf("carveout path = %s, want the .git pointer file %s", specs[0].Path, gitFile) + } + if !specs[0].IsFile { + t.Fatal("the .git pointer carveout is not marked as a file, so materialization would create a directory where git needs a file") + } + // Nothing may name a path BENEATH the pointer file, which is what forces the + // directory descent that aborts setup. + for _, path := range gitMetadataWriteCarveouts(root) { + rest := strings.TrimPrefix(filepath.Clean(path), filepath.Clean(gitFile)) + if rest != "" { + t.Errorf("carveout %s descends through the .git pointer file, which cannot have children", path) + } + } +} + +// The ordinary repository layout must keep the config and hooks carveouts. They +// are the whole reason the set exists: without them a principal reinstates +// credential.helper or core.hooksPath through inherited workspace write access. +func TestGitCarveoutsKeepConfigAndHooksForARealGitDir(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + paths := gitMetadataWriteCarveouts(root) + for _, want := range []string{filepath.Join(root, ".git", "hooks"), filepath.Join(root, ".git", "config")} { + found := false + for _, got := range paths { + if filepath.Clean(got) == filepath.Clean(want) { + found = true + break + } + } + if !found { + t.Errorf("carveout %s missing for an ordinary .git directory: %#v", want, paths) + } + } +} + +// A workspace where git has never run keeps the directory-shaped carveouts. That +// is what lets the Windows plan create them before git first runs, so the deny +// ACEs are already in place rather than being applied to paths git will create +// later with inherited write access. +func TestGitCarveoutsKeepConfigAndHooksWhenGitIsAbsent(t *testing.T) { + root := t.TempDir() + paths := gitMetadataWriteCarveouts(root) + if len(paths) != 2 { + t.Fatalf("carveouts = %#v, want the config and hooks pair when .git is absent", paths) + } +} From 99f340cd0d5ca62c5eb77e57576200ee40cadef3 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 12 Aug 2026 11:48:39 +0530 Subject: [PATCH 64/96] fix(sandbox): write the principal secret through one pinned handle The elevated secret write resolved its path four separate times: MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then WriteFile by name. The sandbox home belongs to the invoking user, who is the party this sandbox contains, so each resolution was a place to swap a component. A symlink leaf lets an Administrator truncate a file of the caller's choosing and then rewrite its DACL; a junction alone is enough to plant the deterministic secret somewhere the caller controls. Create the leaf relative to a pinned no-follow parent handle, refuse it if it is a reparse point, and apply both the DACL and the bytes to that handle. The name is never resolved again after the create. The payload is now sealed before the file exists, so a failure there leaves nothing on disk rather than an empty file for someone to race. Cleanup on failure stays by name, which is safe in the direction that matters: at worst it misses and leaves a locked-down file, never deletes something it did not create. --- .../windows_identity_secret_handle_windows.go | 119 ++++++++++++++++++ .../windows_identity_secret_windows.go | 75 +++++------ 2 files changed, 157 insertions(+), 37 deletions(-) create mode 100644 internal/sandbox/windows_identity_secret_handle_windows.go diff --git a/internal/sandbox/windows_identity_secret_handle_windows.go b/internal/sandbox/windows_identity_secret_handle_windows.go new file mode 100644 index 000000000..1020db227 --- /dev/null +++ b/internal/sandbox/windows_identity_secret_handle_windows.go @@ -0,0 +1,119 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +// createWindowsSecretFileNoFollow creates (or replaces) the secret file as a +// CHILD of a pinned parent handle and returns a handle to it. +// +// The pathname version this replaces resolved the path four separate times in an +// elevated process: MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then +// WriteFile by name. The sandbox home belongs to the invoking user, who is the +// party this sandbox exists to contain, so each of those resolutions was a place +// to swap a component. Worst case an attacker-chosen file gets truncated and then +// has its DACL rewritten by an Administrator; the milder junction-only case still +// plants the deterministic secret somewhere the caller then owns. +// +// One handle, one resolution. The parent is opened no-follow, the leaf is created +// relative to that handle so its name can never be resolved again, and both the +// DACL and the bytes are applied to the handle rather than to a path. +// +// FILE_OVERWRITE_IF, not FILE_CREATE: an existing secret must be replaced, since +// a stale password makes LogonUser fail in a way that reads as a sandbox bug. +// FILE_OPEN_REPARSE_POINT means a leaf that IS a reparse point comes back as +// itself rather than being followed, and the attribute check below then refuses +// it instead of writing through it. +func createWindowsSecretFileNoFollow(path string) (windows.Handle, error) { + parentPath := filepath.Dir(path) + name := filepath.Base(path) + if err := validateWindowsACLComponent(name); err != nil { + return 0, fmt.Errorf("secret file name: %w", err) + } + parent, err := openWindowsACLDirectoryNoFollow(parentPath) + if err != nil { + return 0, fmt.Errorf("open secret directory %s: %w", parentPath, err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, fmt.Errorf("encode secret file name %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, + // WRITE_DAC to lock it down and FILE_WRITE_DATA to fill it, both through + // this one handle. READ_CONTROL because SetSecurityInfo reads the existing + // descriptor before replacing it, and FILE_READ_ATTRIBUTES because the + // reparse-point check below asks the handle what it landed on. + windows.FILE_WRITE_DATA|windows.FILE_READ_ATTRIBUTES|windows.WRITE_DAC|windows.READ_CONTROL|windows.SYNCHRONIZE, + &attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ, + windows.FILE_OVERWRITE_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT, + 0, + 0, + ); err != nil { + return 0, fmt.Errorf("create secret file %s: %w", path, err) + } + if err := rejectWindowsACLReparseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +// lockWindowsSecretHandleToOwner applies the owner-and-SYSTEM-only DACL to an +// open handle instead of to a pathname, so the object being locked is provably +// the object that was just created rather than whatever the name resolves to now. +func lockWindowsSecretHandleToOwner(handle windows.Handle, owner *windows.SID) error { + acl, err := windowsSecretOwnerACL(owner) + if err != nil { + return err + } + if err := windows.SetSecurityInfo( + handle, + 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 +} + +// writeWindowsSecretHandle writes the whole payload through the handle. +func writeWindowsSecretHandle(handle windows.Handle, payload []byte) error { + for written := 0; written < len(payload); { + var n uint32 + if err := windows.WriteFile(handle, payload[written:], &n, nil); err != nil { + return fmt.Errorf("write secret: %w", err) + } + if n == 0 { + return fmt.Errorf("write secret: wrote no bytes with %d remaining", len(payload)-written) + } + written += int(n) + } + return nil +} diff --git a/internal/sandbox/windows_identity_secret_windows.go b/internal/sandbox/windows_identity_secret_windows.go index f331241ea..30d5d8729 100644 --- a/internal/sandbox/windows_identity_secret_windows.go +++ b/internal/sandbox/windows_identity_secret_windows.go @@ -77,17 +77,22 @@ func currentTokenUserSID() (*windows.SID, error) { 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 { +// windowsSecretOwnerACL builds the explicit, inheritance-protected DACL 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. +// +// Returns the ACL rather than applying it, because the caller applies it to an +// open HANDLE. Applying by pathname meant the object being locked was whatever +// the name resolved to at that instant, which is not necessarily the object that +// was just created. +func windowsSecretOwnerACL(owner *windows.SID) (*windows.ACL, error) { if owner == nil { - return errors.New("windows sandbox secret: nil owner SID") + return nil, 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) + return nil, fmt.Errorf("resolve SYSTEM SID: %w", err) } entries := []windows.EXPLICIT_ACCESS{ { @@ -113,20 +118,9 @@ func lockWindowsSecretToOwner(path string, owner *windows.SID) error { } 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, fmt.Errorf("build secret ACL: %w", err) } - return nil + return acl, nil } // writeWindowsSandboxSecret stores a principal's password readable only by the @@ -144,31 +138,38 @@ func writeWindowsSandboxSecret(path string, password string) error { 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) + // Sealed BEFORE the file exists, so a failure here leaves nothing on disk at + // all rather than an empty file for someone else to win a race on. + // + // Encrypted 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 { - 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 } - // 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)) + // ONE resolution of the path, not four. The leaf is created relative to a + // pinned no-follow parent handle, and the DACL and the bytes are both applied + // to that handle. Resolving the name again between those steps is what let a + // junction or symlink swap point an elevated write at a file of the caller's + // choosing. + handle, err := createWindowsSecretFileNoFollow(path) if err != nil { + return err + } + defer func() { _ = windows.CloseHandle(handle) }() + if err := lockWindowsSecretHandleToOwner(handle, owner); err != nil { + // Do not leave an unprotected file behind. Removal is by name, which is + // safe in a way the write was not: worst case a swapped name means the + // cleanup misses and leaves a locked-down file, never that it deletes + // something it did not create. _ = os.Remove(path) return err } - if err := os.WriteFile(path, sealed, 0o600); err != nil { + if err := writeWindowsSecretHandle(handle, sealed); err != nil { _ = os.Remove(path) - return fmt.Errorf("write secret: %w", err) + return err } return nil } From a70f36f9cb06fb6ca2273981cfb600f7df46fbbf Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 12 Aug 2026 12:06:14 +0530 Subject: [PATCH 65/96] fix(sandbox): report the privileges a principal launch actually needs CreateProcessAsUser exempts only a restricted version of the caller's own primary token from SE_ASSIGNPRIMARYTOKEN_NAME, which is precisely why the ordinary restricted-token path works while holding nothing special. A principal token comes from LogonUser against a separate local account, so the exemption does not apply and both that privilege and SE_INCREASE_QUOTA_NAME are required. Nothing enabled or checked either, and a token measured on an ordinary unelevated process holds neither, so the failure arrived as a bare "Access is denied" from inside process creation, before the command's executable was ever opened, and read as the command being rejected. Enable them where they are held, since present-but-disabled still fails the access check and that is where an elevated administrator lands, and refuse with the specific names and a way out where they are not. Detected by ENUMERATING the token. AdjustTokenPrivileges reports an unheld privilege by returning success with ERROR_NOT_ALL_ASSIGNED, which this binding does not surface: it returns nil for SeTcbPrivilege on an ordinary process. A check built on its error passed everywhere, which is worse than no check, since it would call the sandbox ready in exactly the case it cannot run. This does not make the principal launchable. It makes the reason legible while the launch mechanism itself is settled. --- .../sandbox/windows_command_runner_windows.go | 13 ++ .../windows_principal_launch_windows.go | 132 ++++++++++++++++++ .../windows_principal_launch_windows_test.go | 52 +++++++ 3 files changed, 197 insertions(+) create mode 100644 internal/sandbox/windows_principal_launch_windows.go create mode 100644 internal/sandbox/windows_principal_launch_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index e5f94a246..91abf0f75 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -150,6 +150,19 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ return 1 } defer jailedToken.Close() + // CreateProcessAsUser below is about to be handed a token for a DIFFERENT + // account, which forfeits the own-token exemption the ordinary restricted + // path relies on. Enable what that needs, and refuse with a specific reason + // when this process cannot, rather than letting an unelevated run surface a + // bare "Access is denied" that reads as the command being rejected. + // + // Refusing rather than falling back: an operator who set the opt-in to + // confine reads must not be handed the same-user restricted token while + // believing otherwise. + if err := enableWindowsPrincipalLaunchPrivileges(); err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } exitCode, err := runWindowsCommandAsUser(jailedToken, config) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) diff --git a/internal/sandbox/windows_principal_launch_windows.go b/internal/sandbox/windows_principal_launch_windows.go new file mode 100644 index 000000000..09c13b870 --- /dev/null +++ b/internal/sandbox/windows_principal_launch_windows.go @@ -0,0 +1,132 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Privileges CreateProcessAsUser requires when the token belongs to a DIFFERENT +// account than the caller. +// +// The documented exemption from SE_ASSIGNPRIMARYTOKEN_NAME is for a restricted +// version of the caller's OWN primary token, which is exactly what the ordinary +// restricted-token path passes and exactly why that path works without holding +// anything special. A principal token comes from LogonUser against a separate +// local account, so the exemption does not apply and both privileges are back in +// play. +const ( + seAssignPrimaryTokenPrivilege = "SeAssignPrimaryTokenPrivilege" + seIncreaseQuotaPrivilege = "SeIncreaseQuotaPrivilege" +) + +// enableWindowsPrincipalLaunchPrivileges enables what launching another account +// needs, and reports precisely what is missing when it cannot. +// +// Enabling matters on its own: a privilege present in a token but DISABLED still +// fails the access check, and an elevated administrator typically holds +// SeIncreaseQuotaPrivilege in exactly that state. So this is not only a +// diagnostic, it is the step that makes the call work wherever the rights are +// actually held. +// +// Where they are not held, which is the ordinary unelevated case, this turns a +// bare "Access is denied" from deep inside process creation into a statement of +// what the sandbox cannot do and why. That failure is otherwise indistinguishable +// from the command itself being rejected, and it happens before the command's +// executable is ever opened. +func enableWindowsPrincipalLaunchPrivileges() error { + var token windows.Token + if err := windows.OpenProcessToken( + windows.CurrentProcess(), + windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY, + &token, + ); err != nil { + return fmt.Errorf("open process token for principal launch: %w", err) + } + defer token.Close() + + // ENUMERATE the token, do not infer from AdjustTokenPrivileges. + // + // AdjustTokenPrivileges reports a privilege the token does not hold by + // returning success and setting ERROR_NOT_ALL_ASSIGNED, and this binding does + // not surface that: it returns nil for SeTcbPrivilege on an ordinary user + // process, which holds nothing of the sort. A check built on its error would + // pass on every machine, which is worse than no check, because it would report + // the sandbox ready in exactly the case it cannot run. + held, err := windowsTokenPrivilegeLUIDs(token) + if err != nil { + return err + } + var missing []string + for _, name := range []string{seAssignPrimaryTokenPrivilege, seIncreaseQuotaPrivilege} { + luid, err := windowsPrivilegeLUID(name) + if err != nil { + return err + } + if _, ok := held[luid]; !ok { + missing = append(missing, name) + continue + } + // Held but possibly disabled, which still fails the access check. This is + // the case an elevated administrator lands in. + if err := enableWindowsTokenPrivilege(token, name); err != nil { + return fmt.Errorf("enable %s: %w", name, err) + } + } + if len(missing) == 0 { + return nil + } + return fmt.Errorf( + "launching a command as the sandbox principal needs %s, which this process does not hold. "+ + "CreateProcessAsUser exempts only a restricted version of the caller's own token, and a principal "+ + "is a separate account, so an ordinary unelevated process cannot start one. "+ + "Unset %s to use the restricted-token sandbox, which needs no such privilege", + strings.Join(missing, " and "), windowsSandboxIdentityEnv) +} + +// windowsPrivilegeLUID resolves a privilege name to its locally unique id, which +// is how a token actually names the privileges it holds. +func windowsPrivilegeLUID(name string) (windows.LUID, error) { + namePtr, err := windows.UTF16PtrFromString(name) + if err != nil { + return windows.LUID{}, fmt.Errorf("encode privilege name %s: %w", name, err) + } + var luid windows.LUID + if err := windows.LookupPrivilegeValue(nil, namePtr, &luid); err != nil { + return windows.LUID{}, fmt.Errorf("look up privilege %s: %w", name, err) + } + return luid, nil +} + +// windowsTokenPrivilegeLUIDs returns every privilege the token holds, enabled or +// not. Presence and enabled-ness are separate questions: a privilege absent from +// this set can never be enabled, while one present but disabled only needs +// AdjustTokenPrivileges. +func windowsTokenPrivilegeLUIDs(token windows.Token) (map[windows.LUID]struct{}, error) { + var size uint32 + // First call sizes the buffer and is expected to fail with + // ERROR_INSUFFICIENT_BUFFER, so its error is deliberately not checked. + _ = windows.GetTokenInformation(token, windows.TokenPrivileges, nil, 0, &size) + if size == 0 { + return nil, errors.New("query token privileges: zero-length result") + } + buffer := make([]byte, size) + if err := windows.GetTokenInformation(token, windows.TokenPrivileges, &buffer[0], size, &size); err != nil { + return nil, fmt.Errorf("query token privileges: %w", err) + } + privileges := (*windows.Tokenprivileges)(unsafe.Pointer(&buffer[0])) + if privileges.PrivilegeCount == 0 { + return map[windows.LUID]struct{}{}, nil + } + entries := unsafe.Slice(&privileges.Privileges[0], privileges.PrivilegeCount) + held := make(map[windows.LUID]struct{}, len(entries)) + for _, entry := range entries { + held[entry.Luid] = struct{}{} + } + return held, nil +} diff --git a/internal/sandbox/windows_principal_launch_windows_test.go b/internal/sandbox/windows_principal_launch_windows_test.go new file mode 100644 index 000000000..3f25e3499 --- /dev/null +++ b/internal/sandbox/windows_principal_launch_windows_test.go @@ -0,0 +1,52 @@ +//go:build windows + +package sandbox + +import ( + "strings" + "testing" +) + +// The launch preflight must name what is missing and what to do about it. +// +// Without it the failure surfaces as a bare "Access is denied" from inside +// CreateProcessAsUser, which is indistinguishable from the sandboxed command +// itself being rejected. It happens before the command's executable is opened, +// so there is nothing else in the output to tell the two apart. +// +// The assertion is on the shape of the answer rather than on a fixed verdict, +// because the verdict legitimately differs by machine: an unelevated developer +// or CI account holds neither privilege, while a service context may hold both. +// Pinning "always fails" would break on the machines where this is supposed to +// work, and pinning "always succeeds" would break everywhere else. +func TestPrincipalLaunchPreflightExplainsAMissingPrivilege(t *testing.T) { + err := enableWindowsPrincipalLaunchPrivileges() + if err == nil { + t.Log("this process holds the principal launch privileges; nothing to explain") + return + } + for _, want := range []string{ + seAssignPrimaryTokenPrivilege, + windowsSandboxIdentityEnv, + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("preflight error does not mention %s, so an operator cannot act on it: %v", want, err) + } + } + // The point of the message is the way out. Without it the operator is told + // only that something is denied. + if !strings.Contains(err.Error(), "restricted-token sandbox") { + t.Errorf("preflight error does not offer the fallback, so it reads as a dead end: %v", err) + } +} + +// Repeated calls must agree. This runs once per sandboxed command, and a +// preflight that answered differently on the second call would let a command +// through that the first call refused. +func TestPrincipalLaunchPreflightIsStable(t *testing.T) { + first := enableWindowsPrincipalLaunchPrivileges() + second := enableWindowsPrincipalLaunchPrivileges() + if (first == nil) != (second == nil) { + t.Fatalf("preflight verdict changed between calls: first=%v second=%v", first, second) + } +} From ffe71ed16252616f2c23632e200776db7d0c799f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 12 Aug 2026 12:36:56 +0530 Subject: [PATCH 66/96] fix(sandbox): stop the principal's environment naming the caller The child environment starts as the invoking user's, and the deliberate sandbox redirects only replace HOME, the temp variables and the per-tool cache dirs. Everything identifying the account survived, so a command running as the principal read USERPROFILE, APPDATA, LOCALAPPDATA, HOMEDRIVE, HOMEPATH, USERNAME and USERDOMAIN describing the CALLER, whose profile the principal deliberately cannot open. Native tools resolve per-user state through exactly those, so they fail during startup or quietly look somewhere they have no business reading. Point them into the sandbox runtime tree, which is already granted to the principal and already holds its caches, so the paths are writable by construction. Naming the real Windows profile would need LoadUserProfile to have run, and a variable pointing at a directory that does not exist yet is a worse answer than one pointing somewhere usable. Layered under the deliberate redirects rather than over them: sandboxRuntimeEnvironment stays the single owner of HOME, TMPDIR, TMP and TEMP, and a regression pins that so the two cannot drift into setting the same variables from two places. This is the environment half of the finding. Loading the principal's profile and known folders is left until the launch mechanism is settled, since LOGON_WITH_PROFILE would do it as a side effect. --- .../sandbox/windows_command_runner_windows.go | 10 +++ .../sandbox/windows_principal_env_windows.go | 67 ++++++++++++++ .../windows_principal_env_windows_test.go | 90 +++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 internal/sandbox/windows_principal_env_windows.go create mode 100644 internal/sandbox/windows_principal_env_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 91abf0f75..7a0fbeb07 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -163,6 +163,16 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 } + // Stop the child describing the CALLER. Everything identifying the account + // survived from the invoking user's environment, so a principal command + // resolved its per-user state through paths inside a profile it cannot + // open. Applied here, on the principal path only, because this is the first + // point that knows which account the command is about to run as. + config.Env = windowsPrincipalIdentityEnvironment( + config.Env, + windowsSandboxUserName(windowsSandboxPrincipalKey(config)), + config.PermissionProfile.Runtime, + ) exitCode, err := runWindowsCommandAsUser(jailedToken, config) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) diff --git a/internal/sandbox/windows_principal_env_windows.go b/internal/sandbox/windows_principal_env_windows.go new file mode 100644 index 000000000..9de375dd3 --- /dev/null +++ b/internal/sandbox/windows_principal_env_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" +) + +// windowsPrincipalIdentityEnvironment rewrites the variables that name WHO the +// command is running as. +// +// The child environment starts life as the invoking user's, and the deliberate +// sandbox redirects only replace HOME, the temp variables and the per-tool cache +// dirs. Everything identifying the account was left alone, so a command running +// as the principal still read USERPROFILE, APPDATA, LOCALAPPDATA, HOMEDRIVE, +// HOMEPATH and USERNAME describing the CALLER, whose profile the principal +// deliberately cannot open. Native tools resolve config and per-user state +// through exactly those, so they either fail during startup or, worse, quietly +// look somewhere they have no business reading. +// +// The values point into the sandbox runtime tree rather than at the principal's +// real Windows profile. That tree is already granted to the principal and +// already holds its caches, so the paths are writable by construction. Naming +// the real profile would need LoadUserProfile to have run, and a variable +// pointing at a directory that does not exist yet is a worse answer than one +// pointing somewhere usable. +// +// Layered UNDER the deliberate redirects: HOME, TMPDIR, TMP and TEMP are not +// touched here, so sandboxRuntimeEnvironment stays the single owner of those. +func windowsPrincipalIdentityEnvironment(env map[string]string, username string, runtime *SandboxRuntime) map[string]string { + username = strings.TrimSpace(username) + if env == nil || username == "" || runtime == nil { + return env + } + base := strings.TrimSpace(runtime.Data) + if base == "" { + base = strings.TrimSpace(runtime.Root) + } + if base == "" { + // No runtime tree means no writable place to point at, and inventing one + // outside the grants would only move the failure. Leaving the caller's + // values would be worse, so name the account and stop there. + env["USERNAME"] = username + return env + } + + profile := filepath.Join(base, "profile") + env["USERPROFILE"] = profile + env["APPDATA"] = filepath.Join(profile, "AppData", "Roaming") + env["LOCALAPPDATA"] = filepath.Join(profile, "AppData", "Local") + env["USERNAME"] = username + if host, err := os.Hostname(); err == nil && strings.TrimSpace(host) != "" { + // A local account's domain is the machine. Left as the caller's it would + // name a domain the principal is not a member of. + env["USERDOMAIN"] = host + } + // HOMEDRIVE and HOMEPATH are a split of the same location, and tools join + // them back together, so they have to be split from the value above rather + // than carried over from the caller. + if volume := filepath.VolumeName(profile); volume != "" { + env["HOMEDRIVE"] = volume + env["HOMEPATH"] = strings.TrimPrefix(profile, volume) + } + return env +} diff --git a/internal/sandbox/windows_principal_env_windows_test.go b/internal/sandbox/windows_principal_env_windows_test.go new file mode 100644 index 000000000..6c580611a --- /dev/null +++ b/internal/sandbox/windows_principal_env_windows_test.go @@ -0,0 +1,90 @@ +//go:build windows + +package sandbox + +import ( + "strings" + "testing" +) + +// callerEnvironment is the shape the child starts from: the invoking user's +// values, with the deliberate sandbox redirects already layered on top. +func callerEnvironment() map[string]string { + return map[string]string{ + "USERPROFILE": `C:\Users\caller`, + "APPDATA": `C:\Users\caller\AppData\Roaming`, + "LOCALAPPDATA": `C:\Users\caller\AppData\Local`, + "HOMEDRIVE": `C:`, + "HOMEPATH": `\Users\caller`, + "USERNAME": "caller", + "USERDOMAIN": "CALLERDOMAIN", + // Owned by sandboxRuntimeEnvironment, and this must not touch them. + "HOME": `C:\runtime\home`, + "TEMP": `C:\runtime\temp`, + "TMP": `C:\runtime\temp`, + } +} + +// NOTHING MAY STILL NAME THE CALLER. +// +// A principal is a separate account that deliberately cannot open the invoking +// user's profile, so every variable a native tool resolves per-user state +// through has to describe the principal instead. Left alone they pointed at the +// caller, and tools either failed during startup or looked somewhere they had no +// business reading. +func TestPrincipalEnvironmentStopsNamingTheCaller(t *testing.T) { + runtime := &SandboxRuntime{ + Root: `C:\runtime`, + Data: `C:\runtime\data`, + } + env := windowsPrincipalIdentityEnvironment(callerEnvironment(), "zero-sbx-abc123", runtime) + + for _, key := range []string{"USERPROFILE", "APPDATA", "LOCALAPPDATA"} { + if strings.Contains(strings.ToLower(env[key]), "caller") { + t.Errorf("%s still points into the caller's profile: %s", key, env[key]) + } + if !strings.HasPrefix(strings.ToLower(env[key]), strings.ToLower(runtime.Data)) { + t.Errorf("%s is not inside the runtime tree the principal can write: %s", key, env[key]) + } + } + if strings.Contains(strings.ToLower(env["HOMEPATH"]), "caller") { + t.Errorf("HOMEPATH still points into the caller's profile: %s", env["HOMEPATH"]) + } + if env["USERNAME"] != "zero-sbx-abc123" { + t.Errorf("USERNAME = %q, want the principal account", env["USERNAME"]) + } + if env["USERDOMAIN"] == "CALLERDOMAIN" { + t.Error("USERDOMAIN still names the caller's domain, which the principal is not a member of") + } + // HOMEDRIVE and HOMEPATH are rejoined by tools, so they have to describe the + // same location as USERPROFILE rather than a mix of both identities. + if rejoined := env["HOMEDRIVE"] + env["HOMEPATH"]; rejoined != env["USERPROFILE"] { + t.Errorf("HOMEDRIVE+HOMEPATH = %s, want the same location as USERPROFILE %s", rejoined, env["USERPROFILE"]) + } +} + +// The deliberate redirects stay owned by sandboxRuntimeEnvironment. Clobbering +// them here would silently move the sandbox's temp and cache handling into a +// second place, which is how the two drift apart. +func TestPrincipalEnvironmentLeavesTheSandboxRedirectsAlone(t *testing.T) { + before := callerEnvironment() + env := windowsPrincipalIdentityEnvironment(callerEnvironment(), "zero-sbx-abc123", &SandboxRuntime{ + Root: `C:\runtime`, + Data: `C:\runtime\data`, + }) + for _, key := range []string{"HOME", "TEMP", "TMP"} { + if env[key] != before[key] { + t.Errorf("%s = %q, want it left at the sandbox redirect %q", key, env[key], before[key]) + } + } +} + +// Without a runtime tree there is nowhere writable to point at, and inventing a +// path outside the grants would only move the failure. Naming the account is +// still strictly better than advertising the caller's. +func TestPrincipalEnvironmentWithoutARuntimeTreeStillRenamesTheAccount(t *testing.T) { + env := windowsPrincipalIdentityEnvironment(callerEnvironment(), "zero-sbx-abc123", &SandboxRuntime{}) + if env["USERNAME"] != "zero-sbx-abc123" { + t.Errorf("USERNAME = %q, want the principal account even with no runtime tree", env["USERNAME"]) + } +} From 83750ae98831489f880c90bd1ce07be48ae8a511 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 12 Aug 2026 14:03:44 +0530 Subject: [PATCH 67/96] fix(sandbox): drop the unfollowable sandbox override from ACL failures Both unelevated ACL failures told the reader to re-run with `--sandbox forbid`. There is no such option: SandboxPreferenceForbid is an internal engine state with no flag behind it, so acting on it produced an unknown option and left them stuck on the failure they had just been told how to clear. Advice that does not work costs more than none, because finding that out takes the reader's time. Name the real way out instead, the user config key, which is honored from global config only so a cloned repo cannot set it. The elevated-setup remedy beside it was already correct and stays. Reported by jatmn against the same string on #640. It predates this branch, having arrived with the unelevated fallback tier in #427, and the copy on #886 is fixed separately in 1b304e13. Also covers the secret write with the junction regression it was owed: the caller owns the sandbox home, so they can put a reparse point where the secret directory is expected, and the pathname version followed it in an elevated process. The test asserts the refusal names the reparse point and that nothing survives on the far side, since refusing while still creating the file would leave the caller holding it. --- .../sandbox/windows_command_runner_windows.go | 12 +++- ...s_identity_secret_junction_windows_test.go | 71 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 internal/sandbox/windows_identity_secret_junction_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 7a0fbeb07..5a4622e15 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -229,15 +229,23 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { // 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. + // Every remedy named below is one the reader can actually carry out. + // Both messages used to offer `--sandbox forbid`, which is not an option + // at all: SandboxPreferenceForbid is an internal engine state with no flag + // behind it, so acting on it produced an unknown option and left the reader + // stuck on the failure they had just been told how to clear. Advice that + // does not work costs more than none, since finding that out takes time. 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. "+ + "Check those, or turn the sandbox off in your user config with "+ + `"sandbox": {"enabled": false}. `+ "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) + "run `zero sandbox setup` from an elevated (Administrator) terminal, "+ + `or turn the sandbox off in your user config with "sandbox": {"enabled": false}`, err) } return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied) } diff --git a/internal/sandbox/windows_identity_secret_junction_windows_test.go b/internal/sandbox/windows_identity_secret_junction_windows_test.go new file mode 100644 index 000000000..ef918b183 --- /dev/null +++ b/internal/sandbox/windows_identity_secret_junction_windows_test.go @@ -0,0 +1,71 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// THE SECRET MUST NOT BE WRITTEN THROUGH A REPARSE POINT. +// +// The sandbox home belongs to the invoking user, who is the party this sandbox +// exists to contain, so they can put a junction where the secret directory is +// expected before elevated setup runs. The pathname version resolved the path +// four times over (MkdirAll, an O_TRUNC open, SetNamedSecurityInfo by name, then +// WriteFile by name) and every one of them followed it, so an Administrator +// process created the principal's password in a directory of the caller's +// choosing and then granted them control of it. +// +// A junction rather than a symlink on purpose: it needs no privilege, so it is +// reachable by exactly the unprivileged user this guards against. os.Symlink +// would need SeCreateSymbolicLinkPrivilege and would skip on an ordinary +// developer or CI account, which is where this most needs to run. +func TestWriteWindowsSandboxSecretRefusesAJunctionedDirectory(t *testing.T) { + base := t.TempDir() + outside := filepath.Join(base, "outside") + if err := os.MkdirAll(outside, 0o700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // The swap: the directory the secret is addressed into is a junction that + // leads out of the sandbox home. The pathname never changes. + cfg := filepath.Join(base, "cfg") + makeJunction(t, cfg, outside) + + path := filepath.Join(cfg, "zero-sbx-test.secret") + err := writeWindowsSandboxSecret(path, "Zs1!EXAMPLEPASSWORDVALUE") + if err == nil { + t.Fatal("wrote the principal secret through a junction, so an elevated setup would have placed it in a directory the caller controls") + } + if !strings.Contains(err.Error(), "reparse") { + t.Fatalf("refused for the wrong reason: %v", err) + } + // Refusing is only half of it. Nothing may survive on the other side, or the + // caller still ends up holding a file the sandbox created for them. + entries, readErr := os.ReadDir(outside) + if readErr != nil { + t.Fatalf("ReadDir: %v", readErr) + } + if len(entries) != 0 { + t.Fatalf("junction target holds %d entries after the refusal, want none", len(entries)) + } +} + +// The ordinary path must still work, or the refusal above proves only that the +// function fails. +func TestWriteWindowsSandboxSecretStillWritesAnOrdinaryDirectory(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) + } +} From ba5219cc1514a76f1982d0b70fa69ccea714e255 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 12 Aug 2026 20:19:40 +0530 Subject: [PATCH 68/96] fix(sandbox): stop the redirect check resolving what it checks for verifyWindowsACLTargetNotRedirected asks GetFinalPathNameByHandle where the handle landed, then compared that against canonicalSandboxWorkspaceRoot(path), which runs filepath.EvalSymlinks. That is the same resolution the kernel had just performed, so for a directory symlink the two sides agreed precisely BECAUSE the redirect happened: elevated setup went on to rewrite the DACL of an object outside the workspace while the check reported success. Junctions were rejected, but by accident rather than by design. Go reports a junction as ModeIrregular rather than ModeSymlink, so EvalSymlinks refuses it, the canonicalization falls back to the lexical path, and the mismatch surfaces. That is a property of the standard library's mode bits, not of this guard, and a Go release that resolved mount points would silently disarm the one case it was known to catch. The expected side is now normalized without resolving anything. GetLongPathName expands an 8.3 short name by reading directory entries and does not follow a link to its target, and EqualFold still covers casing, so the two spellings that legitimately name the same object still compare equal. Every failure degrades to the lexically cleaned path, which can only produce a spurious refusal, never a spurious match. A target deliberately spelled through a symlink is now refused. That is the intended direction: the question here is whether the object the handle landed on is the object that was named. On the tests, plainly: the directory-symlink regression is the one that separates the old basis from the new, and it needs Developer Mode or SeCreateSymbolicLinkPrivilege, so it skips on a machine without either and skipped on mine. The junction test beside it passes against both bases and says so in its own comment; it is an invariant test guarding the Go behaviour the old code accidentally depended on, not a regression for this change. --- .../sandbox/windows_acl_reparse_windows.go | 65 ++++++++++- ...ndows_acl_symlink_ancestor_windows_test.go | 102 ++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 internal/sandbox/windows_acl_symlink_ancestor_windows_test.go diff --git a/internal/sandbox/windows_acl_reparse_windows.go b/internal/sandbox/windows_acl_reparse_windows.go index 7465e677d..71c6e206c 100644 --- a/internal/sandbox/windows_acl_reparse_windows.go +++ b/internal/sandbox/windows_acl_reparse_windows.go @@ -32,10 +32,32 @@ const ( // 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. +// The comparison basis must NEVER follow a reparse point, and getting that wrong +// is what made this guard mostly ornamental. +// +// It used to compare against canonicalSandboxWorkspaceRoot(path), which runs +// filepath.EvalSymlinks — the same resolution the kernel just performed. So for a +// directory SYMLINK the two sides agreed precisely BECAUSE the redirect happened: +// GetFinalPathNameByHandle followed the link to produce one side, EvalSymlinks +// followed the same link to produce the other, and elevated setup went on to +// rewrite the DACL of an object outside the workspace. +// +// Junctions were rejected, but by accident rather than by design. Go reports a +// junction as ModeIrregular rather than ModeSymlink, so EvalSymlinks refuses it, +// canonicalSandboxWorkspaceRoot falls back to the lexical path, and the mismatch +// surfaces. That is a property of the standard library's mode bits, not of this +// check: a Go release that resolved mount points would silently disarm the one +// case this was known to catch. +// +// So the expected side is now normalized WITHOUT resolving anything. Casing and +// 8.3 short names still have to compare equal, since a legitimate target can be +// spelled either way and still be the same object — EqualFold covers the first +// and GetLongPathName the second, and neither follows a link. +// +// This does mean a target deliberately spelled through a symlink is now refused. +// That is the intended direction: the whole question here is whether the object +// the handle landed on is the object that was named, and a caller that wants a +// link's target should name the target. 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) @@ -46,13 +68,46 @@ func verifyWindowsACLTargetNotRedirected(handle windows.Handle, path string) err buffer = buffer[:n] } actual := trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer)) - expected := trimWindowsExtendedPathPrefix(canonicalSandboxWorkspaceRoot(path)) + expected := trimWindowsExtendedPathPrefix(windowsACLComparablePath(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 } +// windowsACLComparablePath normalizes a path for comparison against where a +// handle actually landed, WITHOUT resolving any reparse point. +// +// The two things that must still compare equal are casing and 8.3 short names, +// because either can spell the same object. GetLongPathName expands the short +// form by reading directory entries; it does not follow a symlink or a junction +// to its target, which is the entire reason it is used here instead of +// EvalSymlinks. Casing is left to the caller's EqualFold. +// +// Every failure degrades to the lexically cleaned path rather than to a resolved +// one. A path this cannot expand is compared as written, which can only produce +// a spurious MISMATCH — a refusal — and never a spurious match. That is the +// direction a redirect check has to fail in. +func windowsACLComparablePath(path string) string { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "" || cleaned == "." { + return cleaned + } + if absolute, err := filepath.Abs(cleaned); err == nil { + cleaned = absolute + } + wide, err := windows.UTF16PtrFromString(cleaned) + if err != nil { + return cleaned + } + buffer := make([]uint16, windows.MAX_LONG_PATH) + n, err := windows.GetLongPathName(wide, &buffer[0], uint32(len(buffer))) + if err != nil || n == 0 || int(n) >= len(buffer) { + return cleaned + } + return filepath.Clean(windows.UTF16ToString(buffer[:n])) +} + // trimWindowsExtendedPathPrefix strips the \?\ form GetFinalPathNameByHandle // returns so it can be compared with an ordinary path. func trimWindowsExtendedPathPrefix(path string) string { diff --git a/internal/sandbox/windows_acl_symlink_ancestor_windows_test.go b/internal/sandbox/windows_acl_symlink_ancestor_windows_test.go new file mode 100644 index 000000000..387ac47dd --- /dev/null +++ b/internal/sandbox/windows_acl_symlink_ancestor_windows_test.go @@ -0,0 +1,102 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// THE COMPARISON BASIS MUST NOT FOLLOW WHAT IT IS CHECKING FOR. +// +// verifyWindowsACLTargetNotRedirected asks GetFinalPathNameByHandle where the +// handle landed and compares that against the requested path. It used to build +// the second side with filepath.EvalSymlinks, which is the same resolution the +// kernel had just performed, so for a directory symlink the two sides agreed +// BECAUSE the redirect happened, and elevated setup rewrote the DACL of an +// object outside the workspace. +// +// Junctions were caught, but only because Go reports one as ModeIrregular and +// EvalSymlinks declines it, leaving the lexical path to disagree. That is a +// standard-library detail, not a property of the guard. + +// The property, testable with no privilege at all: the normalizer must not +// resolve a reparse point. +// +// HONEST LIMIT, because it would be easy to read more into this than it proves. +// A junction is used because creating one needs no privilege, and EvalSymlinks +// does not resolve a junction either — so this test passes against BOTH the old +// basis and the new one, and reverting the fix does not fail it. It is an +// invariant test, not a regression for this change: it pins that normalization +// leaves a reparse point alone, which is what would break first if a future Go +// started resolving mount points and silently disarmed the junction case. +// +// The case that actually separates the two bases is the directory symlink below, +// and that one skips without privilege. On this machine it skipped. +func TestACLComparablePathDoesNotResolveAReparsePoint(t *testing.T) { + base := t.TempDir() + outside := filepath.Join(base, "outside") + if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil { + t.Fatalf("mkdir outside: %v", err) + } + link := filepath.Join(base, "link") + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, outside).CombinedOutput(); err != nil { + t.Skipf("cannot create a junction here: %v (%s)", err, out) + } + + through := filepath.Join(link, "hooks") + comparable := windowsACLComparablePath(through) + + // It must still name the link, not the target. + if !strings.EqualFold(filepath.Clean(comparable), filepath.Clean(through)) { + t.Errorf("windowsACLComparablePath resolved through the reparse point:\n got %s\n want %s", comparable, through) + } + // And it must NOT have become the target, which is what made the old + // comparison agree with a redirected handle. + if strings.EqualFold(filepath.Clean(comparable), filepath.Clean(filepath.Join(outside, "hooks"))) { + t.Errorf("windowsACLComparablePath returned the reparse target %s; the guard compares against this, so a redirect would match itself", comparable) + } + + // The old basis is checked alongside for contrast: where it resolves, it + // could not have been the comparison basis. + if resolved := canonicalSandboxWorkspaceRoot(through); strings.EqualFold(filepath.Clean(resolved), filepath.Clean(filepath.Join(outside, "hooks"))) { + t.Logf("confirmed: canonicalSandboxWorkspaceRoot resolves %s to %s, which is why it cannot be the basis", through, resolved) + } +} + +// The end-to-end symlink case the finding names. Directory symlinks need either +// Developer Mode or SeCreateSymbolicLinkPrivilege, so this skips where it cannot +// build the fixture rather than passing vacuously. +func TestOpenWindowsACLTargetRefusesASymlinkAncestor(t *testing.T) { + base := t.TempDir() + outside := filepath.Join(base, "outside") + if err := os.MkdirAll(filepath.Join(outside, "hooks"), 0o700); err != nil { + t.Fatalf("mkdir outside: %v", err) + } + workspace := filepath.Join(base, "workspace") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + + // A DIRECTORY symlink, not a junction: os.Symlink maps to CreateSymbolicLinkW + // and the directory flag is inferred from the existing target. + link := filepath.Join(workspace, ".git") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("cannot create a directory symlink here (needs Developer Mode or SeCreateSymbolicLinkPrivilege): %v", err) + } + + target := filepath.Join(link, "hooks") + handle, _, err := openWindowsACLTarget(target) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatal("elevated setup opened a target through a symlinked ancestor; its DACL change would land outside the workspace") + } + if !strings.Contains(err.Error(), "reparse point") { + t.Errorf("refusal does not name the cause: %v", err) + } +} From 3b34c83132315a8b4336c1cecacffff3b587920f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 12 Aug 2026 22:31:17 +0530 Subject: [PATCH 69/96] test(sandbox): compare normalized paths, not the raw input TestACLComparablePathDoesNotResolveAReparsePoint failed on Windows CI while passing locally. The test was wrong, not the code. It asserted that windowsACLComparablePath returns a string equal to the path passed in. GetLongPathName legitimately rewrites that string: a CI runner's temp directory is an 8.3 short name, so expanding RUNNER~1 to runneradmin produces a different string naming exactly the same object, and the assertion failed on the one property the function is supposed to have. Both sides are now normalized before comparison, which states the real property: a path THROUGH the reparse point must not normalize to the target's normalization, or the guard would compare a redirected handle against a redirected expectation and match itself. A second assertion keeps the link component present so a resolution to something else entirely still fails. The test's documented limit is unchanged and still honest: a junction cannot separate the old basis from the new one, because EvalSymlinks does not resolve junctions either. It remains an invariant test. --- ...ndows_acl_symlink_ancestor_windows_test.go | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/internal/sandbox/windows_acl_symlink_ancestor_windows_test.go b/internal/sandbox/windows_acl_symlink_ancestor_windows_test.go index 387ac47dd..6794a1aeb 100644 --- a/internal/sandbox/windows_acl_symlink_ancestor_windows_test.go +++ b/internal/sandbox/windows_acl_symlink_ancestor_windows_test.go @@ -52,14 +52,25 @@ func TestACLComparablePathDoesNotResolveAReparsePoint(t *testing.T) { through := filepath.Join(link, "hooks") comparable := windowsACLComparablePath(through) - // It must still name the link, not the target. - if !strings.EqualFold(filepath.Clean(comparable), filepath.Clean(through)) { - t.Errorf("windowsACLComparablePath resolved through the reparse point:\n got %s\n want %s", comparable, through) + // Compared against the normalizer's OWN answer for each path, never against + // the raw input. GetLongPathName legitimately rewrites the string: on a CI + // runner the temp directory is an 8.3 short name, so expanding RUNNER~1 to + // runneradmin makes the result differ from what was passed in while naming + // exactly the same object. An earlier version of this test asserted equality + // with the input and failed on Windows CI for that reason, which was the test + // being wrong rather than the code. + target := windowsACLComparablePath(filepath.Join(outside, "hooks")) + + // The whole property: normalizing a path THROUGH the reparse point must not + // produce the target's normalization. If it did, the guard would be comparing + // a redirected handle against a redirected expectation and matching itself. + if strings.EqualFold(filepath.Clean(comparable), filepath.Clean(target)) { + t.Errorf("windowsACLComparablePath resolved through the reparse point:\n through %s\n target %s", comparable, target) } - // And it must NOT have become the target, which is what made the old - // comparison agree with a redirected handle. - if strings.EqualFold(filepath.Clean(comparable), filepath.Clean(filepath.Join(outside, "hooks"))) { - t.Errorf("windowsACLComparablePath returned the reparse target %s; the guard compares against this, so a redirect would match itself", comparable) + // And it still has to name the link component, or it resolved to something + // else entirely rather than leaving the path alone. + if !strings.Contains(strings.ToLower(comparable), "link") { + t.Errorf("windowsACLComparablePath lost the link component: %s", comparable) } // The old basis is checked alongside for contrast: where it resolves, it From 5ac436ccd63189e42df499c53e18c4f20b6bcad6 Mon Sep 17 00:00:00 2001 From: Vasanth T <148849890+Vasanthdev2004@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:21:58 +0530 Subject: [PATCH 70/96] feat(sandbox): give each workspace an offline and an online principal (#812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sandbox): give each workspace an offline and an online principal The principal backend stood down whenever the network was denied, which is the default, so opting into it left the restricted-token path doing all the work in normal use. The reason was that network denial is enforced by block filters keyed to the offline-marker SID, and a principal token cannot carry it: LogonUser builds a token from an account's real group memberships, and the marker is a synthetic capability SID. A real local group closes that gap. ZeroSandboxOffline is created by setup, the block filters name its SID alongside the marker, and a principal is denied the network by being a member. Each workspace therefore gets two accounts that differ only in that membership, and the command's network mode selects between them. A group rather than each principal's own SID because principals are per workspace: one filter set covers every offline principal on the machine instead of needing a filter per workspace. Both principals are provisioned together even though a given setup run sees one profile, because setup needs elevation and commands do not. Provisioning lazily would mean an unelevated command discovering it needs an account it cannot create. They also get identical filesystem access, so an approved network command sees the same filesystem as an ordinary one. Two orderings are load bearing. The network plan is now built AFTER provisioning, because the group it keys to is created there; planning first installed filters naming only the marker and left every offline principal with an open network while looking correctly set up. And the role tag sits before the workspace hash in the account name, so truncation at the 20 character limit eats hash characters rather than the tag, which would otherwise collide the two roles onto one account on exactly the workspaces most likely to truncate. Anything that is not an explicit allow maps to the offline principal, so an unrecognised mode loses the network rather than keeping it. The filter identity set is resolved rather than assumed, and stays absent until the group exists, so a machine that never provisions principals computes the same plan as before. That matters because the plan is hashed into the setup marker and re-derived on every command; an identity set that differed between setup and the command path would fail every command as out of date. Cost worth stating: this doubles the sandbox accounts on a machine, to two per workspace. * fix(sandbox): prove ownership before deleting a sandbox account removeWindowsSandboxIdentity is called with a DERIVED name, so it could be pointed at a name that happens to belong to somebody else's local account. Deleting a user is not a recoverable mistake, and the only thing standing between the two cases was the name matching a pattern we generate ourselves. Raised by CodeRabbit against the test fixtures, but the production teardown path had the same hazard, so the guard belongs there rather than in the tests. The ownership check itself now lives on the base branch, which grew the same helper to stop provisioning ADOPTING a squatted account. This applies it to the other end: an account that is not ours is left alone rather than deleted. The gated tests get the protection for free, since their pre-clean goes through the same helper. Also appends the trimmed offline-group SID rather than the raw one. Worth noting the reported consequence does not hold: newWindowsWFPUserCondition canonicalises before converting, so a padded value would have been trimmed before reaching StringToSid. The resolver returns SID.String(), which never carries whitespace, so this is defensive tidying rather than a fix. * fix(sandbox): assert the filters cover principals, and report a retained account Two follow-ups from review, both on the same theme: a control that quietly does nothing looks identical to one that works. The network plan must be built AFTER provisioning, because provisioning creates the group the block filters name. Built first, the filters name only the offline marker and every offline principal has an open network while setup reports success. That ordering is invisible at the call site, so setup now checks the plan actually names the offline group before installing anything and refuses if it does not. A later refactor that moves the plan build back fails loudly instead of producing a security control that enforces nothing. The predicate is separate so it can be asserted directly: a plan carrying only the marker must read as uncovered, one carrying the group as covered, case differences must not read as missing, and a host with no group provisioned has no principal to miss and must not be refused. Making coverage always report true fails that test. Removal also reported plain success when it declined to delete an account Zero did not create. Leaving it alone is right, but telling an operator cleanup completed when a name they may care about was deliberately retained is not. That case is now a distinguishable sentinel, and teardown treats it as success, since "no principal of ours under this name" is the goal state either way. * fix(sandbox): spare adopted principals when dual-role setup rolls back Provisioning already declined to delete an account it had adopted, and the outer setup rollback then appended an unconditional removal for every role that got that far. With two roles that is the common case rather than an unlucky one: the offline role usually succeeds, so a failure in the online role or in ACL application destroyed a principal that was working before the run started. The removal closure is now only appended for a principal this run created. Threads the workspace key into the delete path as well. Ownership was proven from the account comment alone, which on a name collision belongs to a DIFFERENT workspace, so deleting it would have been the same unrecoverable mistake the check exists to prevent. Policy DenyWrite now reaches the principal ACL plan here too, matching the single-principal path. Fixes the mode-independence test, which required exactly one identity SID and so failed on any Windows host that already had ZeroSandboxOffline, where the plan legitimately carries two. CI never saw it because the Linux and macOS jobs leave the hook nil and a fresh Windows runner has no group. The hook is now pinned, and the test additionally asserts the property it is named for in the group-present case, including that the infra hash changes when the group appears, which is the cross-workspace coupling raised for a maintainer decision. * test(sandbox): stub the password reset and pin the resolved group SIDs Two review points on the tests added in the previous commit. The provisioning stub left resetWindowsSandboxUserPassword as a real call. Nothing under test reaches it any more, because rotation moved to the caller, but a test that resets a real managed account's password if the code ever moves back is not a risk worth carrying. It is now stubbed to fail the test instead, which also states the contract. The group-present assertion checked only that two identity SIDs were present. A duplicated offline marker or an unrelated SID would satisfy that while meaning something quite different, so it now pins both positions. Also drops a duplicated stub assignment left by the rebase. * fix(sandbox): refuse an offline group zero does not own ensureWindowsLocalGroup accepted NERR_GroupExists and ERROR_ALIAS_EXISTS as success without inspecting the group it was about to reuse. Setup then resolved that group's SID and installed it on the persistent WFP deny filters, and made the sandbox principal a member of it. If anything else on the machine already owns a group named ZeroSandboxOffline — another tool, a policy, a prior unrelated convention — that is not a no-op. Every existing member abruptly loses outbound access, because the filters now name their group. In the other direction the sandbox principal inherits whatever permissions that group carries, which is the opposite of what an offline principal is for. The add now reports its raw status and the already-exists branch verifies the group carries this setup's managed marker before adopting it, failing with an actionable message otherwise. A lookup error fails closed rather than adopting. NetLocalGroupAdd and the ownership lookup sit behind seams so the branch is reachable in tests without an elevated machine; the marker compared is the group's own, so the principals group and the offline group cannot be confused. Reported by jatmn on #812. * fix(sandbox): recheck offline group membership before minting a token Network denial does not follow from picking the offline account. The WFP block filters match the offline GROUP'S SID, and LogonUser builds a token from the account's real memberships — so membership is the whole enforcement, and the command path never revalidated it. An account that drifts out of ZeroSandboxOffline through local policy, an administrator, or a re-setup that could not re-add it still resolves, still has its stored secret, and still logs on. Its token no longer satisfies the filter condition, so a NetworkDeny command gets full egress under a profile that asked for none. The stale setup marker keeps the whole path looking healthy. The offline role now confirms the membership its mode depends on before the secret is read, and falls back to the restricted token when it is absent. That direction is deliberate: the restricted token carries the offline marker the same filters match, so egress stays blocked, and only read confinement is lost. A failed lookup surfaces rather than downgrading silently. The online role is not checked, since it is not in that group by design. Reported by jatmn on #812. * fix(sandbox): derive setup's runtime root deterministically or not at all windowsSandboxRuntimeRootPath resolved through sandboxRuntimeRootFor, which falls back to os.MkdirTemp when the user cache lives inside the workspace and memoizes that only in-process. Elevated setup is its own process. It granted the principals an ACE on temp root A; the next command, being a new process, derived temp root B, where the principal has no ACE, and failed ordinary cache writes with a bare ACCESS_DENIED and nothing pointing at the sandbox. Teardown, a third process, cleaned a third directory. The three callers that have to agree exactly could not agree at all. Setup now uses the same side-effect-free derivation teardown already used, and reports no runtime root when that derivation is unusable rather than inventing one. A root only the granting process can name is worse than no root: the principal loses the runtime tree, which is a degraded sandbox, instead of the sandbox appearing provisioned while every command fails. TestTeardownPathDerivationCreatesNothing asserted the opposite — that setup "should still fall back to a usable tree" — so it is inverted here, with the reasoning recorded in the test. That assertion encoded the assumption this finding overturns: a per-process temp tree is not usable. Restoring the fallback fails it with the invented path in the message, and a new TestSetupAndTeardownDeriveTheSameRuntimeRoot pins the ordinary case, so "report none" cannot quietly become the answer everywhere. Reported by jatmn on #812. * style(sandbox): separate the two doc paragraphs the rebase ran together Adapting the ACL-record test to dual roles left #808's fail-open rationale and #812's per-role rationale as one unbroken block. Both are worth keeping; they are two points, not one. * fix(sandbox): fail closed when offline-group coverage cannot be verified The post-provisioning assertion ran inside `if groupErr == nil`, so a failed lookup skipped it and setup carried on to install filters and write a success marker. The comment directly above it says what that costs: a machine reporting a successful setup while every offline principal has an open network. An empty SID was the same hole by a different route. Resolving to ("", nil) means the group does not exist, which is the ordinary state before provisioning and an impossible one after it, and WindowsNetworkPlanCoversPrincipals answers true for an empty SID (correctly, for the pre-provisioning callers that ask it). So the assertion passed vacuously in exactly the case where the group setup had just created was missing. Move the check into assertWindowsNetworkPlanCoversOfflineGroup, which takes the resolver as a parameter and fails closed on every answer that is not a definite yes: lookup error (wrapped, so the Win32 reason still reaches the operator), empty SID, plan omitting the group, and a nil resolver. Setup rolls back and exits 1 on each. Taking the resolver as a parameter is what makes the error paths testable, which is the regression the review asked for. Reported by @anandh8x on #812. * fix(sandbox): scope the offline-group assert to provisioned runs c404ebd2 made the coverage assert reject an empty group SID, closing the vacuous pass where a missing group counted as covered. It ran the assert unconditionally, and the offline group is only created inside provisionWindowsSandboxIdentity, which runs only under the ZERO_WINDOWS_SANDBOX_IDENTITY opt-in. So on a default machine with principals opted out, the resolver reports ("", nil) exactly as it should, and setup died with "the sandbox offline group does not exist after provisioning" on a path that worked before c404ebd2. The empty-SID rejection is correct after provisioning and wrong before it. Pass provisioned to the assert and return early when it is false, gated at the call site on the same windowsSandboxIdentityEnabled check that decides whether principals are provisioned at all. The fail-closed behaviour anandh8x asked for is unchanged whenever provisioning ran. Reported by @jatmn on #812. * fix(sandbox): keep opted-out setup markers valid, and refuse a foreign offline group Two of jatmn's findings on this PR. Existing markers stay compatible (maintainer decision). The offline group is machine-global, and the plan included its SID whenever the group existed. So the first workspace to opt in changed the computed NetworkInfraHash for every OTHER sandbox home on the machine, and those homes rejected their own stored markers until each was re-run from an elevated terminal, having opted into nothing. The inclusion is now gated on THIS home's opt-in rather than on the group existing, so an opted-out home computes exactly the plan it computed before any of this existed. Setup and the command path read the flag from the same environment, so they agree. Opting in after setup does invalidate that home's marker, which is correct: it has no principals yet. Do not install filters for an unowned offline group (P1). The ownership check only ran through principal provisioning, so an opt-out setup reached the resolver and adopted any local alias carrying the name. applyWindowsNetworkPlan turns every SID in the plan into an allowed-to-match WFP descriptor, so a foreign group meant global deny filters against every one of ITS members: anyone with a local group by that name loses the network for those accounts because we ran setup. The resolver now requires the managed comment, and refuses rather than skipping, because a plan whose filters cover no principal while setup reports success is the failure this backend exists to prevent. Three existing tests exercised the group path without the opt-in and now set it. The new test asserts the other direction, that an opted-out home's hash is unchanged when another workspace creates the group, since that is the property the decision turns on. Verified both ways: disabling the gate fails the existing tests, making it unconditional fails the new one. * fix(doctor): report the principal that dual-role setup actually uses jatmn's P2. This branch made the offline principal work under NetworkDeny, but the doctor helper still described the old restricted-token standdown, so `zero doctor` reported active:false and told operators reads were unconfined for a correctly provisioned offline principal, recommending they enable network or drop the opt-in to fix something that was not broken. WindowsSandboxPrincipalInactiveReason is removed rather than reworded. Its only condition was the deny-mode standdown, so after this branch it could never return anything, and a check that cannot fire is worse than no check. What replaced it matters more than what it said. That helper existed to be the SINGLE rule doctor and the runtime both read, precisely so they could not drift, and drift is what happened anyway when dual-role changed the behaviour under one of them. WindowsSandboxPrincipalRoleForNetwork is now that shared rule: windowsSandboxRoleForNetwork delegates to it and doctor calls it, so the reported account and the used account cannot disagree. Doctor now names which principal a command runs as instead of asserting a standdown. One thing the existing tests caught. Routing the shared rule through NormalizeNetworkMode case-folds, so "ALLOW" selected the ONLINE principal where the runtime required an exact match and failed closed to offline. Sharing a rule is only an improvement if it shares the stricter one, so the comparison is exact and a test pins the casing. * fix(sandbox): converge the dual-role branch with the rebased identity work Rebasing #812 onto the new #808 needed real resolution rather than taking a side, and this records what each conflict actually decided. The account key. #808 made the principal key caller-scoped so elevated setup provisions the account the caller will later look for. #812 derived usernames from the workspace key alone. Every username derivation now uses the caller scoped key, including the two inline call sites a blanket substitution missed: the identity lookup in the unrecorded-retire path and the ledger read in windowsPrincipalRevocationPaths. That second one is why teardown could not find a recorded root the current policy no longer named. The setup LOCK stays keyed to the workspace on purpose, because two users setting up one shared workspace still write DACLs on the same paths and must serialize against each other. The network plan stays where #812 put it, after provisioning, because the block filters are keyed to the offline group that provisioning creates. Building it earlier, as #808 does, would install filters naming only the marker and leave every offline principal with an open network while looking correctly set up. Group ownership converged on #812's implementation, not mine. #808 grew a check hardcoded to the users group; #812 already had the general ensureWindowsLocalGroup plus windowsLocalGroupOwnedByZero, which covers both managed groups. The narrower version was removed and its test rewritten against the general seams, so the users group keeps the coverage anandh8x asked for while the offline group keeps its own. Two functions the resolution dropped and the compiler caught: windowsSandboxPrincipalKey and windowsCurrentUserSID. Worth naming because the previous attempt at this convergence lost the same first function silently. Also closes jatmn's remaining findings on this branch. The opt-out installed check now asks about BOTH role accounts rather than one, since retiring one while the other survives is exactly the half-done teardown an opted-out marker must not report as success. And the post-provisioning filter-coverage assert now resolves the offline group through the existing hook rather than the concrete function, so it is stubbable like everything else around it. The SensitiveEnvKeys omission jatmn reported on sandbox_exec.go arrives with the rebase; it was fixed on #808. * fix(sandbox): keep offline coverage and grant the fallback runtime root Two ways the dual-role split left a workspace worse off than it looked. The block filters are machine-global and every setup installs them by deleting and recreating one fixed set, but the plan a home builds names the offline group only when THAT home opted in. So an ordinary opted-out setup for a second workspace replaced the filters without the group SID, and the first workspace's offline principal, still in the group, still passing the runtime membership check and still holding a valid marker, was no longer matched by any filter. A NetworkDeny command there gained egress silently, because the second setup did exactly what it was asked. The gate itself is left alone, because it is load bearing for a different reason: the plan is hashed into each home's marker, and keying it on the group's existence made the first workspace to opt in invalidate every other home's marker on the machine. What a home RECORDS is about its own configuration; what setup INSTALLS is about the machine. Answering both from one plan is what forced a choice between stale markers and a silent hole, so WindowsNetworkPlanForApply answers the second question only, at the apply call site, leaving the fingerprinted plan untouched. Setup also granted the principals an ACE on the cache-derived runtime root alone, and none at all when the cache sat inside the workspace. That was correct when the other branch minted a random per-process directory through MkdirTemp, but fallbackSandboxRuntimeRoot now derives its path by hashing the workspace and creates nothing, so every process agrees on it. Commands in that layout therefore DO select it and redirect TMP, GOCACHE and the package caches into it, against a tree neither principal could write. Setup now grants the same candidate set the capability plan already covers, and creates each one, since applyWindowsACLPlan fails on a target that does not exist. Reverting either fix fails its regression: the opted-out plan installs filters naming only the marker SID, and setup grants nothing while commands write to Temp\zero\runtime\v1\. Two existing assertions had to be inverted rather than adapted, and both were asserting the old bug. One required setup to report NO runtime root in the cache-inside-workspace layout; the other compared setup's single root for equality against the command's choice. Setup covers the whole candidate set now precisely because that choice is made per process, so the contract is membership. * fix(sandbox): treat an unreadable offline group as a failure, and retire the pre-split principal Three findings from review. A resolution failure in WindowsNetworkPlanForApply returned a marker-only plan, and the machine's WFP filters are replaced wholesale from that plan. So an opted-out setup whose group lookup failed transiently removed the offline group SID another workspace's principals depend on, and that workspace's NetworkDeny commands silently regained egress while its marker and its direct membership check both still passed. The old reasoning was that refusing to install would trade a partial denial for no denial; that is wrong, because the alternative to installing is leaving the existing filters alone. It is fatal now, which is only reachable from an opted-out home since assertWindowsNetworkPlanCoversOfflineGroup is gated on provisioned. Splitting the single principal into offline and online roles changed both account names without changing the marker schema, so an installation made by the previous version kept a valid marker, setup was never re-run, and the runner found neither zero-sbx-d nor zero-sbx-n and fell back to the restricted-token backend with no read confinement. The schema version is bumped so that installation reports as out of date, and a legacy role derives the old untagged name so the ordered retirement can remove the account, its secret, its logon rights, its ACEs and its ledger. It is retired, never provisioned, and there is a test for that because the two lists are one line apart. Doctor reported that commands run as the selected principal whenever the marker validated, but marker validation compares serialized plans and hashes and names no account: deleting the account or its secret, or dropping the offline account out of its group, leaves the marker valid while the runtime falls back or fails. Verifying liveness needs Windows-only queries internal/doctor cannot make, so the claim is narrowed to what the marker actually proves. The role is still reported, since that part is derived rather than assumed. --- internal/doctor/hardening.go | 60 ++- .../hardening_principal_windows_test.go | 72 +++ .../sandbox/windows_command_runner_windows.go | 8 +- .../windows_dualrole_rollback_windows_test.go | 161 ++++++ .../windows_group_adoption_windows_test.go | 89 ++-- .../windows_identity_policy_windows_test.go | 50 +- ...identity_privilege_recheck_windows_test.go | 6 +- .../windows_identity_rollback_windows_test.go | 39 +- .../windows_identity_runtime_windows.go | 469 ++++++++++++------ .../windows_identity_runtime_windows_test.go | 42 +- internal/sandbox/windows_identity_windows.go | 443 +++++++++++++---- .../sandbox/windows_identity_windows_test.go | 71 ++- .../windows_legacy_principal_windows_test.go | 108 ++++ internal/sandbox/windows_network.go | 177 ++++++- .../windows_network_coverage_assert_test.go | 129 +++++ .../windows_network_mixed_optin_test.go | 163 ++++++ internal/sandbox/windows_network_test.go | 151 ++++++ ...ws_offline_group_ownership_windows_test.go | 96 ++++ ...windows_offline_membership_windows_test.go | 102 ++++ .../sandbox/windows_online_offline_test.go | 52 +- .../windows_principal_inactive_test.go | 56 --- .../windows_principal_ledger_windows_test.go | 81 ++- internal/sandbox/windows_role_test.go | 26 + internal/sandbox/windows_setup.go | 66 +-- .../windows_setup_caller_windows_test.go | 13 +- internal/sandbox/windows_setup_windows.go | 77 ++- .../windows_stale_secret_windows_test.go | 4 +- ...indows_workspace_canonical_windows_test.go | 75 ++- 28 files changed, 2381 insertions(+), 505 deletions(-) create mode 100644 internal/doctor/hardening_principal_windows_test.go create mode 100644 internal/sandbox/windows_dualrole_rollback_windows_test.go create mode 100644 internal/sandbox/windows_legacy_principal_windows_test.go create mode 100644 internal/sandbox/windows_network_coverage_assert_test.go create mode 100644 internal/sandbox/windows_network_mixed_optin_test.go create mode 100644 internal/sandbox/windows_offline_group_ownership_windows_test.go create mode 100644 internal/sandbox/windows_offline_membership_windows_test.go delete mode 100644 internal/sandbox/windows_principal_inactive_test.go create mode 100644 internal/sandbox/windows_role_test.go diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index f6c3165df..7df54c705 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -122,24 +122,50 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo }) return &result } - // Setup is valid, but the principal can still be opted into and stand down. - // Under the DEFAULT network-deny policy it always does, so an operator who - // set the opt-in to confine reads gets the same-user restricted token and no - // read confinement at all. Nothing else tells them: the runner cannot warn - // per command without spamming every tool call, and the marker validates - // happily because setup really did provision the account. + // Setup is valid and the opt-in is set, so report which principal a command + // will actually run as. // - // A warning rather than a failure. Commands run correctly and the network is - // still enforced; what is wrong is the operator's picture of what they have. - if reason := sandbox.WindowsSandboxPrincipalInactiveReason(setupConfig.PrincipalOptIn, profile.Network.Mode); reason != "" { - result := check("sandbox.principal", "Sandbox principal", StatusWarn, - fmt.Sprintf("Sandbox principal is opted in but inactive: %s.", reason), map[string]any{ - "backend": string(backend.Name), - "platform": goos, - "optIn": true, - "active": false, - "networkMode": string(profile.Network.Mode), - "remedy": "allow network for this workspace to use the principal, or unset the opt-in to stop expecting read confinement", + // This used to warn that the principal stood down under the DEFAULT + // network-deny policy, which was true while network denial could only be + // expressed on the restricted token: a principal token cannot carry the + // synthetic offline-marker SID the WFP filters matched. Dual-role provisioning + // removed that limit by giving each workspace an offline account whose real + // group membership the same filters match, so deny mode now runs on a + // principal exactly as allow mode does. Leaving the warning would have doctor + // telling operators their reads are unconfined while they are confined. + // + // Naming the role rather than just saying "active" is the useful part. The two + // accounts differ only in that membership, so an operator debugging network + // behaviour needs to know which one this workspace picks. + if setupConfig.PrincipalOptIn { + // Asked of the shared rule rather than re-derived here, so doctor and the + // runtime cannot disagree about which account a command runs as. Two + // copies drifting is what produced the wrong report this replaces. + role := sandbox.WindowsSandboxPrincipalRoleForNetwork(profile.Network.Mode) + // SETUP IS CURRENT IS NOT THE SAME AS THE PRINCIPAL IS LIVE, and this used + // to claim the second from the first. Marker validation compares serialized + // plans and hashes; it names no account. Delete the selected account or its + // secret, or drop the offline account out of ZeroSandboxOffline, and the + // marker still validates while the runtime either falls back to the weaker + // restricted-token path or fails outright. Doctor asserting read + // confinement in that state is worse than saying nothing, because it is the + // surface an operator checks precisely when they are unsure. + // + // Verifying liveness needs the account, a usable secret and (for offline) + // the group membership, all Windows-only live queries this package cannot + // make. So the claim is narrowed to what the marker actually proves, and + // the role is still named because that part IS derived rather than assumed + // and is what an operator debugging network behaviour needs. + result := check("sandbox.principal", "Sandbox principal", StatusPass, + fmt.Sprintf("Sandbox principal setup is current; a command in this workspace selects the %s principal. Setup state does not confirm the account is usable.", role), map[string]any{ + "backend": string(backend.Name), + "platform": goos, + "optIn": true, + // Deliberately not "active": nothing here checked the account, the + // secret or the group membership. + "setupCurrent": true, + "role": role, + "networkMode": string(profile.Network.Mode), }) return &result } diff --git a/internal/doctor/hardening_principal_windows_test.go b/internal/doctor/hardening_principal_windows_test.go new file mode 100644 index 000000000..d1239343a --- /dev/null +++ b/internal/doctor/hardening_principal_windows_test.go @@ -0,0 +1,72 @@ +package doctor + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/sandbox" +) + +// A VALID MARKER IS NOT A LIVE PRINCIPAL. Marker validation compares serialized +// plans and hashes and names no account, so it stays valid after the selected +// account is deleted, its secret is removed, or the offline account is dropped +// out of ZeroSandboxOffline. In each of those states the runtime falls back to +// the restricted-token path with no read confinement, or fails outright. +// +// Doctor is the surface an operator checks precisely when they are unsure, so +// claiming read confinement it has not verified is worse than reporting less. +// This pins the narrower claim: setup is current, the role is named because it +// is derived rather than assumed, and nothing asserts the account is usable. +func TestPrincipalCheckDoesNotClaimAnUnverifiedPrincipalIsActive(t *testing.T) { + sandboxHome := t.TempDir() + workspaceRoot := t.TempDir() + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", sandboxHome) + t.Setenv("ZERO_WINDOWS_SANDBOX_IDENTITY", "1") + + sandboxConfig := config.SandboxConfig{} + scope, err := sandbox.NewScope(workspaceRoot, sandboxConfig.AdditionalWriteRoots) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope) + + // Mirrors what windowsSandboxSetupCheck derives, so the marker it writes is + // the one the check will validate against. Nothing here provisions an + // account, which is the whole point: the marker is valid and the principal + // does not exist. + if _, err := sandbox.WriteWindowsSandboxSetupMarker(sandbox.WindowsSandboxSetupConfig{ + SandboxHome: sandboxHome, + CommandCWD: workspaceRoot, + WorkspaceRoots: []string{workspaceRoot}, + PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}), + PrincipalOptIn: true, + }); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + result := windowsSandboxSetupCheck("windows", sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}, workspaceRoot, sandboxConfig) + if result == nil { + t.Fatal("expected a principal check once the marker validates and the opt-in is set") + } + if result.ID != "sandbox.principal" { + t.Fatalf("check = %q (%s): the marker did not validate, so this test is not exercising the principal branch", result.ID, result.Message) + } + + lowered := strings.ToLower(result.Message) + for _, claim := range []string{"is active", "commands run as"} { + if strings.Contains(lowered, claim) { + t.Errorf("doctor asserts %q about a principal it never checked: %q", claim, result.Message) + } + } + if active, ok := result.Details["active"]; ok { + t.Errorf("details carry active=%v, which nothing here verified", active) + } + + // The role is still worth reporting: it is derived from the network mode by + // the shared rule rather than assumed, and it is what an operator debugging + // network behaviour needs. + if role, ok := result.Details["role"]; !ok || strings.TrimSpace(role.(string)) == "" { + t.Errorf("the selected role should still be reported: %#v", result.Details) + } +} diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 5a4622e15..1b16fb11d 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -168,9 +168,15 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // resolved its per-user state through paths inside a profile it cannot // open. Applied here, on the principal path only, because this is the first // point that knows which account the command is about to run as. + // The role has to be the one the token above was minted for, or the child + // is told it is the other account and resolves its per-user state under a + // profile it cannot open. config.Env = windowsPrincipalIdentityEnvironment( config.Env, - windowsSandboxUserName(windowsSandboxPrincipalKey(config)), + windowsSandboxUserName( + windowsSandboxPrincipalKey(config), + windowsSandboxRoleForNetwork(config.PermissionProfile.Network.Mode), + ), config.PermissionProfile.Runtime, ) exitCode, err := runWindowsCommandAsUser(jailedToken, config) diff --git a/internal/sandbox/windows_dualrole_rollback_windows_test.go b/internal/sandbox/windows_dualrole_rollback_windows_test.go new file mode 100644 index 000000000..381c06364 --- /dev/null +++ b/internal/sandbox/windows_dualrole_rollback_windows_test.go @@ -0,0 +1,161 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +// The outer rollback must only delete principals this run created. +// +// Dual-role setup provisions offline then online. If the offline role adopts an +// account that already existed and anything afterwards fails, the outer rollback +// used to delete it anyway, turning a partial re-setup failure into the loss of +// a principal that was working before the run started. Two roles make that the +// common case rather than an unlucky one: the first role usually succeeds, so +// there is nearly always something for a later failure to destroy. +func TestDualRoleSetupRollbackSparesAdoptedPrincipals(t *testing.T) { + for name, testCase := range map[string]struct { + createdByRole map[windowsSandboxRole]bool + wantRemoved []windowsSandboxRole + }{ + "offline adopted, online created": { + createdByRole: map[windowsSandboxRole]bool{ + windowsSandboxRoleOffline: false, + windowsSandboxRoleOnline: true, + }, + // Only the one this run made. Deleting the adopted offline principal + // is the data loss this guards against. + wantRemoved: []windowsSandboxRole{windowsSandboxRoleOnline}, + }, + "both adopted": { + createdByRole: map[windowsSandboxRole]bool{ + windowsSandboxRoleOffline: false, + windowsSandboxRoleOnline: false, + }, + wantRemoved: nil, + }, + "both created": { + createdByRole: map[windowsSandboxRole]bool{ + windowsSandboxRoleOffline: true, + windowsSandboxRoleOnline: true, + }, + wantRemoved: []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline}, + }, + } { + t.Run(name, func(t *testing.T) { + var removed []windowsSandboxRole + restoreDualRoleSeams(t, testCase.createdByRole, &removed) + + // Fail on the SECOND role, not the first. ACL application happens per + // role inside the loop, so failing the first aborts before the second + // is provisioned at all and the rollback never has more than one + // principal to consider. The case worth testing is the one review + // described: the first role succeeds, the second fails, and the + // question is whether the first gets destroyed on the way out. + // Count GRANT plans, not ACL calls. applyWindowsPrincipalACLs revokes + // this trustee's existing ACEs before applying the new plan, so there + // are two calls per role now. The contract under test is "the second + // ROLE fails"; counting raw calls would fail the first role's grant + // instead and prove something else entirely. + grants := 0 + applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) { + if len(plan.Entries) > 0 && plan.Entries[0].Action == windowsACLRevoke { + return func() error { return nil }, nil + } + grants++ + if grants < 2 { + return func() error { return nil }, nil + } + return nil, errors.New("ACL apply refused") + } + + if _, err := setupWindowsSandboxPrincipal(windowsSandboxTestConfig()); err == nil { + t.Fatal("setup reported success despite an injected ACL failure") + } + assertRolesEqual(t, removed, testCase.wantRemoved) + }) + } +} + +// The same contract on the success path: the rollback handed back to the caller +// for a LATER setup step to invoke must be just as reluctant. +func TestDualRoleSetupReturnedRollbackSparesAdoptedPrincipals(t *testing.T) { + var removed []windowsSandboxRole + restoreDualRoleSeams(t, map[windowsSandboxRole]bool{ + windowsSandboxRoleOffline: false, + windowsSandboxRoleOnline: true, + }, &removed) + + rollback, err := setupWindowsSandboxPrincipal(windowsSandboxTestConfig()) + if err != nil { + t.Fatalf("setup: %v", err) + } + if len(removed) != 0 { + t.Fatalf("setup removed principals before anything failed: %v", removed) + } + if err := rollback(); err != nil { + t.Fatalf("rollback: %v", err) + } + assertRolesEqual(t, removed, []windowsSandboxRole{windowsSandboxRoleOnline}) +} + +func restoreDualRoleSeams(t *testing.T, createdByRole map[windowsSandboxRole]bool, removed *[]windowsSandboxRole) { + t.Helper() + prevProvision := provisionWindowsSandboxPrincipalForSetupFn + prevApply := applyWindowsACLPlanFn + prevRemove := removeWindowsSandboxPrincipalForSetupFn + t.Cleanup(func() { + provisionWindowsSandboxPrincipalForSetupFn = prevProvision + applyWindowsACLPlanFn = prevApply + removeWindowsSandboxPrincipalForSetupFn = prevRemove + }) + + provisionWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) (windowsSandboxIdentity, bool, error) { + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return windowsSandboxIdentity{}, false, err + } + return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, createdByRole[role], nil + } + applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) { + return func() error { return nil }, nil + } + removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error { + *removed = append(*removed, role) + return nil + } +} + +func windowsSandboxTestConfig() WindowsSandboxCommandConfig { + return WindowsSandboxCommandConfig{ + SandboxHome: `C:\sandboxhome`, + CommandCWD: `C:\ws`, + WorkspaceRoots: []string{`C:\ws`}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\ws`}}, + }, + }, + } +} + +func assertRolesEqual(t *testing.T, got []windowsSandboxRole, want []windowsSandboxRole) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("removed %v, want %v", got, want) + } + seen := map[windowsSandboxRole]bool{} + for _, role := range got { + seen[role] = true + } + for _, role := range want { + if !seen[role] { + t.Fatalf("removed %v, want %v", got, want) + } + } +} diff --git a/internal/sandbox/windows_group_adoption_windows_test.go b/internal/sandbox/windows_group_adoption_windows_test.go index 6f18a6dcf..f1652d8f2 100644 --- a/internal/sandbox/windows_group_adoption_windows_test.go +++ b/internal/sandbox/windows_group_adoption_windows_test.go @@ -8,48 +8,62 @@ import ( "testing" ) -// A NAME IS NOT PROOF OF PROVENANCE. +// A NAME IS NOT PROOF OF PROVENANCE, for the USERS group specifically. // -// "Already exists" was treated as plain success, so any local group that -// happened to be called ZeroSandboxUsers was adopted: its members, and every -// grant already keyed to it, silently became part of the sandbox's identity. -// An unprivileged user cannot create a local group, but an administrator, an -// installer or an earlier tool can, and the sandbox would then inherit it. +// ensureWindowsLocalGroup covers both managed groups, and the sibling test in +// windows_offline_group_ownership_windows_test.go pins the offline one. This +// pins ZeroSandboxUsers, which is the group anandh8x reported: it holds every +// sandbox principal, so adopting a same-named group created by an installer or +// by policy would hand the principal whatever that group already grants. // -// Tested through the extracted decision rather than the syscall, so it needs no -// Administrator and leaves no group behind on the machine running the suite. -func TestAdoptingAForeignGroupOfOurNameIsRefused(t *testing.T) { +// Driven through the seams so it needs no Administrator and leaves no real local +// group on the machine running the suite. +func TestAdoptingAForeignUsersGroupIsRefused(t *testing.T) { for _, status := range []uintptr{nerrGroupExists, errorAliasExists} { - err := resolveWindowsSandboxGroupAdd(status, func() (bool, error) { return false, nil }) + prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn + t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned }) + + addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return status, nil } + windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { return false, nil } + + err := ensureWindowsSandboxGroup() if err == nil { - t.Fatalf("status %d adopted a group Zero did not create, handing the sandbox whatever it already grants", status) + t.Fatalf("status %d adopted a group Zero did not create, so the principal inherits whatever it already grants", status) } if !strings.Contains(err.Error(), windowsSandboxGroupName) { - t.Errorf("the refusal must name the group that is in the way, got %q", err) + t.Errorf("the refusal must name the group in the way, got %q", err) } } } -// Our own group is still adopted, or re-running setup would fail on the group -// it created a moment ago and provisioning would never converge. -func TestAdoptingOurOwnGroupStillSucceeds(t *testing.T) { - for _, status := range []uintptr{nerrGroupExists, errorAliasExists} { - if err := resolveWindowsSandboxGroupAdd(status, func() (bool, error) { return true, nil }); err != nil { - t.Errorf("status %d refused a group carrying Zero's own comment: %v", status, err) +// Our own group is still adopted, or re-running setup would fail on the group it +// created a moment ago and provisioning would never converge. +func TestAdoptingOurOwnUsersGroupSucceeds(t *testing.T) { + prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn + t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned }) + + addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil } + windowsLocalGroupOwnedByZeroFn = func(name, comment string) (bool, error) { + if name != windowsSandboxGroupName || comment != windowsSandboxGroupComment { + t.Errorf("ownership probed for %q/%q, want the users group", name, comment) } + return true, nil + } + if err := ensureWindowsSandboxGroup(); err != nil { + t.Errorf("refused a group carrying Zero's own comment: %v", err) } } -// A freshly created group is ours by construction, so no ownership probe should -// run at all. Without this the check could be satisfied by an implementation -// that interrogates the group it just made, which would be a wasted syscall and -// a needless failure mode. -func TestCreatingTheGroupDoesNotProbeOwnership(t *testing.T) { +// A freshly created group is ours by construction, so no ownership probe runs. +func TestCreatingTheUsersGroupDoesNotProbeOwnership(t *testing.T) { + prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn + t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned }) + probed := false - if err := resolveWindowsSandboxGroupAdd(nerrSuccess, func() (bool, error) { - probed = true - return false, nil - }); err != nil { + addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrSuccess, nil } + windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { probed = true; return false, nil } + + if err := ensureWindowsSandboxGroup(); err != nil { t.Fatalf("a successful create was rejected: %v", err) } if probed { @@ -57,19 +71,16 @@ func TestCreatingTheGroupDoesNotProbeOwnership(t *testing.T) { } } -// A failed ownership probe must not be read as "not ours" and must not be read -// as "ours" either. Neither guess is safe, so the error surfaces. -func TestAnUnreadableGroupIsNotGuessedEitherWay(t *testing.T) { +// A failed ownership probe is not read as "ours" and not as "not ours" either. +func TestAnUnreadableUsersGroupIsNotGuessedEitherWay(t *testing.T) { + prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn + t.Cleanup(func() { addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn = prevAdd, prevOwned }) + sentinel := errors.New("NetLocalGroupGetInfo: status 5") - err := resolveWindowsSandboxGroupAdd(nerrGroupExists, func() (bool, error) { return false, sentinel }) - if !errors.Is(err, sentinel) { - t.Fatalf("a failed ownership probe was swallowed, got %v", err) - } -} + addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil } + windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { return false, sentinel } -// A real API failure is still a failure; the new branch must not mask it. -func TestARealGroupAddFailureStillFails(t *testing.T) { - if err := resolveWindowsSandboxGroupAdd(errorAccessDenied32, func() (bool, error) { return true, nil }); err == nil { - t.Fatal("access denied was reported as success") + if err := ensureWindowsSandboxGroup(); !errors.Is(err, sentinel) { + t.Fatalf("a failed ownership probe was swallowed, got %v", err) } } diff --git a/internal/sandbox/windows_identity_policy_windows_test.go b/internal/sandbox/windows_identity_policy_windows_test.go index f1137cebd..5fcf55169 100644 --- a/internal/sandbox/windows_identity_policy_windows_test.go +++ b/internal/sandbox/windows_identity_policy_windows_test.go @@ -115,7 +115,7 @@ func findPrincipalACLEntry(plan WindowsACLPlan, action WindowsACLAction, path st // 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) + stubWindowsProvisioning(t, true, nil, nil, nil) rotated := false previous := resetWindowsSandboxUserPasswordFn @@ -125,7 +125,7 @@ func TestProvisionWindowsSandboxIdentityDefersPasswordRotation(t *testing.T) { return nil } - if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey"); err != nil { + if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil { t.Fatalf("provisioning an adopted account: %v", err) } if rotated { @@ -147,7 +147,7 @@ func TestWindowsSandboxUserCommentDistinguishesWorkspaces(t *testing.T) { } // 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") { + if windowsSandboxUserName("aaaaaaaaaaaabbbbbbbb", windowsSandboxRoleOffline) != windowsSandboxUserName("aaaaaaaaaaaacccccccc", windowsSandboxRoleOffline) { t.Skip("account names no longer collide for these keys; revisit what this test is for") } } @@ -168,7 +168,7 @@ func TestSetupRollbackRevokesRightsOnlyForCreatedPrincipals(t *testing.T) { "created principal": {existed: false, wantRevoked: true}, } { t.Run(name, func(t *testing.T) { - stubWindowsProvisioning(t, testCase.existed, nil, nil) + stubWindowsProvisioning(t, testCase.existed, nil, nil, nil) revoked := false prevGrant, prevRevoke := grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn @@ -189,7 +189,7 @@ func TestSetupRollbackRevokesRightsOnlyForCreatedPrincipals(t *testing.T) { SandboxHome: t.TempDir(), WorkspaceRoots: []string{`C:\ws`}, } - if _, _, err := provisionWindowsSandboxPrincipalForSetup(config); err == nil { + if _, _, err := provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline); err == nil { t.Fatal("provisioning reported success despite an injected grant failure") } if revoked != testCase.wantRevoked { @@ -246,7 +246,7 @@ func TestLookupWindowsSandboxIdentityRejectsForeignWorkspace(t *testing.T) { askedKey = workspaceKey return false, nil } - _, err := lookupWindowsSandboxIdentity("workspacekey") + _, err := lookupWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline) if err == nil { t.Fatal("lookup accepted an account belonging to another workspace") } @@ -267,7 +267,7 @@ func TestLookupWindowsSandboxIdentityAbsentAccountIsUnavailable(t *testing.T) { 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) { + if _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey", windowsSandboxRoleOffline); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { t.Fatalf("absent account returned %v, want errWindowsSandboxIdentityUnavailable", err) } } @@ -323,11 +323,15 @@ func TestSetupGrantsTheRuntimeRootCommandsActuallyUse(t *testing.T) { if err != nil { t.Fatalf("setupWindowsSandboxRuntimeRoot: %v", err) } - if granted == "" { + if len(granted) == 0 { 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) + // EVERY granted candidate has to exist, not just the one this process would + // pick: applyWindowsACLPlan fails the whole run on a target that is absent. + for _, root := range granted { + if info, err := os.Stat(root); 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)", root, err) + } } // What a command would actually use. @@ -338,18 +342,32 @@ func TestSetupGrantsTheRuntimeRootCommandsActuallyUse(t *testing.T) { if release != nil { defer release() } - if filepath.Clean(runtimeState.Root) != filepath.Clean(granted) { + // The command's choice has to be one setup provisioned. Setup grants the whole + // candidate set precisely because the choice is made per process, so the + // contract is membership rather than equality. + if !grantedRuntimeRootsCover(granted, runtimeState.Root) { t.Fatalf("setup granted %q but commands write to %q", granted, runtimeState.Root) } } +// grantedRuntimeRootsCover reports whether the root a command selected is one of +// the roots setup granted an ACE on. +func grantedRuntimeRootsCover(granted []string, selected string) bool { + for _, root := range granted { + if filepath.Clean(root) == filepath.Clean(selected) { + return true + } + } + return false +} + // 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 != "" { + if len(granted) != 0 { t.Fatalf("granted %q with no workspace configured", granted) } } @@ -362,13 +380,13 @@ func TestSetupRuntimeRootWithoutWorkspaceIsNotAnError(t *testing.T) { // 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) + stubWindowsProvisioning(t, true, nil, nil, nil) previous := windowsSandboxUserIsPrivilegedFn t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous }) windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return true, nil } - _, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + _, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline) if !errors.Is(err, errWindowsSandboxPrivilegedAccount) { t.Fatalf("provisioning adopted a privileged account, err = %v", err) } @@ -379,13 +397,13 @@ func TestProvisionWindowsSandboxIdentityRefusesPrivilegedAccount(t *testing.T) { // The ordinary adopted account is unaffected. func TestProvisionWindowsSandboxIdentityAdoptsUnprivilegedAccount(t *testing.T) { - stubWindowsProvisioning(t, true, nil, nil) + stubWindowsProvisioning(t, true, nil, nil, nil) previous := windowsSandboxUserIsPrivilegedFn t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous }) windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil } - if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey"); err != nil { + if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil { t.Fatalf("an unprivileged managed account must still be adopted: %v", err) } } diff --git a/internal/sandbox/windows_identity_privilege_recheck_windows_test.go b/internal/sandbox/windows_identity_privilege_recheck_windows_test.go index 9544b867a..796c80831 100644 --- a/internal/sandbox/windows_identity_privilege_recheck_windows_test.go +++ b/internal/sandbox/windows_identity_privilege_recheck_windows_test.go @@ -26,7 +26,7 @@ func TestLookupPrincipalForCommandRefusesAnAccountThatBecamePrivileged(t *testin return true, nil } - _, err = lookupWindowsSandboxPrincipalForCommand("workspace-key") + _, err = lookupWindowsSandboxPrincipalForCommand("workspace-key", windowsSandboxRoleOnline) if !errors.Is(err, errWindowsSandboxPrivilegedAccount) { t.Fatalf("err = %v, want errWindowsSandboxPrivilegedAccount", err) } @@ -49,7 +49,7 @@ func TestLookupPrincipalForCommandAcceptsAnUnprivilegedAccount(t *testing.T) { restoreLookupSeams(t, sid) windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil } - identity, err := lookupWindowsSandboxPrincipalForCommand("workspace-key") + identity, err := lookupWindowsSandboxPrincipalForCommand("workspace-key", windowsSandboxRoleOnline) if err != nil { t.Fatalf("lookupWindowsSandboxPrincipalForCommand: %v", err) } @@ -72,7 +72,7 @@ func TestLookupIdentityItselfDoesNotConsultPrivilege(t *testing.T) { return true, nil } - if _, err := lookupWindowsSandboxIdentity("workspace-key"); err != nil { + if _, err := lookupWindowsSandboxIdentity("workspace-key", windowsSandboxRoleOnline); err != nil { t.Fatalf("lookupWindowsSandboxIdentity: %v", err) } } diff --git a/internal/sandbox/windows_identity_rollback_windows_test.go b/internal/sandbox/windows_identity_rollback_windows_test.go index e8593b816..25210b76d 100644 --- a/internal/sandbox/windows_identity_rollback_windows_test.go +++ b/internal/sandbox/windows_identity_rollback_windows_test.go @@ -13,23 +13,39 @@ import ( // 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) { +func stubWindowsProvisioning(t *testing.T, existed bool, groupErr error, offlineErr error, sidErr error) { t.Helper() prevGroup, prevUser := ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn + prevOfflineGroup := ensureWindowsSandboxOfflineGroupFn prevAdd, prevSID := addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn prevManaged := windowsSandboxUserIsManagedFn + prevOffline := addWindowsSandboxUserToOfflineGroupFn + prevReset := resetWindowsSandboxUserPasswordFn t.Cleanup(func() { ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn = prevGroup, prevUser + ensureWindowsSandboxOfflineGroupFn = prevOfflineGroup addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn = prevAdd, prevSID windowsSandboxUserIsManagedFn = prevManaged + addWindowsSandboxUserToOfflineGroupFn = prevOffline + resetWindowsSandboxUserPasswordFn = prevReset }) ensureWindowsSandboxGroupFn = func() error { return nil } + ensureWindowsSandboxOfflineGroupFn = func() error { return 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 } + // Stubbed even though provisioning no longer rotates: rotation moved to the + // caller, so nothing under test reaches this today. Left in so that if it + // ever moves back, these tests fail rather than resetting the password of a + // real managed account that happens to exist on the machine running them. + resetWindowsSandboxUserPasswordFn = func(string, string) error { + t.Fatal("provisioning must not rotate an account password; rotation belongs to the caller, immediately before the secret is written") + return nil + } ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return existed, nil } addWindowsSandboxUserToGroupFn = func(string) error { return groupErr } + addWindowsSandboxUserToOfflineGroupFn = func(string) error { return offlineErr } resolveWindowsSandboxSIDFn = func(username string) (*windows.SID, error) { if sidErr != nil { return nil, sidErr @@ -46,19 +62,22 @@ func stubWindowsProvisioning(t *testing.T, existed bool, groupErr error, sidErr // 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") + offlineFailure := errors.New("offline group attachment refused by policy") sidFailure := errors.New("sid lookup failed") for name, testCase := range map[string]struct { - groupErr error - sidErr error + groupErr error + offlineErr error + sidErr error }{ - "group attachment fails": {groupErr: groupFailure}, - "sid resolution fails": {sidErr: sidFailure}, + "group attachment fails": {groupErr: groupFailure}, + "offline group attachment fails": {offlineErr: offlineFailure}, + "sid resolution fails": {sidErr: sidFailure}, } { t.Run(name, func(t *testing.T) { - stubWindowsProvisioning(t, false, testCase.groupErr, testCase.sidErr) + stubWindowsProvisioning(t, false, testCase.groupErr, testCase.offlineErr, testCase.sidErr) - identity, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + identity, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline) if err == nil { t.Fatal("provisioning reported success despite an injected failure") } @@ -69,7 +88,7 @@ func TestProvisionWindowsSandboxIdentityReturnsNameForRollback(t *testing.T) { if identity.Username == "" { t.Fatal("identity carries no username, so the rollback deletes \"\" and strands the account") } - if want := windowsSandboxUserName("workspacekey"); identity.Username != want { + if want := windowsSandboxUserName("workspacekey", windowsSandboxRoleOffline); identity.Username != want { t.Fatalf("username = %q, want %q", identity.Username, want) } }) @@ -80,9 +99,9 @@ func TestProvisionWindowsSandboxIdentityReturnsNameForRollback(t *testing.T) { // 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) + stubWindowsProvisioning(t, true, errors.New("group attachment refused"), nil, nil) - _, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + _, _, created, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline) if err == nil { t.Fatal("provisioning reported success despite an injected failure") } diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 79dce513d..00d268647 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -55,22 +55,29 @@ func windowsSandboxWorkspaceKey(workspaceRoots []string) string { // 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. - // - // Asked of the shared predicate rather than re-tested here, so `zero doctor` - // reports exactly the rule this path applies. Two copies would drift, and the - // failure mode of drift is doctor telling an operator reads are confined - // while commands quietly run on the restricted token. - return WindowsSandboxPrincipalInactiveReason(true, config.PermissionProfile.Network.Mode) == "" + return windowsSandboxIdentityEnabled(config.Env) +} + +// windowsSandboxRoleForNetwork maps a command's network mode onto the principal +// that enforces it. +// +// The two roles differ only in membership of the offline group, which the block +// filters are keyed to. That indirection exists because network denial cannot be +// expressed on a logon token the way it is on a restricted token: the restricted +// token carries the offline-marker SID as a restricting SID, and LogonUser has +// no equivalent, since it builds a token from the account's real memberships. +// +// Defaulting anything that is not an explicit allow to the offline principal is +// deliberate. A mode this does not recognise should lose the network, not keep +// it. +func windowsSandboxRoleForNetwork(mode NetworkMode) windowsSandboxRole { + // Delegated so this and `zero doctor` answer from one rule. Doctor reporting a + // different account than the runtime actually uses is the drift that made the + // old inactive-reason helper wrong. + if WindowsSandboxPrincipalRoleForNetwork(mode) == string(windowsSandboxRoleOnline) { + return windowsSandboxRoleOnline + } + return windowsSandboxRoleOffline } // windowsSandboxPrincipalToken returns a token for this workspace's sandbox @@ -101,7 +108,11 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T return 0, false, nil } key := windowsSandboxPrincipalKey(config) - identity, err := lookupWindowsSandboxPrincipalForCommand(key) + // The network mode picks the principal. Both are provisioned by setup; the + // offline one is in the group the block filters match, so choosing here is + // what enforces the mode. + role := windowsSandboxRoleForNetwork(config.PermissionProfile.Network.Mode) + identity, err := lookupWindowsSandboxPrincipalForCommandFn(key, role) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { // Not provisioned. On the restricted-token tier the marker check has @@ -118,11 +129,31 @@ func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.T // operator has to see, not a reason to pretend setup never ran. return 0, false, err } + // Selecting the offline account is only half of what denies it the network. + // The WFP filters match the OFFLINE GROUP'S SID, so an account that has + // drifted out of that group — local policy, an administrator, a re-setup that + // could not re-add it — still logs on from a stored secret and its token no + // longer satisfies the filter condition. It would get full egress under a + // profile that asked for none. Re-check the membership the mode depends on + // rather than trusting the marker written when setup last succeeded. + if role == windowsSandboxRoleOffline { + member, err := windowsSandboxUserInLocalGroupFn(identity.Username, windowsSandboxOfflineGroupName) + if err != nil { + return 0, false, err + } + if !member { + // Fail closed toward the weaker-but-still-denied path: the restricted + // token carries the offline marker the same filters match, so egress + // stays blocked. Handing back this principal token would not. + warnWindowsSandboxOfflineMembershipMissing(identity.Username) + return 0, false, nil + } + } secretPath, err := windowsSandboxSecretPath(config.SandboxHome, identity.Username) if err != nil { return 0, false, err } - password, err := readWindowsSandboxSecret(secretPath) + password, err := readWindowsSandboxSecretFn(secretPath) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { // The account exists but its password does not. Setup was interrupted @@ -187,6 +218,24 @@ var warnWindowsSandboxPrincipalNotUsed = func(reason string) { var windowsSandboxPrincipalNotUsedWarnOnce sync.Once +// Seamed so the dual-role setup rollback can be exercised without an elevated +// machine. The contract under test is which principals the outer rollback is +// willing to delete, which is a data-loss decision and the one thing here worth +// proving rather than reasoning about. +var ( + provisionWindowsSandboxPrincipalForSetupFn = provisionWindowsSandboxPrincipalForSetup + removeWindowsSandboxPrincipalForSetupFn = removeWindowsSandboxPrincipalForSetup + // The both-roles retirement setup runs when the opt-in is off. It belongs + // beside its single-role sibling for the reason recorded below: one seam + // declared in two places lets a test stub one while production uses the + // other. + removeWindowsSandboxPrincipalsForSetupFn = removeWindowsSandboxPrincipalsForSetup + // applyWindowsACLPlanFn lives with the other identity seams in + // windows_identity_windows.go — the ACL-ordering guarantee it exists for + // predates the dual-role split, and two declarations of the same seam would + // let a test stub one while production used the other. +) + // 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`. @@ -194,9 +243,9 @@ var windowsSandboxPrincipalNotUsedWarnOnce sync.Once // 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, bool, error) { +func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig, role windowsSandboxRole) (windowsSandboxIdentity, bool, error) { key := windowsSandboxPrincipalKey(config) - identity, password, created, err := provisionWindowsSandboxIdentityFn(key) + identity, password, created, err := provisionWindowsSandboxIdentityFn(key, role) // Undo whatever this run actually did, in reverse, on any failure after the // account exists. Without it a failure between creating the account and @@ -212,7 +261,7 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig rotated := false // Resolved from the account name rather than the identity, so it is known // before anything can fail. - secretPath, secretPathErr := windowsSandboxSecretPath(config.SandboxHome, windowsSandboxUserName(key)) + secretPath, secretPathErr := windowsSandboxSecretPath(config.SandboxHome, windowsSandboxUserName(key, role)) 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, @@ -261,7 +310,7 @@ func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig _ = revokeWindowsSandboxLogonRightsFn(identity.SID) } if created { - _ = removeWindowsSandboxIdentity(identity.Username) + _ = removeWindowsSandboxIdentity(identity.Username, key) } return cleanupErr } @@ -310,110 +359,145 @@ 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(windowsSandboxPrincipalKey(config)) - // 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 - } - // 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 + key := windowsSandboxPrincipalKey(config) + // Both roles are provisioned regardless of the profile this setup ran with. + // Setup needs elevation and a command does not, so a command whose network + // mode differs from the one setup happened to see must still find a principal + // waiting; provisioning lazily would mean an unelevated command discovering it + // needs an account it cannot create. + roles := []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline} + + var undo []func() error + rollback := func() error { + // Unwind in reverse, and keep going after a failure so one broken step + // cannot strand everything behind it. The first error is reported. + var firstErr error + for i := len(undo) - 1; i >= 0; i-- { + if err := undo[i](); err != nil && firstErr == nil { + firstErr = err + } } - return removeWindowsSandboxPrincipalForSetup(config) + return firstErr } filesystem := config.PermissionProfile.FileSystem - writeRoots := filesystem.WriteRoots - // The runtime tree has to be granted here, at setup, because nothing grants it - // later. + // Resolved once, before the loop: both principals need write access to the + // same runtime tree. // - // 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}) - } - revertACL, err := applyWindowsPrincipalACLs(config.SandboxHome, username, identity.SID.String(), filesystem, writeRoots) + // permissionProfileWithRuntime appends this root to WriteRoots on every + // COMMAND and redirects HOME, GOCACHE, npm_config_cache and friends into it, + // but it lives under the user cache rather than 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. A principal is a separate + // local account with none of those rights, 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. + runtimeRoots, err := setupWindowsSandboxRuntimeRoot(config) 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 + + for _, role := range roles { + username := windowsSandboxUserName(key, role) + // 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 the + // 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. + // + // Per role, like everything else in this loop: the two principals are + // separate accounts with separate records, and one having lost its record + // says nothing about the other. + if _, recorded := readWindowsPrincipalACLLedger(config.SandboxHome, username); !recorded { + if err := retireUnrecordedWindowsSandboxPrincipal(config, role); err != nil { + _ = rollback() + return nil, err + } } - return removeErr - }, nil + identity, created, err := provisionWindowsSandboxPrincipalForSetupFn(config, role) + if err != nil { + _ = rollback() + return nil, err + } + // Only for a principal this run created. + // + // Appending unconditionally meant that if the offline role adopted an + // account that already existed and the online role, or any later step, + // then failed, the outer rollback deleted a principal that was working + // before setup started. Dual-role provisioning makes that likely rather + // than unlucky: the first role usually succeeds, so there is almost + // always something for a later failure to destroy. This mirrors the + // contract provisioning already applies to its own inner rollback. + if created { + undo = append(undo, func() error { return removeWindowsSandboxPrincipalForSetupFn(config, role) }) + } + + // Both principals get the same filesystem access. They differ only in + // network reach, so granting the offline one less would make an approved + // network command see a different filesystem than an ordinary one. + writeRoots := filesystem.WriteRoots + for _, root := range runtimeRoots { + writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: root}) + } + // Revokes this trustee's existing ACEs on the paths the plan touches AND + // on the paths this role's record names, before applying it, so a re-run + // after narrowing a root does not leave the wider grant behind. Per role: + // the two principals are separate trustees with separate records, and + // revoking one must not disturb the other. + revertACL, err := applyWindowsPrincipalACLs(config.SandboxHome, username, identity.SID.String(), filesystem, writeRoots) + if err != nil { + _ = rollback() + return nil, err + } + // ACEs are reverted before the account they name is deleted, because + // removing the account first leaves ACEs naming a SID that no longer + // resolves, which is the orphaned residue this model exists to avoid. + // Appending after the removal closure puts it earlier in the reverse + // unwind, which is what gets that ordering. + undo = append(undo, revertACL) + } + return rollback, nil } -// retireUnrecordedWindowsSandboxPrincipal removes this workspace's principal -// when one exists, and does nothing when one does not. +// retireUnrecordedWindowsSandboxPrincipal removes this role'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(windowsSandboxPrincipalKey(config)) +// +// Scoped to one role. The other role is a separate account holding its own ACEs +// under its own record, so retiring both because one lost its record would +// destroy a principal there is nothing wrong with. +func retireUnrecordedWindowsSandboxPrincipal(config WindowsSandboxCommandConfig, role windowsSandboxRole) error { + _, err := lookupWindowsSandboxIdentityFn(windowsSandboxPrincipalKey(config), role) if err != nil { if errors.Is(err, errWindowsSandboxIdentityUnavailable) { return nil } return err } - return removeWindowsSandboxPrincipalForSetupFn(config) + return removeWindowsSandboxPrincipalForSetupFn(config, role) } -// removeWindowsSandboxPrincipalForSetup retires a workspace's principal in the +// removeWindowsSandboxPrincipalForSetup retires one role'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 { +func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig, role windowsSandboxRole) error { key := windowsSandboxPrincipalKey(config) - username := windowsSandboxUserName(key) + username := windowsSandboxUserName(key, role) secretPath, err := windowsSandboxSecretPath(config.SandboxHome, username) if err != nil { return err @@ -442,12 +526,14 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // 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(windowsSandboxPrincipalKey(config)); err == nil { + if identity, err := lookupWindowsSandboxIdentity(key, role); 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. + // trustee, so it clears grants written by older versions too, and it is + // scoped to THIS role's principal — the other role is a separate trustee + // whose ACEs must survive one role being retired. // // 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 @@ -464,7 +550,11 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e // 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()) + // + // The revocation is per ROLE: each workspace has two principals and they + // are separate trustees, so the ledger has to be asked for this one's + // paths rather than the workspace's. + paths, pathsErr := windowsPrincipalRevocationPaths(config, identity.SID.String(), role) 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 { @@ -476,11 +566,23 @@ func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) e } else if !errors.Is(err, errWindowsSandboxIdentityUnavailable) { return err } - 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. + // A foreign account under our derived name is left in place deliberately, and + // that is the goal state for teardown: no principal of ours exists under it. + // Surfacing it as a teardown failure would make setup rollback report an + // error for having correctly declined to delete somebody else's account. + if err := removeWindowsSandboxIdentity(username, key); err != nil { + if !errors.Is(err, errWindowsSandboxForeignAccountRetained) { + return err + } + // The record is KEPT in that case. It names paths this role's principal + // was granted, and a foreign account holding the name is not evidence + // those grants are gone — dropping the record would leave them + // unrevokable, which is the whole failure it exists to prevent. A record + // with no principal is revoked harmlessly by the next setup. + return nil + } + // Otherwise 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 @@ -513,42 +615,44 @@ 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 windowsSandboxRuntimeRootPath(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) - } - // 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") - } - return sandboxRuntimeRootFor(workspaceRoot, cacheRoot) +func windowsSandboxRuntimeRootPath(config WindowsSandboxCommandConfig) ([]string, error) { + // EVERY candidate, not just the cache-derived one. + // + // This used to take the deterministic cache-derived root alone and return + // nothing when the cache sat inside the workspace, on the reasoning that the + // other branch minted a random per-process directory through os.MkdirTemp and + // so had no name both sides could agree on. That reasoning is now stale: + // fallbackSandboxRuntimeRoot derives its path by hashing the workspace and + // creates nothing, so every process reaches the same answer. + // + // Leaving it stale had a cost. Commands still SELECT the fallback in that + // layout, and prepareSandboxRuntime redirects TMP, GOCACHE and the package + // caches into it, while setup granted neither principal an ACE on it. A + // principal command in a supported layout then failed ordinary cache writes + // with a bare ACCESS_DENIED and nothing naming the sandbox as the cause. + // + // So this now answers with the same candidate set the capability plan already + // grants, and for the same reason: setup covers both so a command lands on a + // provisioned tree whichever one it picks. + return windowsSandboxRuntimeCandidates(config.WorkspaceRoots), 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. -func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) (string, error) { - root, err := windowsSandboxRuntimeRootPath(config) - if err != nil || root == "" { - return "", err +func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) ([]string, error) { + roots, err := windowsSandboxRuntimeRootPath(config) + if err != nil { + return nil, err } - if err := os.MkdirAll(root, 0o700); err != nil { - return "", fmt.Errorf("create sandbox runtime root: %w", err) + // Every candidate is created, because setup grants an ACE on every candidate + // and applyWindowsACLPlan fails the whole run on a target that does not exist. + for _, root := range roots { + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, fmt.Errorf("create sandbox runtime root: %w", err) + } } - return root, nil + return roots, nil } // revokeWindowsPrincipalACEs drops every ACE naming principalSID on paths and @@ -707,12 +811,14 @@ func applyWindowsPrincipalACLs(sandboxHome string, username string, principalSID func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principalSID string) ([]string, error) { filesystem := config.PermissionProfile.FileSystem writeRoots := filesystem.WriteRoots - runtimeRoot, err := windowsSandboxRuntimeRootPath(config) + runtimeRoots, err := windowsSandboxRuntimeRootPath(config) if err != nil { return nil, err } - if runtimeRoot != "" { - writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: runtimeRoot}) + // Every candidate, matching what setup granted: teardown that revoked only + // one would leave principal ACEs on the tree commands actually used. + for _, root := range runtimeRoots { + writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: root}) } plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ PrincipalSID: principalSID, @@ -735,13 +841,15 @@ func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principal // 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) { +func windowsPrincipalRevocationPaths(config WindowsSandboxCommandConfig, principalSID string, role windowsSandboxRole) ([]string, error) { current, err := windowsPrincipalTeardownPaths(config, principalSID) if err != nil { return nil, err } + // This role's record, not the workspace's: the other role is a separate + // trustee whose recorded paths are none of this revocation's business. recorded, _ := readWindowsPrincipalACLLedger( - config.SandboxHome, windowsSandboxUserName(windowsSandboxPrincipalKey(config))) + config.SandboxHome, windowsSandboxUserName(windowsSandboxPrincipalKey(config), role)) return unionWindowsPrincipalACLPaths(recorded, current), nil } @@ -753,14 +861,44 @@ var ( 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 -) +// warnWindowsSandboxOfflineMembershipMissing reports the one drift that would +// otherwise silently hand a no-network profile full egress. +// +// Separate from the missing-secret warning because the remedy differs and +// because this one is a containment failure rather than a setup gap: the +// account is fine, its group membership is not. Once per process, for the same +// reason as its sibling — this sits on the command path. +var warnWindowsSandboxOfflineMembershipMissing = func(username string) { + windowsSandboxOfflineMembershipWarnOnce.Do(func() { + fmt.Fprintf(os.Stderr, + "[zero] sandbox principal %q is no longer a member of %q, which is the group the network "+ + "block filters match. Falling back to the restricted-token sandbox, which still denies "+ + "the network but does not confine reads. "+ + "Re-run `zero sandbox setup` from an elevated terminal to restore it.\n", + username, windowsSandboxOfflineGroupName) + }) +} + +var windowsSandboxOfflineMembershipWarnOnce sync.Once + +// Seam for the principal lookup, so the command path's mode-enforcement checks +// are reachable in tests without a provisioned machine. +var lookupWindowsSandboxPrincipalForCommandFn = lookupWindowsSandboxPrincipalForCommand + +// Seam for the secret read on the command path, so the mode-enforcement gates +// ahead of it can be exercised without a provisioned secret on disk. +var readWindowsSandboxSecretFn = readWindowsSandboxSecret + +// Seam for the lookup the unrecorded-principal retirement decides on, so that +// decision 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. +// +// The retirement's other elevated call, removeWindowsSandboxPrincipalForSetup, +// is seamed with the dual-role rollback seams above rather than here, for the +// reason recorded there: two declarations of one seam let a test stub one while +// production uses the other. +var lookupWindowsSandboxIdentityFn = lookupWindowsSandboxIdentity // windowsACLPlanPaths returns each distinct path a plan touches, in plan order. // @@ -779,6 +917,39 @@ func windowsACLPlanPaths(plan WindowsACLPlan) []string { return paths } +// removeWindowsSandboxPrincipalsForSetup retires BOTH of a workspace's +// principals. +// +// Each workspace has an offline and an online account, and they are separate +// trustees with their own secret, logon rights, ACEs and ledger entries. +// Retiring one and calling it done is what the opt-out path is there to +// prevent: the marker would flip to opted-out while the other account and +// everything it owns stayed on the machine, invisible, because nothing +// afterwards looks for a principal it believes was never provisioned. +// +// Both are attempted even if the first fails, and the errors are joined, so one +// stubborn account cannot hide residue left by the other. +// windowsSandboxRoleLegacy is retired alongside the two live roles. A machine +// set up before the roles were split holds one untagged "zero-sbx-" +// account, and neither role name resolves to it, so every other path on this +// branch reports it as never provisioned. Opting out or re-running setup would +// otherwise leave that account, its secret, its logon rights, its ACEs and its +// ledger installed and unreferenced. Retirement is idempotent and a principal +// that was never provisioned is not an error, so this costs a lookup on the +// machines that never had one. +func removeWindowsSandboxPrincipalsForSetup(config WindowsSandboxCommandConfig) error { + var errs []error + for _, role := range []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline, windowsSandboxRoleLegacy} { + // Through the seam, like retireUnrecordedWindowsSandboxPrincipal, so the + // set of roles this retires is assertable without provisioning real + // accounts on the machine running the tests. + if err := removeWindowsSandboxPrincipalForSetupFn(config, role); err != nil { + errs = append(errs, fmt.Errorf("retire the %s sandbox principal: %w", role, err)) + } + } + return errors.Join(errs...) +} + // windowsCurrentUserSID returns the SID of the user this process runs as, or // empty when the token cannot be read. Elevation does not change it: a UAC // consent prompt splits the caller's token but keeps the user, so this differs diff --git a/internal/sandbox/windows_identity_runtime_windows_test.go b/internal/sandbox/windows_identity_runtime_windows_test.go index 0332c0bdb..7d5fff53b 100644 --- a/internal/sandbox/windows_identity_runtime_windows_test.go +++ b/internal/sandbox/windows_identity_runtime_windows_test.go @@ -206,10 +206,10 @@ func TestWindowsSandboxWorkspaceKeyIsStableAndDistinct(t *testing.T) { // 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) { +// The backend now runs under both network modes, so the opt-in is the only thing +// eligibility turns on. The mode selects which principal is used, not whether one +// is used at all. +func TestPrincipalBackendEligibilityTurnsOnlyOnTheOptIn(t *testing.T) { eligible := func(mode NetworkMode, optIn string) bool { config := WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), @@ -220,16 +220,34 @@ func TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied(t *testing.T) 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") + for _, mode := range []NetworkMode{NetworkDeny, NetworkAllow} { + if !eligible(mode, "1") { + t.Fatalf("principal backend refused with network %q; the mode should choose a principal, not disable the backend", mode) + } + if eligible(mode, "0") { + t.Fatalf("principal backend eligible without the opt-in for network %q", mode) + } + } +} + +// The network mode is enforced by WHICH principal runs, because the offline one +// is a member of the group the block filters match. Picking the wrong role is +// therefore a silent loss of network enforcement, so the mapping is asserted +// directly rather than inferred from a token that only exists on a provisioned +// machine. +func TestPrincipalRoleFollowsNetworkMode(t *testing.T) { + if got := windowsSandboxRoleForNetwork(NetworkDeny); got != windowsSandboxRoleOffline { + t.Fatalf("deny selected %q, want the offline principal; the online one is not in the blocked group and would have the network", got) } - // 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 got := windowsSandboxRoleForNetwork(NetworkAllow); got != windowsSandboxRoleOnline { + t.Fatalf("allow selected %q, want the online principal", got) } - if eligible(NetworkAllow, "0") { - t.Fatal("principal backend eligible without the opt-in") + // Fail closed. An unrecognised mode must lose the network rather than keep it, + // so anything that is not an explicit allow maps to the offline principal. + for _, mode := range []NetworkMode{NetworkMode(""), NetworkMode("bogus"), NetworkMode("ALLOW")} { + if got := windowsSandboxRoleForNetwork(mode); got != windowsSandboxRoleOffline { + t.Fatalf("unrecognised mode %q selected %q, want the offline principal so an unknown mode fails closed", mode, got) + } } } diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index 36817095c..e9fd1e6a4 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -55,20 +55,75 @@ const ( windowsSandboxUserComment = "Zero sandbox principal (managed)" windowsSandboxUserCommentKey = windowsSandboxUserComment + " key=" windowsSandboxUserNameMax = 20 + + // windowsSandboxOfflineGroupName is what the network block filters are keyed + // to. Network denial cannot be expressed on a principal's own token the way + // it is on a restricted token: the block filters match the offline-marker + // SID, which is a synthetic capability SID, and LogonUser mints a token from + // an account's real group memberships rather than from arbitrary SIDs. A + // principal is therefore denied the network by being a MEMBER of this group, + // whose SID the filters also match. + // + // A group rather than the offline principal's own SID because principals are + // per workspace: one filter set covers every offline principal on the machine + // instead of needing a filter per workspace. + windowsSandboxOfflineGroupName = "ZeroSandboxOffline" + windowsSandboxOfflineGroupComment = "Zero sandbox principals denied network access (managed)" +) + +// windowsSandboxRole distinguishes the two principals a workspace gets. They are +// separate accounts rather than one account reconfigured per command, because +// the network decision is baked into group membership at setup time and setup +// needs elevation; flipping it per command would need an elevated hop on every +// command. +type windowsSandboxRole string + +const ( + // windowsSandboxRoleOffline is a member of the offline group, so the block + // filters match its token and it has no network. + windowsSandboxRoleOffline windowsSandboxRole = "offline" + // windowsSandboxRoleOnline is not, so an approved network command reaches the + // network while still being read-confined by having its own identity. + windowsSandboxRoleOnline windowsSandboxRole = "online" + // windowsSandboxRoleLegacy names the SINGLE UNTAGGED account this branch's + // predecessor provisioned as "zero-sbx-", before the roles were split. + // + // It is never provisioned and must never be passed to a provisioning path: + // it exists so the ordered retirement below can still derive that name and + // remove the account, its secret, its logon rights, its ACEs and its ledger. + // Without it an upgraded machine keeps a fully privileged principal that + // nothing afterwards looks for, because both roles report themselves absent. + windowsSandboxRoleLegacy windowsSandboxRole = "legacy" ) +// roleTag is the single character that distinguishes the two accounts for a +// workspace. One character because the 20-character account-name limit is +// already tight and every character spent here is a bit of workspace hash lost. +func (role windowsSandboxRole) roleTag() string { + switch role { + case windowsSandboxRoleOnline: + return "n" + case windowsSandboxRoleLegacy: + // Deliberately empty: this reproduces the pre-split name exactly, which + // is the only way retirement can find what the predecessor installed. + return "" + default: + return "d" + } +} + // 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 + nerrSuccess = 0 + nerrGroupExists = 2223 + // NERR_GroupNotFound, the group half of nerrUserNotFound below. + nerrGroupNotFound = 2220 nerrUserExists = 2224 errorAliasExists = 1379 errorMemberInAlias = 1378 errorAccessDenied32 = 5 nerrUserNotFound = 2221 - // NERR_GroupNotFound, the group half of nerrUserNotFound above. - nerrGroupNotFound = 2220 ) // USER_INFO_1 privilege and flag values. @@ -84,13 +139,13 @@ var ( netapi32 = windows.NewLazySystemDLL("netapi32.dll") procNetUserAdd = netapi32.NewProc("NetUserAdd") procNetLocalGroupAdd = netapi32.NewProc("NetLocalGroupAdd") + procNetLocalGroupGetInfo = netapi32.NewProc("NetLocalGroupGetInfo") procNetLocalGroupAddMembers = netapi32.NewProc("NetLocalGroupAddMembers") procNetUserDel = netapi32.NewProc("NetUserDel") procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo") procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo") procNetApiBufferFree = netapi32.NewProc("NetApiBufferFree") procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups") - procNetLocalGroupGetInfo = netapi32.NewProc("NetLocalGroupGetInfo") ) // userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32 @@ -146,12 +201,17 @@ func (identity windowsSandboxIdentity) String() string { return identity.Username + " (" + identity.SID.String() + ")" } -// windowsSandboxUserName derives a stable account name for a workspace key. The -// 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. -func windowsSandboxUserName(workspaceKey string) string { +// windowsSandboxUserName derives a stable account name for a workspace key and +// role. The 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 and role always map to the same account, so +// re-running setup reuses the principals instead of accumulating accounts. +// +// The role tag sits before the hash rather than after it, so truncation eats +// hash characters and never the tag. A name that lost its tag would collide the +// two roles onto one account, which would silently put an online principal in +// the offline group or the reverse. +func windowsSandboxUserName(workspaceKey string, role windowsSandboxRole) string { cleaned := strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': @@ -165,7 +225,7 @@ func windowsSandboxUserName(workspaceKey string) string { if cleaned == "" { cleaned = "default" } - name := windowsSandboxUserPrefix + cleaned + name := windowsSandboxUserPrefix + role.roleTag() + cleaned if len(name) > windowsSandboxUserNameMax { name = name[:windowsSandboxUserNameMax] } @@ -215,85 +275,113 @@ func netAPIStatus(call string, status uintptr, okStatuses ...uintptr) error { return fmt.Errorf("%s: status %d", call, status) } -// windowsSandboxGroupIsOwned reports whether an EXISTING group of our name -// carries Zero's managed comment. +// ensureWindowsSandboxGroup creates the managed local group, or adopts it when +// it already exists AND carries our ownership comment. +func ensureWindowsSandboxGroup() error { + return ensureWindowsLocalGroup(windowsSandboxGroupName, windowsSandboxGroupComment) +} + +// ensureWindowsSandboxOfflineGroup creates the group the network block filters +// are keyed to. Membership of it is what denies a principal the network, so it +// has to exist before any offline principal is created. +func ensureWindowsSandboxOfflineGroup() error { + return ensureWindowsLocalGroup(windowsSandboxOfflineGroupName, windowsSandboxOfflineGroupComment) +} + +// Wires the portable network planner to the Windows group lookup. Done here so +// windows_network.go can stay free of Win32 calls while still folding the group +// into the filter identity set. +func init() { + resolveWindowsSandboxOfflineGroupSIDHook = resolveWindowsSandboxOfflineGroupSID +} + +// resolveWindowsSandboxOfflineGroupSID returns the SID the block filters key to. // -// Mirrors windowsSandboxUserIsManaged, which asks the same question of an -// account, and for the same reason: a name is not proof of provenance. -func windowsSandboxGroupIsOwned() (bool, error) { - name, err := windows.UTF16PtrFromString(windowsSandboxGroupName) +// Reports ("", nil) when the group does not exist, which is the state before +// setup has ever run. Callers fold that into "no extra identity", so the plan +// they compute matches what an un-provisioned machine would produce rather than +// failing to build at all. +func resolveWindowsSandboxOfflineGroupSID() (string, error) { + sid, _, accountType, err := windows.LookupSID("", windowsSandboxOfflineGroupName) if err != nil { - return false, err - } - var buffer *byte - status, _, _ := procNetLocalGroupGetInfo.Call( - 0, // local machine - uintptr(unsafe.Pointer(name)), - 1, // level: LOCALGROUP_INFO_1 - uintptr(unsafe.Pointer(&buffer)), - ) - runtime.KeepAlive(name) - if status == nerrGroupNotFound { - return false, nil + if errors.Is(err, windows.ERROR_NONE_MAPPED) { + return "", nil + } + return "", fmt.Errorf("look up %s: %w", windowsSandboxOfflineGroupName, err) } - if err := netAPIStatus("NetLocalGroupGetInfo", status); err != nil { - return false, err + // A local group is an alias. Anything else means the name has been taken by + // something that is not ours, and keying network filters to it would be both + // wrong and a way to affect unrelated accounts. + if accountType != windows.SidTypeAlias && accountType != windows.SidTypeGroup { + return "", fmt.Errorf("%s resolves to a non-group account (type %d)", windowsSandboxOfflineGroupName, accountType) } - if buffer == nil { - return false, nil + // Being a group is not enough: it has to be OUR group. + // + // The ownership check used to live only on the principal-provisioning path, + // so an opt-out setup reached here and adopted whatever carried the name. + // applyWindowsNetworkPlan turns every SID in the plan into an allowed-to-match + // WFP descriptor, so adopting a foreign group installs global deny filters + // against every one of ITS members: someone who happens to have a local group + // by this name loses the network for those accounts because we ran setup. + // + // Refused rather than skipped. Returning "" would build a plan whose filters + // cover no principal at all while reporting a successful setup, and a network + // deny that silently covers nothing is the failure this backend exists to + // prevent. A squatted name is an operator problem and has to say so. + owned, err := windowsLocalGroupOwnedByZero(windowsSandboxOfflineGroupName, windowsSandboxOfflineGroupComment) + if err != nil { + return "", err } - defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer))) - info := (*localGroupInfo1)(unsafe.Pointer(buffer)) - if info.Comment == nil { - return false, nil + if !owned { + return "", fmt.Errorf("%s exists but is not the group this setup manages; rename or remove it, then re-run `zero sandbox setup`", windowsSandboxOfflineGroupName) } - return windows.UTF16PtrToString(info.Comment) == windowsSandboxGroupComment, nil + return sid.String(), nil } -// resolveWindowsSandboxGroupAdd turns NetLocalGroupAdd's status into a verdict. -// -// Split out from the syscall so the DECISION can be tested without creating a -// real local group, which needs Administrator and would leave machine state -// behind. -// -// "Already exists" used to be treated as plain success, so any local group that -// happened to be called ZeroSandboxUsers was adopted: its members, and every -// grant already keyed to it, silently became part of the sandbox's identity. -// A name is not proof of provenance. Creating the group ourselves needs no -// check, since we just made it. Adopting one does. -func resolveWindowsSandboxGroupAdd(status uintptr, owned func() (bool, error)) error { - if err := netAPIStatus("NetLocalGroupAdd", status, nerrGroupExists, errorAliasExists); err != nil { - return err - } - if status != nerrGroupExists && status != errorAliasExists { - return nil - } - isOwned, err := owned() +// ensureWindowsLocalGroup creates a local group, or leaves it alone when it +// already exists. +func ensureWindowsLocalGroup(groupName string, groupComment string) error { + status, err := addWindowsLocalGroupFn(groupName, groupComment) if err != nil { return err } - if !isOwned { - // Refused rather than adopted, renamed around, or deleted. Removing - // somebody else's group would be destructive, and provisioning into it - // would hand the sandbox whatever that group already grants, so the only - // safe move is to stop and name what is in the way. - return fmt.Errorf("a local group named %s already exists but was not created by Zero (its comment is not %q); "+ - "rename or remove it, or the sandbox principal would inherit whatever that group already grants", - windowsSandboxGroupName, windowsSandboxGroupComment) + if status == nerrGroupExists || status == errorAliasExists { + // A group with this name already exists, which is the normal re-run case + // — but only if it is OURS. The offline group's SID is installed on the + // persistent WFP deny filters and the sandbox principal is made a member + // of it, so silently adopting a same-named group created by some other + // tool or by policy would cut off every existing member's outbound + // traffic and hand our principal that group's permissions. + owned, err := windowsLocalGroupOwnedByZeroFn(groupName, groupComment) + if err != nil { + return err + } + if !owned { + return fmt.Errorf("local group %q already exists and is not managed by zero; "+ + "rename or remove it before running sandbox setup", groupName) + } + return nil } - return nil + return netAPIStatus("NetLocalGroupAdd", status) } -// ensureWindowsSandboxGroup creates the managed local group, or adopts it when -// it already exists AND carries our ownership comment. -func ensureWindowsSandboxGroup() error { - name, err := windows.UTF16PtrFromString(windowsSandboxGroupName) +// Seams for the two Win32 calls behind group creation, so the already-exists +// branch is reachable in tests without an elevated machine. +var ( + addWindowsLocalGroupFn = addWindowsLocalGroup + windowsLocalGroupOwnedByZeroFn = windowsLocalGroupOwnedByZero +) + +// addWindowsLocalGroup issues NetLocalGroupAdd and hands back its raw status so +// the caller can distinguish "already exists" from a real failure. +func addWindowsLocalGroup(groupName string, groupComment string) (uintptr, error) { + name, err := windows.UTF16PtrFromString(groupName) if err != nil { - return err + return 0, err } - comment, err := windows.UTF16PtrFromString(windowsSandboxGroupComment) + comment, err := windows.UTF16PtrFromString(groupComment) if err != nil { - return err + return 0, err } info := localGroupInfo1{Name: name, Comment: comment} status, _, _ := procNetLocalGroupAdd.Call( @@ -307,7 +395,42 @@ func ensureWindowsSandboxGroup() error { runtime.KeepAlive(info) runtime.KeepAlive(name) runtime.KeepAlive(comment) - return resolveWindowsSandboxGroupAdd(status, windowsSandboxGroupIsOwnedFn) + return status, nil +} + +// windowsLocalGroupOwnedByZero reports whether an existing local group carries +// the managed-group marker this setup writes, so a foreign group that merely +// shares the name is never adopted. +func windowsLocalGroupOwnedByZero(groupName string, wantComment string) (bool, error) { + name, err := windows.UTF16PtrFromString(groupName) + if err != nil { + return false, err + } + var buffer *byte + status, _, _ := procNetLocalGroupGetInfo.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 1, // level: LOCALGROUP_INFO_1 + uintptr(unsafe.Pointer(&buffer)), + ) + runtime.KeepAlive(name) + if status == nerrGroupNotFound { + // It existed a moment ago and does not now. Treat that as not-ours + // rather than guessing; the next setup run recreates it cleanly. + return false, nil + } + if err := netAPIStatus("NetLocalGroupGetInfo", status); err != nil { + return false, err + } + if buffer == nil { + return false, nil + } + defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer))) + info := (*localGroupInfo1)(unsafe.Pointer(buffer)) + if info.Comment == nil { + return false, nil + } + return windows.UTF16PtrToString(info.Comment) == wantComment, nil } // ensureWindowsSandboxUser creates a sandbox account with the supplied password. @@ -390,7 +513,20 @@ func resetWindowsSandboxUserPassword(username string, password string) error { // 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) + return addWindowsUserToLocalGroup(windowsSandboxGroupName, username) +} + +// addWindowsSandboxUserToOfflineGroup is what actually denies a principal the +// network: the block filters match this group's SID, and LogonUser puts the +// group into the token it mints for a member. +func addWindowsSandboxUserToOfflineGroup(username string) error { + return addWindowsUserToLocalGroup(windowsSandboxOfflineGroupName, username) +} + +// addWindowsUserToLocalGroup puts an account in a local group, ignoring the +// status that means it is already a member. +func addWindowsUserToLocalGroup(groupName string, username string) error { + group, err := windows.UTF16PtrFromString(groupName) if err != nil { return err } @@ -652,21 +788,22 @@ 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 - windowsSandboxGroupIsOwnedFn = windowsSandboxGroupIsOwned - ensureWindowsSandboxUserFn = ensureWindowsSandboxUser - addWindowsSandboxUserToGroupFn = addWindowsSandboxUserToGroup - resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID - resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword - windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged - windowsSandboxUserHasLegacyCommentFn = windowsSandboxUserHasLegacyComment - windowsSandboxUserIsPrivilegedFn = windowsSandboxUserIsPrivileged - grantWindowsSandboxLogonRightsFn = grantWindowsSandboxLogonRights - revokeWindowsSandboxLogonRightsFn = revokeWindowsSandboxLogonRights + ensureWindowsSandboxGroupFn = ensureWindowsSandboxGroup + ensureWindowsSandboxOfflineGroupFn = ensureWindowsSandboxOfflineGroup + ensureWindowsSandboxUserFn = ensureWindowsSandboxUser + addWindowsSandboxUserToGroupFn = addWindowsSandboxUserToGroup + addWindowsSandboxUserToOfflineGroupFn = addWindowsSandboxUserToOfflineGroup + resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID + resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword + windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged + windowsSandboxUserHasLegacyCommentFn = windowsSandboxUserHasLegacyComment + 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. + // work. The revocation 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 ) @@ -680,11 +817,17 @@ var ( // 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, bool, error) { +func provisionWindowsSandboxIdentity(workspaceKey string, role windowsSandboxRole) (windowsSandboxIdentity, string, bool, error) { if err := ensureWindowsSandboxGroupFn(); err != nil { return windowsSandboxIdentity{}, "", false, err } - username := windowsSandboxUserName(workspaceKey) + // The offline group has to exist before an offline principal joins it, and it + // is created unconditionally so the network filters can be keyed to its SID + // whether or not an offline principal has been provisioned yet. + if err := ensureWindowsSandboxOfflineGroupFn(); err != nil { + return windowsSandboxIdentity{}, "", false, err + } + username := windowsSandboxUserName(workspaceKey, role) password, err := newWindowsSandboxPassword() if err != nil { return windowsSandboxIdentity{}, "", false, err @@ -764,6 +907,19 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit if err := addWindowsSandboxUserToGroupFn(username); err != nil { return windowsSandboxIdentity{Username: username}, "", !existed, err } + // Membership of the offline group IS the network denial, so it is applied + // here rather than left to a later step: a principal that reached the runner + // without it would look correctly provisioned and quietly have the network. + // + // This is the failure most worth surviving cleanly. It happens after the + // account exists, and local policy can refuse a group join, so the name has + // to come back with the error or the rollback deletes "" and strands a + // principal that has network access and no offline membership. + if role == windowsSandboxRoleOffline { + if err := addWindowsSandboxUserToOfflineGroupFn(username); err != nil { + return windowsSandboxIdentity{Username: username}, "", !existed, err + } + } sid, err := resolveWindowsSandboxSIDFn(username) if err != nil { return windowsSandboxIdentity{Username: username}, "", !existed, err @@ -779,15 +935,42 @@ func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentit // // A missing account is success, so teardown converges the same way provisioning // does. Requires an elevated caller. -func removeWindowsSandboxIdentity(username string) error { +func removeWindowsSandboxIdentity(username string, workspaceKey string) error { + // Account names here are derived, not discovered, so this could be pointed at + // a name that happens to belong to somebody else's local account. Deleting a + // user is not a recoverable mistake, so ownership is proven before deleting + // rather than inferred from the name matching a pattern we generate. + // Keyed to the workspace as well as to the marker: on a name collision the + // account belongs to a DIFFERENT workspace, and deleting it would be the + // same unrecoverable mistake as deleting a stranger's account. + managed, err := windowsSandboxUserIsManagedFn(username, workspaceKey) + if err != nil { + return err + } + if !managed { + // Distinguishable rather than a bare nil. Leaving the account alone is + // right, and callers that only want the goal state ("no principal of ours + // under this name") can treat it as success by matching this sentinel. But + // reporting a plain success would tell an operator that cleanup completed + // when a name they may care about was deliberately left in place, which is + // the one detail worth surfacing here. + return fmt.Errorf("%w: %q", errWindowsSandboxForeignAccountRetained, username) + } name, err := windows.UTF16PtrFromString(username) if err != nil { return err } status, _, _ := procNetUserDel.Call(0, uintptr(unsafe.Pointer(name))) + runtime.KeepAlive(name) return netAPIStatus("NetUserDel", status, nerrUserNotFound) } +// errWindowsSandboxForeignAccountRetained reports that removal left an account +// in place because it is not one Zero created. It is not a failure to clean up, +// it is a refusal to delete somebody else's account, and teardown paths treat it +// as success. +var errWindowsSandboxForeignAccountRetained = errors.New("left a local account in place because Zero did not create it") + // 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. @@ -797,21 +980,8 @@ var errWindowsSandboxIdentityUnavailable = errors.New("no Zero sandbox principal // 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) - // 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. +func lookupWindowsSandboxIdentity(workspaceKey string, role windowsSandboxRole) (windowsSandboxIdentity, error) { + username := windowsSandboxUserName(workspaceKey, role) // 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 @@ -846,8 +1016,8 @@ func lookupWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, // 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) +func lookupWindowsSandboxPrincipalForCommand(workspaceKey string, role windowsSandboxRole) (windowsSandboxIdentity, error) { + identity, err := lookupWindowsSandboxIdentity(workspaceKey, role) if err != nil { return windowsSandboxIdentity{}, err } @@ -883,3 +1053,56 @@ func classifyWindowsSandboxLookupError(err error) error { } return err } + +// windowsSandboxUserInLocalGroup reports direct membership of one named local +// group. Network denial is keyed to the offline group's SID on the WFP filters, +// so the command path uses this to confirm the account it is about to log on +// still carries the membership that makes those filters apply to it. +// +// Indirected through a var so the command path can be tested without an +// elevated machine. +var windowsSandboxUserInLocalGroupFn = windowsSandboxUserInLocalGroup + +func windowsSandboxUserInLocalGroup(username string, groupName 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))) + want := strings.ToLower(groupName) + groups := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buffer)), entries) + for _, group := range groups { + if group.Name == nil { + continue + } + if strings.ToLower(windows.UTF16PtrToString(group.Name)) == want { + return true, nil + } + } + return false, nil +} diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go index 11d64c741..9c8cdde0f 100644 --- a/internal/sandbox/windows_identity_windows_test.go +++ b/internal/sandbox/windows_identity_windows_test.go @@ -16,7 +16,7 @@ import ( // 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)) + name := windowsSandboxUserName(strings.Repeat("a", 64), windowsSandboxRoleOffline) if len(name) > windowsSandboxUserNameMax { t.Fatalf("name %q is %d chars, want at most %d", name, len(name), windowsSandboxUserNameMax) } @@ -28,12 +28,12 @@ func TestWindowsSandboxUserNameRespectsLengthLimit(t *testing.T) { // 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") + first := windowsSandboxUserName("abc123", windowsSandboxRoleOffline) + second := windowsSandboxUserName("abc123", windowsSandboxRoleOffline) if first != second { t.Fatalf("name is not stable: %q vs %q", first, second) } - if other := windowsSandboxUserName("def456"); other == first { + if other := windowsSandboxUserName("def456", windowsSandboxRoleOffline); other == first { t.Fatalf("different workspaces produced the same principal %q", first) } } @@ -41,7 +41,7 @@ func TestWindowsSandboxUserNameIsStable(t *testing.T) { // 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`) + name := windowsSandboxUserName(`C:\Users\me\proj ect`, windowsSandboxRoleOffline) for _, r := range strings.TrimPrefix(name, windowsSandboxUserPrefix) { isLower := r >= 'a' && r <= 'z' isDigit := r >= '0' && r <= '9' @@ -58,7 +58,7 @@ func TestWindowsSandboxUserNameRejectsUnsafeCharacters(t *testing.T) { // than the bare prefix. func TestWindowsSandboxUserNameHandlesEmptyKey(t *testing.T) { for _, key := range []string{"", "///", " "} { - if got := windowsSandboxUserName(key); got == windowsSandboxUserPrefix { + if got := windowsSandboxUserName(key, windowsSandboxRoleOffline); got == windowsSandboxUserPrefix { t.Fatalf("key %q produced a bare prefix", key) } } @@ -213,9 +213,9 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { // 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")) + _ = removeWindowsSandboxIdentity(windowsSandboxUserName("ziptest01", windowsSandboxRoleOffline), "ziptest01") - identity, password, _, err := provisionWindowsSandboxIdentity("ziptest01") + identity, password, _, err := provisionWindowsSandboxIdentity("ziptest01", windowsSandboxRoleOffline) if err != nil { t.Fatalf("provision: %v", err) } @@ -228,7 +228,7 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { t.Errorf("cleanup: revoke logon rights: %v", err) } - if err := removeWindowsSandboxIdentity(identity.Username); err != nil { + if err := removeWindowsSandboxIdentity(identity.Username, "ziptest01"); err != nil && !errors.Is(err, errWindowsSandboxForeignAccountRetained) { t.Errorf("cleanup: remove principal: %v", err) } }) @@ -240,7 +240,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", windowsSandboxRoleOffline) if err != nil { t.Fatalf("second provision: %v", err) } @@ -265,7 +265,7 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { SandboxHome: t.TempDir(), WorkspaceRoots: []string{`C:\ziptest01`}, } - setupIdentity, _, err := provisionWindowsSandboxPrincipalForSetup(config) + setupIdentity, _, err := provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline) if err != nil { t.Fatalf("setup provision: %v", err) } @@ -284,10 +284,10 @@ func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { _ = token.Close() t.Cleanup(func() { _ = revokeWindowsSandboxLogonRights(setupIdentity.SID) - _ = removeWindowsSandboxIdentity(setupIdentity.Username) + _ = removeWindowsSandboxIdentity(setupIdentity.Username, "ziptest01") }) // Lookup must find what provisioning created. - found, err := lookupWindowsSandboxIdentity("ziptest01") + found, err := lookupWindowsSandboxIdentity("ziptest01", windowsSandboxRoleOffline) if err != nil { t.Fatalf("lookup after provision: %v", err) } @@ -318,9 +318,9 @@ func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) { 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)) + _ = removeWindowsSandboxIdentity(windowsSandboxUserName(key, windowsSandboxRoleOffline), key) - identity, password, _, err := provisionWindowsSandboxIdentity(key) + identity, password, _, err := provisionWindowsSandboxIdentity(key, windowsSandboxRoleOffline) if err != nil { t.Fatalf("provision: %v", err) } @@ -330,7 +330,7 @@ func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) { if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { t.Errorf("cleanup: revoke logon rights: %v", err) } - if err := removeWindowsSandboxIdentity(identity.Username); err != nil { + if err := removeWindowsSandboxIdentity(identity.Username, key); err != nil && !errors.Is(err, errWindowsSandboxForeignAccountRetained) { t.Errorf("cleanup: remove principal: %v", err) } }) @@ -367,7 +367,7 @@ func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) { // "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") + _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey9z", windowsSandboxRoleOffline) if err == nil { t.Skip("a principal for this key unexpectedly exists on this machine") } @@ -459,3 +459,40 @@ func TestWindowsSandboxNameCollisionIsTyped(t *testing.T) { t.Fatalf("collision message = %q, want it to say the account is not ours", wrapped.Error()) } } + +// The two roles must never collide onto one account. If they did, the online +// principal would be whatever the offline one is, which means either an approved +// network command silently has no network, or an ordinary command silently has +// one. The second is the dangerous direction. +func TestWindowsSandboxUserNameSeparatesRoles(t *testing.T) { + const key = "abc123" + offline := windowsSandboxUserName(key, windowsSandboxRoleOffline) + online := windowsSandboxUserName(key, windowsSandboxRoleOnline) + if offline == online { + t.Fatalf("both roles produced %q", offline) + } + // Still stable per role, or setup would create a new account every run. + if again := windowsSandboxUserName(key, windowsSandboxRoleOnline); again != online { + t.Fatalf("online name is not stable: %q then %q", online, again) + } + + // The role tag has to survive truncation. A very long key is exactly when the + // name is cut to the 20-character limit, and losing the tag there is what + // would collide the roles on precisely the workspaces most likely to hit it. + longKey := strings.Repeat("f", 200) + longOffline := windowsSandboxUserName(longKey, windowsSandboxRoleOffline) + longOnline := windowsSandboxUserName(longKey, windowsSandboxRoleOnline) + if longOffline == longOnline { + t.Fatalf("truncation collided the roles onto %q", longOffline) + } + for _, name := range []string{longOffline, longOnline} { + if len(name) > windowsSandboxUserNameMax { + t.Fatalf("name %q is %d chars, want at most %d", name, len(name), windowsSandboxUserNameMax) + } + } + + // Different workspaces still get different accounts within a role. + if other := windowsSandboxUserName("def456", windowsSandboxRoleOffline); other == offline { + t.Fatalf("two workspaces produced the same offline account %q", other) + } +} diff --git a/internal/sandbox/windows_legacy_principal_windows_test.go b/internal/sandbox/windows_legacy_principal_windows_test.go new file mode 100644 index 000000000..3beb1c3dc --- /dev/null +++ b/internal/sandbox/windows_legacy_principal_windows_test.go @@ -0,0 +1,108 @@ +package sandbox + +import ( + "errors" + "sort" + "strings" + "testing" +) + +// The predecessor of this branch provisioned ONE account per workspace, named +// "zero-sbx-" with no role tag. Splitting the roles changed both names, so +// on an upgraded machine neither role resolves to the account that is actually +// installed. Every other path on this branch then reports the principal as never +// provisioned, and the untagged account keeps its secret, its logon rights, its +// ACEs and its ledger with nothing left that looks for them. +// +// windowsSandboxRoleLegacy exists to reproduce that name exactly so retirement +// can still find it. If its tag ever stops being empty, this stops matching what +// the predecessor wrote and the residue becomes unreachable again. +func TestLegacyRoleReproducesTheUntaggedPredecessorName(t *testing.T) { + // Short enough that the 20-character account-name limit does not truncate, + // so the comparison is against the spelling itself rather than the cap. + const key = "abc123" + + legacy := windowsSandboxUserName(key, windowsSandboxRoleLegacy) + if want := windowsSandboxUserPrefix + key; legacy != want { + t.Fatalf("legacy account name = %q, want the pre-split spelling %q", legacy, want) + } + // And the cap still applies to it exactly as it did before the split, since + // the predecessor truncated the same way. + long := windowsSandboxUserName("averylongworkspacekeyindeed", windowsSandboxRoleLegacy) + if len(long) > windowsSandboxUserNameMax { + t.Errorf("legacy name %q is %d characters, over the %d-character limit", long, len(long), windowsSandboxUserNameMax) + } + if tag := windowsSandboxRoleLegacy.roleTag(); tag != "" { + t.Errorf("legacy role tag = %q, want empty; a tag here would stop matching the installed account", tag) + } + + // It must not collide with either live role, or retiring the predecessor + // would delete an account this branch is still using. + offline := windowsSandboxUserName(key, windowsSandboxRoleOffline) + online := windowsSandboxUserName(key, windowsSandboxRoleOnline) + for _, live := range []string{offline, online} { + if strings.EqualFold(legacy, live) { + t.Errorf("legacy name %q collides with a live role name %q", legacy, live) + } + } + if strings.EqualFold(offline, online) { + t.Fatalf("the two live roles derived the same account name %q", offline) + } +} + +// THE LEGACY ROLE IS RETIRED, NEVER PROVISIONED. It derives the untagged name +// on purpose, so provisioning it would create the very account this branch +// replaced, on every setup, forever. Adding it to the retirement list and the +// provisioning list are one character apart in the source and the difference is +// invisible at a glance, so it is asserted rather than left to review. +func TestSetupNeverProvisionsTheLegacyPrincipal(t *testing.T) { + previous := provisionWindowsSandboxPrincipalForSetupFn + t.Cleanup(func() { provisionWindowsSandboxPrincipalForSetupFn = previous }) + + var provisioned []windowsSandboxRole + provisionWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) (windowsSandboxIdentity, bool, error) { + provisioned = append(provisioned, role) + return windowsSandboxIdentity{}, false, errStopProvisioningForTest + } + + // The error stops setup at the first role; what matters is which roles it + // was willing to ask for, and legacy must never be among them. + _, _ = setupWindowsSandboxPrincipal(WindowsSandboxCommandConfig{SandboxHome: t.TempDir()}) + + for _, role := range provisioned { + if role == windowsSandboxRoleLegacy { + t.Fatalf("setup tried to provision the legacy role, which would recreate the pre-split account: %v", provisioned) + } + } +} + +var errStopProvisioningForTest = errors.New("stop provisioning for test") + +// Opting out or re-running setup has to retire the predecessor as well as both +// live roles. Retiring only the two this branch knows about is what leaves a +// fully provisioned account on a machine whose marker says it has none. +func TestSetupRetirementCoversTheLegacyPrincipal(t *testing.T) { + previous := removeWindowsSandboxPrincipalForSetupFn + t.Cleanup(func() { removeWindowsSandboxPrincipalForSetupFn = previous }) + + var retired []string + removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error { + retired = append(retired, string(role)) + return nil + } + + if err := removeWindowsSandboxPrincipalsForSetup(WindowsSandboxCommandConfig{SandboxHome: t.TempDir()}); err != nil { + t.Fatalf("removeWindowsSandboxPrincipalsForSetup: %v", err) + } + + sort.Strings(retired) + want := []string{"legacy", "offline", "online"} + if len(retired) != len(want) { + t.Fatalf("retired %v, want all of %v; a role left out keeps its account and everything it owns", retired, want) + } + for index := range want { + if retired[index] != want[index] { + t.Fatalf("retired %v, want %v", retired, want) + } + } +} diff --git a/internal/sandbox/windows_network.go b/internal/sandbox/windows_network.go index 0d614a0fc..3530603d4 100644 --- a/internal/sandbox/windows_network.go +++ b/internal/sandbox/windows_network.go @@ -62,15 +62,190 @@ func BuildWindowsNetworkInfraPlan(config WindowsSandboxCommandConfig) (WindowsNe if err != nil { return WindowsNetworkPlan{}, err } + // The offline-marker SID stays first: the setup marker records + // IdentitySIDs[0] as the offline filter identity. + identitySIDs := []string{offlineSID} + // A sandbox principal cannot carry the offline-marker SID, because LogonUser + // builds a token from an account's real group memberships and the marker is a + // synthetic capability SID. So the same filters additionally match a real + // local group that network-denied principals belong to. + // + // Gated on THIS home's opt-in, not merely on the group existing. + // + // The group is machine-global, so keying off its existence meant the first + // workspace to opt in changed the computed plan for every OTHER sandbox home + // on the machine. The plan is hashed into the setup marker and compared on + // every command, so those homes started failing every command with "setup is + // out of date" until each was re-run from an elevated terminal, having never + // opted into anything. Existing markers have to keep validating. + // + // Reading the opt-in instead keeps an opted-out home computing exactly the + // plan it computed before any of this existed. An opted-in home records the + // group in its own marker, and setup and the command path both derive the + // flag from the same environment, so they agree. Opting in AFTER setup does + // invalidate that home's marker, which is correct: it has no principals yet + // and setup is genuinely required. + if windowsSandboxIdentityEnabled(config.Env) && resolveWindowsSandboxOfflineGroupSIDHook != nil { + groupSID, err := resolveWindowsSandboxOfflineGroupSIDHook() + if err != nil { + return WindowsNetworkPlan{}, err + } + if trimmed := strings.TrimSpace(groupSID); trimmed != "" { + identitySIDs = append(identitySIDs, trimmed) + } + } return WindowsNetworkPlan{ Mode: NetworkDeny, ProviderKey: windowsWFPProviderKey, SubLayerKey: windowsWFPSubLayerKey, - IdentitySIDs: []string{offlineSID}, + IdentitySIDs: identitySIDs, Filters: windowsDenyWFPFilterSpecs(), }, nil } +// WindowsNetworkPlanForApply returns the plan to INSTALL, which is not always +// the plan a home fingerprints. +// +// The filters are machine-global and every setup installs them by deleting and +// recreating the fixed set. So an opted-OUT setup for workspace B was replacing +// filters that workspace A's opted-in setup had installed, dropping the offline +// group SID as it went. A's offline principal still passed the runtime +// membership check and A's marker still validated, but no filter matched its +// token any more, so a NetworkDeny command in A quietly gained egress. Nothing +// on either side reported anything: B did exactly what it was asked to. +// +// The gate in BuildWindowsNetworkInfraPlan stays as it is, and this is +// deliberately a SEPARATE function rather than a change to it. That gate exists +// because the plan is hashed into each home's setup marker: keying it on the +// group's existence made the first workspace to opt in invalidate every other +// home's marker on the machine, and those homes then failed every command with +// "setup is out of date" having opted into nothing. That has to keep working. +// +// The two questions are simply different. What a home RECORDS is about that +// home's own configuration; what setup INSTALLS is about the machine, where the +// group either exists or does not. Answering both from one plan is what forced a +// choice between stale markers and a silent hole. +// +// The group not existing is the ordinary opted-out machine, and it adds nothing. +// +// A resolution FAILURE is a different answer and is fatal, because the filters +// are replaced wholesale. The earlier reasoning here was that refusing to +// install would trade a partial denial for no denial at all, and that is wrong: +// the alternative to installing is leaving the filters that are already there +// alone. Returning a marker-only plan does not decline to add coverage, it +// actively REMOVES coverage another workspace is relying on. An opted-out home +// skips assertWindowsNetworkPlanCoversOfflineGroup entirely (it is gated on +// provisioned), so this is the only thing between a transient lookup error and +// workspace A silently regaining egress under NetworkDeny. +// +// "Absent" and "could not be read" have to be different answers, which is the +// same distinction the marker/apply split above is built on. +func WindowsNetworkPlanForApply(plan WindowsNetworkPlan, resolveOfflineGroupSID func() (string, error)) (WindowsNetworkPlan, error) { + if resolveOfflineGroupSID == nil { + return plan, nil + } + groupSID, err := resolveOfflineGroupSID() + if err != nil { + return WindowsNetworkPlan{}, fmt.Errorf("resolve the sandbox offline group before replacing the machine network filters: %w", err) + } + trimmed := strings.TrimSpace(groupSID) + if trimmed == "" { + return plan, nil + } + for _, existing := range plan.IdentitySIDs { + if strings.EqualFold(strings.TrimSpace(existing), trimmed) { + return plan, nil + } + } + // Copied rather than appended in place: the caller's plan is what gets + // fingerprinted, and growing its backing array would risk changing the + // recorded plan as a side effect of installing one. + augmented := plan + augmented.IdentitySIDs = append(append([]string{}, plan.IdentitySIDs...), trimmed) + return augmented, nil +} + +// WindowsNetworkPlanCoversPrincipals reports whether a plan's block filters name +// the offline group, which is the only thing that makes them apply to a sandbox +// principal. +// +// This exists to be asserted, not consulted. The plan must be built AFTER +// provisioning, because provisioning is what creates the group it resolves; a +// plan built first names only the offline marker and leaves every offline +// principal with an open network while setup reports success. That is a control +// that enforces nothing while claiming to work, and the ordering which prevents +// it is invisible at the call site, so a later refactor can undo it silently. +func WindowsNetworkPlanCoversPrincipals(plan WindowsNetworkPlan, offlineGroupSID string) bool { + offlineGroupSID = strings.TrimSpace(offlineGroupSID) + if offlineGroupSID == "" { + // No group provisioned on this host, so there is no principal for the + // filters to miss and nothing to assert. + return true + } + for _, sid := range plan.IdentitySIDs { + if strings.EqualFold(strings.TrimSpace(sid), offlineGroupSID) { + return true + } + } + return false +} + +// assertWindowsNetworkPlanCoversOfflineGroup is the post-provisioning check that +// setup runs before it installs filters and reports success. +// +// It fails closed on every answer that is not a definite yes, because the thing +// it guards is invisible when it goes wrong: filters that do not name the +// offline group leave every offline principal with an open network on a machine +// whose setup marker says it is protected. Nobody sees an error, and the sandbox +// looks correctly installed. +// +// Two answers other than "the plan omits the group" also mean the check did not +// happen and must not be treated as a pass: +// +// - The lookup failed. Whether the group is covered is then unknown, and the +// Win32 reason is worth surfacing: an operator seeing "access is denied" +// knows to re-run elevated. +// - The lookup succeeded and found nothing. ("", nil) means the group does not +// exist, which is the ordinary state BEFORE provisioning and an impossible +// one after it, so here it means the group vanished or was never created. +// WindowsNetworkPlanCoversPrincipals answers true for an empty SID, correctly +// for the pre-provisioning callers that ask it, so the emptiness has to be +// rejected here rather than delegated to it. +// +// provisioned says whether principals were actually provisioned on this run. +// When they were not there is no offline group, no principal carrying it, and +// nothing for the filters to miss, so there is nothing to assert. Running the +// check anyway turns the ordinary opt-out setup into a hard failure, because a +// machine with no group resolves to ("", nil) and the empty-SID rejection below +// is correct only after provisioning. +func assertWindowsNetworkPlanCoversOfflineGroup(plan WindowsNetworkPlan, resolve func() (string, error), provisioned bool) error { + if !provisioned { + return nil + } + if resolve == nil { + return errors.New("sandbox offline group resolver is not wired up, so filter coverage cannot be verified") + } + groupSID, err := resolve() + if err != nil { + return fmt.Errorf("resolve the sandbox offline group after provisioning, so filter coverage cannot be verified: %w", err) + } + if strings.TrimSpace(groupSID) == "" { + return errors.New("the sandbox offline group does not exist after provisioning, so the block filters would not apply to any sandbox principal") + } + if !WindowsNetworkPlanCoversPrincipals(plan, groupSID) { + return errors.New("network block filters do not name the sandbox offline group, so they would not apply to any sandbox principal; the network plan must be built after principals are provisioned") + } + return nil +} + +// resolveWindowsSandboxOfflineGroupSIDHook resolves the local group that +// network-denied principals belong to. It is wired up on Windows only, so this +// file stays free of Win32 calls and the plan on other platforms is unchanged. +// +// Returning ("", nil) means the group does not exist yet, which is the state +// before principals have ever been provisioned. +var resolveWindowsSandboxOfflineGroupSIDHook func() (string, error) + // WindowsNetworkInfraHash fingerprints the provisioned (mode-independent) network // infrastructure so the setup marker validates against the same setup for BOTH // command modes. It never folds in the per-command network mode. diff --git a/internal/sandbox/windows_network_coverage_assert_test.go b/internal/sandbox/windows_network_coverage_assert_test.go new file mode 100644 index 000000000..7b07176b9 --- /dev/null +++ b/internal/sandbox/windows_network_coverage_assert_test.go @@ -0,0 +1,129 @@ +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +// The post-provisioning coverage assertion is the last thing standing between a +// mis-ordered plan and a machine that reports a successful setup while every +// offline principal has an open network. It has to fail closed on every way of +// not knowing the answer, not just on a definite negative. +func TestAssertWindowsNetworkPlanCoversOfflineGroupFailsClosed(t *testing.T) { + const groupSID = "S-1-5-32-9999" + covering := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker", groupSID}} + notCovering := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}} + lookupFailed := errors.New("look up zero-sandbox-offline: access is denied") + + tests := []struct { + name string + plan WindowsNetworkPlan + resolve func() (string, error) + wantErr bool + wantText string + }{ + { + name: "plan names the group", + plan: covering, + resolve: func() (string, error) { return groupSID, nil }, + }, + { + // The finding anandh8x raised: a failed lookup skipped the assertion + // entirely and setup carried on to write a success marker. + name: "lookup fails", + plan: covering, + resolve: func() (string, error) { return "", lookupFailed }, + wantErr: true, + wantText: "access is denied", + }, + { + // Empty-and-no-error means "the group does not exist", which is a + // legitimate answer BEFORE provisioning and an impossible one after it. + // WindowsNetworkPlanCoversPrincipals answers true for an empty SID, so + // without an explicit check the assertion passes vacuously. + name: "group missing after provisioning", + plan: covering, + resolve: func() (string, error) { return "", nil }, + wantErr: true, + wantText: "does not exist", + }, + { + name: "whitespace-only SID", + plan: covering, + resolve: func() (string, error) { return " ", nil }, + wantErr: true, + wantText: "does not exist", + }, + { + name: "plan omits the group", + plan: notCovering, + resolve: func() (string, error) { return groupSID, nil }, + wantErr: true, + wantText: "do not name the sandbox offline group", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := assertWindowsNetworkPlanCoversOfflineGroup(test.plan, test.resolve, true) + if test.wantErr && err == nil { + t.Fatalf("assertWindowsNetworkPlanCoversOfflineGroup() = nil, want an error so setup rolls back") + } + if !test.wantErr { + if err != nil { + t.Fatalf("assertWindowsNetworkPlanCoversOfflineGroup() = %v, want nil", err) + } + return + } + if !strings.Contains(err.Error(), test.wantText) { + t.Fatalf("error = %q, want it to mention %q so the operator can tell the cases apart", err, test.wantText) + } + }) + } + + // The lookup failure has to stay unwrapped-comparable: setup logs it and an + // operator needs the underlying Win32 reason, not a flattened string. + err := assertWindowsNetworkPlanCoversOfflineGroup(covering, func() (string, error) { return "", lookupFailed }, true) + if !errors.Is(err, lookupFailed) { + t.Fatalf("errors.Is(err, lookupFailed) = false; the cause was dropped: %v", err) + } +} + +// The assert only means something AFTER principals were provisioned. The offline +// group is created inside provisionWindowsSandboxIdentity, which runs only under +// the ZERO_WINDOWS_SANDBOX_IDENTITY opt-in, so on a default machine there is no +// group, no principal, and nothing for the filters to miss. +// +// Rejecting an empty SID is right once provisioning ran (that is the vacuous +// pass anandh8x found) and wrong before it: it turned the ordinary opt-out setup +// into a hard failure, which is the regression jatmn caught on #812. +func TestAssertWindowsNetworkPlanCoversOfflineGroupSkipsWhenNothingWasProvisioned(t *testing.T) { + plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}} + + // The default machine: no group exists, so the resolver reports ("", nil). + if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) { return "", nil }, false); err != nil { + t.Fatalf("opt-out setup was refused: %v", err) + } + // Same state WITH provisioning claimed: now the missing group is a real fault. + if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) { return "", nil }, true); err == nil { + t.Fatal("a missing group after provisioning must still fail closed") + } + // Not provisioned must not become a licence to skip a lookup failure either: + // the resolver is never consulted, so a broken lookup cannot matter here. + if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, func() (string, error) { + t.Fatal("the resolver must not be consulted when nothing was provisioned") + return "", nil + }, false); err != nil { + t.Fatalf("opt-out setup was refused: %v", err) + } +} + +// A nil resolver is a wiring mistake, and guessing that coverage is fine is the +// one thing it must not do. +func TestAssertWindowsNetworkPlanCoversOfflineGroupRejectsNilResolver(t *testing.T) { + plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-32-9999"}} + if err := assertWindowsNetworkPlanCoversOfflineGroup(plan, nil, true); err == nil { + t.Fatal("assertWindowsNetworkPlanCoversOfflineGroup(nil resolver) = nil, want an error") + } +} diff --git a/internal/sandbox/windows_network_mixed_optin_test.go b/internal/sandbox/windows_network_mixed_optin_test.go new file mode 100644 index 000000000..4a96b0000 --- /dev/null +++ b/internal/sandbox/windows_network_mixed_optin_test.go @@ -0,0 +1,163 @@ +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +// THE BLOCK FILTERS ARE MACHINE-GLOBAL, SO ONE WORKSPACE'S SETUP DECIDES ANOTHER +// WORKSPACE'S NETWORK. +// +// Every setup installs them by deleting and recreating one fixed set. The plan +// each home builds names the offline group only when THAT home opted in, which +// is right for the marker it fingerprints and wrong for the filters it installs: +// an ordinary opted-out setup for a second workspace replaced the filters +// without the group SID, and the first workspace's offline principal — still in +// the group, still passing the runtime membership check, still holding a valid +// marker — was no longer matched by any filter. A NetworkDeny command there +// gained egress, silently, because the second setup did exactly what it was +// asked to do. +// +// The plan a home installs and the plan it records are therefore different +// questions, and these pin both. + +const testOfflineGroupSID = "S-1-5-32-9990" + +func offlineGroupResolver(sid string) func() (string, error) { + return func() (string, error) { return sid, nil } +} + +// The regression itself: an opted-OUT home must still install filters covering +// the managed offline group, because an opted-IN home's principals depend on it. +func TestOptedOutSetupKeepsOfflineGroupCoverageForOtherWorkspaces(t *testing.T) { + optedOut, err := BuildWindowsNetworkInfraPlan(WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + // No opt-in in the environment: this is the ordinary workspace. + Env: map[string]string{}, + }) + if err != nil { + t.Fatalf("BuildWindowsNetworkInfraPlan: %v", err) + } + + // What this home RECORDS must not change, or every opted-out marker on the + // machine goes stale the moment another workspace opts in. + if WindowsNetworkPlanCoversPrincipals(optedOut, testOfflineGroupSID) { + t.Fatal("the recorded plan named the offline group; that is what invalidated other homes' markers") + } + + // What this home INSTALLS must cover the group, since the group exists. + applied, err := WindowsNetworkPlanForApply(optedOut, offlineGroupResolver(testOfflineGroupSID)) + if err != nil { + t.Fatalf("WindowsNetworkPlanForApply: %v", err) + } + if !WindowsNetworkPlanCoversPrincipals(applied, testOfflineGroupSID) { + t.Errorf("an opted-out setup would install filters that do not match the offline group, so another workspace's NetworkDeny commands gain egress: %v", applied.IdentitySIDs) + } + // The offline-marker SID has to survive alongside it: the restricted-token + // tier is matched by that one, and it is the whole denial on machines with no + // principals at all. + if len(applied.IdentitySIDs) == 0 || strings.TrimSpace(applied.IdentitySIDs[0]) == "" { + t.Fatalf("the offline-marker SID was lost from the applied plan: %v", applied.IdentitySIDs) + } + if applied.IdentitySIDs[0] != optedOut.IdentitySIDs[0] { + t.Errorf("the marker SID must stay first, since the setup marker records IdentitySIDs[0]: %v", applied.IdentitySIDs) + } +} + +// Augmenting must not mutate the plan the caller fingerprints. Appending in +// place would let installing a plan change the plan that was recorded. +func TestPlanForApplyDoesNotMutateTheRecordedPlan(t *testing.T) { + recorded, err := BuildWindowsNetworkInfraPlan(WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + Env: map[string]string{}, + }) + if err != nil { + t.Fatalf("BuildWindowsNetworkInfraPlan: %v", err) + } + before := append([]string{}, recorded.IdentitySIDs...) + + if _, err := WindowsNetworkPlanForApply(recorded, offlineGroupResolver(testOfflineGroupSID)); err != nil { + t.Fatalf("WindowsNetworkPlanForApply: %v", err) + } + + if len(recorded.IdentitySIDs) != len(before) { + t.Fatalf("the recorded plan grew from %v to %v", before, recorded.IdentitySIDs) + } + for index := range before { + if recorded.IdentitySIDs[index] != before[index] { + t.Errorf("the recorded plan changed at %d: %v vs %v", index, before, recorded.IdentitySIDs) + } + } +} + +// An opted-IN home already names the group, and must not name it twice: a +// duplicate identity would change the installed filter set for no reason. +func TestPlanForApplyDoesNotDuplicateAnAlreadyCoveredGroup(t *testing.T) { + plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker", testOfflineGroupSID}} + applied, err := WindowsNetworkPlanForApply(plan, offlineGroupResolver(strings.ToLower(testOfflineGroupSID))) + if err != nil { + t.Fatalf("WindowsNetworkPlanForApply: %v", err) + } + if len(applied.IdentitySIDs) != 2 { + t.Errorf("the group was added again despite already being covered: %v", applied.IdentitySIDs) + } +} + +// On an ordinary machine with no principals the group does not exist, and the +// plan must be left exactly as it was. +func TestPlanForApplyLeavesAMachineWithoutTheGroupAlone(t *testing.T) { + plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-marker"}} + + for _, testCase := range []struct { + name string + resolve func() (string, error) + }{ + {name: "no resolver", resolve: nil}, + {name: "group absent", resolve: func() (string, error) { return "", nil }}, + {name: "blank SID", resolve: func() (string, error) { return " ", nil }}, + } { + t.Run(testCase.name, func(t *testing.T) { + applied, err := WindowsNetworkPlanForApply(plan, testCase.resolve) + if err != nil { + t.Fatalf("a machine with no offline group is not a failure: %v", err) + } + if len(applied.IdentitySIDs) != 1 || applied.IdentitySIDs[0] != "S-1-5-21-marker" { + t.Errorf("plan changed on a machine with no offline group: %v", applied.IdentitySIDs) + } + }) + } +} + +// A LOOKUP FAILURE IS NOT AN ABSENT GROUP. The filters are machine-global and +// are replaced wholesale, so returning a marker-only plan here does not decline +// to add coverage, it removes coverage another workspace is relying on. +// +// This is reachable only from an opted-OUT home, because +// assertWindowsNetworkPlanCoversOfflineGroup is gated on provisioned and never +// runs for one. Workspace A opts in, the group SID lands in the global filters; +// workspace B is opted out and its lookup fails transiently; B installs a plan +// without the group and A's NetworkDeny commands silently regain egress while +// A's marker and its direct membership check both still pass. +func TestPlanForApplyRefusesToReplaceFiltersWhenTheGroupCannotBeRead(t *testing.T) { + // Opted-out home B: the coverage assert is skipped for it, so nothing else + // stands between the failure and the wholesale replace. + if err := assertWindowsNetworkPlanCoversOfflineGroup( + WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-B-marker"}}, + func() (string, error) { return "", errors.New("group lookup failed") }, + false, + ); err != nil { + t.Fatalf("the coverage assert is meant to be skipped for an opted-out home: %v", err) + } + + plan := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-5-21-B-marker"}} + applied, err := WindowsNetworkPlanForApply(plan, func() (string, error) { + return "", errors.New("group lookup failed") + }) + if err == nil { + t.Fatalf("a failed group lookup installed %v, stripping the offline group from the machine's filters", applied.IdentitySIDs) + } + if !strings.Contains(err.Error(), "group lookup failed") { + t.Errorf("the underlying failure must be reported so the operator can act on it: %v", err) + } +} diff --git a/internal/sandbox/windows_network_test.go b/internal/sandbox/windows_network_test.go index 4253916f8..3a31388af 100644 --- a/internal/sandbox/windows_network_test.go +++ b/internal/sandbox/windows_network_test.go @@ -118,3 +118,154 @@ func assertWindowsWFPCommonFilter(t *testing.T, specs map[string]WindowsWFPFilte // Coverage for the network infra plan + hash and the per-mode token-SID // composition lives in windows_online_offline_test.go. + +// The block filters have to name the offline group as well as the offline-marker +// SID, or a sandbox principal is not covered by them at all: LogonUser builds a +// token from real group memberships and cannot carry the synthetic marker. This +// is the single property that makes network denial work for the principal +// backend, so assert it on the plan rather than trusting the wiring. +func TestNetworkInfraPlanIncludesOfflineGroupIdentity(t *testing.T) { + previous := resolveWindowsSandboxOfflineGroupSIDHook + t.Cleanup(func() { resolveWindowsSandboxOfflineGroupSIDHook = previous }) + + config := WindowsSandboxCommandConfig{SandboxHome: t.TempDir(), Env: map[string]string{windowsSandboxIdentityEnv: "1"}} + + // Before principals have ever been provisioned the group does not exist, and + // the plan must be exactly what it was before this feature. The plan is hashed + // into the setup marker and re-derived on every command, so an identity set + // that appeared out of nowhere would fail every command with "setup is out of + // date". + resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { return "", nil } + base, err := BuildWindowsNetworkInfraPlan(config) + if err != nil { + t.Fatalf("build plan without the group: %v", err) + } + if len(base.IdentitySIDs) != 1 { + t.Fatalf("identity SIDs = %v, want only the offline marker when the group is absent", base.IdentitySIDs) + } + + const groupSID = "S-1-5-32-9999" + resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { return groupSID, nil } + withGroup, err := BuildWindowsNetworkInfraPlan(config) + if err != nil { + t.Fatalf("build plan with the group: %v", err) + } + if len(withGroup.IdentitySIDs) != 2 || withGroup.IdentitySIDs[1] != groupSID { + t.Fatalf("identity SIDs = %v, want the offline marker plus %s", withGroup.IdentitySIDs, groupSID) + } + // The marker records IdentitySIDs[0] as the offline filter identity, so the + // marker SID has to stay first. + if withGroup.IdentitySIDs[0] != base.IdentitySIDs[0] { + t.Fatalf("offline marker moved from position 0: %v", withGroup.IdentitySIDs) + } + // Adding the group must change the fingerprint, or setup and the command path + // could disagree about the installed filters without anything noticing. + baseHash, err := WindowsNetworkInfraHash(base) + if err != nil { + t.Fatalf("hash base: %v", err) + } + groupHash, err := WindowsNetworkInfraHash(withGroup) + if err != nil { + t.Fatalf("hash with group: %v", err) + } + if baseHash == groupHash { + t.Fatal("the infra hash ignored the offline group identity") + } +} + +// A lookup failure must not be swallowed into "no group", because that silently +// produces a plan whose filters do not cover sandbox principals. +func TestNetworkInfraPlanPropagatesOfflineGroupLookupFailure(t *testing.T) { + previous := resolveWindowsSandboxOfflineGroupSIDHook + t.Cleanup(func() { resolveWindowsSandboxOfflineGroupSIDHook = previous }) + + resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { + return "", errors.New("boom") + } + if _, err := BuildWindowsNetworkInfraPlan(WindowsSandboxCommandConfig{SandboxHome: t.TempDir(), Env: map[string]string{windowsSandboxIdentityEnv: "1"}}); err == nil { + t.Fatal("a failed offline-group lookup produced a plan; the filters would not cover any principal") + } +} + +// The ordering that makes the block filters apply to sandbox principals is +// invisible at the call site: the plan must be built AFTER provisioning, +// because provisioning creates the group the filters name. A plan built first +// names only the offline marker, and a machine would then report a successful +// setup while every offline principal had an open network. +// +// Asserted on the predicate setup checks before installing anything, so moving +// the plan build back ahead of provisioning fails loudly instead of silently +// producing a control that enforces nothing. +func TestNetworkPlanCoverageDetectsPrincipalsLeftUncovered(t *testing.T) { + const groupSID = "S-1-5-32-4242" + + // What a plan built BEFORE provisioning looks like: marker only. + tooEarly := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-15-3-1111"}} + if WindowsNetworkPlanCoversPrincipals(tooEarly, groupSID) { + t.Fatal("a plan naming only the offline marker was reported as covering principals; the ordering guard would not fire") + } + + // And after: marker plus the group. + correct := WindowsNetworkPlan{IdentitySIDs: []string{"S-1-15-3-1111", groupSID}} + if !WindowsNetworkPlanCoversPrincipals(correct, groupSID) { + t.Fatal("a plan naming the offline group was reported as not covering principals; setup would refuse a correct plan") + } + // SID comparison is case-insensitive, so a differently-cased resolve does not + // read as a missing group and block a valid setup. + mixed := WindowsNetworkPlan{IdentitySIDs: []string{strings.ToLower(groupSID)}} + if !WindowsNetworkPlanCoversPrincipals(mixed, groupSID) { + t.Fatal("case difference read as a missing group") + } + + // A host with no group provisioned has no principal to miss, so there is + // nothing to assert and setup must not refuse. + if !WindowsNetworkPlanCoversPrincipals(tooEarly, "") { + t.Fatal("an unprovisioned host was treated as a coverage failure") + } +} + +// AN OPTED-OUT HOME MUST KEEP ITS EXISTING MARKER VALID. +// +// The offline group is machine-global. Keyed on its existence, the first +// workspace to opt in changed the computed plan for every OTHER sandbox home on +// the machine, so their stored NetworkInfraHash stopped matching and every one +// of their commands failed with "setup is out of date" until each was re-run +// from an elevated terminal. They had opted into nothing. +// +// The hashes are compared rather than the SID lists because the hash is what the +// marker actually stores and what validation compares. +func TestOfflineGroupDoesNotChangeAnOptedOutHomesInfraHash(t *testing.T) { + previous := resolveWindowsSandboxOfflineGroupSIDHook + t.Cleanup(func() { resolveWindowsSandboxOfflineGroupSIDHook = previous }) + + optedOut := WindowsSandboxCommandConfig{SandboxHome: t.TempDir()} + + // No group on the machine yet: the state before anyone opted in. + resolveWindowsSandboxOfflineGroupSIDHook = nil + before, err := BuildWindowsNetworkInfraPlan(optedOut) + if err != nil { + t.Fatalf("build plan before any opt-in: %v", err) + } + beforeHash, err := WindowsNetworkInfraHash(before) + if err != nil { + t.Fatalf("hash before: %v", err) + } + + // Another workspace has now opted in and created the machine-global group. + resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { + return "S-1-5-21-1111111111-1111111111-1111111111-4002", nil + } + after, err := BuildWindowsNetworkInfraPlan(optedOut) + if err != nil { + t.Fatalf("build plan after another home opted in: %v", err) + } + afterHash, err := WindowsNetworkInfraHash(after) + if err != nil { + t.Fatalf("hash after: %v", err) + } + + if beforeHash != afterHash { + t.Fatalf("another workspace opting in changed this home's infra hash (%s -> %s), so its stored marker stops validating and every command fails until it is re-set-up", + beforeHash, afterHash) + } +} diff --git a/internal/sandbox/windows_offline_group_ownership_windows_test.go b/internal/sandbox/windows_offline_group_ownership_windows_test.go new file mode 100644 index 000000000..4ca357ba7 --- /dev/null +++ b/internal/sandbox/windows_offline_group_ownership_windows_test.go @@ -0,0 +1,96 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +// The offline group's SID goes onto the persistent WFP deny filters and the +// sandbox principal is made a member of it. Adopting a same-named group that +// something else owns therefore cuts off every existing member's outbound +// traffic and hands our principal that group's permissions, so an unmarked +// group has to be refused rather than reused. +func TestEnsureWindowsLocalGroupRefusesForeignSameNameGroup(t *testing.T) { + for name, testCase := range map[string]struct { + status uintptr + owned bool + ownedErr error + wantError string + }{ + "foreign group with our name": { + status: nerrGroupExists, owned: false, + wantError: "not managed by zero", + }, + "foreign group reported via ERROR_ALIAS_EXISTS": { + status: errorAliasExists, owned: false, + wantError: "not managed by zero", + }, + "our own group on a re-run": { + status: nerrGroupExists, owned: true, + }, + "ownership lookup fails": { + status: nerrGroupExists, ownedErr: errors.New("lookup refused"), + wantError: "lookup refused", + }, + "group did not exist": { + status: nerrSuccess, + }, + } { + t.Run(name, func(t *testing.T) { + prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn + t.Cleanup(func() { + addWindowsLocalGroupFn = prevAdd + windowsLocalGroupOwnedByZeroFn = prevOwned + }) + addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return testCase.status, nil } + lookups := 0 + windowsLocalGroupOwnedByZeroFn = func(string, string) (bool, error) { + lookups++ + return testCase.owned, testCase.ownedErr + } + + err := ensureWindowsSandboxOfflineGroup() + if testCase.wantError == "" { + if err != nil { + t.Fatalf("ensureWindowsSandboxOfflineGroup: %v", err) + } + return + } + if err == nil { + t.Fatalf("adopted a group it should have refused (lookups=%d)", lookups) + } + if !strings.Contains(err.Error(), testCase.wantError) { + t.Fatalf("error = %q, want it to mention %q", err, testCase.wantError) + } + }) + } +} + +// The marker compared against is the offline group's own comment, not the +// principals group's: two managed groups with different markers must not be +// mistaken for each other. +func TestEnsureWindowsLocalGroupChecksTheGroupsOwnMarker(t *testing.T) { + prevAdd, prevOwned := addWindowsLocalGroupFn, windowsLocalGroupOwnedByZeroFn + t.Cleanup(func() { + addWindowsLocalGroupFn = prevAdd + windowsLocalGroupOwnedByZeroFn = prevOwned + }) + addWindowsLocalGroupFn = func(string, string) (uintptr, error) { return nerrGroupExists, nil } + var gotName, gotComment string + windowsLocalGroupOwnedByZeroFn = func(name, comment string) (bool, error) { + gotName, gotComment = name, comment + return true, nil + } + if err := ensureWindowsSandboxOfflineGroup(); err != nil { + t.Fatalf("ensureWindowsSandboxOfflineGroup: %v", err) + } + if gotName != windowsSandboxOfflineGroupName { + t.Fatalf("checked group %q, want %q", gotName, windowsSandboxOfflineGroupName) + } + if gotComment != windowsSandboxOfflineGroupComment { + t.Fatalf("compared marker %q, want %q", gotComment, windowsSandboxOfflineGroupComment) + } +} diff --git a/internal/sandbox/windows_offline_membership_windows_test.go b/internal/sandbox/windows_offline_membership_windows_test.go new file mode 100644 index 000000000..e41fe09c1 --- /dev/null +++ b/internal/sandbox/windows_offline_membership_windows_test.go @@ -0,0 +1,102 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +// Choosing the offline account is only half of what denies it the network: the +// WFP filters match the offline GROUP'S SID. An account that has drifted out of +// that group still logs on from its stored secret, and its token no longer +// satisfies the filter condition — so a no-network profile would get full +// egress. The command path has to re-check the membership, not trust the marker +// setup wrote when it last succeeded. +func TestPrincipalTokenRechecksOfflineGroupMembership(t *testing.T) { + for name, testCase := range map[string]struct { + mode NetworkMode + member bool + memberErr error + wantChecked bool + wantReached bool + wantErr bool + }{ + "offline principal drifted out of the group": { + mode: NetworkDeny, member: false, + wantChecked: true, wantReached: false, + }, + "offline principal still a member": { + mode: NetworkDeny, member: true, + wantChecked: true, wantReached: true, + }, + "membership lookup fails": { + mode: NetworkDeny, memberErr: errors.New("group lookup refused"), + wantChecked: true, wantReached: false, wantErr: true, + }, + // An allow-network command uses the online principal, which is not in the + // offline group by design. Checking it there would refuse every approved + // network command. + "online principal is not checked": { + mode: NetworkAllow, member: false, + wantChecked: false, wantReached: true, + }, + } { + t.Run(name, func(t *testing.T) { + prevLookup := lookupWindowsSandboxPrincipalForCommandFn + prevMember := windowsSandboxUserInLocalGroupFn + prevSecret := readWindowsSandboxSecretFn + prevWarn := warnWindowsSandboxOfflineMembershipMissing + t.Cleanup(func() { + lookupWindowsSandboxPrincipalForCommandFn = prevLookup + windowsSandboxUserInLocalGroupFn = prevMember + readWindowsSandboxSecretFn = prevSecret + warnWindowsSandboxOfflineMembershipMissing = prevWarn + }) + + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatal(err) + } + lookupWindowsSandboxPrincipalForCommandFn = func(string, windowsSandboxRole) (windowsSandboxIdentity, error) { + return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, nil + } + checkedGroup := "" + windowsSandboxUserInLocalGroupFn = func(_ string, group string) (bool, error) { + checkedGroup = group + return testCase.member, testCase.memberErr + } + secretRead := false + readWindowsSandboxSecretFn = func(string) (string, error) { + secretRead = true + return "", errWindowsSandboxIdentityUnavailable + } + warnWindowsSandboxOfflineMembershipMissing = func(string) {} + + config := windowsSandboxTestConfig() + config.Env = map[string]string{windowsSandboxIdentityEnv: "1"} + config.PermissionProfile.Network.Mode = testCase.mode + + _, ok, err := windowsSandboxPrincipalToken(config) + if testCase.wantErr { + if err == nil { + t.Fatal("a failed membership lookup was swallowed") + } + } else if err != nil { + t.Fatalf("windowsSandboxPrincipalToken: %v", err) + } + _ = ok + if secretRead != testCase.wantReached { + t.Fatalf("reached the secret read = %v, want %v (gate must short-circuit before it)", secretRead, testCase.wantReached) + } + if checked := checkedGroup != ""; checked != testCase.wantChecked { + t.Fatalf("membership checked = %v, want %v", checked, testCase.wantChecked) + } + if testCase.wantChecked && checkedGroup != windowsSandboxOfflineGroupName { + t.Fatalf("checked group %q, want %q", checkedGroup, windowsSandboxOfflineGroupName) + } + }) + } +} diff --git a/internal/sandbox/windows_online_offline_test.go b/internal/sandbox/windows_online_offline_test.go index 370abee73..759113cd4 100644 --- a/internal/sandbox/windows_online_offline_test.go +++ b/internal/sandbox/windows_online_offline_test.go @@ -37,9 +37,25 @@ func TestWindowsRuntimeTokenSIDs(t *testing.T) { // setup serves both modes (and its fingerprint is stable across modes). func TestBuildWindowsNetworkInfraPlanIsModeIndependent(t *testing.T) { home := t.TempDir() + // Pinned, because the count below depends on whether this machine happens to + // have the offline group already. BuildWindowsNetworkInfraPlan folds that + // group's SID in when it resolves, so on a Windows host where an earlier + // elevated setup created ZeroSandboxOffline the plan legitimately carries two + // identity SIDs and this test failed on a correct plan. CI never saw it: the + // Linux and macOS jobs leave the hook nil, and a fresh Windows runner has no + // group yet. Stubbing it makes the assertion about the plan rather than about + // the machine it runs on. + previousHook := resolveWindowsSandboxOfflineGroupSIDHook + t.Cleanup(func() { resolveWindowsSandboxOfflineGroupSIDHook = previousHook }) + resolveWindowsSandboxOfflineGroupSIDHook = nil + mk := func(mode NetworkMode) WindowsSandboxCommandConfig { return WindowsSandboxCommandConfig{ - SandboxHome: home, + SandboxHome: home, + // Opted in, because the offline group only enters the plan for a home + // that asked for principals. Without this the plan is the pre-principal + // one and the group assertions below have nothing to find. + Env: map[string]string{windowsSandboxIdentityEnv: "1"}, CommandCWD: `C:\ws`, WorkspaceRoots: []string{`C:\ws`}, PermissionProfile: PermissionProfile{ @@ -72,6 +88,40 @@ func TestBuildWindowsNetworkInfraPlanIsModeIndependent(t *testing.T) { if denyPlan.IdentitySIDs[0] != offline { t.Errorf("infra plan SID = %q, want offline-marker %q", denyPlan.IdentitySIDs[0], offline) } + + // Mode independence has to hold on a machine that already has the offline + // group too, which is where the pinned counts above would otherwise be + // asserting something about the host rather than the plan. The group SID is + // folded in for both modes, so the hashes must still agree; only the count + // changes. + resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { return "S-1-5-32-9999", nil } + withGroupDeny, err := BuildWindowsNetworkInfraPlan(mk(NetworkDeny)) + if err != nil { + t.Fatalf("deny infra plan with the group present: %v", err) + } + withGroupAllow, err := BuildWindowsNetworkInfraPlan(mk(NetworkAllow)) + if err != nil { + t.Fatalf("allow infra plan with the group present: %v", err) + } + // Assert the SIDs themselves, not the count. A duplicate offline marker or an + // unrelated SID would satisfy a length check while meaning something quite + // different. + if len(withGroupDeny.IdentitySIDs) != 2 || + withGroupDeny.IdentitySIDs[0] != offline || + withGroupDeny.IdentitySIDs[1] != "S-1-5-32-9999" { + t.Fatalf("group present should add its SID after the offline marker, got %v (offline marker %q)", withGroupDeny.IdentitySIDs, offline) + } + groupDenyHash, _ := WindowsNetworkInfraHash(withGroupDeny) + groupAllowHash, _ := WindowsNetworkInfraHash(withGroupAllow) + if groupDenyHash != groupAllowHash || groupDenyHash == "" { + t.Fatalf("infra hash must stay mode-independent with the group present: deny=%q allow=%q", groupDenyHash, groupAllowHash) + } + // And it must differ from the no-group hash, which is the cross-workspace + // coupling called out for a maintainer decision: one workspace creating the + // group changes every other sandbox home's expected fingerprint. + if groupDenyHash == denyHash { + t.Error("group presence did not change the infra hash; the marker staleness this causes is a deliberate property and should be visible here") + } } // A pre-existing schema-1 capability file (no Offline SID) is upgraded in place: diff --git a/internal/sandbox/windows_principal_inactive_test.go b/internal/sandbox/windows_principal_inactive_test.go deleted file mode 100644 index bb8ec3a7a..000000000 --- a/internal/sandbox/windows_principal_inactive_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package sandbox - -import ( - "strings" - "testing" -) - -// An opted-in principal that stands down must be reportable. -// -// This is the whole point of the predicate: under the DEFAULT network-deny -// policy the principal never runs, so an operator who set the opt-in to confine -// reads gets the same-user restricted token and no read confinement, with -// nothing anywhere saying so. The runner cannot warn per command, and the setup -// marker validates happily because the account really was provisioned. -func TestPrincipalReportsInactiveUnderTheDefaultDenyPolicy(t *testing.T) { - reason := WindowsSandboxPrincipalInactiveReason(true, NetworkDeny) - if reason == "" { - t.Fatal("opted in under network-deny reported as active, so the standdown is invisible to doctor") - } - // The message has to say WHY, not just that something is off. An operator - // reading it needs to know reads are not confined. - for _, want := range []string{"restricted token", "reads"} { - if !strings.Contains(reason, want) { - t.Errorf("reason %q does not mention %q", reason, want) - } - } -} - -// With the network allowed the principal genuinely runs, so there is nothing to -// report and doctor must stay quiet. -func TestPrincipalReportsActiveWhenNetworkIsAllowed(t *testing.T) { - if reason := WindowsSandboxPrincipalInactiveReason(true, NetworkAllow); reason != "" { - t.Errorf("opted in with network allowed reported inactive: %q", reason) - } -} - -// Not opting in is not a standdown. Warning every default install that a -// backend it never asked for is inactive would be noise, and noise that repeats -// gets filtered rather than acted on. -func TestNotOptingInIsNotReportedAsInactive(t *testing.T) { - for _, mode := range []NetworkMode{NetworkDeny, NetworkAllow, ""} { - if reason := WindowsSandboxPrincipalInactiveReason(false, mode); reason != "" { - t.Errorf("opt-out with network %q reported inactive: %q", mode, reason) - } - } -} - -// An unset network mode must normalize the same way the rest of the package -// treats it, so doctor and the runtime agree on a config that omits it. -func TestPrincipalInactiveHandlesAnUnsetNetworkMode(t *testing.T) { - unset := WindowsSandboxPrincipalInactiveReason(true, "") - normalized := WindowsSandboxPrincipalInactiveReason(true, NormalizeNetworkMode("")) - if unset != normalized { - t.Errorf("unset mode reported %q but its normalized form reported %q", unset, normalized) - } -} diff --git a/internal/sandbox/windows_principal_ledger_windows_test.go b/internal/sandbox/windows_principal_ledger_windows_test.go index 651506f1a..dfbf7cf0a 100644 --- a/internal/sandbox/windows_principal_ledger_windows_test.go +++ b/internal/sandbox/windows_principal_ledger_windows_test.go @@ -126,56 +126,84 @@ func TestPrincipalACLRecordCoversTheGrantBeforeItIsMade(t *testing.T) { // 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. +// +// The record is per role because the principals are: seeding one role's record +// must retire the other and only the other. Retiring both would destroy an +// account there is nothing wrong with, and retiring neither is the fail-open. func TestSetupRetiresAPrincipalWithNoRecordOfItsGrants(t *testing.T) { for name, testCase := range map[string]struct { - seedRecord bool + seedRoles []windowsSandboxRole identityFound bool - wantRetired int + wantRetired []windowsSandboxRole }{ - "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}, + "no record and principals from an earlier setup": { + identityFound: true, + wantRetired: []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline}, + }, + "no record and nothing provisioned": {identityFound: false}, + "only the offline role has a record": { + seedRoles: []windowsSandboxRole{windowsSandboxRoleOffline}, + identityFound: true, + wantRetired: []windowsSandboxRole{windowsSandboxRoleOnline}, + }, + "both roles have a record to reconcile against": { + seedRoles: []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline}, + identityFound: true, + }, } { t.Run(name, func(t *testing.T) { config := stubWindowsPrincipalSetup(t) - username := windowsSandboxUserName(windowsSandboxPrincipalKey(config)) - if testCase.seedRecord { + key := windowsSandboxPrincipalKey(config) + for _, role := range testCase.seedRoles { + username := windowsSandboxUserName(key, role) if err := writeWindowsPrincipalACLLedger(config.SandboxHome, username, []string{`C:\ws\recorded`}); err != nil { - t.Fatalf("seed the record: %v", err) + t.Fatalf("seed the %s record: %v", role, err) } } - lookupWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, error) { + lookupWindowsSandboxIdentityFn = func(_ string, role windowsSandboxRole) (windowsSandboxIdentity, error) { if testCase.identityFound { - return windowsSandboxIdentity{Username: username, SID: guestsSID(t)}, nil + return windowsSandboxIdentity{Username: windowsSandboxUserName(key, role), SID: guestsSID(t)}, nil } return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable } - retired := 0 - removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { - retired++ + var retired []windowsSandboxRole + removeWindowsSandboxPrincipalForSetupFn = func(_ WindowsSandboxCommandConfig, role windowsSandboxRole) error { + retired = append(retired, role) 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) + if !sameRoles(retired, testCase.wantRetired) { + t.Errorf("retired %v, want %v", retired, testCase.wantRetired) } }) } } +func sameRoles(got []windowsSandboxRole, want []windowsSandboxRole) bool { + if len(got) != len(want) { + return false + } + for index := range got { + if got[index] != want[index] { + return false + } + } + return true +} + // 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) { + lookupWindowsSandboxIdentityFn = func(string, windowsSandboxRole) (windowsSandboxIdentity, error) { return windowsSandboxIdentity{Username: "zerosbx", SID: guestsSID(t)}, nil } - removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { + removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig, windowsSandboxRole) error { return errors.New("account is in use") } if _, err := setupWindowsSandboxPrincipal(config); err == nil { @@ -202,15 +230,26 @@ func TestTeardownRevokesRecordedPathsTheCurrentPolicyNoLongerNames(t *testing.T) }, }, } - username := windowsSandboxUserName(windowsSandboxPrincipalKey(config)) + role := windowsSandboxRoleOffline + username := windowsSandboxUserName(windowsSandboxPrincipalKey(config), role) 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") + paths, err := windowsPrincipalRevocationPaths(config, "S-1-5-32-546", role) if err != nil { t.Fatalf("windowsPrincipalRevocationPaths: %v", err) } + + // The other role's revocation must not pick this up: separate accounts, + // separate records, and retiring one must leave the other's ACEs alone. + other, err := windowsPrincipalRevocationPaths(config, "S-1-5-32-546", windowsSandboxRoleOnline) + if err != nil { + t.Fatalf("windowsPrincipalRevocationPaths(online): %v", err) + } + if containsPathFold(other, dropped) { + t.Errorf("the online role would revoke %q, which only the offline role's record names", dropped) + } if !containsPathFold(paths, dropped) { t.Errorf("teardown would revoke %v, missing the recorded root %q the policy no longer names", paths, dropped) } @@ -246,8 +285,8 @@ func stubWindowsPrincipalSetup(t *testing.T) WindowsSandboxCommandConfig { sandboxUserCacheDir = prevCache }) - provisionWindowsSandboxIdentityFn = func(key string) (windowsSandboxIdentity, string, bool, error) { - return windowsSandboxIdentity{Username: windowsSandboxUserName(key), SID: guestsSID(t)}, "pw", true, nil + provisionWindowsSandboxIdentityFn = func(key string, role windowsSandboxRole) (windowsSandboxIdentity, string, bool, error) { + return windowsSandboxIdentity{Username: windowsSandboxUserName(key, role), SID: guestsSID(t)}, "pw", true, nil } grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { return nil } resetWindowsSandboxUserPasswordFn = func(string, string) error { return nil } diff --git a/internal/sandbox/windows_role_test.go b/internal/sandbox/windows_role_test.go new file mode 100644 index 000000000..42f094b23 --- /dev/null +++ b/internal/sandbox/windows_role_test.go @@ -0,0 +1,26 @@ +package sandbox + +import "testing" + +// Doctor and the runtime must name the SAME principal. +// +// The helper this replaces existed to be that single rule and said so, and the +// drift it was meant to prevent happened anyway: dual-role provisioning made the +// runtime use an offline principal under NetworkDeny while doctor still reported +// the old restricted-token standdown, so an operator was told their reads were +// unconfined while they were confined. Both sides now call this, and the test +// pins the mapping rather than either caller's copy of it. +func TestPrincipalRoleForNetwork(t *testing.T) { + for mode, want := range map[NetworkMode]string{ + NetworkAllow: "online", + NetworkDeny: "offline", + // Unset and unrecognized lose the network rather than keeping it, matching + // what the runtime does with a mode it does not know. + "": "offline", + "not-a-mode": "offline", + } { + if got := WindowsSandboxPrincipalRoleForNetwork(mode); got != want { + t.Errorf("role for %q = %q, want %q", mode, got, want) + } + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index d583b9488..2f61fa8d5 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -15,7 +15,18 @@ import ( const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe" -const windowsSandboxSetupMarkerSchemaVersion = 6 +// Bumped to 7 when the single principal was split into offline and online role +// accounts. THE NAMES CHANGED, so a marker written by the previous version is +// not evidence that this version's setup has run: it validates by comparing +// serialized plans and hashes, and neither names an account. Left at 6, an +// upgraded machine kept a valid marker, setup was never re-run, and the runner +// found neither "zero-sbx-d" nor "zero-sbx-n" and fell back to the +// restricted-token backend with no read confinement, silently. +// +// Bumping it makes that installation report as out of date, which is what sends +// the operator back through elevated setup. Setup then provisions both roles and +// retires the untagged predecessor (see removeWindowsSandboxPrincipalsForSetup). +const windowsSandboxSetupMarkerSchemaVersion = 7 // windowsSandboxIdentityEnv opts a machine into the principal backend while it // is still experimental. Provisioning is inert without it, so an existing @@ -44,33 +55,6 @@ func WindowsSandboxPrincipalOptIn(env map[string]string) bool { return windowsSandboxIdentityEnabled(env) } -// WindowsSandboxPrincipalInactiveReason explains why the sandbox principal will -// NOT be used even though it is opted into, or returns empty when it will be. -// -// This is the single source of truth for that rule: windowsSandboxPrincipalEligible -// asks it too, so the runtime and `zero doctor` cannot drift into disagreeing -// about whether a principal is in play. -// -// It exists because the standdown is otherwise invisible. The runner cannot -// announce it, being re-exec'd per command so the notice would land on the -// stderr of essentially every tool call, and it is not per-command actionable -// anyway. But an operator who set the opt-in and believes reads are confined, -// when they are not, is holding a false picture of their own machine. Doctor is -// read once, which is where a standing configuration fact belongs. -// -// Returns empty when the opt-in is off: that is not a standdown, it is simply -// not asking for the backend. -func WindowsSandboxPrincipalInactiveReason(optIn bool, network NetworkMode) string { - if !optIn { - return "" - } - if NormalizeNetworkMode(network) != NetworkDeny { - return "" - } - return "network denial is enforced by WFP filters keyed to the offline-marker SID, which a principal token cannot carry, " + - "so commands run on the restricted token instead and reads are not confined to the principal" -} - func windowsSandboxPrincipalOptInValue(optIn bool) string { if optIn { return "1" @@ -712,3 +696,29 @@ func shortWindowsACLPlanHash(hash string) string { func WindowsSandboxProfileWithRuntimeRoots(profile PermissionProfile, workspaceRoots []string) PermissionProfile { return windowsSandboxProfileWithRuntime(profile, workspaceRoots) } + +// WindowsSandboxPrincipalRoleForNetwork names the principal a command in this +// network mode runs as. +// +// Exported and portable so `zero doctor` and the Windows runtime answer from ONE +// rule. The helper this replaces existed for that reason and said so, and the +// thing it was protecting against happened anyway once dual-role provisioning +// changed the behaviour under NetworkDeny: the runtime started using an offline +// principal while doctor still reported the old restricted-token standdown, so +// operators were told their reads were unconfined when they were confined. +// windowsSandboxRoleForNetwork delegates here rather than repeating the switch. +// +// Anything that is not an EXACT allow is offline: a mode this does not recognise +// should lose the network, not keep it. +// +// Deliberately not normalized first. NormalizeNetworkMode case-folds, so routing +// through it made "ALLOW" select the online principal where the runtime required +// an exact match and failed closed to offline. Sharing one rule is only an +// improvement if it shares the STRICTER one; a shared rule that quietly widened +// the runtime would be worse than the drift it fixes. +func WindowsSandboxPrincipalRoleForNetwork(network NetworkMode) string { + if network == NetworkAllow { + return "online" + } + return "offline" +} diff --git a/internal/sandbox/windows_setup_caller_windows_test.go b/internal/sandbox/windows_setup_caller_windows_test.go index d36491c91..082915161 100644 --- a/internal/sandbox/windows_setup_caller_windows_test.go +++ b/internal/sandbox/windows_setup_caller_windows_test.go @@ -35,7 +35,8 @@ func withWindowsSetupSeams(t *testing.T, seams windowsSetupSeams) { originalLock := acquireWindowsSandboxSetupLockFn originalACL := applyWindowsACLPlanFn originalPrincipal := setupWindowsSandboxPrincipalFn - originalRetire := removeWindowsSandboxPrincipalForSetupFn + originalRetire := removeWindowsSandboxPrincipalsForSetupFn + originalGroupSID := resolveWindowsSandboxOfflineGroupSIDHook originalLookup := lookupWindowsSandboxIdentityFn originalNetwork := applyWindowsNetworkPlanFn originalMarker := writeWindowsSandboxSetupMarkerFn @@ -45,7 +46,8 @@ func withWindowsSetupSeams(t *testing.T, seams windowsSetupSeams) { acquireWindowsSandboxSetupLockFn = originalLock applyWindowsACLPlanFn = originalACL setupWindowsSandboxPrincipalFn = originalPrincipal - removeWindowsSandboxPrincipalForSetupFn = originalRetire + removeWindowsSandboxPrincipalsForSetupFn = originalRetire + resolveWindowsSandboxOfflineGroupSIDHook = originalGroupSID applyWindowsNetworkPlanFn = originalNetwork writeWindowsSandboxSetupMarkerFn = originalMarker }) @@ -73,10 +75,13 @@ func withWindowsSetupSeams(t *testing.T, seams windowsSetupSeams) { } return func() error { return nil }, nil } - removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { + removeWindowsSandboxPrincipalsForSetupFn = func(WindowsSandboxCommandConfig) error { return seams.retireErr } - lookupWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, error) { + // Dual-role setup asserts the block filters name the offline group, which a + // stubbed provisioning never creates. + resolveWindowsSandboxOfflineGroupSIDHook = func() (string, error) { return "S-1-5-32-999", nil } + lookupWindowsSandboxIdentityFn = func(string, windowsSandboxRole) (windowsSandboxIdentity, error) { if seams.principalStillInstalled { return windowsSandboxIdentity{Username: "zero-sbx-stub"}, nil } diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 1c6f242c9..31546def3 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -97,14 +97,6 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 } - // Always provision the mode-INDEPENDENT infrastructure: the outbound block - // filters scoped to the offline-marker SID. Runtime gates network per command - // by whether the token carries that SID, so one setup serves both modes. - networkPlan, err := BuildWindowsNetworkInfraPlan(config.commandConfig()) - if err != nil { - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) - return 1 - } rollback, err := applyWindowsACLPlanFn(plan) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) @@ -136,7 +128,7 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) } return aclErr } - } else if err := removeWindowsSandboxPrincipalForSetupFn(config.commandConfig()); err != nil { + } else if err := removeWindowsSandboxPrincipalsForSetupFn(config.commandConfig()); err != nil { // Opting out has to actually retire the principal, because that is what // we tell people it does. ValidateWindowsSandboxSetupMarker sends an // operator here in as many words: re-run setup from an elevated terminal @@ -183,7 +175,56 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintf(stderr, "%s: opted out; the sandbox principal was retired, but some of its residue could not be removed: %v\n", WindowsSandboxSetupName, err) } - if err := applyWindowsNetworkPlanFn(networkPlan); err != nil { + // Built AFTER any principal provisioning, not before. The block filters are + // keyed to the offline group as well as the offline-marker SID, and that group + // is created as part of provisioning: planning first would install filters + // that name only the marker, leaving every offline principal with an open + // network while looking correctly set up. + // + // Mode-INDEPENDENT by design. Which identity a command runs under is what + // selects allow or deny, so one setup serves both modes. + networkPlan, err := BuildWindowsNetworkInfraPlan(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 + } + // Refuse to install filters that would not cover the principals just + // provisioned. The ordering above is what makes them cover it, and nothing at + // this call site shows that, so assert it rather than trusting it to survive + // a later refactor. Installing anyway would leave a machine reporting a + // successful setup while every offline principal had an open network. + // Gated on the SAME opt-in that decides whether principals were provisioned + // at all (line 36). The offline group is created inside provisioning, so on + // an opt-out machine it legitimately does not exist, and asserting there + // would refuse a setup that has nothing to get wrong. + if err := assertWindowsNetworkPlanCoversOfflineGroup(networkPlan, resolveWindowsSandboxOfflineGroupSIDHook, + windowsSandboxIdentityEnabled(config.commandConfig().Env)); 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 + } + // Install the machine's plan, not this home's. The filters are global and are + // replaced wholesale on every setup, so an opted-out run here would otherwise + // strip the offline group SID that another workspace's principals depend on + // and hand them egress under a NetworkDeny profile. networkPlan itself is left + // alone because it is what this home's marker fingerprints. + planToInstall, planErr := WindowsNetworkPlanForApply(networkPlan, resolveWindowsSandboxOfflineGroupSIDHook) + if planErr != nil { + if rollbackErr := rollback(); rollbackErr != nil { + fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, planErr, rollbackErr) + return 1 + } + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+planErr.Error()) + return 1 + } + if err := applyWindowsNetworkPlanFn(planToInstall); err != nil { if rollbackErr := rollback(); rollbackErr != nil { fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) return 1 @@ -210,9 +251,21 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // is treated as still installed. The consumer uses this to decide whether an // opted-out marker would be a lie, and the expensive mistake there is claiming a // principal is gone when nobody actually checked. +// A workspace now has TWO principals, and either one still installed makes the +// answer yes. Checking a single role would let an opted-out marker claim the +// workspace is clean while the other account is still on the machine, which is +// the exact lie this guard exists to prevent. func windowsSandboxPrincipalIsInstalled(config WindowsSandboxCommandConfig) bool { - _, err := lookupWindowsSandboxIdentityFn(windowsSandboxPrincipalKey(config)) - return !errors.Is(err, errWindowsSandboxIdentityUnavailable) + // BOTH roles, because dual-role setup provisions two accounts and retiring + // one while the other survives is exactly the half-done teardown an + // opted-out marker must not claim to have completed. + key := windowsSandboxPrincipalKey(config) + for _, role := range []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline} { + if _, err := lookupWindowsSandboxIdentityFn(key, role); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + return true + } + } + return false } // assertWindowsSetupRunsAsCaller refuses to provision a sandbox principal when diff --git a/internal/sandbox/windows_stale_secret_windows_test.go b/internal/sandbox/windows_stale_secret_windows_test.go index 7e4e3ebef..222b8fde3 100644 --- a/internal/sandbox/windows_stale_secret_windows_test.go +++ b/internal/sandbox/windows_stale_secret_windows_test.go @@ -52,7 +52,7 @@ func TestProvisionSurfacesFailedStaleSecretCleanup(t *testing.T) { t.Fatal(err) } // created=false so the run ADOPTS an account and rotation applies. - provisionWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, string, bool, error) { + provisionWindowsSandboxIdentityFn = func(string, windowsSandboxRole) (windowsSandboxIdentity, string, bool, error) { return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, "pw", false, nil } grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { return nil } @@ -80,7 +80,7 @@ func TestProvisionSurfacesFailedStaleSecretCleanup(t *testing.T) { CommandCWD: `C:\ws`, WorkspaceRoots: []string{`C:\ws`}, } - _, _, err = provisionWindowsSandboxPrincipalForSetup(config) + _, _, err = provisionWindowsSandboxPrincipalForSetup(config, windowsSandboxRoleOffline) if removed != testCase.wantRemove { t.Fatalf("stale secret removal attempted = %v, want %v", removed, testCase.wantRemove) diff --git a/internal/sandbox/windows_workspace_canonical_windows_test.go b/internal/sandbox/windows_workspace_canonical_windows_test.go index 0997c3113..533770afd 100644 --- a/internal/sandbox/windows_workspace_canonical_windows_test.go +++ b/internal/sandbox/windows_workspace_canonical_windows_test.go @@ -58,7 +58,7 @@ func TestSetupRuntimeRootMatchesTheCommandPathForADifferentlyCasedRoot(t *testin if err != nil { t.Fatalf("sandboxRuntimeRootFor(command): %v", err) } - if fromSetup != fromCommand { + if !grantedRuntimeRootsCover(fromSetup, fromCommand) { t.Errorf("setup grants a runtime tree commands never use:\n setup: %s\n command: %s", fromSetup, fromCommand) } } @@ -118,7 +118,7 @@ func TestSetupAndPrepareRuntimeAgreeOnANonCanonicalRoot(t *testing.T) { if release != nil { defer release() } - if filepath.Clean(granted) != filepath.Clean(state.Root) { + if !grantedRuntimeRootsCover(granted, state.Root) { t.Errorf("setup granted %q but commands write to %q", granted, state.Root) } } @@ -202,16 +202,79 @@ func TestTeardownPathDerivationCreatesNothing(t *testing.T) { 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{ + // Setup's resolver must agree with teardown's, and create nothing either. + // + // This assertion has now been inverted twice, so the history is worth keeping. + // It first required setup to fall back to a usable tree. That was then wrong, + // because the fallback was os.MkdirTemp memoized only in-process: setup would + // grant an ACE on temp root A, the next command would derive root B and fail + // ACCESS_DENIED, and teardown would clean a third. So it was changed to demand + // setup report NOTHING here. + // + // That is what is wrong now. fallbackSandboxRuntimeRoot derives its path by + // hashing the workspace and creates nothing, so every process reaches the same + // answer. Commands in this exact layout therefore DO select it and redirect + // TMP, GOCACHE and the package caches into it, while setup granting nothing + // left both principals without an ACE on the one tree those writes land in. + // Reporting none is no longer the safe answer; it is the ACCESS_DENIED. + beforeSetup := tempDirEntryCount(t) + setupRoots, 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") + if len(setupRoots) == 0 { + t.Error("setup named no runtime root for a workspace whose cache-derived root is unusable; commands still select the stable fallback and would write there with no ACE") + } + // And it must be the tree a command actually picks, not merely some tree. + commandState, release, err := prepareSandboxRuntime(workspace) + if err != nil { + t.Fatalf("prepareSandboxRuntime: %v", err) + } + if release != nil { + defer release() + } + if !grantedRuntimeRootsCover(setupRoots, commandState.Root) { + t.Errorf("setup granted %q but commands write to %q", setupRoots, commandState.Root) + } + if after := tempDirEntryCount(t); after != beforeSetup { + t.Errorf("setup's resolver created %d temp entries; it must create nothing", after-beforeSetup) + } +} + +// Setup and teardown must derive the SAME root in the ordinary case, since one +// grants the ACE the other revokes. This is the case the fix above must not +// break: reporting "no root" is only correct when the root is underivable. +func TestSetupAndTeardownDeriveTheSameRuntimeRoot(t *testing.T) { + workspace := t.TempDir() + cacheRoot := t.TempDir() // outside the workspace, so the derivation is usable + previous := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = previous }) + + setupRoot, err := windowsSandboxRuntimeRootPath(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{workspace}, + CommandCWD: workspace, + }) + if err != nil { + t.Fatalf("windowsSandboxRuntimeRootPath: %v", err) + } + if len(setupRoot) == 0 { + t.Fatal("setup named no runtime root for a derivable workspace") + } + teardownRoot, ok := deterministicSandboxRuntimeRoot( + canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatal("precondition: the deterministic root should be usable here") + } + // The cache-derived root is the one a command picks in this layout, so it has + // to be among what setup granted. Setup covering the fallback as well is the + // point rather than a discrepancy: whichever the command lands on is + // provisioned. + if !grantedRuntimeRootsCover(setupRoot, teardownRoot) { + t.Errorf("setup grants on %q but teardown revokes %q", setupRoot, teardownRoot) } } From 79716d983dfc613d4b545ded9141a4a6d60fc22e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 21 Aug 2026 14:22:38 +0530 Subject: [PATCH 71/96] fix(sandbox): bind the ACL restore to the object it snapshotted jatmn's P1 asked for creation AND cleanup to be handle-bound rather than resolved from a pathname. Creation and the materialization unwind both are. The DACL restore was not, and its comment read as though it were. It re-opened the target with a no-follow open, which rules out a reparse point swapped in since apply and nothing else. The other substitution passes it untouched: rename the target aside and put an ordinary directory of the same name in its place. Nothing there is a link, so the open succeeds, the pre-apply DACL lands on the decoy, and the real object keeps the ACEs from the setup that just aborted. The snapshot now records the volume serial and file index of the object it read the DACL from, the restore proves it is writing back to that same object, and a mismatch is refused rather than forced through. Leaving the real object with the aborted setup's ACEs is the safe direction, since the caller is failing anyway. Three things nothing was pinning, all of which could be deleted with a green suite. This repository has already had a fix silently reverted by a later change, so these are worth more than their size. rollbackWindowsACLSnapshots documented its reverse iteration as pinned by TestRollbackUnwindsDescendantsBeforeAncestors. That test did not exist anywhere in the repo; the only match for the name was the sentence claiming it. The ordering is load-bearing twice over, because a materialized directory must be empty before its own removal and SetSecurityInfo propagates inheritable ACEs downward, so the ancestor has to go last. It exists now. The principal ACL rollback restores the ledger alongside the DACLs, and the neighbouring test asserted only the order of the two ACL reverts and never read the ledger. Deleting the restore left the suite green while the paths it put back were unnamed, so cleanup could not find them. windowsSandboxUserIsManaged promises an account carrying the legacy bare comment gets the workspace key stamped on. The probe for the legacy comment was seamed and the upgrade itself was not, so nothing could observe the call. It is seamed now, with the negative case covered too so the assertion cannot be satisfied by an unconditional rewrite. --- internal/sandbox/windows_acl_apply_windows.go | 57 ++++++- ...ndows_acl_restore_identity_windows_test.go | 148 ++++++++++++++++++ internal/sandbox/windows_identity_windows.go | 13 +- ...ows_legacy_comment_upgrade_windows_test.go | 90 +++++++++++ .../sandbox/windows_stale_ace_windows_test.go | 67 ++++++++ 5 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 internal/sandbox/windows_acl_restore_identity_windows_test.go create mode 100644 internal/sandbox/windows_legacy_comment_upgrade_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index ef02ea0b4..cd85d03b5 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -66,10 +66,29 @@ func (materialization windowsACLMaterialization) createdAnything() bool { return false } +// windowsACLRestoreHook observes restore order. Nil in production; the ordering +// it exposes is load-bearing and was previously pinned by nothing at all, +// despite a comment claiming otherwise. +var windowsACLRestoreHook func(path string) + type windowsACLSnapshot struct { Path string Descriptor *windows.SECURITY_DESCRIPTOR Created windowsACLMaterialization + // TargetID is the object the DACL was read from, so the restore can prove it + // is writing back to that same object rather than to whatever the pathname + // resolves to at rollback time. + // + // A no-follow re-open catches a REPARSE POINT swapped in since apply, and + // that is what the restore relied on. It cannot catch the other + // substitution: rename the target aside and put an ordinary directory of the + // same name in its place. Nothing about that decoy is a reparse point, so the + // re-open succeeds and the old DACL is written onto the attacker's object + // while the real one keeps the aborted setup's ACEs. + // + // Volume serial plus file index is the same identity the materialization + // unwind already anchors on. See reopenWindowsACLDirectoryAsIdentity. + TargetID windowsFileIdentity } func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { @@ -240,8 +259,14 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // The handle has served its purpose (read+write bound to one object) and is // closed now — rollback re-opens no-follow rather than holding a handle for // the whole sandbox lifetime, since one caller discards the rollback closure. + // Captured BEFORE the handle closes, because that handle is the only thing + // that names the object rather than the path. + targetID, err := windowsIdentityOfHandle(handle) + if err != nil { + return fail(fmt.Errorf("read windows ACL target identity for %s: %w", path, err)) + } _ = windows.CloseHandle(handle) - return windowsACLSnapshot{Path: path, Descriptor: descriptor, Created: created}, true, nil + return windowsACLSnapshot{Path: path, Descriptor: descriptor, Created: created, TargetID: targetID}, true, nil } // openWindowsACLTarget opens path for reading and rewriting its DACL without @@ -420,16 +445,36 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err)) continue } - // Re-open no-follow rather than restoring by pathname: the restore must - // land on the real object, not a reparse point swapped in since apply. The - // residual window is small because the target is ACL-restricted by now, but - // a handle keeps the restore honest. On a materialized-target rollback we - // remove it above, so only the restore-existing path opens here. + // Re-open no-follow AND prove it is the same object. The no-follow open + // alone only rules out a reparse point swapped in since apply; a target + // renamed aside and replaced by an ordinary directory of the same name + // passes it, and the old DACL would land on the decoy while the real + // object kept the aborted setup's ACEs. See windowsACLSnapshot.TargetID. handle, _, err := openWindowsACLTarget(snapshot.Path) if err != nil { errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err)) continue } + if !snapshot.TargetID.empty() { + got, identityErr := windowsIdentityOfHandle(handle) + if identityErr != nil { + _ = windows.CloseHandle(handle) + errs = append(errs, fmt.Errorf("read rollback identity for %s: %w", snapshot.Path, identityErr)) + continue + } + if got != snapshot.TargetID { + // REFUSED, not forced through. Leaving the real object with the + // aborted setup's ACEs is the safe direction: the caller is failing + // anyway, and writing a stale DACL onto an object setup never + // touched is the one outcome rollback must not produce. + _ = windows.CloseHandle(handle) + errs = append(errs, fmt.Errorf("refusing to restore windows ACL for %s: it is no longer the object setup applied to", snapshot.Path)) + continue + } + } + if windowsACLRestoreHook != nil { + windowsACLRestoreHook(snapshot.Path) + } if err := windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { errs = append(errs, fmt.Errorf("rollback windows ACL for %s: %w", snapshot.Path, err)) } diff --git a/internal/sandbox/windows_acl_restore_identity_windows_test.go b/internal/sandbox/windows_acl_restore_identity_windows_test.go new file mode 100644 index 000000000..c3c04a8ff --- /dev/null +++ b/internal/sandbox/windows_acl_restore_identity_windows_test.go @@ -0,0 +1,148 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// THE RESTORE MUST LAND ON THE OBJECT THE DACL CAME FROM. +// +// Creation and the materialization unwind were both bound to handles, and the +// restore branch was left re-resolving the target by pathname. Its comment +// justified that with a no-follow re-open, which rules out a REPARSE POINT +// swapped in since apply and nothing else. The other substitution passes it +// untouched: rename the target aside and put an ordinary directory of the same +// name in its place. Nothing in that decoy is a link, so the no-follow open +// succeeds, the pre-apply DACL is written onto the attacker's object, and the +// real one keeps the ACEs from the setup that just aborted. +func TestRollbackRefusesToRestoreOntoAReplacedTarget(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + target := filepath.Join(approved, "target") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("seed the approved tree: %v", err) + } + + handle, _, err := openWindowsACLTarget(target) + if err != nil { + t.Fatalf("open the target: %v", err) + } + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + _ = windows.CloseHandle(handle) + t.Fatalf("read the target DACL: %v", err) + } + identity, err := windowsIdentityOfHandle(handle) + if err != nil { + _ = windows.CloseHandle(handle) + t.Fatalf("read the target identity: %v", err) + } + _ = windows.CloseHandle(handle) + + // The swap, exactly as it would happen between apply and rollback. Ordinary + // directories throughout: no links, no reparse points. + if err := os.Rename(target, target+"-moved"); err != nil { + t.Skipf("cannot rename here: %v", err) + } + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatalf("plant the replacement: %v", err) + } + + snapshot := windowsACLSnapshot{Path: target, Descriptor: descriptor, TargetID: identity} + err = rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}) + if err == nil { + t.Fatal("rollback wrote the pre-apply DACL onto a different object wearing the target's name") + } + if !strings.Contains(err.Error(), "no longer the object") { + t.Errorf("refused for the wrong reason: %v", err) + } +} + +// And an untouched target still restores, or the guard above would be satisfied +// by a rollback that refuses everything. +func TestRollbackRestoresAnUnchangedTarget(t *testing.T) { + target := filepath.Join(t.TempDir(), "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatalf("seed the target: %v", err) + } + + handle, _, err := openWindowsACLTarget(target) + if err != nil { + t.Fatalf("open the target: %v", err) + } + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + _ = windows.CloseHandle(handle) + t.Fatalf("read the target DACL: %v", err) + } + identity, err := windowsIdentityOfHandle(handle) + if err != nil { + _ = windows.CloseHandle(handle) + t.Fatalf("read the target identity: %v", err) + } + _ = windows.CloseHandle(handle) + + snapshot := windowsACLSnapshot{Path: target, Descriptor: descriptor, TargetID: identity} + if err := rollbackWindowsACLSnapshots([]windowsACLSnapshot{snapshot}); err != nil { + t.Fatalf("an unchanged target was refused: %v", err) + } +} + +// THE COMMENT AT rollbackWindowsACLSnapshots CLAIMED A TEST THAT DID NOT EXIST. +// +// It said "TestRollbackUnwindsDescendantsBeforeAncestors pins it" about the +// reverse iteration order, and grepping the repo found only that sentence. The +// ordering is load-bearing twice over: a materialized directory must be empty +// before its own removal is attempted, and SetSecurityInfo propagates +// inheritable ACEs downward so the ancestor has to be restored last. Neither +// was pinned by anything. +func TestRollbackUnwindsDescendantsBeforeAncestors(t *testing.T) { + root := t.TempDir() + ancestor := filepath.Join(root, "ws") + descendant := filepath.Join(ancestor, "child") + if err := os.MkdirAll(descendant, 0o700); err != nil { + t.Fatalf("seed the tree: %v", err) + } + + snapshotFor := func(path string) windowsACLSnapshot { + t.Helper() + handle, _, err := openWindowsACLTarget(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer windows.CloseHandle(handle) + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the DACL for %s: %v", path, err) + } + identity, err := windowsIdentityOfHandle(handle) + if err != nil { + t.Fatalf("read the identity for %s: %v", path, err) + } + return windowsACLSnapshot{Path: path, Descriptor: descriptor, TargetID: identity} + } + + // Ascending by path key, the order applyWindowsACLPlan produces. + snapshots := []windowsACLSnapshot{snapshotFor(ancestor), snapshotFor(descendant)} + + var order []string + restore := windowsACLRestoreHook + windowsACLRestoreHook = func(path string) { order = append(order, path) } + t.Cleanup(func() { windowsACLRestoreHook = restore }) + + if err := rollbackWindowsACLSnapshots(snapshots); err != nil { + t.Fatalf("rollbackWindowsACLSnapshots: %v", err) + } + if len(order) != 2 { + t.Fatalf("restored %d targets, want 2: %v", len(order), order) + } + if order[0] != descendant || order[1] != ancestor { + t.Errorf("restore order was %v; the descendant must be restored before its ancestor, because SetSecurityInfo propagates inheritable ACEs downward", order) + } +} diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index e9fd1e6a4..7d8169d36 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -797,9 +797,14 @@ var ( resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged windowsSandboxUserHasLegacyCommentFn = windowsSandboxUserHasLegacyComment - windowsSandboxUserIsPrivilegedFn = windowsSandboxUserIsPrivileged - grantWindowsSandboxLogonRightsFn = grantWindowsSandboxLogonRights - revokeWindowsSandboxLogonRightsFn = revokeWindowsSandboxLogonRights + // Seamed so the upgrade CALL SITE can be observed. The legacy-comment probe + // was already seamed and the upgrade itself was not, so nothing could tell + // whether it ran: deleting the call left the whole suite green, which is + // exactly how a fix gets silently reverted by a later change. + upgradeWindowsSandboxUserCommentFn = upgradeWindowsSandboxUserComment + windowsSandboxUserIsPrivilegedFn = windowsSandboxUserIsPrivileged + grantWindowsSandboxLogonRightsFn = grantWindowsSandboxLogonRights + revokeWindowsSandboxLogonRightsFn = revokeWindowsSandboxLogonRights // applyWindowsACLPlanFn is a seam so a test can pin the ORDER of setup's ACL // work. The revocation 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 @@ -873,7 +878,7 @@ func provisionWindowsSandboxIdentity(workspaceKey string, role windowsSandboxRol // for bookkeeping. Reported rather than swallowed so a run that keeps // re-adopting the same legacy account is visible. if legacy, err := windowsSandboxUserHasLegacyCommentFn(username); err == nil && legacy { - if err := upgradeWindowsSandboxUserComment(username, workspaceKey); err != nil { + if err := upgradeWindowsSandboxUserCommentFn(username, workspaceKey); err != nil { fmt.Fprintf(os.Stderr, "%s: could not stamp the workspace key onto sandbox principal %s: %v\n", WindowsSandboxSetupName, username, err) } diff --git a/internal/sandbox/windows_legacy_comment_upgrade_windows_test.go b/internal/sandbox/windows_legacy_comment_upgrade_windows_test.go new file mode 100644 index 000000000..37d7d93c7 --- /dev/null +++ b/internal/sandbox/windows_legacy_comment_upgrade_windows_test.go @@ -0,0 +1,90 @@ +//go:build windows + +package sandbox + +import ( + "testing" + + "golang.org/x/sys/windows" +) + +// THE UPGRADE HAS TO BE OBSERVABLE, or it is not really there. +// +// windowsSandboxUserIsManaged accepts the old bare account comment and promises +// the workspace key gets stamped on so the bare form can eventually be retired. +// The probe for the legacy comment was seamed and the upgrade itself was not, so +// nothing in the suite could tell whether the call ran: deleting it left +// everything green. This repository has already had a fix silently reverted by a +// later change, which is the failure this pins. +func TestAdoptingALegacyAccountStampsTheWorkspaceKey(t *testing.T) { + stub := func(t *testing.T, legacy bool) *string { + t.Helper() + var upgraded string + + prevGroup := ensureWindowsSandboxGroupFn + prevOffline := ensureWindowsSandboxOfflineGroupFn + prevEnsure := ensureWindowsSandboxUserFn + prevManaged := windowsSandboxUserIsManagedFn + prevLegacy := windowsSandboxUserHasLegacyCommentFn + prevPrivileged := windowsSandboxUserIsPrivilegedFn + prevUpgrade := upgradeWindowsSandboxUserCommentFn + prevReset := resetWindowsSandboxUserPasswordFn + prevGroupAdd := addWindowsSandboxUserToGroupFn + prevOfflineAdd := addWindowsSandboxUserToOfflineGroupFn + prevSID := resolveWindowsSandboxSIDFn + t.Cleanup(func() { + ensureWindowsSandboxGroupFn = prevGroup + ensureWindowsSandboxOfflineGroupFn = prevOffline + ensureWindowsSandboxUserFn = prevEnsure + windowsSandboxUserIsManagedFn = prevManaged + windowsSandboxUserHasLegacyCommentFn = prevLegacy + windowsSandboxUserIsPrivilegedFn = prevPrivileged + upgradeWindowsSandboxUserCommentFn = prevUpgrade + resetWindowsSandboxUserPasswordFn = prevReset + addWindowsSandboxUserToGroupFn = prevGroupAdd + addWindowsSandboxUserToOfflineGroupFn = prevOfflineAdd + resolveWindowsSandboxSIDFn = prevSID + }) + + ensureWindowsSandboxGroupFn = func() error { return nil } + ensureWindowsSandboxOfflineGroupFn = func() error { return nil } + // existed = true: the adoption path, which is the only one that upgrades. + ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return true, nil } + windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil } + windowsSandboxUserHasLegacyCommentFn = func(string) (bool, error) { return legacy, nil } + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil } + upgradeWindowsSandboxUserCommentFn = func(_ string, workspaceKey string) error { + upgraded = workspaceKey + return nil + } + resetWindowsSandboxUserPasswordFn = func(string, string) error { return nil } + addWindowsSandboxUserToGroupFn = func(string) error { return nil } + addWindowsSandboxUserToOfflineGroupFn = func(string) error { return nil } + resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) { + return windows.StringToSid("S-1-5-32-546") + } + return &upgraded + } + + t.Run("a legacy comment is upgraded", func(t *testing.T) { + upgraded := stub(t, true) + if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil { + t.Fatalf("provisionWindowsSandboxIdentity: %v", err) + } + if *upgraded != "workspacekey" { + t.Errorf("the workspace key was never stamped onto the adopted legacy account (got %q); windowsSandboxUserIsManaged goes on accepting the bare comment forever", *upgraded) + } + }) + + // And an account that already carries the key is left alone, or the + // assertion above would be satisfied by an unconditional rewrite. + t.Run("a current comment is left alone", func(t *testing.T) { + upgraded := stub(t, false) + if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey", windowsSandboxRoleOffline); err != nil { + t.Fatalf("provisionWindowsSandboxIdentity: %v", err) + } + if *upgraded != "" { + t.Errorf("an account that already carried the workspace key was rewritten anyway: %q", *upgraded) + } + }) +} diff --git a/internal/sandbox/windows_stale_ace_windows_test.go b/internal/sandbox/windows_stale_ace_windows_test.go index b6920c897..21497aff9 100644 --- a/internal/sandbox/windows_stale_ace_windows_test.go +++ b/internal/sandbox/windows_stale_ace_windows_test.go @@ -205,3 +205,70 @@ func TestApplyPrincipalACLsRollbackRestoresTheRevokedACEs(t *testing.T) { t.Errorf("rollback never restored the revoked ACEs, got %v", reverted) } } + +// THE LEDGER IS PART OF THE ROLLBACK, and nothing pinned it. +// +// applyWindowsPrincipalACLs narrows the ledger before the whole setup +// transaction has succeeded. If network setup or the marker write then fails, +// rollback restores the DACLs, and the ledger has to come back with them: a +// ledger that still records only the narrowed path set leaves the restored +// grants unnamed, so later cleanup cannot find them. +// +// The neighbouring test asserts only the ORDER of the two ACL reverts and never +// reads the ledger afterwards, so the restore could be deleted and the suite +// would stay green. +func TestApplyPrincipalACLsRollbackRestoresTheLedger(t *testing.T) { + prevApply := applyWindowsACLPlanFn + t.Cleanup(func() { applyWindowsACLPlanFn = prevApply }) + applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) { + return func() error { return nil }, nil + } + + sandboxHome := t.TempDir() + const username = "zero-sbx-test" + + // A wider path set than the new plan will record, standing in for a previous + // policy's grants. + stale := []string{filepath.Join(t.TempDir(), "previously-granted")} + if err := writeWindowsPrincipalACLLedger(sandboxHome, username, stale); err != nil { + t.Fatalf("seed the ledger: %v", err) + } + + workspace := t.TempDir() + filesystem := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + } + rollback, err := applyWindowsPrincipalACLs(sandboxHome, username, "S-1-5-32-546", filesystem, filesystem.WriteRoots) + if err != nil { + t.Fatalf("applyWindowsPrincipalACLs: %v", err) + } + + narrowed, ok := readWindowsPrincipalACLLedger(sandboxHome, username) + if !ok { + t.Fatal("no ledger after apply") + } + if slicesContainsString(narrowed, stale[0]) { + t.Fatalf("the ledger still names the old path after apply, so this test is not exercising the restore: %v", narrowed) + } + + if err := rollback(); err != nil { + t.Fatalf("rollback: %v", err) + } + restored, ok := readWindowsPrincipalACLLedger(sandboxHome, username) + if !ok { + t.Fatal("no ledger after rollback") + } + if !slicesContainsString(restored, stale[0]) { + t.Errorf("rollback restored the DACLs but left the narrowed ledger %v; the path %q it put back is now unnamed, so cleanup cannot find it", restored, stale[0]) + } +} + +func slicesContainsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} From 909c7416d612cb9ebf5ef9cb15cb55496fff5d35 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 21 Aug 2026 14:32:16 +0530 Subject: [PATCH 72/96] fix(sandbox): name the two role inventories, and fingerprint the whole plan Three follow-ups from going back over the review findings. None was raised directly; two are the same shape as things that were. windowsPrincipalPlanFingerprint was the one of three buildWindowsPrincipalACLPlan call sites that did not pass DenyWrite. Apply and teardown both did, so the hash the marker carries described a different plan than the one that actually gets applied, and a change to the policy's deny-write paths moved the applied plan while leaving the marker where it was. It is not a live hole, because the capability ACLPlanHash covers the same paths and moves the marker anyway. That is also exactly what would have kept it invisible until somebody changed the capability plan's shape. The role list was spelled out in three places and two of them are meant to differ, which is why writing them out by hand kept going wrong. windowsSandboxPrincipalIsInstalled asked only the offline and online roles while teardown retires the legacy account too, so a machine still holding the untagged pre-split account was reported clean and the opted-out marker claimed a teardown that had not happened. Provisioning has the opposite constraint: legacy must never appear there or setup would recreate that account on every run of an already-upgraded machine. Both are named now, windowsSandboxLiveRoles and windowsSandboxRetirableRoles, with a test pinning the legacy role into exactly one of them. And the opt-out error said "retire the principal" when a workspace has two plus the legacy one, all of which that re-run retires. --- .../windows_identity_runtime_windows.go | 4 +- internal/sandbox/windows_identity_windows.go | 18 +++++ .../windows_principal_fingerprint_test.go | 47 +++++++++++++ .../windows_role_inventory_windows_test.go | 68 +++++++++++++++++++ internal/sandbox/windows_setup.go | 15 +++- internal/sandbox/windows_setup_test.go | 2 +- internal/sandbox/windows_setup_windows.go | 9 +-- 7 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 internal/sandbox/windows_principal_fingerprint_test.go create mode 100644 internal/sandbox/windows_role_inventory_windows_test.go diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go index 00d268647..be55a034b 100644 --- a/internal/sandbox/windows_identity_runtime_windows.go +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -365,7 +365,7 @@ func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() er // mode differs from the one setup happened to see must still find a principal // waiting; provisioning lazily would mean an unelevated command discovering it // needs an account it cannot create. - roles := []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline} + roles := windowsSandboxLiveRoles var undo []func() error rollback := func() error { @@ -939,7 +939,7 @@ func windowsACLPlanPaths(plan WindowsACLPlan) []string { // machines that never had one. func removeWindowsSandboxPrincipalsForSetup(config WindowsSandboxCommandConfig) error { var errs []error - for _, role := range []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline, windowsSandboxRoleLegacy} { + for _, role := range windowsSandboxRetirableRoles { // Through the seam, like retireUnrecordedWindowsSandboxPrincipal, so the // set of roles this retires is assertable without provisioning real // accounts on the machine running the tests. diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go index 7d8169d36..b26885d2f 100644 --- a/internal/sandbox/windows_identity_windows.go +++ b/internal/sandbox/windows_identity_windows.go @@ -96,6 +96,24 @@ const ( windowsSandboxRoleLegacy windowsSandboxRole = "legacy" ) +// TWO INVENTORIES, because they are two different questions and answering one +// with the other is a bug in both directions. +// +// windowsSandboxLiveRoles are the roles setup PROVISIONS. Legacy must never +// appear here: it names the single untagged pre-split account, and provisioning +// it would create that account fresh on every setup of an already-upgraded +// machine. +// +// windowsSandboxRetirableRoles are every role that may EXIST on a machine, which +// is the live pair plus legacy. Teardown and the is-it-still-installed check +// both belong to this one. Asking the live list instead let an opted-out marker +// report a workspace clean while the legacy account was still on the box, which +// is precisely the lie that check exists to prevent. +var ( + windowsSandboxLiveRoles = []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline} + windowsSandboxRetirableRoles = append(append([]windowsSandboxRole{}, windowsSandboxLiveRoles...), windowsSandboxRoleLegacy) +) + // roleTag is the single character that distinguishes the two accounts for a // workspace. One character because the 20-character account-name limit is // already tight and every character spent here is a bit of workspace hash lost. diff --git a/internal/sandbox/windows_principal_fingerprint_test.go b/internal/sandbox/windows_principal_fingerprint_test.go new file mode 100644 index 000000000..5f5d82fb2 --- /dev/null +++ b/internal/sandbox/windows_principal_fingerprint_test.go @@ -0,0 +1,47 @@ +package sandbox + +import "testing" + +// THE FINGERPRINT MUST DESCRIBE THE PLAN THAT ACTUALLY GETS APPLIED. +// +// windowsPrincipalPlanFingerprint builds a principal plan to hash into the setup +// marker, and it was the one of three buildWindowsPrincipalACLPlan call sites +// that did not pass DenyWrite. Apply and teardown both did. So a change to the +// policy's deny-write paths moved the plan those two build and left the marker's +// hash unchanged, which is the marker failing at the one job it has. +// +// It was not a live hole, because the capability ACLPlanHash covers the same +// paths and moves the marker anyway. That is exactly what would have kept it +// invisible until somebody changed the capability plan's shape. +func TestThePrincipalFingerprintCoversDenyWrite(t *testing.T) { + workspace := t.TempDir() + base := WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PrincipalOptIn: true, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + } + + without, err := windowsPrincipalPlanFingerprint(base) + if err != nil { + t.Fatalf("windowsPrincipalPlanFingerprint: %v", err) + } + + withDeny := base + withDeny.PermissionProfile.FileSystem.DenyWrite = []string{workspace + string('/') + "protected"} + changed, err := windowsPrincipalPlanFingerprint(withDeny) + if err != nil { + t.Fatalf("windowsPrincipalPlanFingerprint: %v", err) + } + + if without == changed { + t.Error("adding a deny-write path did not move the principal fingerprint, so the marker cannot tell that the applied plan changed") + } +} diff --git a/internal/sandbox/windows_role_inventory_windows_test.go b/internal/sandbox/windows_role_inventory_windows_test.go new file mode 100644 index 000000000..5a2140b1e --- /dev/null +++ b/internal/sandbox/windows_role_inventory_windows_test.go @@ -0,0 +1,68 @@ +//go:build windows + +package sandbox + +import "testing" + +func containsRole(roles []windowsSandboxRole, want windowsSandboxRole) bool { + for _, role := range roles { + if role == want { + return true + } + } + return false +} + +// THE LEGACY ROLE BELONGS TO EXACTLY ONE OF THE TWO INVENTORIES. +// +// Both directions are real failures that have nearly happened here. +// +// In the retirable list: teardown retires legacy, and the is-it-still-installed +// guard used to ask only the live pair. An upgraded machine still holding the +// untagged pre-split account was therefore reported clean, and the opted-out +// marker claimed a teardown that had not happened. +// +// Out of the live list: provisioning legacy would create that pre-split account +// fresh on every setup of a machine that had already been upgraded past it. +func TestTheLegacyRoleIsRetirableButNeverProvisioned(t *testing.T) { + if containsRole(windowsSandboxLiveRoles, windowsSandboxRoleLegacy) { + t.Error("the legacy role is in the provisioning inventory; setup would recreate the pre-split account on every run") + } + if !containsRole(windowsSandboxRetirableRoles, windowsSandboxRoleLegacy) { + t.Error("the legacy role is not in the retirable inventory; teardown and the installed check would both miss the pre-split account") + } + for _, role := range windowsSandboxLiveRoles { + if !containsRole(windowsSandboxRetirableRoles, role) { + t.Errorf("live role %q is not retirable, so teardown would leave it behind", role) + } + } +} + +// And the installed check consults the retirable inventory, so a machine still +// holding only the legacy account is not reported clean. +func TestPrincipalIsInstalledSeesTheLegacyAccount(t *testing.T) { + prev := lookupWindowsSandboxIdentityFn + t.Cleanup(func() { lookupWindowsSandboxIdentityFn = prev }) + + var asked []windowsSandboxRole + lookupWindowsSandboxIdentityFn = func(_ string, role windowsSandboxRole) (windowsSandboxIdentity, error) { + asked = append(asked, role) + if role == windowsSandboxRoleLegacy { + return windowsSandboxIdentity{}, nil + } + return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable + } + + workspace := t.TempDir() + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + } + if !windowsSandboxPrincipalIsInstalled(config) { + t.Errorf("a machine still holding the legacy account was reported clean; roles asked: %v", asked) + } + if !containsRole(asked, windowsSandboxRoleLegacy) { + t.Errorf("the legacy role was never looked up: %v", asked) + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 2f61fa8d5..6dfb91cb9 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -412,11 +412,19 @@ func windowsPrincipalPlanFingerprint(config WindowsSandboxSetupConfig) (string, return "", nil } filesystem := config.commandConfig().PermissionProfile.FileSystem + // EVERY FIELD apply and teardown pass, or the fingerprint describes a + // different plan than the one that gets applied. This omitted DenyWrite while + // both buildWindowsPrincipalACLPlan call sites in windows_identity_runtime_windows.go + // passed it, so a change to the policy's deny-write paths moved the applied + // plan and left this hash where it was. The capability ACLPlanHash happens to + // cover the same paths today, which is what kept it from being a live hole + // and also what would have kept it invisible. plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ PrincipalSID: windowsPrincipalFingerprintSID, WriteRoots: filesystem.WriteRoots, ReadRoots: filesystem.ReadRoots, DenyRead: filesystem.DenyRead, + DenyWrite: filesystem.DenyWrite, }) if err != nil { return "", fmt.Errorf("fingerprint windows principal ACL plan: %w", err) @@ -504,8 +512,11 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { "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", + // "principals", plural: a workspace gets an offline and an online account, + // and the opt-out path retires the legacy one alongside them. The singular + // described the pre-split world and understated what the re-run does. + return fmt.Errorf("windows sandbox setup is out of date: setup provisioned sandbox principals, 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 them", windowsSandboxIdentityEnv, windowsSandboxIdentityEnv) } if actual.ACLPlanHash != expected.ACLPlanHash || actual.ACLPlanEntries != expected.ACLPlanEntries { diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 3294ec139..2ef2614fc 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -282,7 +282,7 @@ func TestWindowsSandboxSetupMarkerRejectsPrincipalOptInMismatch(t *testing.T) { name: "setup provisioned a principal, command opts out", setupOptIn: true, commandEnv: map[string]string{windowsSandboxIdentityEnv: "0"}, - wantError: "setup provisioned a sandbox principal", + wantError: "setup provisioned sandbox principals", }, } for _, testCase := range testCases { diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 31546def3..ac9de6c28 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -256,11 +256,12 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // workspace is clean while the other account is still on the machine, which is // the exact lie this guard exists to prevent. func windowsSandboxPrincipalIsInstalled(config WindowsSandboxCommandConfig) bool { - // BOTH roles, because dual-role setup provisions two accounts and retiring - // one while the other survives is exactly the half-done teardown an - // opted-out marker must not claim to have completed. + // EVERY RETIRABLE ROLE, not just the live pair. Teardown retires legacy too, + // so an upgraded machine still holding the untagged pre-split account would + // otherwise be reported clean here while that account was still installed, + // and the opted-out marker would be exactly the lie this guard prevents. key := windowsSandboxPrincipalKey(config) - for _, role := range []windowsSandboxRole{windowsSandboxRoleOffline, windowsSandboxRoleOnline} { + for _, role := range windowsSandboxRetirableRoles { if _, err := lookupWindowsSandboxIdentityFn(key, role); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { return true } From ab3265caa69d289f0ffa0d9b282067762c92aa43 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 24 Aug 2026 19:41:18 +0530 Subject: [PATCH 73/96] fix(sandbox): refuse to provision a principal this caller could never launch Measured on an ordinary unelevated session: the token holds neither SeAssignPrimaryTokenPrivilege nor SeIncreaseQuotaPrivilege, so the principal launch path can never engage for the process the runner is designed to be called from. Elevated setup does not change that, because the command runs later from the caller rather than from setup. Until now setup succeeded completely in that situation. It created a local account, its password, its logon-right assignments, the workspace ACEs, the recovery ledger and the network filter state, and then every principal-mode command refused before opening its executable. The operator was left with durable machine state serving a backend that cannot run, and nothing said so at the point they could still act on it. The check runs in the caller's own process, which is the one whose privileges decide the answer, and before anything crosses the UAC boundary. It is wired to the same function the launch path uses, so a change to what a launch requires cannot leave setup provisioning for a capability that no longer exists. Three existing tests asserted argument plumbing with the opt-in on and passed only because nothing checked; they stub the preflight now, so they no longer depend on the privileges of whoever runs the suite. This does not give the principal backend a working launch path. That needs a different architecture and is not in this change. --- .../windows_principal_launch_windows.go | 7 ++ .../windows_principal_preflight_test.go | 84 +++++++++++++++++++ internal/sandbox/windows_setup.go | 26 ++++++ internal/sandbox/windows_setup_test.go | 3 + 4 files changed, 120 insertions(+) create mode 100644 internal/sandbox/windows_principal_preflight_test.go diff --git a/internal/sandbox/windows_principal_launch_windows.go b/internal/sandbox/windows_principal_launch_windows.go index 09c13b870..b4ecf3359 100644 --- a/internal/sandbox/windows_principal_launch_windows.go +++ b/internal/sandbox/windows_principal_launch_windows.go @@ -130,3 +130,10 @@ func windowsTokenPrivilegeLUIDs(token windows.Token) (map[windows.LUID]struct{}, } return held, nil } + +func init() { + // One source of truth: the preflight that gates provisioning is the same + // function the launch path runs, so a change to what a launch requires + // cannot leave setup provisioning for a capability that no longer exists. + windowsPrincipalLaunchPreflight = enableWindowsPrincipalLaunchPrivileges +} diff --git a/internal/sandbox/windows_principal_preflight_test.go b/internal/sandbox/windows_principal_preflight_test.go new file mode 100644 index 000000000..e2c7d0e8b --- /dev/null +++ b/internal/sandbox/windows_principal_preflight_test.go @@ -0,0 +1,84 @@ +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +// SETUP MUST NOT PROVISION A PRINCIPAL THE CALLER COULD NEVER LAUNCH. +// +// Launching a command as a separate account needs SeAssignPrimaryTokenPrivilege +// and SeIncreaseQuotaPrivilege, which an ordinary unelevated process does not +// hold, and elevated setup cannot supply them because the command runs later +// from the caller rather than from setup. Without this gate setup succeeded +// completely: a local account, its password, its logon rights, workspace ACEs, +// the recovery ledger and network filter state all landed, and then every +// principal-mode command refused before opening its executable. +// +// The check belongs in the caller's process, which is the one whose privileges +// decide the answer, and it has to run before anything crosses the UAC +// boundary. +func TestSetupRefusesAPrincipalThisCallerCannotLaunch(t *testing.T) { + previous := windowsPrincipalLaunchPreflight + t.Cleanup(func() { windowsPrincipalLaunchPreflight = previous }) + + optIn := true + options := WindowsSandboxSetupArgsOptions{ + CommandCWD: t.TempDir(), + SandboxHome: t.TempDir(), + PrincipalOptIn: &optIn, + } + + windowsPrincipalLaunchPreflight = func() error { + return errors.New("needs SeAssignPrimaryTokenPrivilege and SeIncreaseQuotaPrivilege, which this process does not hold") + } + args, err := BuildWindowsSandboxSetupArgs(options) + if err == nil { + t.Fatalf("setup args were built for a principal that can never be launched: %v", args) + } + if !strings.Contains(err.Error(), "SeAssignPrimaryTokenPrivilege") { + t.Errorf("the refusal does not say what is missing: %v", err) + } + if !strings.Contains(err.Error(), "provision") { + t.Errorf("the refusal does not say that provisioning was declined: %v", err) + } + + // A caller that CAN launch still provisions, or the gate would have disabled + // the feature rather than gated it. + windowsPrincipalLaunchPreflight = func() error { return nil } + if _, err := BuildWindowsSandboxSetupArgs(options); err != nil { + t.Errorf("a caller holding the privileges was refused anyway: %v", err) + } +} + +// AND THE GATE IS ONLY FOR THE OPTED-IN PATH. Without the opt-in there is no +// principal to provision, so a caller lacking the privileges must be able to +// set up the ordinary restricted-token sandbox, which needs none of them. +func TestSetupWithoutTheOptInIgnoresTheLaunchPreflight(t *testing.T) { + previous := windowsPrincipalLaunchPreflight + t.Cleanup(func() { windowsPrincipalLaunchPreflight = previous }) + windowsPrincipalLaunchPreflight = func() error { + return errors.New("this process cannot launch a principal") + } + + optOut := false + if _, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + CommandCWD: t.TempDir(), + SandboxHome: t.TempDir(), + PrincipalOptIn: &optOut, + }); err != nil { + t.Errorf("the restricted-token sandbox was refused for a privilege it does not need: %v", err) + } +} + +// allowPrincipalLaunchForTest stubs the launch preflight for tests that are +// about argument plumbing rather than about whether this machine can launch a +// principal. Without it they depend on the privileges of whoever runs the +// suite, and they only passed before because nothing checked. +func allowPrincipalLaunchForTest(t *testing.T) { + t.Helper() + previous := windowsPrincipalLaunchPreflight + windowsPrincipalLaunchPreflight = func() error { return nil } + t.Cleanup(func() { windowsPrincipalLaunchPreflight = previous }) +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 6dfb91cb9..06cd48f5c 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -188,6 +188,26 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str if len(workspaceRoots) == 0 { workspaceRoots = []string{commandCWD} } + // DO NOT PROVISION A PRINCIPAL THIS CALLER COULD NEVER LAUNCH. + // + // Launching a command as a separate account needs SeAssignPrimaryTokenPrivilege + // and SeIncreaseQuotaPrivilege, and an ordinary unelevated process holds + // neither. Elevated setup does not fix that, because the command runs later + // from the caller, not from setup. Without this check setup succeeds + // completely, creating a local account, its password, its logon-right + // assignments, workspace ACEs, the recovery ledger and network filter state, + // and then every principal-mode command refuses before opening its executable. + // The operator is left with durable machine state serving a backend that + // cannot run, and nothing said so at the point they could act on it. + // + // Checked HERE, in the caller's own process, because that is the process whose + // privileges decide the answer, and this runs before anything crosses the UAC + // boundary. The error names the opt-out so there is a way forward. + if options.principalOptIn() && windowsPrincipalLaunchPreflight != nil { + if err := windowsPrincipalLaunchPreflight(); err != nil { + return nil, fmt.Errorf("refusing to provision a sandbox principal: %w", err) + } + } // Augmented here, in the caller's shell, before the args cross into the // elevated helper. Same reason the opt-in and caller SID are: the value has to // be resolved where the environment is the operator's. @@ -733,3 +753,9 @@ func WindowsSandboxPrincipalRoleForNetwork(network NetworkMode) string { } return "offline" } + +// windowsPrincipalLaunchPreflight reports whether this process could start a +// command as a separate principal. Nil off Windows, where the principal backend +// does not exist, and set from the Windows launch path so the check and the +// launch cannot disagree about what is required. +var windowsPrincipalLaunchPreflight func() error diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 2ef2614fc..1aa5e0ce6 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -163,6 +163,7 @@ func TestWindowsSandboxSetupMarkerRejectsOldSchema(t *testing.T) { // environment there let the two halves disagree, so the serialized value must // win over the environment in BOTH directions. func TestWindowsSandboxSetupPrincipalOptInSurvivesElevatedEnvironment(t *testing.T) { + allowPrincipalLaunchForTest(t) profile := PermissionProfile{ FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, Network: NetworkPolicy{Mode: NetworkAllow}, @@ -217,6 +218,7 @@ func TestWindowsSandboxSetupPrincipalOptInSurvivesElevatedEnvironment(t *testing // "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) { + allowPrincipalLaunchForTest(t) optIn := true args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ SandboxHome: t.TempDir(), @@ -367,6 +369,7 @@ func TestWindowsSandboxSetupConfigFromCommandPreservesProfileInputs(t *testing.T // states, so getting it backwards is a test failure here rather than a surprise // on a real elevated machine. func TestWindowsSandboxSetupArgsUnsetPrincipalOptInConsultsEnvironment(t *testing.T) { + allowPrincipalLaunchForTest(t) profile := PermissionProfile{ FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, Network: NetworkPolicy{Mode: NetworkAllow}, From f369e334e0166abe75cf2311796e64047e8d4cdd Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 14:23:38 +0530 Subject: [PATCH 74/96] test(sandbox): assert nothing was created, not that a count held still TestTeardownPathDerivationCreatesNothing compared the number of entries in the SHARED temp directory before and after deriving the teardown paths. The assertion it wants is that deriving a path creates nothing, and a count cannot tell creation from removal: that root is also used by every other test binary running at the same time and by the OS, so a concurrent cleanup made the count fall and the failure read "temp directory gained -1 entries". It compares the entry names now and reports anything that APPEARED, which is the question actually being asked and is indifferent to whatever else disappears. The failure also names the entry rather than a delta, so the next person sees what was created instead of a number. --- ...indows_workspace_canonical_windows_test.go | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/internal/sandbox/windows_workspace_canonical_windows_test.go b/internal/sandbox/windows_workspace_canonical_windows_test.go index 533770afd..99d2dc1dc 100644 --- a/internal/sandbox/windows_workspace_canonical_windows_test.go +++ b/internal/sandbox/windows_workspace_canonical_windows_test.go @@ -177,7 +177,7 @@ func TestTeardownPathDerivationCreatesNothing(t *testing.T) { sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil } t.Cleanup(func() { sandboxUserCacheDir = original }) - before := tempDirEntryCount(t) + before := tempDirEntryNames(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 @@ -198,9 +198,7 @@ func TestTeardownPathDerivationCreatesNothing(t *testing.T) { 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) - } + assertCreatedNothing(t, before, "teardown path derivation") // Setup's resolver must agree with teardown's, and create nothing either. // @@ -217,7 +215,7 @@ func TestTeardownPathDerivationCreatesNothing(t *testing.T) { // TMP, GOCACHE and the package caches into it, while setup granting nothing // left both principals without an ACE on the one tree those writes land in. // Reporting none is no longer the safe answer; it is the ACCESS_DENIED. - beforeSetup := tempDirEntryCount(t) + beforeSetup := tempDirEntryNames(t) setupRoots, err := windowsSandboxRuntimeRootPath(WindowsSandboxCommandConfig{ WorkspaceRoots: []string{workspace}, CommandCWD: workspace, @@ -239,9 +237,7 @@ func TestTeardownPathDerivationCreatesNothing(t *testing.T) { if !grantedRuntimeRootsCover(setupRoots, commandState.Root) { t.Errorf("setup granted %q but commands write to %q", setupRoots, commandState.Root) } - if after := tempDirEntryCount(t); after != beforeSetup { - t.Errorf("setup's resolver created %d temp entries; it must create nothing", after-beforeSetup) - } + assertCreatedNothing(t, beforeSetup, "setup path derivation") } // Setup and teardown must derive the SAME root in the ordinary case, since one @@ -278,11 +274,33 @@ func TestSetupAndTeardownDeriveTheSameRuntimeRoot(t *testing.T) { } } -func tempDirEntryCount(t *testing.T) int { +// tempDirEntryNames snapshots what the shared temp directory holds. +// +// NAMES, NOT A COUNT. The assertion is that deriving a path CREATES nothing, +// and a count cannot tell creation from removal: the shared temp root is also +// used by every other test binary running at the same time and by the OS, so a +// concurrent cleanup made the count fall and the test reported that naming the +// paths had "gained -1 entries". Comparing the sets answers the question that +// was actually being asked and is indifferent to anything disappearing. +func tempDirEntryNames(t *testing.T) map[string]struct{} { t.Helper() entries, err := os.ReadDir(os.TempDir()) if err != nil { t.Fatalf("read temp dir: %v", err) } - return len(entries) + names := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + names[entry.Name()] = struct{}{} + } + return names +} + +// assertCreatedNothing reports entries that appeared since the snapshot. +func assertCreatedNothing(t *testing.T, before map[string]struct{}, what string) { + t.Helper() + for name := range tempDirEntryNames(t) { + if _, existed := before[name]; !existed { + t.Errorf("%s created %q in the temp directory; naming the paths must not create one", what, name) + } + } } From fe0c697c4c40587c5fe4af80a800cea8936d0e9e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 31 Aug 2026 15:25:00 +0530 Subject: [PATCH 75/96] fix(sandbox): keep principal provisioning closed until a launch path exists The gate asked whether the CALLING process held SeAssignPrimaryTokenPrivilege and SeIncreaseQuotaPrivilege. That is the wrong lifetime, and it got the answer backwards where it mattered. The process that needs those privileges is the later, ordinary command process. Setup does not run it, and nothing carries launch authority across the UAC boundary. So the caller-token check refused unelevated setup, which was never the dangerous case, and PASSED elevated setup, which is precisely the case that lands a local account, its password, its logon-right assignments, workspace ACEs, the recovery ledger and network filter state for a backend no later command can use. The check existed to prevent durable machine state serving something that cannot run, and in the one configuration that creates that state it allowed it. Requiring every sandboxed command to run elevated would line the privileges up and is deliberately not done: it is a worse boundary than the restricted token it would replace, and it is not the lifecycle this is for. Until a reviewed bootstrap or broker exists, provisioning is closed rather than half-working. windowsPrincipalLaunchAvailable replaces the preflight var and takes no argument, because the answer is about which mechanisms exist rather than about the current process. It is the one place that learns to say yes when a broker lands, and everything below it -- the plans, the ledger, the ACL and filter work -- still builds and is still tested against it. The runner keeps its own privilege check at launch time, which is the correct lifetime for one; only the setup wiring is removed. The regression test asserts the property the old shape could not express: the refusal is stable regardless of what the calling process holds, and it does not tell the operator to re-run from an elevated terminal, which is the advice that produced the unusable state. Restoring the caller-passes behaviour fails both new tests. Refs #662, which this is foundation for and does not close. --- .../windows_principal_launch_windows.go | 7 -- .../windows_principal_preflight_test.go | 105 +++++++++++------- internal/sandbox/windows_setup.go | 75 +++++++++---- 3 files changed, 118 insertions(+), 69 deletions(-) diff --git a/internal/sandbox/windows_principal_launch_windows.go b/internal/sandbox/windows_principal_launch_windows.go index b4ecf3359..09c13b870 100644 --- a/internal/sandbox/windows_principal_launch_windows.go +++ b/internal/sandbox/windows_principal_launch_windows.go @@ -130,10 +130,3 @@ func windowsTokenPrivilegeLUIDs(token windows.Token) (map[windows.LUID]struct{}, } return held, nil } - -func init() { - // One source of truth: the preflight that gates provisioning is the same - // function the launch path runs, so a change to what a launch requires - // cannot leave setup provisioning for a capability that no longer exists. - windowsPrincipalLaunchPreflight = enableWindowsPrincipalLaunchPrivileges -} diff --git a/internal/sandbox/windows_principal_preflight_test.go b/internal/sandbox/windows_principal_preflight_test.go index e2c7d0e8b..dc199ff38 100644 --- a/internal/sandbox/windows_principal_preflight_test.go +++ b/internal/sandbox/windows_principal_preflight_test.go @@ -6,23 +6,23 @@ import ( "testing" ) -// SETUP MUST NOT PROVISION A PRINCIPAL THE CALLER COULD NEVER LAUNCH. +// PROVISIONING STAYS CLOSED WHILE NO LAUNCH MECHANISM EXISTS. // // Launching a command as a separate account needs SeAssignPrimaryTokenPrivilege -// and SeIncreaseQuotaPrivilege, which an ordinary unelevated process does not -// hold, and elevated setup cannot supply them because the command runs later -// from the caller rather than from setup. Without this gate setup succeeded -// completely: a local account, its password, its logon rights, workspace ACEs, -// the recovery ledger and network filter state all landed, and then every -// principal-mode command refused before opening its executable. +// and SeIncreaseQuotaPrivilege. The process that needs them is the LATER, +// ordinary command process, not setup, and nothing carries launch authority +// across that boundary. // -// The check belongs in the caller's process, which is the one whose privileges -// decide the answer, and it has to run before anything crosses the UAC -// boundary. -func TestSetupRefusesAPrincipalThisCallerCannotLaunch(t *testing.T) { - previous := windowsPrincipalLaunchPreflight - t.Cleanup(func() { windowsPrincipalLaunchPreflight = previous }) - +// The previous gate asked whether the CALLING process held the privileges, +// which is the wrong lifetime and got the answer backwards where it mattered: +// it refused unelevated setup, which was never dangerous, and it PASSED +// elevated setup, which is precisely the case that lands a local account, its +// password, its logon rights, workspace ACEs, the recovery ledger and network +// filter state for a backend no later command can use. The gate existed to +// prevent durable state serving something that cannot run, and it produced it. +// +// So the refusal must not be satisfiable by holding the privileges right now. +func TestSetupRefusesToProvisionAPrincipalNothingCanLaunch(t *testing.T) { optIn := true options := WindowsSandboxSetupArgsOptions{ CommandCWD: t.TempDir(), @@ -30,38 +30,56 @@ func TestSetupRefusesAPrincipalThisCallerCannotLaunch(t *testing.T) { PrincipalOptIn: &optIn, } - windowsPrincipalLaunchPreflight = func() error { - return errors.New("needs SeAssignPrimaryTokenPrivilege and SeIncreaseQuotaPrivilege, which this process does not hold") - } args, err := BuildWindowsSandboxSetupArgs(options) if err == nil { - t.Fatalf("setup args were built for a principal that can never be launched: %v", args) + t.Fatalf("setup args were built for a principal nothing can launch: %v", args) } - if !strings.Contains(err.Error(), "SeAssignPrimaryTokenPrivilege") { - t.Errorf("the refusal does not say what is missing: %v", err) + if !errors.Is(err, errWindowsPrincipalLaunchUnavailable) { + t.Errorf("the refusal is not the launch-unavailable one: %v", err) } - if !strings.Contains(err.Error(), "provision") { - t.Errorf("the refusal does not say that provisioning was declined: %v", err) + for _, want := range []string{"SeAssignPrimaryTokenPrivilege", "SeIncreaseQuotaPrivilege", "provision"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %v", want, err) + } } - - // A caller that CAN launch still provisions, or the gate would have disabled - // the feature rather than gated it. - windowsPrincipalLaunchPreflight = func() error { return nil } - if _, err := BuildWindowsSandboxSetupArgs(options); err != nil { - t.Errorf("a caller holding the privileges was refused anyway: %v", err) + // The way forward has to be the one that works. Telling the operator to + // re-run elevated is the advice that produced the unusable state. + if strings.Contains(strings.ToLower(err.Error()), "elevated terminal") { + t.Errorf("the refusal still points at an elevated terminal, which does not help: %v", err) + } + if !strings.Contains(err.Error(), windowsSandboxIdentityEnv) { + t.Errorf("the refusal does not name the opt-out: %v", err) } } -// AND THE GATE IS ONLY FOR THE OPTED-IN PATH. Without the opt-in there is no -// principal to provision, so a caller lacking the privileges must be able to -// set up the ordinary restricted-token sandbox, which needs none of them. -func TestSetupWithoutTheOptInIgnoresTheLaunchPreflight(t *testing.T) { - previous := windowsPrincipalLaunchPreflight - t.Cleanup(func() { windowsPrincipalLaunchPreflight = previous }) - windowsPrincipalLaunchPreflight = func() error { - return errors.New("this process cannot launch a principal") +// THE REGRESSION, STATED DIRECTLY: an elevated caller must be refused too. +// +// This is the case the old check let through, and it is the only one that +// creates durable machine state. A gate that consults the current process +// cannot express it, which is why the seam takes no token and no argument. +func TestSetupRefusalDoesNotDependOnTheCallersPrivileges(t *testing.T) { + optIn := true + options := WindowsSandboxSetupArgsOptions{ + CommandCWD: t.TempDir(), + SandboxHome: t.TempDir(), + PrincipalOptIn: &optIn, + } + + // Whatever this process holds, twice, must give the same answer. + first, firstErr := BuildWindowsSandboxSetupArgs(options) + second, secondErr := BuildWindowsSandboxSetupArgs(options) + if firstErr == nil || secondErr == nil { + t.Fatalf("provisioning succeeded: %v / %v", first, second) + } + if firstErr.Error() != secondErr.Error() { + t.Errorf("the refusal is not stable across calls:\n %v\n %v", firstErr, secondErr) } +} +// AND THE GATE IS ONLY FOR THE OPTED-IN PATH. Without the opt-in there is no +// principal to provision, so the ordinary restricted-token sandbox, which needs +// none of those privileges, must still set up. +func TestSetupWithoutTheOptInIsUnaffected(t *testing.T) { optOut := false if _, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ CommandCWD: t.TempDir(), @@ -72,13 +90,14 @@ func TestSetupWithoutTheOptInIgnoresTheLaunchPreflight(t *testing.T) { } } -// allowPrincipalLaunchForTest stubs the launch preflight for tests that are -// about argument plumbing rather than about whether this machine can launch a -// principal. Without it they depend on the privileges of whoever runs the -// suite, and they only passed before because nothing checked. +// allowPrincipalLaunchForTest opens the gate for tests that are about argument +// PLUMBING rather than about whether a principal can be launched. The opt-in +// still has to round-trip correctly for `zero doctor` and for the day a broker +// lands, and those assertions should not be deleted just because the entry +// point is closed today. func allowPrincipalLaunchForTest(t *testing.T) { t.Helper() - previous := windowsPrincipalLaunchPreflight - windowsPrincipalLaunchPreflight = func() error { return nil } - t.Cleanup(func() { windowsPrincipalLaunchPreflight = previous }) + previous := windowsPrincipalLaunchAvailable + windowsPrincipalLaunchAvailable = func() error { return nil } + t.Cleanup(func() { windowsPrincipalLaunchAvailable = previous }) } diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 06cd48f5c..79dda2a2e 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -188,23 +188,36 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str if len(workspaceRoots) == 0 { workspaceRoots = []string{commandCWD} } - // DO NOT PROVISION A PRINCIPAL THIS CALLER COULD NEVER LAUNCH. + // THE PRINCIPAL BACKEND HAS NO LAUNCH PATH YET, SO IT IS NOT PROVISIONED. // // Launching a command as a separate account needs SeAssignPrimaryTokenPrivilege - // and SeIncreaseQuotaPrivilege, and an ordinary unelevated process holds - // neither. Elevated setup does not fix that, because the command runs later - // from the caller, not from setup. Without this check setup succeeds - // completely, creating a local account, its password, its logon-right - // assignments, workspace ACEs, the recovery ledger and network filter state, - // and then every principal-mode command refuses before opening its executable. - // The operator is left with durable machine state serving a backend that - // cannot run, and nothing said so at the point they could act on it. + // and SeIncreaseQuotaPrivilege. An ordinary unelevated process holds neither, + // and the command runs LATER, from the caller, not from setup. So the + // advertised lifecycle -- provision once from an elevated terminal, then run + // ordinary commands -- cannot execute: the elevated setup process holds the + // privileges and the process that needs them does not. // - // Checked HERE, in the caller's own process, because that is the process whose - // privileges decide the answer, and this runs before anything crosses the UAC - // boundary. The error names the opt-out so there is a way forward. - if options.principalOptIn() && windowsPrincipalLaunchPreflight != nil { - if err := windowsPrincipalLaunchPreflight(); err != nil { + // An earlier version of this check tested the CALLING process's token, which + // is the wrong lifetime. It refused unelevated setup, which was never the + // dangerous case, and PASSED elevated setup, which is exactly the case that + // creates a local account, its password, its logon-right assignments, + // workspace ACEs, the recovery ledger and network filter state for a backend + // no later command can use. Durable machine state serving something that + // cannot run is the outcome the check existed to prevent, and it produced it. + // + // Requiring every sandboxed command to run elevated would make the privileges + // line up, and is deliberately not done: it is a worse boundary than the + // restricted token this would replace, and it is not the lifecycle the feature + // is for. Until a reviewed launch mechanism exists -- a bootstrap or an + // authenticated broker that carries launch authority across the UAC boundary + // -- provisioning stays unavailable rather than half-working. + // + // Everything below the launch path (the plans, the ledger, the ACL and filter + // work) still builds and is still tested; it is the provisioning entry point + // that is closed. See #662, which this PR is foundation for and does not + // close. + if options.principalOptIn() { + if err := windowsPrincipalLaunchAvailable(); err != nil { return nil, fmt.Errorf("refusing to provision a sandbox principal: %w", err) } } @@ -754,8 +767,32 @@ func WindowsSandboxPrincipalRoleForNetwork(network NetworkMode) string { return "offline" } -// windowsPrincipalLaunchPreflight reports whether this process could start a -// command as a separate principal. Nil off Windows, where the principal backend -// does not exist, and set from the Windows launch path so the check and the -// launch cannot disagree about what is required. -var windowsPrincipalLaunchPreflight func() error +// errWindowsPrincipalLaunchUnavailable is why provisioning refuses. +// +// Stated as one sentence about the MECHANISM rather than about the caller's +// token, because the caller's token was never the question: the process that +// needs the launch privileges is a later, ordinary one, and no supported path +// carries launch authority to it. A message about the current process invited +// the operator to re-run from an elevated terminal, which passes such a check +// and still cannot run a command. +var errWindowsPrincipalLaunchUnavailable = errors.New( + "launching a command as a separate account needs SeAssignPrimaryTokenPrivilege and SeIncreaseQuotaPrivilege, " + + "which an ordinary command process does not hold; elevated setup cannot grant them to it, and no bootstrap " + + "or broker exists yet to carry that authority across. The restricted-token sandbox is unaffected: unset " + + windowsSandboxIdentityEnv + " to use it", +) + +// windowsPrincipalLaunchAvailable reports whether a provisioned principal could +// actually be launched by the process that will run the command. +// +// ONE SEAM, DEFAULTING TO UNAVAILABLE. The answer does not depend on the token +// of whoever calls setup, so this takes no argument: it is a statement about +// which mechanisms exist, not about the current process. When a bootstrap or +// broker lands, this is the single place that learns to say yes, and the plans, +// ledger and ACL work below it are already built and tested against it. +// +// It stays a var so the argument-plumbing tests can exercise the opt-in path +// without depending on a capability no machine currently has. +var windowsPrincipalLaunchAvailable = func() error { + return errWindowsPrincipalLaunchUnavailable +} From b40d72dbb6ea83e3700a047a7144da1379ffd870 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 31 Aug 2026 15:26:25 +0530 Subject: [PATCH 76/96] test(sandbox): keep the runtime-root candidates inside test-owned storage runtimeRootTestConfig put the workspace and sandbox home under t.TempDir, but windowsSandboxRuntimeCandidates still derived a candidate through the production sandboxUserCacheDir seam, and the test creates every candidate and registers os.RemoveAll cleanup for each. So the test reached into the real user cache: it fails outright on a read-only home, and on an ordinary developer or CI account it creates and then deletes a path outside its own storage. The workspace hash makes a collision unlikely; it does not make somebody else's directory test-owned. Redirected before any candidate is derived, and restored with t.Cleanup. Production derivation is unchanged, and the assertion that setup materializes every granted write root still stands. A before/after count of the real cache cannot observe this, since the test creates and removes in the same run. The mechanism is the evidence: prepareSandboxRuntime reads sandboxUserCacheDir, and the test RemoveAlls every candidate that derivation produces. --- .../sandbox/windows_setup_runtime_root_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 9a7cd22d1..e18a88b86 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -11,6 +11,21 @@ import ( // runtime root necessary in the first place. func runtimeRootTestConfig(t *testing.T) WindowsSandboxCommandConfig { t.Helper() + // REDIRECT THE AMBIENT CACHE PRODUCER, not only the obvious inputs. + // + // The workspace and sandbox home below are test-owned, but + // windowsSandboxRuntimeCandidates still derives a candidate from + // sandboxUserCacheDir, and the runtime-root test creates every candidate and + // registers os.RemoveAll cleanup for them. That reaches into the real user + // cache: it fails outright on a read-only home, and on an ordinary developer + // or CI account it creates and then deletes a path outside the test's + // storage. The workspace hash makes a collision unlikely; it does not make + // somebody else's directory test-owned. + cache := t.TempDir() + previousCache := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cache, nil } + t.Cleanup(func() { sandboxUserCacheDir = previousCache }) + workspace := t.TempDir() return WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), From f882848a703f61ee1a9148da90fa7fc5f232c3ef Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 31 Aug 2026 15:32:01 +0530 Subject: [PATCH 77/96] test(sandbox): stop the launch preflight test asserting a machine's token TestPrincipalLaunchPreflightExplainsAMissingPrivilege ran the real preflight and then asserted SeAssignPrimaryTokenPrivilege appeared in the message. Which privilege is missing is a property of the account running the test, not of the code: an unelevated user often holds neither, but this box holds SeAssignPrimaryTokenPrivilege and not SeIncreaseQuotaPrivilege, so only the other name appeared and the test failed for a reason that was never a defect. Its own comment said it asserted "the shape of the answer rather than a fixed verdict, because the verdict legitimately differs by machine", and then it hardcoded one of the two names. The comment was right and the assertion did not match it. principalLaunchPrivilegeError splits the rendering out from the token work, so each combination is driven directly: neither held, only assign-primary-token, only increase-quota, and nothing missing. Each asserts the message names what IS missing and does NOT name the one that is held, since sending an operator after a privilege they already have is its own failure. Making the message always name both fails the two single-privilege cases. The real preflight keeps a test, reduced to what does not vary: if it refuses, the refusal names at least one of the two rather than something the operator cannot act on. Stability across calls is unchanged. --- .../windows_principal_launch_windows.go | 15 +++ .../windows_principal_launch_windows_test.go | 96 +++++++++++++++---- 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/internal/sandbox/windows_principal_launch_windows.go b/internal/sandbox/windows_principal_launch_windows.go index 09c13b870..406a36302 100644 --- a/internal/sandbox/windows_principal_launch_windows.go +++ b/internal/sandbox/windows_principal_launch_windows.go @@ -78,6 +78,21 @@ func enableWindowsPrincipalLaunchPrivileges() error { return fmt.Errorf("enable %s: %w", name, err) } } + return principalLaunchPrivilegeError(missing) +} + +// principalLaunchPrivilegeError renders the refusal for a set of missing +// privileges, and is separate from the token work so it can be driven for every +// combination. +// +// It has to be, because WHICH privilege is missing depends on the account +// running the test. An ordinary unelevated user typically holds neither, but not +// always: this developer box holds SeAssignPrimaryTokenPrivilege and not +// SeIncreaseQuotaPrivilege, so a test that named one of them and ran the real +// preflight asserted a machine's configuration rather than the code. Splitting +// the rendering out makes each case deterministic and leaves the token path with +// only the question that genuinely varies. +func principalLaunchPrivilegeError(missing []string) error { if len(missing) == 0 { return nil } diff --git a/internal/sandbox/windows_principal_launch_windows_test.go b/internal/sandbox/windows_principal_launch_windows_test.go index 3f25e3499..338ffb66c 100644 --- a/internal/sandbox/windows_principal_launch_windows_test.go +++ b/internal/sandbox/windows_principal_launch_windows_test.go @@ -14,29 +14,80 @@ import ( // itself being rejected. It happens before the command's executable is opened, // so there is nothing else in the output to tell the two apart. // -// The assertion is on the shape of the answer rather than on a fixed verdict, -// because the verdict legitimately differs by machine: an unelevated developer -// or CI account holds neither privilege, while a service context may hold both. -// Pinning "always fails" would break on the machines where this is supposed to -// work, and pinning "always succeeds" would break everywhere else. -func TestPrincipalLaunchPreflightExplainsAMissingPrivilege(t *testing.T) { +// WHICH privilege is missing is a property of the account running the test, not +// of the code. An unelevated user often holds neither, but not always: the box +// this was written on holds SeAssignPrimaryTokenPrivilege and not +// SeIncreaseQuotaPrivilege. The previous version of this test ran the real +// preflight and then asserted SeAssignPrimaryTokenPrivilege appeared in the +// message, so it passed or failed on the tester's token rather than on +// anything in the source, and it failed here for a reason that was never a +// defect. Rendering is tested directly instead, once per combination. +func TestPrincipalLaunchPrivilegeErrorNamesWhatIsMissing(t *testing.T) { + cases := []struct { + name string + missing []string + }{ + {"neither held", []string{seAssignPrimaryTokenPrivilege, seIncreaseQuotaPrivilege}}, + {"only assign-primary-token missing", []string{seAssignPrimaryTokenPrivilege}}, + {"only increase-quota missing", []string{seIncreaseQuotaPrivilege}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := principalLaunchPrivilegeError(tc.missing) + if err == nil { + t.Fatal("a missing privilege produced no error") + } + for _, want := range tc.missing { + if !strings.Contains(err.Error(), want) { + t.Errorf("the message does not name %s, so an operator cannot act on it: %v", want, err) + } + } + // And it must not name one that IS held, or the operator goes looking + // for a privilege that was never the problem. + for _, other := range []string{seAssignPrimaryTokenPrivilege, seIncreaseQuotaPrivilege} { + if contains(tc.missing, other) { + continue + } + if strings.Contains(err.Error(), other) { + t.Errorf("the message names %s, which this case holds: %v", other, err) + } + } + if !strings.Contains(err.Error(), windowsSandboxIdentityEnv) { + t.Errorf("the message does not name the opt-out: %v", err) + } + // The point of the message is the way out. Without it the operator is + // told only that something is denied. + if !strings.Contains(err.Error(), "restricted-token sandbox") { + t.Errorf("the message does not offer the fallback, so it reads as a dead end: %v", err) + } + }) + } +} + +// Holding both is not an error, or the sandbox would refuse on exactly the +// machines it is meant to run on. +func TestPrincipalLaunchPrivilegeErrorIsNilWhenNothingIsMissing(t *testing.T) { + if err := principalLaunchPrivilegeError(nil); err != nil { + t.Errorf("no missing privileges produced an error: %v", err) + } + if err := principalLaunchPrivilegeError([]string{}); err != nil { + t.Errorf("an empty missing set produced an error: %v", err) + } +} + +// The real preflight against this machine's token. The verdict legitimately +// differs by account, so the only thing asserted is that a refusal is +// actionable: it names at least one of the two privileges rather than failing +// with something the operator cannot use. +func TestPrincipalLaunchPreflightRefusalIsActionable(t *testing.T) { err := enableWindowsPrincipalLaunchPrivileges() if err == nil { t.Log("this process holds the principal launch privileges; nothing to explain") return } - for _, want := range []string{ - seAssignPrimaryTokenPrivilege, - windowsSandboxIdentityEnv, - } { - if !strings.Contains(err.Error(), want) { - t.Errorf("preflight error does not mention %s, so an operator cannot act on it: %v", want, err) - } - } - // The point of the message is the way out. Without it the operator is told - // only that something is denied. - if !strings.Contains(err.Error(), "restricted-token sandbox") { - t.Errorf("preflight error does not offer the fallback, so it reads as a dead end: %v", err) + if !strings.Contains(err.Error(), seAssignPrimaryTokenPrivilege) && + !strings.Contains(err.Error(), seIncreaseQuotaPrivilege) { + t.Errorf("the preflight refused without naming either privilege: %v", err) } } @@ -50,3 +101,12 @@ func TestPrincipalLaunchPreflightIsStable(t *testing.T) { t.Fatalf("preflight verdict changed between calls: first=%v second=%v", first, second) } } + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} From 7f95315f434602242023b4fb362ce690fca7fdba Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 2 Sep 2026 12:36:42 +0530 Subject: [PATCH 78/96] fix(sandbox): root the fallback runtime tree beneath a validated private anchor The fallback runtime root moved from a fresh os.MkdirTemp parent to the predictable /zero/runtime/v1/ so that elevated setup and the later command process agree on it. That agreement is required and stays. But a predictable path directly under the shared system temp is not a private allocation. On an ordinary shared /tmp another local user can create /tmp/zero with mode 0700 first, and every affected user then fails before the sandbox command starts; a pre-existing redirected component makes host-side preparation lease, create, chmod and clean somewhere other than the derived tree. Determinism was being used as if it were ownership. The tree now lives beneath /zero-runtime-, and that anchor is created and validated by peermsg.EnsurePrivateDir before anything is built under it: handle-relative descent, no-follow, refused if any component is a link or the leaf is not owned by the current user, mode 0700. That primitive already existed with exactly the semantics jatmn described, so it is exported and reused rather than written a fourth time. On Unix the uid is in the name because /tmp is shared; on Windows os.TempDir is already per-user and the private descriptor plus owner check do the rest. Validation runs on both paths that can hand back a fallback root: sandboxRuntimeRootFor returns one itself when the cache lands inside the workspace, and prepareSandboxRuntime asks for one when the cache root cannot be leased. Both are covered, each with a junction planted at the anchor, and both refuse with the junction target untouched; a control shows a clean fallback still prepares beneath the anchor. Two things went wrong on the way and are worth recording. The first draft called pathWithinRoot with its arguments reversed, which asked whether the anchor was beneath the root, answered false every time, and skipped the validation entirely while every dependent test reported success through the junction. And the first lease-failure trigger planted its file at the derived leaf, so preparation failed on that mkdir before ever reaching the fallback branch. Both fixed; with validation disabled the two refusal tests now die on "reported success" while the control stays green. --- internal/peermsg/private_dir_other.go | 2 +- internal/peermsg/private_dir_unix.go | 2 +- internal/peermsg/private_dir_unix_test.go | 4 +- internal/peermsg/private_dir_windows.go | 2 +- internal/peermsg/private_dir_windows_test.go | 10 +- internal/peermsg/service.go | 6 +- internal/peermsg/transport_unix.go | 2 +- .../runtime_fallback_anchor_windows_test.go | 146 ++++++++++++++++++ internal/sandbox/runtime_state.go | 76 ++++++++- 9 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 internal/sandbox/runtime_fallback_anchor_windows_test.go diff --git a/internal/peermsg/private_dir_other.go b/internal/peermsg/private_dir_other.go index 8ce11c413..be145e55b 100644 --- a/internal/peermsg/private_dir_other.go +++ b/internal/peermsg/private_dir_other.go @@ -10,7 +10,7 @@ import ( "strings" ) -func ensurePrivateDir(path string) error { +func EnsurePrivateDir(path string) error { abs, err := filepath.Abs(path) if err != nil { return err diff --git a/internal/peermsg/private_dir_unix.go b/internal/peermsg/private_dir_unix.go index 664550997..ed0d684d2 100644 --- a/internal/peermsg/private_dir_unix.go +++ b/internal/peermsg/private_dir_unix.go @@ -12,7 +12,7 @@ import ( "golang.org/x/sys/unix" ) -func ensurePrivateDir(path string) error { +func EnsurePrivateDir(path string) error { abs, err := filepath.Abs(path) if err != nil { return err diff --git a/internal/peermsg/private_dir_unix_test.go b/internal/peermsg/private_dir_unix_test.go index 3945c58ef..16f0df06b 100644 --- a/internal/peermsg/private_dir_unix_test.go +++ b/internal/peermsg/private_dir_unix_test.go @@ -18,7 +18,7 @@ func TestEnsurePrivateDirRejectsSymlink(t *testing.T) { if err := os.Symlink(target, link); err != nil { t.Fatal(err) } - if err := ensurePrivateDir(link); err == nil { + if err := EnsurePrivateDir(link); err == nil { t.Fatal("expected symlink runtime directory to be rejected") } } @@ -33,7 +33,7 @@ func TestEnsurePrivateDirRejectsSymlinkedParent(t *testing.T) { if err := os.Symlink(target, link); err != nil { t.Fatal(err) } - if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil { + if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil { t.Fatal("expected symlinked parent to be rejected") } } diff --git a/internal/peermsg/private_dir_windows.go b/internal/peermsg/private_dir_windows.go index 812b29808..27e961db6 100644 --- a/internal/peermsg/private_dir_windows.go +++ b/internal/peermsg/private_dir_windows.go @@ -12,7 +12,7 @@ import ( "golang.org/x/sys/windows" ) -func ensurePrivateDir(path string) (resultErr error) { +func EnsurePrivateDir(path string) (resultErr error) { abs, err := filepath.Abs(path) if err != nil { return err diff --git a/internal/peermsg/private_dir_windows_test.go b/internal/peermsg/private_dir_windows_test.go index 7e7f751b1..21ae2f13f 100644 --- a/internal/peermsg/private_dir_windows_test.go +++ b/internal/peermsg/private_dir_windows_test.go @@ -54,7 +54,7 @@ func TestEnsurePrivateDirAppliesOwnerOnlyProtectedDACL(t *testing.T) { if !windowsDirectoryDACLContains(t, path, worldSID) { t.Fatal("test setup did not grant the broad Everyone ACE") } - if err := ensurePrivateDir(path); err != nil { + if err := EnsurePrivateDir(path); err != nil { t.Fatal(err) } descriptor, err := windows.GetNamedSecurityInfo( @@ -149,7 +149,7 @@ func windowsDirectoryDACLContains(t *testing.T, path string, wanted *windows.SID func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) { path := filepath.Join(t.TempDir(), "private") - if err := ensurePrivateDir(path); err != nil { + if err := EnsurePrivateDir(path); err != nil { t.Fatal(err) } handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL|windows.WRITE_DAC) @@ -172,7 +172,7 @@ func TestSecurePrivateDirectoryRejectsOwnerMismatch(t *testing.T) { func TestSecurePrivateDirectoryReportsDACLWriteFailure(t *testing.T) { path := filepath.Join(t.TempDir(), "private") - if err := ensurePrivateDir(path); err != nil { + if err := EnsurePrivateDir(path); err != nil { t.Fatal(err) } handle, err := openWindowsDirectory(path, windows.FILE_LIST_DIRECTORY|windows.FILE_TRAVERSE|windows.SYNCHRONIZE|windows.READ_CONTROL) @@ -206,7 +206,7 @@ func TestEnsurePrivateDirRejectsWindowsReparseParent(t *testing.T) { if err := os.Symlink(target, link); err != nil { t.Skipf("cannot create Windows directory symlink: %v", err) } - if err := ensurePrivateDir(filepath.Join(link, "peers")); err == nil { + if err := EnsurePrivateDir(filepath.Join(link, "peers")); err == nil { t.Fatal("expected reparse-point parent to be rejected") } } @@ -217,7 +217,7 @@ func TestEnsurePrivateDirRejectsWindowsFileComponent(t *testing.T) { if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil { t.Fatal(err) } - if err := ensurePrivateDir(filepath.Join(file, "peers")); err == nil { + if err := EnsurePrivateDir(filepath.Join(file, "peers")); err == nil { t.Fatal("expected file path component to be rejected") } } diff --git a/internal/peermsg/service.go b/internal/peermsg/service.go index e32735c4a..f1afb8862 100644 --- a/internal/peermsg/service.go +++ b/internal/peermsg/service.go @@ -158,7 +158,7 @@ func New(options Options) (*Service, error) { } // canonicalRuntimePath normalizes aliases in the existing prefix. It does not -// establish a security boundary; ensurePrivateDir validates that boundary when +// establish a security boundary; EnsurePrivateDir validates that boundary when // the service starts. func canonicalRuntimePath(path string) (string, error) { missing := make([]string, 0, 4) @@ -219,10 +219,10 @@ func (service *Service) Start(handler Handler) error { if service.closed { return errors.New("peer messaging: service is closed") } - if err := ensurePrivateDir(service.root); err != nil { + if err := EnsurePrivateDir(service.root); err != nil { return fmt.Errorf("peer messaging: create runtime directory: %w", err) } - if err := ensurePrivateDir(service.registryDir()); err != nil { + if err := EnsurePrivateDir(service.registryDir()); err != nil { return fmt.Errorf("peer messaging: create registry: %w", err) } endpoint, err := service.transport.Endpoint(service.root, service.nonce, service.pid) diff --git a/internal/peermsg/transport_unix.go b/internal/peermsg/transport_unix.go index c32418dbb..651a85176 100644 --- a/internal/peermsg/transport_unix.go +++ b/internal/peermsg/transport_unix.go @@ -44,7 +44,7 @@ func canonicalPrivateDir(path string) (string, error) { if err != nil { return "", err } - if err := ensurePrivateDir(canonical); err != nil { + if err := EnsurePrivateDir(canonical); err != nil { return "", err } return canonical, nil diff --git a/internal/sandbox/runtime_fallback_anchor_windows_test.go b/internal/sandbox/runtime_fallback_anchor_windows_test.go new file mode 100644 index 000000000..9bc0a6acc --- /dev/null +++ b/internal/sandbox/runtime_fallback_anchor_windows_test.go @@ -0,0 +1,146 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// DETERMINISM IS NOT OWNERSHIP. +// +// The fallback runtime root moved from a fresh os.MkdirTemp parent to a +// predictable name so that elevated setup and the later command agree on it. +// That is required. But a predictable path beneath the shared system temp is +// not a private allocation: whatever another local user, or a stray earlier +// process, has already placed at that name is what preparation then leases, +// creates, chmods and cleans through. The anchor beneath which the tree lives +// must therefore be proven to be this user's private directory before anything +// is built under it, on BOTH paths that can hand back a fallback root. +// +// Junctions need no privilege on Windows, so the "attacker-precreated parent" +// jatmn described is modelled as a junction at the anchor pointing into a +// directory the test owns and watches. Either trigger must refuse, and the +// watched directory must stay empty. + +// fallbackAnchorFixture redirects TEMP so the anchor lands inside a directory +// this test owns, plants a junction at the anchor into a watched target, and +// returns the target. The junction is the shape that defeats a pathname walk: +// it is not a symlink to os.ModeSymlink, and EvalSymlinks will not traverse it. +func fallbackAnchorFixture(t *testing.T) (target string) { + t.Helper() + tempRoot := t.TempDir() + target = t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + anchor := fallbackRuntimeAnchor() + if !strings.HasPrefix(strings.ToLower(anchor), strings.ToLower(tempRoot)) { + t.Fatalf("SETUP INVALID: anchor %s is not under the redirected TEMP %s", anchor, tempRoot) + } + if out, err := exec.Command("cmd", "/c", "mklink", "/J", anchor, target).CombinedOutput(); err != nil { + t.Fatalf("mklink /J: %v\n%s", err, out) + } + return target +} + +func assertNothingUnder(t *testing.T, target string) { + t.Helper() + entries, err := os.ReadDir(target) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Errorf("preparation built %v beneath the junction target, which is somebody else's directory", names) + } +} + +// Trigger one: the user cache resolves INSIDE the workspace, so +// sandboxRuntimeRootFor itself returns the fallback root and the first lease +// attempt is already against it. +func TestFallbackAnchorIsRefusedWhenTheCacheLandsInsideTheWorkspace(t *testing.T) { + target := fallbackAnchorFixture(t) + workspace := t.TempDir() + + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil } + + _, release, err := prepareSandboxRuntime(workspace) + if err == nil { + release() + t.Fatal("preparation went through a junction at the fallback anchor and reported success") + } + if !strings.Contains(err.Error(), "fallback anchor") { + t.Errorf("the refusal does not name the anchor, so the operator cannot act on it: %v", err) + } + assertNothingUnder(t, target) +} + +// Trigger two: the cache root cannot be leased (a FILE sits where the cache +// runtime tree would go), so preparation falls back explicitly. +func TestFallbackAnchorIsRefusedWhenTheCacheRootCannotBeLeased(t *testing.T) { + target := fallbackAnchorFixture(t) + workspace := t.TempDir() + + // Make the cache-derived root unleasable at its ROOT: the user cache is a + // regular file, so nothing beneath it can be created and the first lease + // fails, which is the path that asks for the fallback explicitly. The + // first draft planted the file at the derived leaf instead, and the + // preparation failed on that mkdir before ever reaching the fallback + // branch, so the trigger under test never fired. + cache := filepath.Join(t.TempDir(), "cache-is-a-file") + if err := os.WriteFile(cache, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cache, nil } + + _, release, err := prepareSandboxRuntime(workspace) + if err == nil { + release() + t.Fatal("preparation fell back through a junction at the anchor and reported success") + } + if !strings.Contains(err.Error(), "fallback anchor") { + t.Errorf("the refusal does not name the anchor: %v", err) + } + assertNothingUnder(t, target) +} + +// And the honest control: with nothing planted, the fallback prepares, the +// anchor is a real directory this test can see, and the runtime tree sits +// beneath it. +func TestFallbackAnchorIsCreatedPrivatelyWhenNothingIsInTheWay(t *testing.T) { + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + workspace := t.TempDir() + + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil } + + runtimeState, release, err := prepareSandboxRuntime(workspace) + if err != nil { + t.Fatalf("a clean fallback was refused: %v", err) + } + defer release() + + anchor := fallbackRuntimeAnchor() + info, err := os.Lstat(anchor) + if err != nil || !info.IsDir() { + t.Fatalf("the anchor was not created as a directory: err=%v", err) + } + if !pathWithinRoot(anchor, runtimeState.Root) { + t.Errorf("runtime root %s is not beneath the anchor %s", runtimeState.Root, anchor) + } +} diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 0bde14441..1a18da838 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -7,10 +7,14 @@ import ( "fmt" "os" "path/filepath" + "runtime" "sort" + "strconv" "strings" "sync" "time" + + "github.com/Gitlawb/zero/internal/peermsg" ) var sandboxUserCacheDir = os.UserCacheDir @@ -81,12 +85,25 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) if err != nil { return SandboxRuntime{}, nil, err } + // THE ANCHOR IS PROVEN BEFORE THE TREE IS BUILT, on both paths that can + // hand back a fallback root: sandboxRuntimeRootFor returns one itself when + // the cache lands inside the workspace, and the branch below asks for one + // when the cache root cannot be leased. A deterministic name under the + // shared temp is only safe beneath a directory this user owns; if the anchor + // is a link, or exists and belongs to someone else, this refuses rather than + // leasing, creating and chmod-ing through it. + if err := ensureFallbackRuntimeAnchor(root); err != nil { + return SandboxRuntime{}, nil, err + } lease, err := prepareSandboxRuntimeLease(root) if err != nil { root, err = fallbackSandboxRuntimeRoot(workspaceRoot) if err != nil { return SandboxRuntime{}, nil, err } + if err := ensureFallbackRuntimeAnchor(root); err != nil { + return SandboxRuntime{}, nil, err + } lease, err = prepareSandboxRuntimeLease(root) if err != nil { return SandboxRuntime{}, nil, err @@ -201,6 +218,63 @@ func combineSandboxCleanups(cleanups ...func()) func() { } } +// fallbackRuntimeAnchor is the per-user directory the deterministic fallback +// runtime tree lives beneath. +// +// DETERMINISM IS NOT OWNERSHIP. The fallback moved from a fresh, private +// os.MkdirTemp parent to a predictable name so that elevated setup and the +// later command process agree on the runtime root. That agreement is required +// and must stay. But a predictable path directly under the shared system temp +// is not a private allocation: on an ordinary shared /tmp another local user +// can create /tmp/zero with mode 0700 first, and every affected user then fails +// before the sandbox command starts; a pre-existing redirected component makes +// host-side preparation operate somewhere other than the derived tree. +// +// So the tree is rooted beneath an anchor that names THIS user, and the anchor +// is created and validated by peermsg.EnsurePrivateDir before anything is +// built under it: handle-relative descent, no-follow, refused if any component +// is a link or the leaf is not owned by the current user, mode 0700. On Unix +// the uid is in the name because /tmp is shared between users; on Windows +// os.TempDir is already per-user, and the private descriptor plus the owner +// check do the rest. +func fallbackRuntimeAnchor() string { + tag := "user" + if runtime.GOOS != "windows" { + tag = strconv.Itoa(os.Getuid()) + } + return filepath.Join(os.TempDir(), "zero-runtime-"+tag) +} + +// isFallbackRuntimeRoot reports whether root was derived by +// fallbackSandboxRuntimeRoot, so the caller knows the anchor must be validated +// before the tree beneath it is prepared. Both candidates a command derives can +// be fallback roots: sandboxRuntimeRootFor returns one itself when the cache +// lands inside the workspace, and prepareSandboxRuntime asks for one when the +// cache root cannot be leased. +func isFallbackRuntimeRoot(root string) bool { + // pathWithinRoot(parent, child): is root beneath the anchor. The first + // draft had these reversed, which asked whether the anchor was beneath the + // root, answered false every time, and skipped the validation entirely + // while every test that depended on it reported success through a + // junction. Argument order is the whole function. + return pathWithinRoot(fallbackRuntimeAnchor(), root) +} + +// ensureFallbackRuntimeAnchor validates the anchor beneath which a fallback +// runtime tree is about to be built, and fails closed. Called only when the +// root is a fallback root; the cache-derived root lives under the user cache, +// which is already the user's own directory. +func ensureFallbackRuntimeAnchor(root string) error { + if !isFallbackRuntimeRoot(root) { + return nil + } + anchor := fallbackRuntimeAnchor() + if err := peermsg.EnsurePrivateDir(anchor); err != nil { + return fmt.Errorf("sandbox runtime fallback anchor %s is not a private directory owned by this user: %w", anchor, err) + } + return nil +} + // fallbackSandboxRuntimeRoot returns the runtime root for a workspace whose // cache-derived root would land inside itself. // @@ -222,7 +296,7 @@ func combineSandboxCleanups(cleanups ...func()) func() { // a tree without materializing it now holds for the fallback as well. func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) { digest := sha256.Sum256([]byte(workspaceRoot)) - root := filepath.Join(os.TempDir(), "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) + root := filepath.Join(fallbackRuntimeAnchor(), "v1", hex.EncodeToString(digest[:8])) if pathWithinRoot(workspaceRoot, root) { // Both candidates land inside the workspace, so there is nowhere left to // put a runtime tree the workspace's own policy does not govern. Refused From 41d2ab9f22026293cc1b0eaf6335aea4f59ab5f2 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 2 Sep 2026 12:56:38 +0530 Subject: [PATCH 79/96] fix(sandbox): resolve the temp dir physically before proving the fallback anchor peermsg.EnsurePrivateDir walks every component from the root and refuses any link. That is the right rule for the directory Zero owns and the wrong rule for the operator's temp location above it: on macOS os.TempDir sits under /var, a symlink to /private/var, so 8af05997 refused every fallback on every Mac with "refusing non-directory or symlink runtime path component var". Anchor the fallback under the physical temp dir instead, so only the anchor itself is a new component. EvalSymlinks off Windows; GetFinalPathNameByHandle on Windows, where EvalSymlinks does not traverse a junction and a redirected TEMP would trip the same way. The fixture compares against the resolved temp dir rather than its spelling, since t.TempDir can come back as an 8.3 short name. --- .../sandbox/runtime_fallback_anchor_other.go | 27 +++++++++ .../runtime_fallback_anchor_windows.go | 57 +++++++++++++++++++ .../runtime_fallback_anchor_windows_test.go | 13 ++++- internal/sandbox/runtime_state.go | 17 +++++- 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 internal/sandbox/runtime_fallback_anchor_other.go create mode 100644 internal/sandbox/runtime_fallback_anchor_windows.go diff --git a/internal/sandbox/runtime_fallback_anchor_other.go b/internal/sandbox/runtime_fallback_anchor_other.go new file mode 100644 index 000000000..5338e78b8 --- /dev/null +++ b/internal/sandbox/runtime_fallback_anchor_other.go @@ -0,0 +1,27 @@ +//go:build !windows + +package sandbox + +import ( + "os" + "path/filepath" +) + +// physicalTempDir returns os.TempDir with every symlink in its ancestry +// resolved, so the fallback anchor's parent chain is physical and only the +// anchor itself is a new component. +// +// peermsg.EnsurePrivateDir walks from the root with O_NOFOLLOW and refuses any +// component that is a link. That is right for the owned tail and wrong for the +// ancestors above it: on macOS os.TempDir lives under /var, which is a symlink +// to /private/var, so the first version of this fix refused every fallback on +// every Mac with "refusing non-directory or symlink runtime path component +// var". The constraint jatmn stated for #901 applies here identically: +// redirected cache and TEMP locations above the owned tail are the operator's +// business and must keep working; the restriction is on what Zero owns. +// +// EvalSymlinks is the correct resolver off Windows, where the only reparse +// shape is a symlink and it traverses them. +func physicalTempDir() (string, error) { + return filepath.EvalSymlinks(os.TempDir()) +} diff --git a/internal/sandbox/runtime_fallback_anchor_windows.go b/internal/sandbox/runtime_fallback_anchor_windows.go new file mode 100644 index 000000000..940bafc6b --- /dev/null +++ b/internal/sandbox/runtime_fallback_anchor_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +// physicalTempDir returns os.TempDir as the object it actually names, so the +// fallback anchor's parent chain is physical and only the anchor itself is a +// new component. +// +// peermsg.EnsurePrivateDir walks from the volume root with OBJ_DONT_REPARSE and +// refuses any component that is a reparse point. That is right for the owned +// tail and wrong for the ancestors above it: a redirected %LOCALAPPDATA% is an +// ordinary Windows configuration, and TEMP sits beneath it. The constraint +// jatmn stated for #901 applies here identically: redirected cache and TEMP +// locations above the owned tail must keep working; the restriction is on what +// Zero owns. +// +// GetFinalPathNameByHandle rather than filepath.EvalSymlinks, because +// EvalSymlinks does not traverse a junction on Windows: it returns the +// junction's own path and the walker then refuses it. The handle answers where +// the directory actually is. Same recipe as verifyWindowsACLTargetNotRedirected, +// which is why the flag constants and the prefix trim are shared. +func physicalTempDir() (string, error) { + path := os.TempDir() + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", fmt.Errorf("encode temp dir %s: %w", path, err) + } + handle, err := windows.CreateFile( + utf16Path, + windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return "", fmt.Errorf("open temp dir %s: %w", path, err) + } + defer windows.CloseHandle(handle) + 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 temp dir %s: %w", path, err) + } + if int(n) < len(buffer) { + buffer = buffer[:n] + } + return trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer)), nil +} diff --git a/internal/sandbox/runtime_fallback_anchor_windows_test.go b/internal/sandbox/runtime_fallback_anchor_windows_test.go index 9bc0a6acc..55caffd50 100644 --- a/internal/sandbox/runtime_fallback_anchor_windows_test.go +++ b/internal/sandbox/runtime_fallback_anchor_windows_test.go @@ -37,9 +37,18 @@ func fallbackAnchorFixture(t *testing.T) (target string) { t.Setenv("TMP", tempRoot) t.Setenv("TEMP", tempRoot) + // The anchor's parent is the PHYSICAL temp dir, so compare against the + // resolved form of the redirect rather than its spelling: t.TempDir can + // come back as an 8.3 short name or in a different case from what the + // handle reports, and a prefix check on the raw string would fail for a + // correct anchor. anchor := fallbackRuntimeAnchor() - if !strings.HasPrefix(strings.ToLower(anchor), strings.ToLower(tempRoot)) { - t.Fatalf("SETUP INVALID: anchor %s is not under the redirected TEMP %s", anchor, tempRoot) + resolvedTempRoot, err := physicalTempDir() + if err != nil { + t.Fatalf("SETUP INVALID: cannot resolve the redirected TEMP %s: %v", tempRoot, err) + } + if !pathWithinRoot(resolvedTempRoot, anchor) { + t.Fatalf("SETUP INVALID: anchor %s is not under the redirected TEMP %s (resolved %s)", anchor, tempRoot, resolvedTempRoot) } if out, err := exec.Command("cmd", "/c", "mklink", "/J", anchor, target).CombinedOutput(); err != nil { t.Fatalf("mklink /J: %v\n%s", err, out) diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 1a18da838..71f11cbbc 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -242,7 +242,22 @@ func fallbackRuntimeAnchor() string { if runtime.GOOS != "windows" { tag = strconv.Itoa(os.Getuid()) } - return filepath.Join(os.TempDir(), "zero-runtime-"+tag) + // PHYSICAL PARENT, OWNED LEAF. The validator walks every component no-follow + // and refuses links, which is right for the anchor Zero owns and wrong for + // the operator's ancestors above it: macOS puts TempDir under /var, a + // symlink to /private/var, and a redirected %LOCALAPPDATA% is ordinary on + // Windows. The first version handed the validator the unresolved path and + // refused every fallback on every Mac. Resolving the parent first means the + // only new component the validator sees is the anchor itself. + // + // On a resolve error the unresolved path is used, deliberately: the + // validator is still the fail-closed check, and refusing there names the + // real component rather than hiding it behind a resolver failure. + base := os.TempDir() + if physical, err := physicalTempDir(); err == nil && physical != "" { + base = physical + } + return filepath.Join(base, "zero-runtime-"+tag) } // isFallbackRuntimeRoot reports whether root was derived by From 0d331a445f686df57058ae714894e34c3683d75a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 3 Sep 2026 13:45:34 +0530 Subject: [PATCH 80/96] fix(sandbox,cli): plan the grant and its guard together, and state the tier boundary The .git rename guard belonged to one planner rather than to the grant. The allow-write mask both backends share includes DELETE, and it inherits from each write root onto .git; the carveouts that actually protect git are attached to .git/config and .git/hooks as objects, so renaming .git aside and recreating it discards them and hands back credential.helper and core.hooksPath. The principal planner denied DELETE there and the capability planner did not, and the capability backend is the default. Both now derive that object from one place, so a change to the shared mask cannot update one consumer and miss the other. The regression reads the ACE back off the applied object rather than asserting plan shape, because the plan is what was wrong. The unelevated tier now states its boundary before it mutates anything. A profile carrying denyRead runs on a strict token, which applies the restricted-SID check to reads, so the read capability must be granted at the volume root that permissionProfileReadRoots seeds. Elevated setup can write that DACL; an ordinary user cannot, and the common opener asks for WRITE_DAC on every entry, so this tier failed on that one root on every command. Dropping the root ACE instead is not available, because the strict token would then fail its own read check for the executable. So it is refused up front, naming the root, the denyRead cause and elevated setup as the remedy. That last part matters: the previous after-the-fact diagnostic told the reader that running setup elevated would NOT fix it, which is the opposite of the truth for this case. Separately, `sandbox exec` no longer reports a signaled child as exit 255. On Unix ExitCode() answers -1 for signal termination and os.Exit truncates that, so a child killed by SIGTERM was indistinguishable from one that chose to exit 255. It now returns the conventional 128+signal, read from the ProcessState, with Windows keeping the exit code it already reports. --- internal/cli/sandbox_exec.go | 8 ++ internal/cli/sandbox_exec_signal_other.go | 28 ++++ .../cli/sandbox_exec_signal_other_test.go | 65 ++++++++++ internal/cli/sandbox_exec_signal_windows.go | 12 ++ internal/sandbox/windows_acl.go | 42 ++++++ .../windows_acl_git_guard_windows_test.go | 122 ++++++++++++++++++ .../sandbox/windows_command_runner_windows.go | 22 ++++ internal/sandbox/windows_identity_acl.go | 2 +- .../windows_unelevated_tier_windows_test.go | 102 +++++++++++++++ 9 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 internal/cli/sandbox_exec_signal_other.go create mode 100644 internal/cli/sandbox_exec_signal_other_test.go create mode 100644 internal/cli/sandbox_exec_signal_windows.go create mode 100644 internal/sandbox/windows_acl_git_guard_windows_test.go create mode 100644 internal/sandbox/windows_unelevated_tier_windows_test.go diff --git a/internal/cli/sandbox_exec.go b/internal/cli/sandbox_exec.go index 77ce2e9b8..29bacd4c8 100644 --- a/internal/cli/sandbox_exec.go +++ b/internal/cli/sandbox_exec.go @@ -130,6 +130,14 @@ func runSandboxPlannedCommand(plan zeroSandbox.CommandPlan, stdout io.Writer, st 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. + // A SIGNALED CHILD HAS NO EXIT CODE TO REPORT. ExitCode() answers -1 + // there, and os.Exit truncates that to 255, so a child that took SIGTERM + // became indistinguishable from one that chose to exit 255. Fold the + // signal into the conventional 128+n a shell would report, which needs + // the ProcessState rather than the integer. + if status, signaled := signaledExitStatus(exitErr.ProcessState); signaled { + return status + } return exitErr.ExitCode() } fmt.Fprintf(stderr, "sandbox exec: %v\n", err) diff --git a/internal/cli/sandbox_exec_signal_other.go b/internal/cli/sandbox_exec_signal_other.go new file mode 100644 index 000000000..c47442a41 --- /dev/null +++ b/internal/cli/sandbox_exec_signal_other.go @@ -0,0 +1,28 @@ +//go:build !windows + +package cli + +import ( + "os" + "syscall" +) + +// signaledExitStatus reports the conventional shell status for a child that was +// terminated by a signal, which exec.ExitError cannot represent. +// +// ExitCode() returns -1 for a signaled child, and the top level hands that to +// os.Exit, which truncates it to 255. A child that takes SIGTERM is then +// indistinguishable from one that exited 255 of its own accord, which breaks the +// documented status contract for a command whose whole job is to report the +// child's own status faithfully. 128+signal is what a shell reports and what a +// harness comparing statuses expects. +func signaledExitStatus(state *os.ProcessState) (int, bool) { + if state == nil { + return 0, false + } + status, ok := state.Sys().(syscall.WaitStatus) + if !ok || !status.Signaled() { + return 0, false + } + return 128 + int(status.Signal()), true +} diff --git a/internal/cli/sandbox_exec_signal_other_test.go b/internal/cli/sandbox_exec_signal_other_test.go new file mode 100644 index 000000000..bd838c934 --- /dev/null +++ b/internal/cli/sandbox_exec_signal_other_test.go @@ -0,0 +1,65 @@ +//go:build !windows + +package cli + +import ( + "errors" + "os/exec" + "syscall" + "testing" +) + +// A SIGNALED CHILD MUST NOT LOOK LIKE ONE THAT CHOSE TO EXIT 255. +// +// `sandbox exec` exists to report the child's own status faithfully, and +// exec.ExitError cannot represent signal termination: ExitCode() answers -1, and +// the top level hands that to os.Exit, which truncates it to 255. So a child +// killed by SIGTERM was indistinguishable from an ordinary exit of 255, and a +// harness comparing statuses could not tell a refusal from a kill. +// +// Driven with a real subprocess and a real signal rather than a synthetic +// WaitStatus, because the mapping is only correct if the OS agrees with it. +func TestSignaledChildReportsTheConventionalStatus(t *testing.T) { + for _, testCase := range []struct { + name string + script string + want int + }{ + {name: "SIGTERM", script: "kill -TERM $$; sleep 5", want: 128 + int(syscall.SIGTERM)}, + {name: "SIGINT", script: "kill -INT $$; sleep 5", want: 128 + int(syscall.SIGINT)}, + } { + t.Run(testCase.name, func(t *testing.T) { + err := exec.Command("/bin/sh", "-c", testCase.script).Run() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err) + } + // The behaviour being corrected: the integer alone cannot say this. + if code := exitErr.ExitCode(); code != -1 { + t.Fatalf("SETUP INVALID: a signaled child reported exit code %d, expected -1 on this platform", code) + } + status, signaled := signaledExitStatus(exitErr.ProcessState) + if !signaled { + t.Fatal("a signaled child was not recognised as signaled, so it would be reported as exit 255") + } + if status != testCase.want { + t.Fatalf("status = %d, want %d (128 + signal), which is what a shell reports", status, testCase.want) + } + }) + } +} + +// An ordinary non-zero exit is untouched: it has a real code and must keep it. +func TestOrdinaryExitIsNotTreatedAsSignaled(t *testing.T) { + err := exec.Command("/bin/sh", "-c", "exit 3").Run() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("SETUP INVALID: the child did not end with an ExitError: %v", err) + } + if _, signaled := signaledExitStatus(exitErr.ProcessState); signaled { + t.Fatal("an ordinary exit was reported as signaled, which would rewrite its status") + } + if code := exitErr.ExitCode(); code != 3 { + t.Fatalf("exit code = %d, want the child's own 3", code) + } +} diff --git a/internal/cli/sandbox_exec_signal_windows.go b/internal/cli/sandbox_exec_signal_windows.go new file mode 100644 index 000000000..826400fdc --- /dev/null +++ b/internal/cli/sandbox_exec_signal_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package cli + +import "os" + +// signaledExitStatus has no Windows counterpart: a process there ends with an +// exit code, and there is no signal to fold into one. Windows keeps the exit +// code exec.ExitError already reports. +func signaledExitStatus(*os.ProcessState) (int, bool) { + return 0, false +} diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 03196cb09..cd14e73e2 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -106,6 +106,15 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er Path: capability.Root, Capability: capability.SID, }) + // The grant above carries DELETE, so the same object guard the principal + // plan has must come with it: 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: windowsRenameProtectedObject(capability.Root), + Capability: capability.SID, + }) for _, path := range capability.ProtectedWriteDenyPaths { entries = append(entries, WindowsACLEntry{ Action: WindowsACLDenyWrite, @@ -170,6 +179,39 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil } +// windowsPlanVolumeRootGrant returns the first volume root the plan would have +// to change permissions on, or empty when it needs none. +// +// Only elevated setup can write a volume-root DACL, so this is the one entry +// that decides whether a plan is applicable by the unelevated tier at all. The +// check is on the PLAN rather than on the profile because the plan is what gets +// applied: a future entry that lands at a volume root for some other reason is +// caught by the same test. +func windowsPlanVolumeRootGrant(plan WindowsACLPlan) string { + for _, entry := range plan.Entries { + if isWindowsVolumeRoot(normalizeProfilePath(entry.Path)) { + return entry.Path + } + } + return "" +} + +// windowsRenameProtectedObject names the object whose DELETE must be denied on +// a write root, whatever trustee holds the grant. +// +// ONE DERIVATION, BECAUSE TWO PLANNERS CONSUME IT. The allow-write mask both +// backends share includes DELETE, and it inherits from the write root onto .git. +// The carveouts that actually protect git live on .git/config and .git/hooks as +// OBJECTS, so renaming .git aside and recreating it discards them: the fresh +// config and hooks inherit the workspace allow with no deny of their own, which +// hands back credential.helper and core.hooksPath. The principal planner denied +// DELETE here and the capability planner did not, so the default restricted-token +// backend was missing the guard entirely. Deriving it in one place is what stops +// a change to the shared mask from updating one consumer and not the other. +func windowsRenameProtectedObject(root string) string { + return filepath.Join(root, sandboxRenameProtectedMetadataName) +} + type windowsWriteRootCapability struct { Root string SID string diff --git a/internal/sandbox/windows_acl_git_guard_windows_test.go b/internal/sandbox/windows_acl_git_guard_windows_test.go new file mode 100644 index 000000000..b72c1cfce --- /dev/null +++ b/internal/sandbox/windows_acl_git_guard_windows_test.go @@ -0,0 +1,122 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// gitGuardDenyDeleteApplied reports whether the applied DACL on path carries a +// DENY ace granting DELETE to sid. Read back from the object rather than from +// the plan, because the plan is what was already wrong. +func gitGuardDenyDeleteApplied(t *testing.T, path string, 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 the DACL of %s: %v", path, err) + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + t.Fatalf("read the DACL of %s: %v", path, err) + } + wanted, err := windows.StringToSid(sid) + if err != nil { + t.Fatalf("parse the capability SID %q: %v", sid, err) + } + // GetAce hands back a generic header that is reinterpreted here. Sound only + // for the fixed-layout ACE types: an object ACE carries Flags and two GUIDs + // ahead of the trustee, so SidStart would land mid-structure. Allowed and + // denied ACEs share that fixed layout, and nothing under test builds an + // object ACE, so a type this does not recognise is skipped rather than + // decoded into a nonsense 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 { + continue + } + if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE { + continue + } + aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !aceSID.Equals(wanted) { + continue + } + if ace.Mask&windows.DELETE != 0 { + return true + } + } + return false +} + +// THE GUARD BELONGS TO THE GRANT, NOT TO ONE PLANNER. +// +// The allow-write mask both backends share includes DELETE, and it inherits from +// the write root onto .git. What protects git is attached to .git/config and +// .git/hooks as OBJECTS, so renaming .git aside and recreating it discards those +// carveouts: the fresh config and hooks inherit the workspace allow with no deny +// of their own, which hands back credential.helper and core.hooksPath. +// +// The principal planner denied DELETE on .git; the capability planner did not, +// and the capability backend is the default. So on the reachable path the +// caller's own token and the capability restricting SID could both authorise the +// rename. +// +// Read back from the applied object, not asserted on the plan, because the plan +// is exactly what was wrong. DACL edits on a user-owned directory need no +// Administrator rights, so this runs unelevated. +func TestCapabilityPlanDeniesDeleteOnGit(t *testing.T) { + workspace := t.TempDir() + gitDir := filepath.Join(workspace, ".git") + if err := os.MkdirAll(gitDir, 0o700); err != nil { + t.Fatal(err) + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + }, + } + plan, err := BuildWindowsACLPlan(config) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + sid, err := windowsCapabilitySIDForWriteRoot(config, workspace) + if err != nil { + t.Fatalf("resolve the workspace capability SID: %v", err) + } + // SETUP: the plan must actually grant this root, or the guard below would be + // vacuously satisfied by a plan that grants nothing. + granted := false + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowWrite && entry.Path == workspace { + granted = true + } + } + if !granted { + t.Fatalf("SETUP INVALID: the capability plan does not grant write on %s", workspace) + } + + if _, err := applyWindowsACLPlan(plan); err != nil { + t.Skipf("cannot apply an ACL plan here: %v", err) + } + + if !gitGuardDenyDeleteApplied(t, gitDir, sid) { + t.Fatalf(".git carries no deny-DELETE ace for the capability SID %s, so the sandboxed token can rename it and recreate it without the config and hooks carveouts", sid) + } +} diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 1b16fb11d..cafacdaac 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -223,6 +223,28 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { if marker.contains(applied) { return nil } + // THE TIER BOUNDARY, STATED BEFORE ANY MUTATION IS ATTEMPTED. + // + // One plan is consumed by two tiers with different authority. A profile that + // carries DenyRead runs on a strict token, and the strict token applies the + // restricted-SID check to READS, so the read capability has to be granted + // wherever the command reads from -- including the volume root that + // permissionProfileReadRoots seeds. Elevated setup can write that DACL. An + // ordinary user cannot, and the common opener asks for WRITE_DAC on every + // entry, so this tier fails on that one root every time. + // + // Dropping the root ACE instead is not an option: the strict token would then + // fail its own read check for the executable and every ambient read. So the + // tier is refused explicitly, naming the root and the reason, rather than + // discovered as an ACCESS_DENIED after the fact. The real smoke test misses + // this because it substitutes a user-owned temporary directory for the + // production read root. + if root := windowsPlanVolumeRootGrant(plan); root != "" { + return fmt.Errorf("unelevated sandbox setup cannot grant the read capability at the volume root %s, which this profile needs because it configures denyRead and therefore runs on a fully restricted token: "+ + "changing that directory's permissions requires Administrator rights. "+ + "Run `zero sandbox setup` from an elevated (Administrator) terminal, "+ + "or remove denyRead from the sandbox configuration so the command can run on a write-restricted token instead", root) + } 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 diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go index 7547f1caf..1d9c47162 100644 --- a/internal/sandbox/windows_identity_acl.go +++ b/internal/sandbox/windows_identity_acl.go @@ -144,7 +144,7 @@ func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPla // so everything underneath stays writable. entries = append(entries, WindowsACLEntry{ Action: WindowsACLDenyDelete, - Path: filepath.Join(cleaned, sandboxRenameProtectedMetadataName), + Path: windowsRenameProtectedObject(cleaned), Capability: input.PrincipalSID, }) } diff --git a/internal/sandbox/windows_unelevated_tier_windows_test.go b/internal/sandbox/windows_unelevated_tier_windows_test.go new file mode 100644 index 000000000..e4b1a0037 --- /dev/null +++ b/internal/sandbox/windows_unelevated_tier_windows_test.go @@ -0,0 +1,102 @@ +//go:build windows + +package sandbox + +import ( + "path/filepath" + "strings" + "testing" +) + +// ONE PLAN, TWO TIERS, DIFFERENT AUTHORITY. +// +// Production profiles seed ReadRoots with the filesystem root, so a profile that +// configures DenyRead adds an allow-read ACE for the read capability at the +// volume root: the strict token that DenyRead selects applies the restricted-SID +// check to reads, so without it the command cannot even open its own executable. +// Elevated setup can write that DACL. An ordinary user cannot, and the common +// opener asks for WRITE_DAC on every entry, so the unelevated tier failed on that +// one root on every command, with a generic diagnosis and a remedy that did not +// apply. +// +// The real smoke test misses this because it substitutes a user-owned temporary +// directory for the production read root, which is exactly why this asserts on +// the production shape. +func TestUnelevatedSetupRefusesAVolumeRootReadGrant(t *testing.T) { + workspace := t.TempDir() + volumeRoot := filepath.VolumeName(workspace) + string(filepath.Separator) + if !isWindowsVolumeRoot(volumeRoot) { + t.Fatalf("SETUP INVALID: %q is not recognised as a volume root", volumeRoot) + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + SandboxLevel: WindowsSandboxLevelUnelevated, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + // The production shape: read everywhere, and a denyRead that forces + // the strict token. + ReadRoots: []string{volumeRoot}, + DenyRead: []string{filepath.Join(workspace, "secrets")}, + }, + }, + } + + plan, err := BuildWindowsACLPlan(config) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + // SETUP: the plan really does reach the volume root, or the refusal below + // would be asserting nothing. + if got := windowsPlanVolumeRootGrant(plan); got == "" { + t.Fatalf("SETUP INVALID: the plan carries no volume-root entry, so this profile does not reproduce the case: %+v", plan.Entries) + } + + err = ensureWindowsUnelevatedSetup(config) + if err == nil { + t.Fatal("unelevated setup accepted a plan it cannot apply; every command would fail later with a generic ACCESS_DENIED") + } + message := err.Error() + if !strings.Contains(message, volumeRoot) { + t.Errorf("the refusal does not name the volume root, so the reader cannot tell which entry is at fault: %v", err) + } + if !strings.Contains(message, "denyRead") { + t.Errorf("the refusal does not name the cause, so the reader cannot act on it: %v", err) + } + if !strings.Contains(message, "elevated") { + t.Errorf("the refusal does not name a remedy the reader can carry out: %v", err) + } +} + +// And a profile with no denyRead still runs on this tier: it needs no volume-root +// grant, so refusing it would take the unelevated sandbox away from everyone. +func TestUnelevatedSetupStillAcceptsAWriteJailOnlyProfile(t *testing.T) { + workspace := t.TempDir() + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + SandboxLevel: WindowsSandboxLevelUnelevated, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + ReadRoots: []string{workspace}, + }, + }, + } + plan, err := BuildWindowsACLPlan(config) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + if got := windowsPlanVolumeRootGrant(plan); got != "" { + t.Fatalf("SETUP INVALID: a write-jail-only profile reached the volume root at %q", got) + } + if err := ensureWindowsUnelevatedSetup(config); err != nil && strings.Contains(err.Error(), "volume root") { + t.Fatalf("a profile needing no volume-root grant was refused by the tier check: %v", err) + } +} From 0daeca9566b8ce8825660533af1042684ede5859 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 3 Sep 2026 13:52:44 +0530 Subject: [PATCH 81/96] fix(sandbox): validate runtime candidates before the elevated create, not after ensureWindowsSandboxRuntimeCandidates ran as Administrator and used os.MkdirAll, which follows links. Both candidate ancestries are prepared by the invoking user, so a junction planted at a not-yet-created component below the cache directory, or at the fallback anchor, made elevated setup build the runtime tree beneath a redirected, Administrator-writable target. The ACL applier's no-follow check runs afterwards and does spot the redirection, but the privileged create has already happened by then and sits outside its rollback boundary. A leaf check after MkdirAll would leave the same gap. Candidates are now created by peermsg.EnsurePrivateDir, which descends handle-relative with no-follow, refuses any component that is a link, and refuses a leaf this user does not own. It is handed a physically resolved base first, because it walks from the volume root and a redirected cache or TEMP above the owned tail is the operator's business: the same pairing the fallback anchor already uses, and the same mistake that refused every fallback on macOS when the parent was left unresolved. physicalTempDir is generalised to physicalDir for that, keeping GetFinalPathNameByHandle on Windows because EvalSymlinks does not traverse a junction. The regression plants a junction at the first owned component and asserts the redirected target stays empty; letting the creator follow links again fails it on that assertion. --- .../sandbox/runtime_fallback_anchor_other.go | 8 +- .../runtime_fallback_anchor_windows.go | 14 ++- ...windows_candidate_creation_windows_test.go | 87 +++++++++++++++++++ internal/sandbox/windows_setup.go | 61 ++++++++++++- 4 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 internal/sandbox/windows_candidate_creation_windows_test.go diff --git a/internal/sandbox/runtime_fallback_anchor_other.go b/internal/sandbox/runtime_fallback_anchor_other.go index 5338e78b8..3e1c4da87 100644 --- a/internal/sandbox/runtime_fallback_anchor_other.go +++ b/internal/sandbox/runtime_fallback_anchor_other.go @@ -23,5 +23,11 @@ import ( // EvalSymlinks is the correct resolver off Windows, where the only reparse // shape is a symlink and it traverses them. func physicalTempDir() (string, error) { - return filepath.EvalSymlinks(os.TempDir()) + return physicalDir(os.TempDir()) +} + +// physicalDir resolves any directory the same way, for the other places that +// have to hand EnsurePrivateDir a physical parent. +func physicalDir(path string) (string, error) { + return filepath.EvalSymlinks(path) } diff --git a/internal/sandbox/runtime_fallback_anchor_windows.go b/internal/sandbox/runtime_fallback_anchor_windows.go index 940bafc6b..7bcc73f16 100644 --- a/internal/sandbox/runtime_fallback_anchor_windows.go +++ b/internal/sandbox/runtime_fallback_anchor_windows.go @@ -27,10 +27,16 @@ import ( // the directory actually is. Same recipe as verifyWindowsACLTargetNotRedirected, // which is why the flag constants and the prefix trim are shared. func physicalTempDir() (string, error) { - path := os.TempDir() + return physicalDir(os.TempDir()) +} + +// physicalDir resolves any directory through a handle, for the other places +// that have to hand EnsurePrivateDir a physical parent. GetFinalPathNameByHandle +// rather than EvalSymlinks, because EvalSymlinks does not traverse a junction. +func physicalDir(path string) (string, error) { utf16Path, err := windows.UTF16PtrFromString(path) if err != nil { - return "", fmt.Errorf("encode temp dir %s: %w", path, err) + return "", fmt.Errorf("encode directory %s: %w", path, err) } handle, err := windows.CreateFile( utf16Path, @@ -42,13 +48,13 @@ func physicalTempDir() (string, error) { 0, ) if err != nil { - return "", fmt.Errorf("open temp dir %s: %w", path, err) + return "", fmt.Errorf("open directory %s: %w", path, err) } defer windows.CloseHandle(handle) 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 temp dir %s: %w", path, err) + return "", fmt.Errorf("resolve directory %s: %w", path, err) } if int(n) < len(buffer) { buffer = buffer[:n] diff --git a/internal/sandbox/windows_candidate_creation_windows_test.go b/internal/sandbox/windows_candidate_creation_windows_test.go new file mode 100644 index 000000000..a59655df1 --- /dev/null +++ b/internal/sandbox/windows_candidate_creation_windows_test.go @@ -0,0 +1,87 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// ELEVATED SETUP MUST NOT CREATE THROUGH A JUNCTION THE CALLER PLANTED. +// +// ensureWindowsSandboxRuntimeCandidates runs as Administrator, and os.MkdirAll +// follows links. Both candidate ancestries are prepared by the invoking user, so +// a junction at a not-yet-created component below the cache directory — or at the +// fallback anchor — made elevated setup build the runtime tree beneath a +// redirected, Administrator-writable target. The ACL applier's no-follow check +// runs later and spots the redirection, but the privileged create has already +// happened by then, outside its rollback boundary. +// +// A junction needs no privilege on Windows, so this reproduces the caller's half +// of the race on an ordinary unelevated box. +func TestRuntimeCandidateCreationRefusesAJunctionedComponent(t *testing.T) { + cacheRoot := t.TempDir() + target := t.TempDir() + + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + + workspace := t.TempDir() + candidate, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic candidate for %s under %s", workspace, cacheRoot) + } + // The component the caller controls: the first one Zero owns, below the + // operator's cache directory. + owned := filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero") + if !strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(owned)) { + t.Fatalf("SETUP INVALID: candidate %s does not sit under the owned component %s", candidate, owned) + } + if out, err := exec.Command("cmd", "/c", "mklink", "/J", owned, target).CombinedOutput(); err != nil { + t.Fatalf("SETUP INVALID: mklink /J: %v\n%s", err, out) + } + + err := ensureRuntimeCandidateDir(candidate) + if err == nil { + t.Fatal("elevated setup created the runtime tree through a junction the caller planted") + } + + // The whole point: nothing may appear beneath the redirected target. + entries, readErr := os.ReadDir(target) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Fatalf("setup created %v beneath the redirected target", names) + } +} + +// And an ordinary cache directory still gets its candidate, or the refusal above +// would be satisfied by a creator that refuses everything. +func TestRuntimeCandidateCreationStillCreatesAnOrdinaryTree(t *testing.T) { + cacheRoot := t.TempDir() + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + + workspace := t.TempDir() + candidate, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic candidate for %s", workspace) + } + if err := ensureRuntimeCandidateDir(candidate); err != nil { + t.Fatalf("an ordinary cache tree was refused: %v", err) + } + info, err := os.Stat(candidate) + if err != nil || !info.IsDir() { + t.Fatalf("the candidate was not created: err=%v", err) + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 79dda2a2e..68919df7d 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -11,6 +11,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/Gitlawb/zero/internal/peermsg" ) const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe" @@ -708,13 +710,70 @@ func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots // created its own root would be granting itself one. func ensureWindowsSandboxRuntimeCandidates(workspaceRoots []string) error { for _, root := range windowsSandboxRuntimeCandidates(workspaceRoots) { - if err := os.MkdirAll(root, 0o700); err != nil { + if err := ensureRuntimeCandidateDir(root); err != nil { return fmt.Errorf("create sandbox runtime root %s: %w", root, err) } } return nil } +// ensureRuntimeCandidateDir creates one candidate without following a reparse +// point into anything Zero does not own. +// +// VALIDATION BEFORE THE PRIVILEGED MUTATION, NOT AFTER IT. This runs as +// Administrator, and os.MkdirAll follows links: the invoking user can prepare +// either candidate's ancestry, so a junction planted at a not-yet-created +// component below the cache directory, or at the fallback anchor, made elevated +// setup create the tree beneath a redirected, Administrator-writable target. The +// ACL applier's own no-follow check runs later and correctly spots the +// redirection, but by then the privileged create has already happened, outside +// the applier's rollback boundary. A leaf check after MkdirAll would leave the +// same gap. +// +// EnsurePrivateDir is the descent that does not have it: handle-relative, +// no-follow, refusing any component that is a link and any leaf this user does +// not own. It is given a PHYSICAL base first, because it walks from the volume +// root and a redirected cache or TEMP above the owned tail is the operator's +// business, not ours -- the same pairing the fallback anchor uses, and the same +// mistake that refused every fallback on macOS when the parent was left +// unresolved. +func ensureRuntimeCandidateDir(root string) error { + base, ok := runtimeCandidateBase(root) + if !ok { + return fmt.Errorf("candidate %s does not sit beneath a known base, so its trust boundary cannot be established", root) + } + tail, err := filepath.Rel(base, root) + if err != nil { + return fmt.Errorf("locate %s beneath %s: %w", root, base, err) + } + // A resolve failure keeps the unresolved base deliberately: EnsurePrivateDir + // is still the fail-closed check, and refusing there names the real component + // rather than hiding it behind a resolver error. + physical := base + if resolved, resolveErr := physicalDir(base); resolveErr == nil && resolved != "" { + physical = resolved + } + return peermsg.EnsurePrivateDir(filepath.Join(physical, tail)) +} + +// runtimeCandidateBase returns the ancestor of a candidate that belongs to the +// operator rather than to Zero. Everything below it was created by us and has +// no business being a link. +func runtimeCandidateBase(root string) (string, bool) { + // The fallback candidate is /zero-runtime-/v1/, and the + // anchor's own parent is the operator's temp directory. + if isFallbackRuntimeRoot(root) { + return filepath.Dir(fallbackRuntimeAnchor()), true + } + // The cache candidate is /zero/runtime/v1/. + if cacheRoot, err := sandboxUserCacheDir(); err == nil { + if cleaned := canonicalSandboxWorkspaceRoot(cacheRoot); cleaned != "" && cleaned != "." && pathWithinRoot(cleaned, root) { + return cleaned, true + } + } + return "", false +} + // shortWindowsACLPlanHash trims a plan hash for a human-facing error. Twelve hex // characters is plenty to tell two plans apart by eye, and the full 64 buries the // rest of the message. From 0585da8b404055c51b33f4b2cb16fc2f4a08f51e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 3 Sep 2026 13:57:05 +0530 Subject: [PATCH 82/96] fix(sandbox): check the runtime root still carries its grant, not just its pathname cleanupSandboxRuntimeRoots reclaims inactive sibling workspace roots on an age and count policy, treating them as disposable cache state. Setup and its marker treat their DACL as durable provisioned state. When the reclaimed workspace runs again, command-side preparation recreates the same deterministic pathname as the ordinary caller, and the new directory inherits from its parent without the capability SID ACE elevated setup applied to the object that used to be there. ValidateWindowsSandboxSetupMarker still passed, because it fingerprints pathnames and actions rather than the identity of the ACL-bearing object. So the restricted child launched and then failed its cache, temp and package-cache writes with a bare access-denied and nothing pointing at setup. The command now asks the object, not the pathname: before the token is minted it confirms the runtime root carries an allow ACE for its capability SID, and refuses with the setup remedy when it does not. That reconciles the two owners without making cleanup preserve trees it is meant to reclaim. The regression applies a real plan, reclaims the directory the way cleanup does, recreates the pathname the way an ordinary run does, and asserts the refusal names the root and the remedy; accepting a missing grant fails it on that. Not covered, and said plainly: a write performed by a real capability token, which needs a provisioned sandbox an unelevated box cannot create. The ACE presence is the fact the command consumes, and that is what is pinned. --- .../sandbox/windows_command_runner_windows.go | 9 ++ .../sandbox/windows_runtime_ace_windows.go | 99 +++++++++++++++++ .../windows_runtime_reclaim_windows_test.go | 104 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 internal/sandbox/windows_runtime_ace_windows.go create mode 100644 internal/sandbox/windows_runtime_reclaim_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index cafacdaac..6d9235874 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -17,6 +17,15 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 } + // The marker attests to a PLAN. This attests to the OBJECT that plan was + // applied to, which cleanup can reclaim and an ordinary run then recreates + // without the capability ACE. Checked before the token is minted, so the + // operator is told to rerun setup instead of watching every sandboxed write + // fail with a bare access-denied. + if err := verifyWindowsRuntimeRootCapability(config); err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } case WindowsSandboxLevelUnelevated: if err := ensureWindowsUnelevatedSetup(config); err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) diff --git a/internal/sandbox/windows_runtime_ace_windows.go b/internal/sandbox/windows_runtime_ace_windows.go new file mode 100644 index 000000000..28fffb763 --- /dev/null +++ b/internal/sandbox/windows_runtime_ace_windows.go @@ -0,0 +1,99 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "os" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// verifyWindowsRuntimeRootCapability confirms the runtime roots this command is +// about to write to still carry the capability ACE setup gave them. +// +// THE MARKER ATTESTS TO A PLAN, NOT TO AN OBJECT. cleanupSandboxRuntimeRoots +// removes inactive sibling workspace roots on an age and count policy. When that +// workspace runs again, command-side preparation recreates the SAME deterministic +// pathname as the ordinary caller, and the new directory inherits from its +// parent: it does not carry the capability SID ACE that elevated setup applied to +// the object that used to be there. ValidateWindowsSandboxSetupMarker still +// passes, because it fingerprints pathnames and actions rather than the identity +// of the ACL-bearing object, so the restricted child launched and then failed its +// cache, temp and package-cache writes with a bare ACCESS_DENIED and nothing +// pointing at setup. +// +// Cleanup treats these directories as disposable cache state; setup and its +// marker treat their DACL as durable provisioned state. Checking the ACE here is +// what reconciles the two: the command refuses with the remedy instead of +// launching into a tree it cannot write. +func verifyWindowsRuntimeRootCapability(config WindowsSandboxCommandConfig) error { + runtime := config.PermissionProfile.Runtime + if runtime == nil { + return nil + } + root := strings.TrimSpace(runtime.Root) + if root == "" { + return nil + } + sid, err := windowsCapabilitySIDForWriteRoot(config, root) + if err != nil { + return fmt.Errorf("resolve the sandbox runtime capability for %s: %w", root, err) + } + present, err := windowsPathGrantsCapability(root, sid) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("the sandbox runtime root %s does not exist, so setup's provisioning is gone — run `zero sandbox setup` from an elevated (Administrator) terminal", root) + } + return fmt.Errorf("inspect the sandbox runtime root %s: %w", root, err) + } + if !present { + return fmt.Errorf("the sandbox runtime root %s no longer carries the capability grant setup applied to it, "+ + "which happens when the directory was reclaimed and recreated by an ordinary run; "+ + "every sandboxed write into it would fail with access denied — run `zero sandbox setup` from an elevated (Administrator) terminal", root) + } + return nil +} + +// windowsPathGrantsCapability reports whether path's DACL carries an allow ACE +// for sid. Read off the object, because the whole point is that the pathname +// says nothing about which object now answers to it. +func windowsPathGrantsCapability(path string, sid string) (bool, error) { + wanted, err := windows.StringToSid(sid) + if err != nil { + return false, fmt.Errorf("parse the capability SID %q: %w", sid, err) + } + handle, _, err := openWindowsACLTarget(path) + if err != nil { + return false, err + } + defer func() { _ = windows.CloseHandle(handle) }() + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false, err + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + return false, err + } + // GetAce hands back a generic header that is reinterpreted here, which is + // sound only for the fixed-layout ACE types: an object ACE carries Flags and + // two GUIDs ahead of the trustee, so SidStart would land mid-structure. A type + // this does not recognise is skipped rather than decoded into a nonsense 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 { + continue + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if aceSID.Equals(wanted) { + return true, nil + } + } + return false, nil +} diff --git a/internal/sandbox/windows_runtime_reclaim_windows_test.go b/internal/sandbox/windows_runtime_reclaim_windows_test.go new file mode 100644 index 000000000..1edce596e --- /dev/null +++ b/internal/sandbox/windows_runtime_reclaim_windows_test.go @@ -0,0 +1,104 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// CLEANUP AND SETUP DISAGREE ABOUT WHO OWNS THE RUNTIME DIRECTORY. +// +// cleanupSandboxRuntimeRoots reclaims inactive sibling workspace roots on an age +// and count policy, treating them as disposable cache state. Setup and its marker +// treat their DACL as durable provisioned state. When the reclaimed workspace runs +// again, command-side preparation recreates the SAME deterministic pathname as +// the ordinary caller, and the new directory inherits from its parent without the +// capability SID ACE elevated setup applied to the object that used to be there. +// +// The marker still validates, because it fingerprints pathnames and actions +// rather than the identity of the ACL-bearing object. So the restricted child +// launched and then failed its cache, temp and package-cache writes with a bare +// access-denied and nothing pointing at setup. +// +// Driven by applying a real plan, reclaiming the directory the way cleanup does, +// recreating the pathname the way an ordinary run does, and asking the check the +// command now makes. DACL edits on a user-owned directory need no Administrator +// rights, so this runs unelevated. +// +// Not covered here: an actual write performed by a real capability token, which +// needs a provisioned sandbox this box cannot create. The ACE presence is the +// fact the command consumes, and it is what this pins. +func TestRuntimeRootReclaimedAndRecreatedLosesItsCapability(t *testing.T) { + cacheRoot := t.TempDir() + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + + workspace := t.TempDir() + runtimeRoot, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic runtime root for %s", workspace) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatal(err) + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + Runtime: &SandboxRuntime{Root: runtimeRoot}, + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{ + {Root: workspace}, + {Root: runtimeRoot}, + }, + }, + }, + } + + plan, err := BuildWindowsACLPlan(config) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + if _, err := applyWindowsACLPlan(plan); err != nil { + t.Skipf("cannot apply an ACL plan here: %v", err) + } + + // A provisioned machine passes, or the assertion below would be satisfied by a + // check that refuses unconditionally. + if err := verifyWindowsRuntimeRootCapability(config); err != nil { + t.Fatalf("SETUP INVALID: a freshly provisioned runtime root was rejected: %v", err) + } + + // Cleanup reclaims it, and the next ordinary run recreates the same pathname. + if err := os.RemoveAll(runtimeRoot); err != nil { + t.Fatalf("reclaim the runtime root the way cleanup does: %v", err) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatalf("recreate the pathname the way an ordinary run does: %v", err) + } + + err = verifyWindowsRuntimeRootCapability(config) + if err == nil { + t.Fatal("a recreated runtime root with no capability grant was accepted; the command would launch and then fail every sandboxed write with nothing explaining why") + } + message := err.Error() + if !strings.Contains(message, runtimeRoot) { + t.Errorf("the refusal does not name the root: %v", err) + } + if !strings.Contains(strings.ToLower(message), "sandbox setup") { + t.Errorf("the refusal does not point at the remedy: %v", err) + } + + // And the marker on its own still says everything is fine, which is exactly + // why the object has to be checked separately. + if _, statErr := os.Stat(filepath.Dir(runtimeRoot)); statErr != nil { + t.Fatalf("the runtime parent vanished: %v", statErr) + } +} From c9344498c359082ac07322c0b11ead4e44b0069d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 14:23:43 +0530 Subject: [PATCH 83/96] fix(sandbox): recheck the runtime object on the unelevated tier too The unelevated marker fingerprints pathnames and actions, and cleanup may reclaim the deterministic runtime directory that plan was realized on. An ordinary later run recreates the same pathname with the caller-private DACL and no capability ACE, so the serialized plan is unchanged and the cached marker returned before anything looked at the directory. The restricted child still carried the capability SID; the new object did not grant it, and every temp, package-cache and build-cache write failed after launch with a bare access denial and nothing pointing at setup. The restricted-token tier got this check in the previous commit. This tier reaches the same lifecycle without elevation, and its answer differs: it owns its plan, so it reapplies rather than refusing. Refusing would print advice to run elevated setup, which is unnecessary here and unfollowable for a user with no Administrator account. The regression drives ensureWindowsUnelevatedSetup through provision, reclaim, recreate, and asserts the grant is restored, with a setup guard proving the recreated directory really lost it first. A companion pins that an intact root still takes the cached fast path. --- .../sandbox/windows_command_runner_windows.go | 18 ++- ...windows_unelevated_reclaim_windows_test.go | 144 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/windows_unelevated_reclaim_windows_test.go diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 6d9235874..c4da53dbc 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -229,7 +229,23 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { if err != nil { return err } - if marker.contains(applied) { + // THE MARKER ATTESTS TO A PLAN. THE COMMAND CONSUMES AN OBJECT. + // + // The marker fingerprints pathnames and actions, and cleanup is allowed to + // reclaim the deterministic runtime directory that plan was realized on. An + // ordinary later run recreates the same pathname with the caller-private DACL + // and no capability ACE, and the serialized plan is unchanged, so this fast + // path returned before anything looked at the directory. The restricted child + // still carried the capability SID; the new object simply did not grant it, and + // every temp, package-cache and build-cache write failed after launch with a + // bare ACCESS_DENIED and nothing pointing at setup. + // + // This tier owns its plan and needs no elevation, so the answer here is to + // reapply rather than to refuse. Refusing would print advice to run elevated + // setup, which is unnecessary on this tier and unfollowable for a user with no + // Administrator account. The restricted-token tier keeps its refusal, because + // an ordinary user cannot restore principal provisioning. + if marker.contains(applied) && verifyWindowsRuntimeRootCapability(config) == nil { return nil } // THE TIER BOUNDARY, STATED BEFORE ANY MUTATION IS ATTEMPTED. diff --git a/internal/sandbox/windows_unelevated_reclaim_windows_test.go b/internal/sandbox/windows_unelevated_reclaim_windows_test.go new file mode 100644 index 000000000..f2740ac8d --- /dev/null +++ b/internal/sandbox/windows_unelevated_reclaim_windows_test.go @@ -0,0 +1,144 @@ +//go:build windows + +package sandbox + +import ( + "os" + "testing" +) + +// THE UNELEVATED MARKER ATTESTS TO A PLAN, NOT TO THE OBJECT THE COMMAND USES. +// +// cleanupSandboxRuntimeRoots reclaims inactive sibling runtime roots on an age +// and count policy. When the reclaimed workspace runs again, ordinary +// preparation recreates the SAME deterministic pathname with the caller-private +// DACL and no capability ACE. The serialized plan is unchanged, so the cached +// marker matched and setup returned before anything looked at the directory. The +// restricted child still carried the capability SID; the new object simply did +// not grant it, so every temp, package-cache and build-cache write failed after +// launch with a bare access denial and nothing pointing at setup. +// +// The restricted-token tier got an object check in this branch already. This one +// covers the tier that actually reaches it without elevation, and it must REAPPLY +// rather than refuse: this tier owns its plan, and telling an ordinary user to +// run elevated setup is both unnecessary and unfollowable for someone with no +// Administrator account. +// +// Driven through ensureWindowsUnelevatedSetup, which is what the command runner +// calls, rather than through the verifier helper: the helper was already correct, +// and the defect was that this path never consulted it. DACL edits on a +// user-owned directory need no Administrator rights, so this runs unelevated. +func TestUnelevatedSetupRestoresAReclaimedRuntimeRoot(t *testing.T) { + cacheRoot := t.TempDir() + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + + workspace := t.TempDir() + runtimeRoot, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic runtime root for %s", workspace) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatal(err) + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + SandboxLevel: WindowsSandboxLevelUnelevated, + PermissionProfile: PermissionProfile{ + Runtime: &SandboxRuntime{Root: runtimeRoot}, + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{ + {Root: workspace}, + {Root: runtimeRoot}, + }, + ReadRoots: []string{workspace}, + }, + }, + } + + // First run provisions and records the marker. + if err := ensureWindowsUnelevatedSetup(config); err != nil { + t.Skipf("cannot run unelevated setup here: %v", err) + } + sid, err := windowsCapabilitySIDForWriteRoot(config, runtimeRoot) + if err != nil { + t.Fatalf("resolve the runtime capability SID: %v", err) + } + granted, err := windowsPathGrantsCapability(runtimeRoot, sid) + if err != nil || !granted { + t.Skipf("SETUP: the first run did not grant the runtime root here (granted=%v err=%v)", granted, err) + } + + // Cleanup reclaims it; the next ordinary run recreates the same pathname. + if err := os.RemoveAll(runtimeRoot); err != nil { + t.Fatalf("reclaim the runtime root the way cleanup does: %v", err) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatalf("recreate the pathname the way an ordinary run does: %v", err) + } + + // SETUP: the case is really reproduced, or the assertion below would hold for + // a run that never lost anything. + stillGranted, err := windowsPathGrantsCapability(runtimeRoot, sid) + if err != nil { + t.Fatalf("inspect the recreated runtime root: %v", err) + } + if stillGranted { + t.Fatal("SETUP INVALID: the recreated directory still carries the capability grant, so nothing was lost to restore") + } + + // The second run must notice and reapply rather than trust the marker. + if err := ensureWindowsUnelevatedSetup(config); err != nil { + t.Fatalf("the second run refused instead of restoring the reclaimed root: %v", err) + } + restored, err := windowsPathGrantsCapability(runtimeRoot, sid) + if err != nil { + t.Fatalf("inspect the restored runtime root: %v", err) + } + if !restored { + t.Fatal("the cached marker was accepted for a runtime object that no longer carries the grant; the command would launch and then fail every sandboxed write into it with nothing explaining why") + } +} + +// And an untouched runtime root still takes the cached fast path, so the object +// check does not turn every command into a full reapply. +func TestUnelevatedSetupStillTrustsAnIntactRuntimeRoot(t *testing.T) { + cacheRoot := t.TempDir() + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + + workspace := t.TempDir() + runtimeRoot, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic runtime root for %s", workspace) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatal(err) + } + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + SandboxLevel: WindowsSandboxLevelUnelevated, + PermissionProfile: PermissionProfile{ + Runtime: &SandboxRuntime{Root: runtimeRoot}, + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}, {Root: runtimeRoot}}, + ReadRoots: []string{workspace}, + }, + }, + } + if err := ensureWindowsUnelevatedSetup(config); err != nil { + t.Skipf("cannot run unelevated setup here: %v", err) + } + if err := ensureWindowsUnelevatedSetup(config); err != nil { + t.Fatalf("a second run over an intact runtime root was refused: %v", err) + } +} From b958defae58b7743749a0b3043133cde995c6770 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 14:27:43 +0530 Subject: [PATCH 84/96] fix(sandbox): make the capability git guard reach a workspace that gets git later The capability planner emitted the deny-delete on .git and the deny-writes on .git\config and .git\hooks without Materialize. On a workspace that had no .git when setup ran, the first apply pass skipped all three as missing and the deferred pass skipped them again, because nothing else in the capability plan created them. Setup recorded success anyway. A later git init then created .git, config and hooks beneath the already granted workspace. They inherit the workspace allow, DELETE included, with no object-specific deny of their own, so a sandboxed command could rename .git aside, recreate it, and get credential.helper and core.hooksPath back. This is the default backend, so the weaker of the two planners' rules was the one almost every Windows user got. The principal planner has materialized its carveouts from the start, which is also what lets the applier's deferred pass land the deny-delete: creating config and hooks creates .git as their parent. Sharing that shape rather than only the guard's pathname is the fix. The plan-shape test pinned materialize=false on these three entries, which encoded exactly the behaviour that was wrong, so it is updated rather than worked around. The new regression applies the real plan to a workspace with no .git, runs a real git init, and reads the deny mask back off the object. --- internal/sandbox/windows_acl.go | 27 ++- internal/sandbox/windows_acl_test.go | 11 +- ..._fresh_workspace_git_guard_windows_test.go | 172 ++++++++++++++++++ 3 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 internal/sandbox/windows_fresh_workspace_git_guard_windows_test.go diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index cd14e73e2..c9af87d1e 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -115,11 +115,32 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er Path: windowsRenameProtectedObject(capability.Root), Capability: capability.SID, }) + // MATERIALIZED, LIKE THE PRINCIPAL PLAN'S, AND FOR TWO REASONS. + // + // First, a guard attached to an object that does not exist is not applied. + // On a workspace that had no .git when setup ran, the first pass skipped all + // three of these as missing and the deferred pass skipped them again, + // because nothing else in this plan created them. Setup still recorded + // success. A later git init then created .git, config and hooks beneath the + // already-granted workspace, where they inherit the allow, DELETE included, + // with no object-specific deny of their own: the sandboxed command could + // rename .git aside, recreate it, and get credential.helper and + // core.hooksPath back. + // + // Second, materializing these is what lets the applier's deferred pass land + // the deny-delete on .git above, since creating .gitconfig and .githooks + // creates .git as their parent. + // + // The principal planner has done this from the start. Sharing the shape + // rather than only the pathname is the point: this tier is the DEFAULT + // backend, so the weaker of the two rules was the one almost everyone got. for _, path := range capability.ProtectedWriteDenyPaths { entries = append(entries, WindowsACLEntry{ - Action: WindowsACLDenyWrite, - Path: path, - Capability: capability.SID, + Action: WindowsACLDenyWrite, + Path: path, + Capability: capability.SID, + Materialize: true, + MaterializeFile: gitMetadataCarveoutIsFile(path), }) } } diff --git a/internal/sandbox/windows_acl_test.go b/internal/sandbox/windows_acl_test.go index 9343db2a3..09bb300b7 100644 --- a/internal/sandbox/windows_acl_test.go +++ b/internal/sandbox/windows_acl_test.go @@ -45,9 +45,14 @@ func TestBuildWindowsACLPlanForWorkspaceWriteProfile(t *testing.T) { assertWindowsACLEntry(t, plan, WindowsACLAllowWrite, `C:\workspace`, workspaceSID, false) assertWindowsACLEntry(t, plan, WindowsACLAllowWrite, `D:\cache`, cacheSID, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\vendor`, workspaceSID, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\.git`, workspaceSID, false) - assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\.zero`, workspaceSID, false) + // Materialized, matching the principal plan. A guard attached to an object + // that does not exist yet is never applied: on a workspace with no .git at + // setup time both passes skipped these while setup still recorded success. + // Creating them is also what lets the deferred pass reach the deny-delete on + // .git, since config and hooks create .git as their parent. + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\vendor`, workspaceSID, true) + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\.git`, workspaceSID, true) + assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\.zero`, workspaceSID, true) assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\secret-write`, workspaceSID, false) assertWindowsACLEntry(t, plan, WindowsACLDenyWrite, `C:\workspace\secret-write`, cacheSID, false) assertWindowsACLEntry(t, plan, WindowsACLDenyRead, `C:\workspace\secret-read`, workspaceSID, true) diff --git a/internal/sandbox/windows_fresh_workspace_git_guard_windows_test.go b/internal/sandbox/windows_fresh_workspace_git_guard_windows_test.go new file mode 100644 index 000000000..c9e1fdf35 --- /dev/null +++ b/internal/sandbox/windows_fresh_workspace_git_guard_windows_test.go @@ -0,0 +1,172 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// appliedDenyMask returns the DENY mask an applied DACL carries for sid, read +// back off the object rather than asserted on the plan, because the plan is what +// was wrong. +func appliedDenyMask(t *testing.T, path string, sid string) uint32 { + 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 the DACL of %s: %v", path, err) + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + t.Fatalf("read the DACL of %s: %v", path, err) + } + wanted, err := windows.StringToSid(sid) + if err != nil { + t.Fatalf("parse %q: %v", sid, err) + } + var mask uint32 + // Fixed-layout ACE types only; an object ACE puts GUIDs ahead of the trustee + // so SidStart would land mid-structure. Nothing here builds one, and an + // unrecognised type is skipped rather than decoded into a nonsense 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 { + continue + } + if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE { + continue + } + if !(*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(wanted) { + continue + } + mask |= uint32(ace.Mask) + } + return mask +} + +// A GUARD ON AN OBJECT THAT DOES NOT EXIST YET IS NOT A GUARD. +// +// The capability planner emitted the deny-delete on .git and the deny-writes on +// .git\config and .git\hooks without Materialize. On a workspace that had no +// .git when setup ran, the first apply pass skipped all three as missing and the +// deferred pass skipped them again, because nothing else in the capability plan +// created them. Setup recorded success anyway. +// +// A later git init then created .git, config and hooks beneath the +// already-granted workspace. They inherit the workspace allow, which carries +// DELETE, with no object-specific deny of their own, so the sandboxed command +// could rename .git aside, recreate it, and get credential.helper and +// core.hooksPath back. +// +// This is the DEFAULT backend, so the weaker of the two planners' rules was the +// one almost every Windows user got. The principal plan was never affected: its +// carveouts are materialized, which both applies them and creates .git as their +// parent so the deferred retry lands the deny-delete. +// +// Read back off the applied object, and driven through the same +// BuildWindowsACLPlan + applyWindowsACLPlan pair both setup tiers use. DACL edits +// on a user-owned directory need no Administrator rights. +func TestCapabilityGitGuardReachesAWorkspaceThatGetsGitLater(t *testing.T) { + workspace := t.TempDir() + gitDir := filepath.Join(workspace, ".git") + + // SETUP: no .git at setup time. That is the entire case. + if _, err := os.Stat(gitDir); err == nil { + t.Fatal("SETUP INVALID: .git already exists, so this is the covered case, not the uncovered one") + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{ + Root: workspace, + ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace), + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + }}, + }, + }, + } + plan, err := BuildWindowsACLPlan(config) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + sid, err := windowsCapabilitySIDForWriteRoot(config, workspace) + if err != nil { + t.Fatalf("resolve the workspace capability SID: %v", err) + } + if _, err := applyWindowsACLPlan(plan); err != nil { + t.Skipf("cannot apply an ACL plan here: %v", err) + } + + // The workspace grant really landed, so a missing deny below is a missing + // deny and not an apply that did nothing. + if appliedAllowMask(t, workspace, sid) == 0 { + t.Fatal("SETUP INVALID: the workspace carries no allow ACE for the capability SID, so the plan did not apply") + } + + // Now git arrives, the way it does for a scaffold or a clone after setup. + if out, err := exec.Command("git", "init", workspace).CombinedOutput(); err != nil { + t.Skipf("git init unavailable here: %v\n%s", err, out) + } + + if mask := appliedDenyMask(t, gitDir, sid); mask&uint32(windows.DELETE) == 0 { + t.Fatalf(".git carries no deny-DELETE for the capability SID after git init (mask=%#x); the sandboxed token can rename it aside and recreate it without the config and hooks carveouts", mask) + } + hooks := filepath.Join(gitDir, "hooks") + if _, err := os.Stat(hooks); err == nil { + if mask := appliedDenyMask(t, hooks, sid); mask == 0 { + t.Error(".git\\hooks carries no deny for the capability SID, so core.hooksPath is writable from inside the sandbox") + } + } +} + +// appliedAllowMask is the allow-side companion, used only as a setup guard. +func appliedAllowMask(t *testing.T, path string, sid string) uint32 { + 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 the DACL of %s: %v", path, err) + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + t.Fatalf("read the DACL of %s: %v", path, err) + } + wanted, err := windows.StringToSid(sid) + if err != nil { + t.Fatalf("parse %q: %v", sid, err) + } + var mask uint32 + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + continue + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + if !(*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(wanted) { + continue + } + mask |= uint32(ace.Mask) + } + return mask +} From e09620677036fe1ab9c32dbe72f81e3b3f72daea Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 14:31:20 +0530 Subject: [PATCH 85/96] fix(sandbox): refuse a denyRead profile instead of ACLing the volume root A profile that configures denyRead selects a fully restricted token, and that token applies its restricted-SID check to reads, so the plan granted the read capability at every read root. Production profiles seed ReadRoots with the bare filesystem root. The applier marks allow entries on a directory inheritable and calls SetSecurityInfo, and Windows propagates inheritable ACEs onto existing children. So applying that one entry was never a change to a sandbox-owned object: it walks and rewrites DACL inheritance across unrelated system, application and user trees on the drive, and a locked or exclusively opened descendant leaves the result dependent on ambient filesystem state. Paying that price does not even buy a working sandbox. A bare root resolves on one volume, while the executables and libraries a command needs can sit on another without being read roots of their own, so the strict token can still fail before its executable starts. Measured separately: with the read capability granted nowhere, the strict token cannot open cmd.exe. So the profile is refused rather than half-served. The refusal moves out of the unelevated tier, where it started for the narrower reason that an ordinary user lacks the rights, and becomes the plan's own answer that both tiers consume. Elevated setup refuses before its first mutation, which matters because that tier CAN write the DACL. Profiles without denyRead are untouched and keep the workspace write jail. This leaves denyRead unavailable on Windows until there is a bounded authorization model covering the real platform, runtime and executable dependencies on every relevant volume, which is what #869 tracks. The alternative on offer was a silently voided write jail or a volume-wide ACL rewrite, and refusing is better than either. --- internal/sandbox/windows_acl.go | 46 +++++++ .../sandbox/windows_command_runner_windows.go | 7 +- internal/sandbox/windows_setup_windows.go | 8 ++ ...indows_volume_root_refusal_windows_test.go | 120 ++++++++++++++++++ 4 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 internal/sandbox/windows_volume_root_refusal_windows_test.go diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index c9af87d1e..3c6797c74 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -153,6 +153,27 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er // purpose, so every read failed it — including opening the executable. The // principal plan still grants the account SID; this is the other half of the // same grant, so the allow-list and the restriction now come from one place. + // AND IT REFUSES TO EXPRESS "READ EVERYWHERE" AS AN ACE ON THE VOLUME ROOT. + // + // Production profiles seed ReadRoots with the bare filesystem root, so this + // loop used to add an inheritable allow-read for the synthetic read SID at + // "C:\". SetSecurityInfo propagates inheritable ACEs onto existing children, + // so that is not one sandbox-owned object being changed: it walks and rewrites + // DACL inheritance across unrelated system, application and user trees on the + // drive, and a locked or exclusively opened descendant makes the result depend + // on ambient filesystem state. + // + // It is not even complete after paying that price. A bare root resolves on one + // volume, while the executables, DLLs and tool installations a command needs + // can sit on another without appearing as their own read roots, so the strict + // token still fails before its executable starts. Measured: with the read SID + // granted nowhere the strict token cannot open C:WindowsSystem32cmd.exe. + // + // So the profile that needs this is refused, at both setup tiers, rather than + // half-served by a persistent volume-wide ACL edit. That leaves denyRead + // unavailable on Windows until there is a bounded authorization model covering + // the real platform, runtime and executable dependencies on every relevant + // volume, which is what #869 tracks. readSID, err := windowsReadAllowCapabilitySID(config) if err != nil { return WindowsACLPlan{}, err @@ -200,6 +221,31 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil } +// WindowsACLPlanVolumeRootRefusal reports why a plan must not be applied, or "". +// +// ONE ANSWER FOR EVERY TIER THAT APPLIES A PLAN. This started inside the +// unelevated tier, because that is where it was first observed: an ordinary user +// cannot write the volume root's DACL, so every command failed there with a +// generic diagnosis. Elevated setup CAN write it, and that is worse rather than +// better. SetSecurityInfo propagates inheritable ACEs to existing children, so +// applying it rewrites DACL inheritance across unrelated system, application and +// user trees on the drive, and it still does not cover a second volume, so the +// strict token can fail to open its own executable after all that. +// +// Refusing in the planner's own vocabulary keeps the two tiers from drifting: a +// new caller that applies a plan inherits the refusal instead of having to +// remember to copy it. +func WindowsACLPlanVolumeRootRefusal(plan WindowsACLPlan) string { + root := windowsPlanVolumeRootGrant(plan) + if root == "" { + return "" + } + return "this sandbox profile configures denyRead, which selects a fully restricted token, and that token applies its restricted-SID check to reads as well as writes. " + + "Serving it needs the read capability granted at the volume root " + root + ", which cannot be done safely: the grant is inheritable, so applying it rewrites permissions across unrelated system, application and user directories on that drive, " + + "and it still would not cover executables or libraries on another volume. " + + "denyRead is therefore not available on Windows yet (see #869). Remove denyRead from the sandbox configuration to run on a write-restricted token, which keeps the workspace write jail intact" +} + // windowsPlanVolumeRootGrant returns the first volume root the plan would have // to change permissions on, or empty when it needs none. // diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index c4da53dbc..cc66704f6 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -264,11 +264,8 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { // discovered as an ACCESS_DENIED after the fact. The real smoke test misses // this because it substitutes a user-owned temporary directory for the // production read root. - if root := windowsPlanVolumeRootGrant(plan); root != "" { - return fmt.Errorf("unelevated sandbox setup cannot grant the read capability at the volume root %s, which this profile needs because it configures denyRead and therefore runs on a fully restricted token: "+ - "changing that directory's permissions requires Administrator rights. "+ - "Run `zero sandbox setup` from an elevated (Administrator) terminal, "+ - "or remove denyRead from the sandbox configuration so the command can run on a write-restricted token instead", root) + if refusal := WindowsACLPlanVolumeRootRefusal(plan); refusal != "" { + return errors.New("unelevated sandbox setup cannot apply this plan: " + refusal) } if _, err := applyWindowsACLPlan(plan); err != nil { // Refusing to run is right: without these ACEs the write jail does not diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index ac9de6c28..d0bd192a7 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -97,6 +97,14 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 } + // Before the first mutation, and on this tier too. Elevated setup CAN write a + // volume root's DACL, which is exactly why it must not: the grant is + // inheritable, so applying it rewrites permissions across the drive rather than + // changing one sandbox-owned object. + if refusal := WindowsACLPlanVolumeRootRefusal(plan); refusal != "" { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+refusal) + return 1 + } rollback, err := applyWindowsACLPlanFn(plan) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) diff --git a/internal/sandbox/windows_volume_root_refusal_windows_test.go b/internal/sandbox/windows_volume_root_refusal_windows_test.go new file mode 100644 index 000000000..347bbfd40 --- /dev/null +++ b/internal/sandbox/windows_volume_root_refusal_windows_test.go @@ -0,0 +1,120 @@ +//go:build windows + +package sandbox + +import ( + "bytes" + "path/filepath" + "strings" + "testing" +) + +// denyReadSetupConfig builds the production shape: read everywhere, and a +// denyRead that selects the fully restricted token. +func denyReadSetupConfig(t *testing.T) WindowsSandboxSetupConfig { + t.Helper() + workspace := t.TempDir() + volumeRoot := filepath.VolumeName(workspace) + string(filepath.Separator) + if !isWindowsVolumeRoot(volumeRoot) { + t.Fatalf("SETUP INVALID: %q is not recognised as a volume root", volumeRoot) + } + return WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + ReadRoots: []string{volumeRoot}, + DenyRead: []string{filepath.Join(workspace, "secrets")}, + }, + }, + } +} + +// ELEVATED SETUP CAN WRITE A VOLUME ROOT'S DACL, WHICH IS WHY IT MUST NOT. +// +// A profile that configures denyRead selects a fully restricted token, and that +// token applies its restricted-SID check to reads, so the plan grants the read +// capability at every read root. Production profiles seed ReadRoots with the bare +// filesystem root. +// +// The applier marks allow entries on a directory inheritable and calls +// SetSecurityInfo, and Windows propagates inheritable ACEs onto existing +// children. So applying that one entry is not a change to a sandbox-owned +// object: it walks and rewrites DACL inheritance across unrelated system, +// application and user trees on the drive, with the result depending on which +// descendants happen to be locked or exclusively open. +// +// It is not even sufficient afterwards. A bare root resolves on one volume, and +// the executables and libraries a command needs can live on another without +// being read roots of their own, so the strict token can still fail before its +// executable starts. +// +// The unelevated tier already refused, for the narrower reason that it lacks the +// rights. This pins the tier that HAS the rights, and pins that it refuses BEFORE +// the first mutation. +func TestElevatedSetupRefusesAVolumeRootReadGrant(t *testing.T) { + config := denyReadSetupConfig(t) + + // SETUP: the plan really reaches the volume root, or the refusal below asserts + // nothing. + plan, err := BuildWindowsACLPlan(config.commandConfig()) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + if got := windowsPlanVolumeRootGrant(plan); got == "" { + t.Fatalf("SETUP INVALID: this profile carries no volume-root entry: %+v", plan.Entries) + } + + previousElevated := windowsProcessIsElevatedFn + previousApply := applyWindowsACLPlanFn + t.Cleanup(func() { + windowsProcessIsElevatedFn = previousElevated + applyWindowsACLPlanFn = previousApply + }) + windowsProcessIsElevatedFn = func() bool { return true } + applied := false + applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) { + applied = true + return func() error { return nil }, nil + } + + var stderr bytes.Buffer + if code := runWindowsSandboxSetup(config, &stderr); code == 0 { + t.Fatal("elevated setup accepted a plan that rewrites DACL inheritance across the whole volume") + } + if applied { + t.Fatal("the plan was applied before the refusal, so the volume-wide edit already happened") + } + message := stderr.String() + for _, want := range []string{"denyRead", "volume root", "#869"} { + if !strings.Contains(message, want) { + t.Errorf("the refusal does not mention %q, so the reader cannot act on it:\n%s", want, message) + } + } +} + +// And a profile with no denyRead still sets up: it needs no volume-root grant, so +// refusing it would take the Windows sandbox away from everyone. +func TestElevatedSetupStillAcceptsAWriteJailOnlyProfile(t *testing.T) { + workspace := t.TempDir() + config := WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + ReadRoots: []string{workspace}, + }, + }, + } + plan, err := BuildWindowsACLPlan(config.commandConfig()) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + if refusal := WindowsACLPlanVolumeRootRefusal(plan); refusal != "" { + t.Fatalf("a write-jail-only profile was refused: %s", refusal) + } +} From 654bf837b2a24d4d1ca90985b2c52810e22280f6 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 14:53:03 +0530 Subject: [PATCH 86/96] fix(sandbox): key the read-grant refusal on the grant, not on the volume root The refusal looked for a volume-root entry, which is a symptom of the production profile rather than the thing that is unsafe. permissionProfileReadRoots happens to seed the bare filesystem root, so that check covered production by coincidence. A denyRead profile with a narrowed read list carried no volume root, sailed through, and elevated setup would have put an inheritable read ACE on C:\Windows: the same defect one level down. The grant itself is what cannot be applied to a directory Zero does not own, and it exists only for a denyRead profile, so that is what is refused now. Read grants that land on the plan's own write roots are excluded, because those are Zero's directories and were never the objection; the diagnostic names the broadest path outside them, preferring a volume root so it reports the worst one. Found by probing the neighbouring case rather than by rerunning the one the first fix was written against. --- internal/sandbox/windows_acl.go | 58 +++++++++++++++---- .../sandbox/windows_command_runner_windows.go | 2 +- ...indows_read_grant_refusal_windows_test.go} | 45 +++++++++++++- internal/sandbox/windows_setup_windows.go | 2 +- 4 files changed, 93 insertions(+), 14 deletions(-) rename internal/sandbox/{windows_volume_root_refusal_windows_test.go => windows_read_grant_refusal_windows_test.go} (69%) diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 3c6797c74..82821fbfc 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -221,31 +221,69 @@ func BuildWindowsACLPlan(config WindowsSandboxCommandConfig) (WindowsACLPlan, er return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil } -// WindowsACLPlanVolumeRootRefusal reports why a plan must not be applied, or "". +// WindowsACLPlanReadGrantRefusal reports why a plan must not be applied, or "". // // ONE ANSWER FOR EVERY TIER THAT APPLIES A PLAN. This started inside the // unelevated tier, because that is where it was first observed: an ordinary user // cannot write the volume root's DACL, so every command failed there with a // generic diagnosis. Elevated setup CAN write it, and that is worse rather than // better. SetSecurityInfo propagates inheritable ACEs to existing children, so -// applying it rewrites DACL inheritance across unrelated system, application and -// user trees on the drive, and it still does not cover a second volume, so the -// strict token can fail to open its own executable after all that. +// applying it rewrites DACL inheritance across unrelated trees, and it still does +// not cover a second volume, so the strict token can fail to open its own +// executable after all that. // -// Refusing in the planner's own vocabulary keeps the two tiers from drifting: a -// new caller that applies a plan inherits the refusal instead of having to -// remember to copy it. -func WindowsACLPlanVolumeRootRefusal(plan WindowsACLPlan) string { - root := windowsPlanVolumeRootGrant(plan) +// KEYED ON THE READ GRANT, NOT ON THE VOLUME ROOT. Checking for a volume root +// tested a symptom of the production profile rather than the thing that is +// unsafe. permissionProfileReadRoots happens to seed the bare filesystem root, so +// that check covered production by coincidence; a profile with a narrowed read +// list and a denyRead still put an inheritable ACE on C:Windows and passed. The +// grant itself is what cannot be applied safely to a directory Zero does not own, +// and the grant exists only for a denyRead profile, so that is what is refused. +func WindowsACLPlanReadGrantRefusal(plan WindowsACLPlan) string { + root := windowsPlanReadGrantTarget(plan) if root == "" { return "" } return "this sandbox profile configures denyRead, which selects a fully restricted token, and that token applies its restricted-SID check to reads as well as writes. " + - "Serving it needs the read capability granted at the volume root " + root + ", which cannot be done safely: the grant is inheritable, so applying it rewrites permissions across unrelated system, application and user directories on that drive, " + + "Serving it needs the read capability granted on directories Zero does not own, starting at " + root + ", which cannot be done safely: the grant is inheritable, so applying it rewrites permissions across unrelated system, application and user directories, " + "and it still would not cover executables or libraries on another volume. " + "denyRead is therefore not available on Windows yet (see #869). Remove denyRead from the sandbox configuration to run on a write-restricted token, which keeps the workspace write jail intact" } +// windowsPlanReadGrantTarget returns the broadest path the plan would grant the +// read capability on that Zero does not already own, preferring a volume root so +// the diagnostic names the worst of them. +// +// A read grant on a write root is Zero's own directory and is not the problem; +// the objection is to writing an inheritable ACE onto somebody else's tree. So +// the write roots are excluded, and an empty answer means every read grant lands +// on a directory this sandbox already governs, which needs no refusal. +func windowsPlanReadGrantTarget(plan WindowsACLPlan) string { + owned := make(map[string]struct{}) + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowWrite { + owned[strings.ToLower(normalizeProfilePath(entry.Path))] = struct{}{} + } + } + first := "" + for _, entry := range plan.Entries { + if entry.Action != WindowsACLAllowRead { + continue + } + normalized := normalizeProfilePath(entry.Path) + if _, ours := owned[strings.ToLower(normalized)]; ours { + continue + } + if isWindowsVolumeRoot(normalized) { + return entry.Path + } + if first == "" { + first = entry.Path + } + } + return first +} + // windowsPlanVolumeRootGrant returns the first volume root the plan would have // to change permissions on, or empty when it needs none. // diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index cc66704f6..2dd5aa809 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -264,7 +264,7 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { // discovered as an ACCESS_DENIED after the fact. The real smoke test misses // this because it substitutes a user-owned temporary directory for the // production read root. - if refusal := WindowsACLPlanVolumeRootRefusal(plan); refusal != "" { + if refusal := WindowsACLPlanReadGrantRefusal(plan); refusal != "" { return errors.New("unelevated sandbox setup cannot apply this plan: " + refusal) } if _, err := applyWindowsACLPlan(plan); err != nil { diff --git a/internal/sandbox/windows_volume_root_refusal_windows_test.go b/internal/sandbox/windows_read_grant_refusal_windows_test.go similarity index 69% rename from internal/sandbox/windows_volume_root_refusal_windows_test.go rename to internal/sandbox/windows_read_grant_refusal_windows_test.go index 347bbfd40..54d147033 100644 --- a/internal/sandbox/windows_volume_root_refusal_windows_test.go +++ b/internal/sandbox/windows_read_grant_refusal_windows_test.go @@ -88,7 +88,8 @@ func TestElevatedSetupRefusesAVolumeRootReadGrant(t *testing.T) { t.Fatal("the plan was applied before the refusal, so the volume-wide edit already happened") } message := stderr.String() - for _, want := range []string{"denyRead", "volume root", "#869"} { + volumeRoot := filepath.VolumeName(config.WorkspaceRoots[0]) + string(filepath.Separator) + for _, want := range []string{"denyRead", volumeRoot, "#869"} { if !strings.Contains(message, want) { t.Errorf("the refusal does not mention %q, so the reader cannot act on it:\n%s", want, message) } @@ -114,7 +115,47 @@ func TestElevatedSetupStillAcceptsAWriteJailOnlyProfile(t *testing.T) { if err != nil { t.Fatalf("BuildWindowsACLPlan: %v", err) } - if refusal := WindowsACLPlanVolumeRootRefusal(plan); refusal != "" { + if refusal := WindowsACLPlanReadGrantRefusal(plan); refusal != "" { t.Fatalf("a write-jail-only profile was refused: %s", refusal) } } + +// AND A NARROWED READ LIST IS REFUSED TOO. +// +// The first version of this guard looked for a volume root, which is a symptom +// of the production profile rather than the thing that is unsafe. +// permissionProfileReadRoots happens to seed the bare filesystem root, so that +// check covered production by coincidence. A denyRead profile with a narrowed +// read list still put an inheritable ACE on C:\Windows and sailed through. +func TestSetupRefusesAReadGrantOnANarrowedReadList(t *testing.T) { + workspace := t.TempDir() + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + // No volume root anywhere in this list. + ReadRoots: []string{workspace, `C:\Windows`}, + DenyRead: []string{filepath.Join(workspace, "secrets")}, + }, + }, + } + plan, err := BuildWindowsACLPlan(config) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + // SETUP: no volume root, or this is the case the old guard already caught. + if got := windowsPlanVolumeRootGrant(plan); got != "" { + t.Fatalf("SETUP INVALID: the plan reaches the volume root at %q", got) + } + refusal := WindowsACLPlanReadGrantRefusal(plan) + if refusal == "" { + t.Fatal("a denyRead profile with a narrowed read list was accepted; elevated setup would put an inheritable read ACE on the Windows directory") + } + if !strings.Contains(refusal, `C:\Windows`) { + t.Errorf("the refusal does not name the directory at fault: %s", refusal) + } +} diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index d0bd192a7..47cf0ab86 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -101,7 +101,7 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // volume root's DACL, which is exactly why it must not: the grant is // inheritable, so applying it rewrites permissions across the drive rather than // changing one sandbox-owned object. - if refusal := WindowsACLPlanVolumeRootRefusal(plan); refusal != "" { + if refusal := WindowsACLPlanReadGrantRefusal(plan); refusal != "" { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+refusal) return 1 } From f8c752da70372a8a936d20ccb3cb776ac4292a5a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 20:48:39 +0530 Subject: [PATCH 87/96] fix(sandbox): do not synthesize a .git inside an ancestor repository A missing .git meant "this directory may become a repository", so the Windows plan materialized .git/config and .git/hooks to get the deny ACE in place before git first ran. That is right for a standalone directory. It is wrong when the workspace is a subdirectory of an existing repository: the created directory is a control directory competing with the ancestor's for git's discovery walk, synthesized inside a repository Zero does not own. The carveouts are now skipped when an ancestor carries git metadata, which is git's own discovery rule. That is the same argument the linked-worktree branch already makes: the metadata governing this workspace lives outside the write root, the sandboxed principal has no inherited access to it, so it needs no carveout here. The non-materialized deny-delete on /.git is emitted separately and still guards the name if a repository is ever created. Ancestor detection accepts .git of either shape, since a linked worktree or submodule ancestor carries it as a pointer file and still owns the directory. Reported by jatmn. Worth recording that the stated consequence did not reproduce here: a .git holding only config and hooks is not a valid repository, so this git version walks past it and status, log and rev-parse still resolve to the ancestor. The fix stands on the narrower ground that creating repository metadata Zero does not own is wrong regardless, and anything later adding a HEAD there would break discovery for real. --- internal/sandbox/git_nested_workspace_test.go | 118 ++++++++++++++++++ internal/sandbox/profile.go | 41 ++++++ 2 files changed, 159 insertions(+) create mode 100644 internal/sandbox/git_nested_workspace_test.go diff --git a/internal/sandbox/git_nested_workspace_test.go b/internal/sandbox/git_nested_workspace_test.go new file mode 100644 index 000000000..3e930984d --- /dev/null +++ b/internal/sandbox/git_nested_workspace_test.go @@ -0,0 +1,118 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// ZERO MUST NOT SYNTHESIZE A CONTROL DIRECTORY IN SOMEBODY ELSE'S REPOSITORY. +// +// A missing .git used to mean "this directory may become a repository", so the +// Windows plan materialized .git/config and .git/hooks to get the deny ACE in +// place before git first ran. That is right for a standalone directory. It is +// wrong when the workspace is a subdirectory of an existing repository: the +// created .git competes with the ancestor's for git's discovery walk, inside a +// repository Zero does not own. +// +// The metadata for such a workspace belongs to the ancestor, sits outside this +// write root, and the sandboxed principal has no inherited access to it, which +// is the same reason the linked-worktree branch denies only the pointer file. +func TestCarveoutsAreNotSynthesizedInsideAnAncestorRepository(t *testing.T) { + parent := t.TempDir() + if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil { + t.Fatal(err) + } + workspace := filepath.Join(parent, "sub", "project") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + + // SETUP: the workspace itself has no .git, or this is a different case. + if _, err := os.Lstat(filepath.Join(workspace, ".git")); err == nil { + t.Fatal("SETUP INVALID: the workspace already carries .git") + } + + specs := gitMetadataWriteCarveoutSpecs(workspace) + for _, spec := range specs { + if spec.Path == filepath.Join(workspace, ".git", "config") || spec.Path == filepath.Join(workspace, ".git", "hooks") { + t.Fatalf("a nested workspace asks to materialize %s, which creates a control directory competing with the ancestor repository at %s", spec.Path, parent) + } + } +} + +// And a standalone directory keeps the materialized carveouts, or the guard +// above would be satisfied by returning nothing everywhere and the protection +// would be gone for the case it was written for. +func TestCarveoutsStillCoverAStandaloneDirectory(t *testing.T) { + workspace := t.TempDir() + if gitMetadataGovernedByAncestor(workspace) { + t.Skip("this temp directory sits inside a repository, so it is not the standalone case") + } + + specs := gitMetadataWriteCarveoutSpecs(workspace) + wantConfig := filepath.Join(workspace, ".git", "config") + wantHooks := filepath.Join(workspace, ".git", "hooks") + var sawConfig, sawHooks bool + for _, spec := range specs { + switch spec.Path { + case wantConfig: + sawConfig = true + if !spec.IsFile { + t.Errorf("%s is planned as a directory; creating .git/config as a directory makes git init fail", spec.Path) + } + case wantHooks: + sawHooks = true + } + } + if !sawConfig || !sawHooks { + t.Fatalf("a standalone directory lost its carveouts: config=%t hooks=%t specs=%+v", sawConfig, sawHooks, specs) + } +} + +// A linked worktree is unchanged: the pointer file is denied and nothing beneath +// it is named, which is what keeps elevated setup from descending through a +// regular file. +func TestCarveoutsStillDenyTheLinkedWorktreePointer(t *testing.T) { + workspace := t.TempDir() + pointer := filepath.Join(workspace, ".git") + if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil { + t.Fatal(err) + } + specs := gitMetadataWriteCarveoutSpecs(workspace) + if len(specs) != 1 || specs[0].Path != pointer || !specs[0].IsFile { + t.Fatalf("linked worktree carveouts = %+v, want exactly the file-shaped pointer %s", specs, pointer) + } +} + +// The ancestor test itself has to distinguish shapes, because a linked worktree +// or submodule ancestor carries .git as a FILE and still owns this directory. +func TestAncestorDetectionAcceptsBothGitShapes(t *testing.T) { + for _, shape := range []struct { + name string + make func(t *testing.T, parent string) + }{ + {name: "directory", make: func(t *testing.T, parent string) { + if err := os.MkdirAll(filepath.Join(parent, ".git"), 0o700); err != nil { + t.Fatal(err) + } + }}, + {name: "pointer file", make: func(t *testing.T, parent string) { + if err := os.WriteFile(filepath.Join(parent, ".git"), []byte("gitdir: elsewhere\n"), 0o600); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(shape.name, func(t *testing.T) { + parent := t.TempDir() + shape.make(t, parent) + workspace := filepath.Join(parent, "nested", "deep") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + if !gitMetadataGovernedByAncestor(workspace) { + t.Fatalf("an ancestor carrying .git as a %s was not recognised as governing %s", shape.name, workspace) + } + }) + } +} diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 8983cf538..d067eed69 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -157,12 +157,53 @@ func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout { if info, err := os.Lstat(gitPath); err == nil && !info.IsDir() { return []gitMetadataCarveout{{Path: gitPath, IsFile: true}} } + // A MISSING .git IS NOT THE SAME AS "THIS WILL BECOME A REPOSITORY". + // + // The directory-shaped carveouts below are materialized by the Windows plan so + // the deny ACE is in place before git first runs. That is right for a + // standalone directory somebody may later git init. It is wrong when this + // workspace already sits inside a repository: creating .git/config and + // .git/hooks synthesizes a control directory that competes with the ancestor's + // for git's discovery walk, in a repository Zero does not own. + // + // The same argument the linked-worktree branch above makes applies here. This + // workspace's git metadata belongs to the ancestor, it lives outside this write + // root, and the sandboxed principal has no inherited access to it, so it needs + // no carveout here. The non-materialized deny-delete on /.git is emitted + // separately and still guards the name if a repository is ever created here. + if gitMetadataGovernedByAncestor(root) { + return nil + } return []gitMetadataCarveout{ {Path: filepath.Join(root, ".git", "hooks")}, {Path: filepath.Join(root, ".git", "config"), IsFile: true}, } } +// gitMetadataGovernedByAncestor reports whether an ancestor of root carries git +// metadata, which is git's own discovery rule: the nearest ancestor with a .git +// entry owns this directory. +// +// Lstat rather than a resolved walk, because a .git of either shape answers the +// question: a directory is an ordinary repository root and a file is a linked +// worktree or submodule pointer. Both mean the metadata is not ours to create. +func gitMetadataGovernedByAncestor(root string) bool { + current := filepath.Clean(strings.TrimSpace(root)) + if current == "" || current == "." { + return false + } + for { + parent := filepath.Dir(current) + if parent == current { + return false + } + if _, err := os.Lstat(filepath.Join(parent, ".git")); err == nil { + return true + } + current = parent + } +} + // 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" From 4981ccfd876264a35288bcbfc1cda31e605cdc2f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 21:27:04 +0530 Subject: [PATCH 88/96] fix(cli): keep a specified-empty sandbox environment from inheriting exec.Cmd reads a nil Env as "inherit this process's entire environment", which is a different statement from "run with no variables". The plan owns its environment: directCommandEnv and scrubSensitiveEnv return a slice they built, and that slice is non-nil with length zero when every entry was sensitive. Testing its length collapsed those two states and turned the strictest possible answer into the loosest one. Reported by jatmn as a credential leak. Being precise about reachability: it is not reachable through `zero sandbox exec` today. The child environment is os.Environ(), so an environment holding only sensitive keys also has no %AppData%, and config resolution fails at sandbox_exec.go before the planner runs. Two independent checks reached that conclusion and I confirmed it at the call site. Fixed regardless. The guard is one assignment, and the next caller that hands the plan a deliberately narrow environment would inherit everything instead, silently. Both directions are pinned by a child that prints its own environment, on every platform rather than behind a Unix-only skip. --- internal/cli/sandbox_exec.go | 16 +++++- internal/cli/sandbox_exec_env_test.go | 71 +++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 internal/cli/sandbox_exec_env_test.go diff --git a/internal/cli/sandbox_exec.go b/internal/cli/sandbox_exec.go index 29bacd4c8..17cd0db86 100644 --- a/internal/cli/sandbox_exec.go +++ b/internal/cli/sandbox_exec.go @@ -118,7 +118,21 @@ func runSandboxPlannedCommand(plan zeroSandbox.CommandPlan, stdout io.Writer, st if process.Dir == "" { process.Dir = plan.WorkspaceRoot } - if len(plan.Env) > 0 { + // SPECIFIED-EMPTY IS NOT UNSPECIFIED. + // + // exec.Cmd treats a nil Env as "inherit this process's entire environment", + // which is a different statement from "run with no variables". The plan owns + // its environment: directCommandEnv and scrubSensitiveEnv return a slice they + // built, and that slice is non-nil with length zero when every entry was + // sensitive. Testing length collapsed those two states and turned the strictest + // possible answer into the loosest one. + // + // Not reachable through `zero sandbox exec` today, because the child + // environment is os.Environ() and an environment holding only sensitive keys + // has no %AppData%, so config resolution fails before the planner runs. Fixed + // anyway: the guard is one assignment, and the next caller that hands the plan + // a deliberately narrow environment would inherit everything instead, silently. + if plan.Env != nil { process.Env = plan.Env } process.Stdin = os.Stdin diff --git a/internal/cli/sandbox_exec_env_test.go b/internal/cli/sandbox_exec_env_test.go new file mode 100644 index 000000000..327b854cf --- /dev/null +++ b/internal/cli/sandbox_exec_env_test.go @@ -0,0 +1,71 @@ +package cli + +import ( + "os" + "runtime" + "strings" + "testing" + + zeroSandbox "github.com/Gitlawb/zero/internal/sandbox" +) + +// envPrinterPlan returns a plan whose child prints its own environment, so the +// assertion is about what the child received rather than about a field value. +func envPrinterPlan(t *testing.T, env []string) zeroSandbox.CommandPlan { + t.Helper() + plan := zeroSandbox.CommandPlan{Dir: t.TempDir(), Env: env} + if runtime.GOOS == "windows" { + plan.Name = "cmd.exe" + plan.Args = []string{"/c", "set"} + return plan + } + plan.Name = "/bin/sh" + plan.Args = []string{"-c", "env"} + return plan +} + +// A PLAN THAT SPECIFIES NO VARIABLES MUST NOT INHERIT EVERY VARIABLE. +// +// exec.Cmd reads a nil Env as "inherit this process's entire environment", which +// is a different statement from "run with no variables". The plan owns its +// environment: directCommandEnv and scrubSensitiveEnv return a slice they built, +// and that slice is non-nil with length zero when every entry was sensitive. +// Testing its length collapsed the two states and turned the strictest possible +// answer into the loosest one. +// +// Not reachable through `zero sandbox exec` today, because the child environment +// is os.Environ() and an environment holding only sensitive keys has no +// %AppData%, so config resolution fails before the planner runs. Pinned anyway, +// because the next caller that hands the plan a deliberately narrow environment +// would silently inherit everything instead. +func TestAnEmptyPlannedEnvironmentDoesNotInherit(t *testing.T) { + const marker = "ZZ_SANDBOX_ENV_MARKER" + t.Setenv(marker, "leaked-value") + + var out strings.Builder + // Specified, and deliberately empty. + code := runSandboxPlannedCommand(envPrinterPlan(t, []string{}), &out, os.Stderr) + if code != 0 { + t.Fatalf("child exited %d: %s", code, out.String()) + } + if strings.Contains(out.String(), marker) { + t.Fatalf("a plan specifying no environment leaked the caller's %s to the child:\n%s", marker, out.String()) + } +} + +// And a nil environment still inherits, which is the meaning the planner relies +// on when it did not build one. Without this the fix above could be "never pass +// the environment", which would break every ordinary command. +func TestANilPlannedEnvironmentStillInherits(t *testing.T) { + const marker = "ZZ_SANDBOX_ENV_MARKER" + t.Setenv(marker, "inherited-value") + + var out strings.Builder + code := runSandboxPlannedCommand(envPrinterPlan(t, nil), &out, os.Stderr) + if code != 0 { + t.Fatalf("child exited %d: %s", code, out.String()) + } + if !strings.Contains(out.String(), marker) { + t.Fatalf("a nil planned environment stopped inheriting, which changes the meaning the planner relies on:\n%s", out.String()) + } +} From 8f47de5773f1b6e55892cab1e91a0e1b3839b355 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 21:28:30 +0530 Subject: [PATCH 89/96] test(sandbox): stop probing for privilege by mutating System32 The test applied the real ACL plan to C:\Windows\System32 and used the result as its privilege probe: on success it skipped, having already mutated. Both return values were discarded at the call site, so the snapshot holding the original descriptor was gone and nothing could put it back. A process that does hold WRITE_DAC there would leave an inheritable allow ACE for a synthetic SID on System32 and on everything later created beneath it, permanently. Reachability is narrower than reported, and worth writing down. System32 grants WRITE_DAC only to TrustedInstaller; Administrators and SYSTEM get 0x1301bf on the object, which does not include it, and their GA aces are inherit-only. So an ordinary elevated run takes the denial branch. The branch that mutates needs a token whose backup or restore privilege is enabled, since the applier opens with FILE_FLAG_BACKUP_SEMANTICS, which is the normal shape of a LocalSystem service token and therefore of a self-hosted runner installed as a service. That could not be driven here, so it rests on documented behaviour rather than on output. The trigger is narrow; the residue is unbounded and silent. The coupling this test exists to pin, openWindowsACLTarget's wording against windowsACLPlanDeniedPath's marker, is now pinned against a disposable directory rigged to refuse WRITE_DAC. A plain deny ace is not enough there, because the owner keeps an implicit WRITE_DAC that defeats it and the apply succeeds; OWNER_RIGHTS is what makes the DACL the whole story. That was measured, not assumed, and a naive deny-only fix would have relocated the same bug. No run can now leave residue outside t.TempDir(), and the skip that used to follow a completed mutation is a SETUP INVALID failure instead. --- .../windows_unelevated_denied_windows_test.go | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/windows_unelevated_denied_windows_test.go b/internal/sandbox/windows_unelevated_denied_windows_test.go index 60b710279..8d9266eb3 100644 --- a/internal/sandbox/windows_unelevated_denied_windows_test.go +++ b/internal/sandbox/windows_unelevated_denied_windows_test.go @@ -5,18 +5,76 @@ package sandbox import ( "errors" "os" + "path/filepath" "testing" + + "golang.org/x/sys/windows" ) +// denyWriteDACTarget returns a disposable directory this process cannot re-DACL. +// +// A plain deny ACE is not enough: the owner keeps an implicit WRITE_DAC that +// defeats it, and the apply succeeds. OWNER_RIGHTS (S-1-3-4) is what replaces +// that implicit grant, so the DACL becomes the whole story and the access check +// actually fails. Probed rather than reasoned; the deny-only shape was measured +// to still let the mutation through. +func denyWriteDACTarget(t *testing.T) string { + t.Helper() + target := filepath.Join(t.TempDir(), "denied") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatal(err) + } + ownerRights, err := windows.StringToSid("S-1-3-4") + if err != nil { + t.Fatalf("parse OWNER RIGHTS: %v", err) + } + // READ_CONTROL only: the applier can read the descriptor and then fails on the + // write, which is the exact production shape this pins. + dacl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ + AccessPermissions: windows.READ_CONTROL | windows.SYNCHRONIZE | windows.FILE_GENERIC_READ, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(ownerRights), + }, + }}, nil) + if err != nil { + t.Fatalf("build the deny-WRITE_DAC descriptor: %v", err) + } + if err := windows.SetNamedSecurityInfo( + target, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, nil, dacl, nil, + ); err != nil { + t.Skipf("cannot rig a deny-WRITE_DAC directory here: %v", err) + } + return target +} + // 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. +// +// THE TARGET IS DISPOSABLE, AND THAT IS THE POINT. +// +// This used to apply the real plan to C:\Windows\System32 and use the result as +// its privilege probe: on success it skipped, having already mutated. The +// snapshot and the applied result were both discarded at the call site, so +// nothing could put the descriptor back. Any process that does hold WRITE_DAC +// there would leave an inheritable allow ACE for a synthetic SID on System32 and +// everything later created beneath it, with no code path anywhere to remove it. +// +// The rigged directory below produces the same access denial from the same +// producer without depending on a system path or on the test's own privilege +// level, so the coupling stays pinned and no run can leave residue outside +// t.TempDir(). 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` + target := denyWriteDACTarget(t) _, _, err := applyWindowsACLPathGroup(windowsACLPathGroup{ Path: target, @@ -27,7 +85,7 @@ func TestDeniedPathIsRecoveredFromARealApplyFailure(t *testing.T) { }}, }) if err == nil { - t.Skip("this process can re-DACL System32, so it is elevated and cannot exercise the denial path") + t.Fatal("SETUP INVALID: the apply succeeded on a directory rigged to refuse WRITE_DAC, so the denial path was not exercised") } if !errors.Is(err, os.ErrPermission) { t.Skipf("failed for a reason other than access denial, nothing to extract here: %v", err) From 0154b91219c05d58d2eff22d68c27e6b4e8a8433 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 21:33:36 +0530 Subject: [PATCH 90/96] fix(sandbox): keep a linked worktree's .git typed as a file through planning gitMetadataWriteCarveoutSpecs types a linked worktree's or submodule's .git as a pointer FILE. That typed result is flattened to a path before ACL planning, and gitMetadataCarveoutIsFile tried to recover the shape by deriving suffixes under a sentinel root whose .git never exists. The derivation therefore always took the directory branch and produced exactly one file-shaped suffix, .git\config, which a bare \.git can never match. Both planners emitted MaterializeFile:false for a path stage one had already typed correctly. The window that turns a wrong plan into damage is the caller-to-elevated-helper gap. The profile is built in the user's shell and the plan is applied in a separately launched helper, across the UAC prompt, so a concurrent `git worktree remove`, `git submodule deinit`, or hostile local process can delete the pointer in between. The applier then creates a DIRECTORY at the pointer path and the worktree is broken, persistently, because the runner discards the rollback closure. The carveout SET already carries the answer, so no type change is needed. gitMetadataWriteCarveoutSpecs is the only producer of ReadOnlySubpaths, and it emits a bare .git in exactly one case: the pointer. The directory case emits .git\hooks and .git\config and never their parent. So the shape is read off which carveout this is rather than guessed from a pathname. The regression drives BuildWindowsACLPlan and applyWindowsACLPlan with the pointer removed after planning, which is the only place the loss is visible; a stage-one assertion on specs[0].IsFile passed for the whole time the bug was live. A control with the pointer present keeps a green result from meaning an apply that did nothing. --- .../sandbox/git_pointer_shape_windows_test.go | 119 ++++++++++++++++++ internal/sandbox/profile.go | 18 +++ 2 files changed, 137 insertions(+) create mode 100644 internal/sandbox/git_pointer_shape_windows_test.go diff --git a/internal/sandbox/git_pointer_shape_windows_test.go b/internal/sandbox/git_pointer_shape_windows_test.go new file mode 100644 index 000000000..4802e202a --- /dev/null +++ b/internal/sandbox/git_pointer_shape_windows_test.go @@ -0,0 +1,119 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +func pointerWorkspace(t *testing.T) (workspace string, pointer string) { + t.Helper() + workspace = t.TempDir() + pointer = filepath.Join(workspace, ".git") + if err := os.WriteFile(pointer, []byte("gitdir: ../real/.git/worktrees/w\n"), 0o600); err != nil { + t.Fatal(err) + } + return workspace, pointer +} + +func pointerPlanConfig(t *testing.T, workspace string) WindowsSandboxCommandConfig { + t.Helper() + return WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{ + Root: workspace, + ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace), + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + }}, + }, + }, + } +} + +// THE SHAPE HAS TO SURVIVE THE STAGE BOUNDARY, NOT JUST BE CORRECT AT STAGE ONE. +// +// gitMetadataWriteCarveoutSpecs types a linked worktree's .git as a file. That +// typed result was flattened to a path before ACL planning, and the planner tried +// to recover the shape by deriving suffixes under a sentinel root whose .git never +// exists. The derivation therefore always took the directory branch, and a bare +// \.git could never match as a file. +// +// The window that turns a wrong plan into damage is the caller-to-elevated-helper +// gap: the profile is built in the user's shell and the plan is applied in a +// separately launched helper, across the UAC prompt. If the pointer goes away in +// between, the applier materializes a DIRECTORY at the pointer path and the +// worktree is broken, persistently, because the runner discards the rollback. +// +// Driven through BuildWindowsACLPlan and applyWindowsACLPlan with the pointer +// removed after planning, which is the only place this is visible. A stage-one +// assertion on specs[0].IsFile passed throughout the whole time the bug was live. +func TestALinkedWorktreePointerIsNeverMaterializedAsADirectory(t *testing.T) { + workspace, pointer := pointerWorkspace(t) + plan, err := BuildWindowsACLPlan(pointerPlanConfig(t, workspace)) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + + // SETUP: the plan really does carry the pointer, typed as a file. + var found bool + for _, entry := range plan.Entries { + if entry.Path == pointer && entry.Action == WindowsACLDenyWrite { + found = true + if !entry.MaterializeFile { + t.Fatalf("the plan types %s as a directory; applying it would replace the worktree pointer with a directory", pointer) + } + } + } + if !found { + t.Fatalf("SETUP INVALID: no deny-write entry for the pointer %s: %+v", pointer, plan.Entries) + } + + // The window: the pointer goes away between planning and applying. + if err := os.Remove(pointer); err != nil { + t.Fatal(err) + } + if _, err := applyWindowsACLPlan(plan); err != nil { + t.Skipf("cannot apply an ACL plan here: %v", err) + } + + info, err := os.Lstat(pointer) + if err != nil { + // Nothing recreated is also acceptable: what must not happen is a directory. + return + } + if info.IsDir() { + t.Fatalf("a directory was created at the worktree pointer %s, which breaks the worktree", pointer) + } +} + +// Control: with the pointer present at apply, it stays a file and is not +// replaced. Without this, the assertion above could be satisfied by an apply +// that does nothing at all. +func TestALinkedWorktreePointerSurvivesAnApply(t *testing.T) { + workspace, pointer := pointerWorkspace(t) + plan, err := BuildWindowsACLPlan(pointerPlanConfig(t, workspace)) + if err != nil { + t.Fatalf("BuildWindowsACLPlan: %v", err) + } + if _, err := applyWindowsACLPlan(plan); err != nil { + t.Skipf("cannot apply an ACL plan here: %v", err) + } + info, err := os.Lstat(pointer) + if err != nil { + t.Fatalf("the pointer vanished across the apply: %v", err) + } + if info.IsDir() { + t.Fatalf("the pointer at %s became a directory", pointer) + } + body, err := os.ReadFile(pointer) + if err != nil || len(body) == 0 { + t.Fatalf("the pointer lost its contents: body=%q err=%v", body, err) + } +} diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index d067eed69..d8f422915 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -225,6 +225,24 @@ func gitMetadataCarveoutIsFile(path string) bool { if candidate == "" { return false } + // A BARE .git CARVEOUT IS THE LINKED-WORKTREE POINTER, AND ONLY THAT. + // + // The suffix derivation below runs specs against a sentinel root whose .git + // never exists, so it always takes the directory branch and yields exactly one + // file-shaped suffix, .gitconfig. A bare .git could therefore never + // match, and both planners emitted MaterializeFile:false for a path the profile + // stage had already typed correctly as a file. With the pointer absent at apply + // time, the plan then created a DIRECTORY at the pointer path and broke the + // worktree. + // + // The carveout SET already carries the answer. gitMetadataWriteCarveoutSpecs is + // the only producer of ReadOnlySubpaths, and it emits a bare .git in exactly one + // case: the linked-worktree or submodule pointer. The directory case emits + // .githooks and .gitconfig and never the parent. So the shape is not being + // guessed from a pathname here, it is being read off which carveout this is. + if strings.EqualFold(filepath.Base(candidate), ".git") { + return true + } for _, spec := range gitMetadataWriteCarveoutSpecs(gitMetadataCarveoutSuffixBase) { if !spec.IsFile { continue From 214127be3b60e5484a6e5889436e970a483ade2f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 21:37:44 +0530 Subject: [PATCH 91/96] fix(sandbox): stop following sandbox-controlled runtime descendants The runtime root is deterministic so setup and later runners agree on one path, and the sandboxed command is granted write access to cache, data and tmp. So it can replace one of them with a symlink or a Windows junction on its way out. Preparation then ran os.MkdirAll and os.Chmod on raw pathnames, which follow, and the HOST Zero process, the ordinary user rather than the confined principal, created package-cache directories inside a target the previous command chose. ensureFallbackRuntimeAnchor proves the per-user anchor and says nothing about the reusable root or anything beneath it, so it never covered this. Preparation now descends from the operator-owned base through retained no-follow handles: NtCreateFile with OBJ_DONT_REPARSE and FILE_OPEN_REPARSE_POINT on Windows, openat and mkdirat with O_NOFOLLOW plus fchmod on the descriptor elsewhere. fchmod rather than chmod is the half that matters off Windows, since chmod follows symlinks. The deterministic naming contract is untouched. Deliberately NOT peermsg.EnsurePrivateDir, which is the obvious patch and the wrong one: it ends in a protected-DACL write that strips the sandbox principal's grant elevated setup installed on the runtime tree, bringing back the bare access denials from npm and go that the grant exists to prevent. That would have passed every unit test on an unprovisioned box. This descent validates and creates; it does not re-secure. Two precisions worth recording. The Windows chmod is dropped rather than ported, because it only toggles READONLY and was measured landing on the junction rather than its target, so it bought nothing. And the exposure on the cache-derived root predates this PR, byte-identical to main; what this PR added was making the fallback root deterministic and persistent too, extending the same shape to it. --- internal/sandbox/runtime_state.go | 14 +-- internal/sandbox/runtime_tree.go | 80 +++++++++++++++ internal/sandbox/runtime_tree_other.go | 49 +++++++++ .../runtime_tree_redirect_windows_test.go | 99 +++++++++++++++++++ internal/sandbox/runtime_tree_windows.go | 44 +++++++++ 5 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 internal/sandbox/runtime_tree.go create mode 100644 internal/sandbox/runtime_tree_other.go create mode 100644 internal/sandbox/runtime_tree_redirect_windows_test.go create mode 100644 internal/sandbox/runtime_tree_windows.go diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 71f11cbbc..97ede0313 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -134,13 +134,13 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) filepath.Join(runtimeState.Data, "go-mod"), filepath.Join(runtimeState.Data, "cargo"), } - for _, directory := range directories { - if err := os.MkdirAll(directory, 0o700); err != nil { - return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err) - } - if err := os.Chmod(directory, 0o700); err != nil { - return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err) - } + // Handle-relative and no-follow below the operator-owned base. The tree is + // persistent input from the previous sandboxed command, which is granted write + // access to cache, data and tmp and can leave a link in place of one of them. + // os.MkdirAll and os.Chmod both follow, so the host process created package + // caches wherever that command pointed them. See ensureRuntimeTreeDirs. + if err := ensureRuntimeTreeDirs(runtimeState.Root, directories); err != nil { + return SandboxRuntime{}, nil, err } now := sandboxRuntimeNow() if err := os.Chtimes(runtimeState.Root, now, now); err != nil { diff --git a/internal/sandbox/runtime_tree.go b/internal/sandbox/runtime_tree.go new file mode 100644 index 000000000..e70b71705 --- /dev/null +++ b/internal/sandbox/runtime_tree.go @@ -0,0 +1,80 @@ +package sandbox + +import ( + "fmt" + "path/filepath" + "strings" +) + +// ensureRuntimeTreeDirs creates every directory of the runtime tree without ever +// following a link below the operator-owned base. +// +// THE TREE IS PERSISTENT INPUT FROM THE PREVIOUS SANDBOXED COMMAND. +// +// The runtime root is deterministic so setup and later runners agree on one +// path, and the sandboxed command is granted write access to cache, data and +// tmp. So the command can replace one of them with a symlink or a Windows +// junction on its way out. The next preparation ran os.MkdirAll and os.Chmod on +// raw pathnames, which follow, and the HOST Zero process, the ordinary user +// rather than the confined principal, then created package-cache directories +// inside a target the previous command chose. +// +// The anchor check is not enough on its own: it proves the per-user anchor and +// says nothing about the reusable root or any descendant beneath it. +// +// NOT peermsg.EnsurePrivateDir. That ends in a protected-DACL write, which on +// Windows strips the sandbox principal's grant that elevated setup installed on +// the runtime tree and brings back the bare access denials from npm and go that +// the grant exists to prevent. This descent validates and creates; it does not +// re-secure. +func ensureRuntimeTreeDirs(root string, directories []string) error { + base, ok := runtimeCandidateBase(root) + if !ok || strings.TrimSpace(base) == "" { + return fmt.Errorf("sandbox runtime root %s has no operator-owned base to descend from", root) + } + // Physical, so a redirected cache or TEMP above the tree stays the operator's + // business while everything Zero owns below it is addressed no-follow. + physical, err := physicalDir(base) + if err != nil { + return fmt.Errorf("resolve the sandbox runtime base %s: %w", base, err) + } + for _, directory := range directories { + tail, err := runtimeTreeTail(physical, base, directory) + if err != nil { + return err + } + if err := ensureRuntimeTreeDir(physical, tail); err != nil { + return err + } + } + return nil +} + +// runtimeTreeTail returns the components of directory below the base, which are +// the ones Zero owns and the ones that must not be links. +func runtimeTreeTail(physicalBase string, base string, directory string) ([]string, error) { + cleaned := filepath.Clean(strings.TrimSpace(directory)) + relative, err := filepath.Rel(base, cleaned) + if err != nil || relative == "." || strings.HasPrefix(relative, "..") { + // Try the physical spelling too: the caller's path may already be physical. + relative, err = filepath.Rel(physicalBase, cleaned) + if err != nil || relative == "." || strings.HasPrefix(relative, "..") { + return nil, fmt.Errorf("sandbox runtime directory %s does not sit under its base %s", directory, base) + } + } + parts := strings.Split(relative, string(filepath.Separator)) + out := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" || part == "." { + continue + } + if part == ".." { + return nil, fmt.Errorf("sandbox runtime directory %s escapes its base %s", directory, base) + } + out = append(out, part) + } + if len(out) == 0 { + return nil, fmt.Errorf("sandbox runtime directory %s resolves to its own base", directory) + } + return out, nil +} diff --git a/internal/sandbox/runtime_tree_other.go b/internal/sandbox/runtime_tree_other.go new file mode 100644 index 000000000..e0a3f4439 --- /dev/null +++ b/internal/sandbox/runtime_tree_other.go @@ -0,0 +1,49 @@ +//go:build !windows + +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +// ensureRuntimeTreeDir creates the owned tail beneath a physical base using +// openat and mkdirat with O_NOFOLLOW, so a symlink planted at any component is +// refused rather than followed. +// +// fchmod on the retained descriptor rather than chmod on the pathname: chmod(2) +// follows symlinks, which is the half of this defect that actually applies here. +// Every owned component gets the mode, not only the leaf, because every one of +// them is ours. +func ensureRuntimeTreeDir(physicalBase string, tail []string) error { + parent, err := unix.Open(physicalBase, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("open the sandbox runtime base %s: %w", physicalBase, err) + } + defer func() { _ = unix.Close(parent) }() + + current := physicalBase + for _, name := range tail { + current = filepath.Join(current, name) + if err := unix.Mkdirat(parent, name, 0o700); err != nil && err != unix.EEXIST { + return fmt.Errorf("create sandbox runtime directory %s: %w", current, err) + } + child, openErr := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if openErr != nil { + if openErr == unix.ELOOP || openErr == unix.ENOTDIR { + return fmt.Errorf("sandbox runtime component %s is a link or not a directory, so a previous sandboxed command may have redirected it: %w", current, os.ErrInvalid) + } + return fmt.Errorf("open sandbox runtime directory %s: %w", current, openErr) + } + if err := unix.Fchmod(child, 0o700); err != nil { + _ = unix.Close(child) + return fmt.Errorf("secure sandbox runtime directory %s: %w", current, err) + } + _ = unix.Close(parent) + parent = child + } + return nil +} diff --git a/internal/sandbox/runtime_tree_redirect_windows_test.go b/internal/sandbox/runtime_tree_redirect_windows_test.go new file mode 100644 index 000000000..f3194997d --- /dev/null +++ b/internal/sandbox/runtime_tree_redirect_windows_test.go @@ -0,0 +1,99 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// A PREVIOUS SANDBOXED COMMAND CAN LEAVE A REDIRECT BEHIND. +// +// The runtime root is deterministic so setup and later runners agree on one +// path, and the sandboxed command is granted write access to cache, data and +// tmp. So it can replace one of them with a junction on its way out. The next +// preparation used os.MkdirAll and os.Chmod on raw pathnames, which follow, and +// the HOST Zero process, the ordinary user rather than the confined principal, +// then created package-cache directories inside a target the previous command +// chose. +// +// The anchor check does not cover this: it proves the per-user anchor and says +// nothing about the reusable root or anything beneath it. +// +// A junction needs no privilege, so the previous command's half runs here on an +// ordinary unelevated box. +func TestRuntimeTreePreparationRefusesARedirectedDescendant(t *testing.T) { + base := t.TempDir() + target := t.TempDir() + root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789") + cache := filepath.Join(root, "cache") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + // The previous lifetime leaves a junction where cache used to be. + if out, err := exec.Command("cmd", "/c", "mklink", "/J", cache, target).CombinedOutput(); err != nil { + t.Skipf("mklink /J unavailable here: %v\n%s", err, out) + } + // SETUP: the junction really redirects, or a clean target proves nothing. + probe := filepath.Join(cache, "probe.txt") + if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil { + t.Skipf("the junction does not accept writes here: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "probe.txt")); err != nil { + t.Skipf("SETUP: the junction does not redirect on this filesystem: %v", err) + } + if err := os.Remove(probe); err != nil { + t.Fatal(err) + } + + err := ensureRuntimeTreeDirs(root, []string{root, cache, filepath.Join(cache, "npm")}) + if err == nil { + t.Fatal("preparation descended through a junction the previous sandboxed command planted") + } + + entries, readErr := os.ReadDir(target) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Fatalf("preparation created %v beneath the redirected target", names) + } +} + +// And an ordinary tree is still created, or the refusal above would be satisfied +// by a preparation that refuses everything. +func TestRuntimeTreePreparationStillCreatesAnOrdinaryTree(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789") + cache := filepath.Join(root, "cache") + npm := filepath.Join(cache, "npm") + + if err := ensureRuntimeTreeDirs(root, []string{root, cache, npm}); err != nil { + t.Fatalf("an ordinary runtime tree was refused: %v", err) + } + for _, dir := range []string{root, cache, npm} { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + t.Fatalf("%s was not created: err=%v", dir, err) + } + } +} + +// Reusing an existing tree is the common case and must not be refused. +func TestRuntimeTreePreparationIsIdempotent(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789") + dirs := []string{root, filepath.Join(root, "cache"), filepath.Join(root, "cache", "npm")} + if err := ensureRuntimeTreeDirs(root, dirs); err != nil { + t.Fatalf("first preparation: %v", err) + } + if err := ensureRuntimeTreeDirs(root, dirs); err != nil { + t.Fatalf("second preparation over an existing tree was refused: %v", err) + } +} diff --git a/internal/sandbox/runtime_tree_windows.go b/internal/sandbox/runtime_tree_windows.go new file mode 100644 index 000000000..bf10ae9b1 --- /dev/null +++ b/internal/sandbox/runtime_tree_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// ensureRuntimeTreeDir creates the owned tail beneath a physical base, one +// component at a time, from retained handles. +// +// Every open and create goes through the NtCreateFile helpers with +// OBJ_DONT_REPARSE and FILE_OPEN_REPARSE_POINT, so a junction planted at any +// component is seen as the link it is and refused rather than descended. No +// component name is resolved twice, so there is no interval for a swap to land +// in. +// +// No chmod. On Windows os.Chmod only toggles READONLY, and against a junction it +// lands on the link rather than the target, so it bought nothing here. The +// directory's protection is the ACL that elevated setup applied, which this +// descent deliberately does not touch. +func ensureRuntimeTreeDir(physicalBase string, tail []string) error { + parent, err := openWindowsACLDirectoryNoFollow(physicalBase) + if err != nil { + return fmt.Errorf("open the sandbox runtime base %s: %w", physicalBase, err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + for _, name := range tail { + child, _, createErr := createWindowsACLChildDirectory(parent, name) + if createErr != nil { + existing, openErr := openWindowsACLChildDirectory(parent, name) + if openErr != nil { + return fmt.Errorf("prepare the sandbox runtime component %s beneath %s: %w", name, physicalBase, createErr) + } + child = existing + } + _ = windows.CloseHandle(parent) + parent = child + } + return nil +} From be9fcb47ac61153ca256ee1daed078d569963003 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 21:38:56 +0530 Subject: [PATCH 92/96] test(sandbox): drive the runtime-tree descent through its entry point The helper tests call ensureRuntimeTreeDirs directly, so reverting prepareSandboxRuntime to the old pathname loop left every one of them green. The defect was in what the entry point called, so it needs a test that goes through the entry point. --- .../runtime_tree_redirect_windows_test.go | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/internal/sandbox/runtime_tree_redirect_windows_test.go b/internal/sandbox/runtime_tree_redirect_windows_test.go index f3194997d..14e7ad2b0 100644 --- a/internal/sandbox/runtime_tree_redirect_windows_test.go +++ b/internal/sandbox/runtime_tree_redirect_windows_test.go @@ -97,3 +97,79 @@ func TestRuntimeTreePreparationIsIdempotent(t *testing.T) { t.Fatalf("second preparation over an existing tree was refused: %v", err) } } + +// AND THE PRODUCTION ENTRY POINT USES IT, WHICH THE HELPER TESTS CANNOT SEE. +// +// The three tests above call ensureRuntimeTreeDirs directly, so reverting +// prepareSandboxRuntime to the old pathname loop leaves every one of them green. +// The defect was in what the entry point called, so this drives the entry point. +func TestPrepareSandboxRuntimeRefusesARedirectedDescendant(t *testing.T) { + cacheRoot := t.TempDir() + target := t.TempDir() + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + + workspace := t.TempDir() + root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot)) + if !ok { + t.Fatalf("SETUP INVALID: no deterministic runtime root for %s", workspace) + } + cache := filepath.Join(root, "cache") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("cmd", "/c", "mklink", "/J", cache, target).CombinedOutput(); err != nil { + t.Skipf("mklink /J unavailable here: %v\n%s", err, out) + } + probe := filepath.Join(cache, "probe.txt") + if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil { + t.Skipf("the junction does not accept writes here: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "probe.txt")); err != nil { + t.Skipf("SETUP: the junction does not redirect on this filesystem: %v", err) + } + if err := os.Remove(probe); err != nil { + t.Fatal(err) + } + + _, release, err := prepareSandboxRuntime(workspace) + if release != nil { + release() + } + if err == nil { + t.Fatal("prepareSandboxRuntime descended through a junction the previous sandboxed command planted") + } + + entries, readErr := os.ReadDir(target) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Fatalf("prepareSandboxRuntime created %v beneath the redirected target", names) + } +} + +// And an ordinary workspace still prepares through the entry point. +func TestPrepareSandboxRuntimeStillPreparesAnOrdinaryTree(t *testing.T) { + cacheRoot := t.TempDir() + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + + runtimeState, release, err := prepareSandboxRuntime(t.TempDir()) + if err != nil { + t.Fatalf("an ordinary workspace was refused: %v", err) + } + defer release() + for _, dir := range []string{runtimeState.Root, runtimeState.Cache, runtimeState.Data, runtimeState.Temp} { + info, statErr := os.Stat(dir) + if statErr != nil || !info.IsDir() { + t.Fatalf("%s was not created: err=%v", dir, statErr) + } + } +} From 12d082e9a87427ae90904c9d1118680a45fedcd1 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 21:42:40 +0530 Subject: [PATCH 93/96] fix(sandbox): restore a raced leaf whose parent this run created rollbackWindowsACLSnapshots branched on createdAnything(), which is an OR across the whole materialization record. When this run creates the parent chain and a racer wins the leaf, that predicate is true while the ACL-bearing file belongs to somebody else: Chain carries Made:true and FileMade is false. The rollback then could not remove the parent, because it was no longer empty, and the unconditional skip meant the existing no-follow, identity-validated restore never ran either. A file that existed before setup started was left wearing an aborted setup's DACL, and the failure was loud about the directory it could not remove while silent about the descriptor it never put back. rollbackWindowsACLMaterialization now reports whether the ACL-BEARING TARGET is among what it removed: the file leaf when FileMade is set, the deepest chain entry otherwise. Anything it cannot prove it removed reports false, which routes the caller to the restore that is already no-follow and TargetID-guarded and refuses on a mismatch rather than forcing. All three conservative properties are unchanged and none needed new code: nothing new is ever deleted, so a raced leaf is still never removed; there is still no pathname fallback; and a rollback that could not remove the parent still says so while now also restoring the leaf. The regression is the mixed-ownership case rather than a wholly created directory becoming non-empty, driven through applyWindowsACLPlan with the leaf created inside the existing swap hook so the race is deterministic. --- internal/sandbox/windows_acl_apply_windows.go | 71 +++++++--- ...ndows_acl_materialize_swap_windows_test.go | 8 +- .../windows_acl_raced_leaf_windows_test.go | 124 ++++++++++++++++++ 3 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 internal/sandbox/windows_acl_raced_leaf_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index cd85d03b5..548ed7088 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -204,7 +204,9 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo if handle != 0 { _ = windows.CloseHandle(handle) } - if unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil { + // The apply itself is failing, so nothing has been left with a DACL to + // restore; only the removal outcome matters here. + if _, unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil { return windowsACLSnapshot{}, false, fmt.Errorf("%w; cleanup failed: %v", err, unwindErr) } return windowsACLSnapshot{}, false, err @@ -435,10 +437,20 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { for index := len(snapshots) - 1; index >= 0; index-- { snapshot := snapshots[index] if snapshot.Created.createdAnything() { - if err := rollbackWindowsACLMaterialization(snapshot.Created); err != nil { + removed, err := rollbackWindowsACLMaterialization(snapshot.Created) + if err != nil { errs = append(errs, err) } - continue + // Only skip the restore when the object carrying the DACL is actually + // gone. A racer can win the leaf while this run made its parent, and + // then the aggregate says "ours" about a file that is not: the parent + // cannot be removed because it is non-empty, and skipping here left a + // pre-existing file wearing an aborted setup's ACL. Falling through + // reaches the same no-follow, TargetID-guarded restore as any other + // snapshot, which refuses on a mismatch rather than forcing. + if removed { + continue + } } dacl, _, err := snapshot.Descriptor.DACL() if err != nil { @@ -531,18 +543,32 @@ func materializeWindowsACLTarget(path string, asFile bool) (windowsACLMaterializ // 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 { +// rollbackWindowsACLMaterialization removes what this run created and reports +// whether the ACL-BEARING TARGET is among what it removed. +// +// AGGREGATE OWNERSHIP IS NOT THE TARGET'S DISPOSITION. The caller used to treat +// createdAnything() as "this run owns the object it applied a DACL to", which is +// an OR across the whole materialization record. When this run creates the +// parent chain and a racer wins the leaf, that is true while the ACL-bearing +// file belongs to somebody else. The caller then skipped the identity-validated +// restore and left a pre-existing file carrying an aborted setup's ACL. +// +// The target is the file leaf when FileMade is set, and the deepest chain entry +// otherwise. Anything this cannot prove it removed reports false, which routes +// the caller to a restore that is already no-follow and TargetID-guarded, so the +// conservative direction is the default. +func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization) (targetRemoved bool, err error) { if !materialization.createdAnything() { - return nil + return false, nil } - anchor, err := reopenWindowsACLDirectoryAsIdentity(materialization.AnchorPath, materialization.AnchorID) - if err != nil { - 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 + anchor, anchorErr := reopenWindowsACLDirectoryAsIdentity(materialization.AnchorPath, materialization.AnchorID) + if anchorErr != nil { + if errors.Is(anchorErr, os.ErrNotExist) { + // The anchor is gone, so everything created beneath it is gone too, + // the target included. Nothing to undo, and no way to undo it. + return true, nil } - return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err) + return false, fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, anchorErr) } // One handle per level: handles[i] is the parent of Chain[i], which is what @@ -568,24 +594,28 @@ func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization } depth := 0 for ; depth < needed; depth++ { - child, err := openWindowsACLChildDirectory(handles[depth], materialization.Chain[depth].Name) - if err != nil { + child, openErr := openWindowsACLChildDirectory(handles[depth], materialization.Chain[depth].Name) + if openErr != nil { // Already removed by something else. Stop descending; whatever is // below it is gone with it. - if isWindowsNotExist(err) { + if isWindowsNotExist(openErr) { break } - return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err) + return false, fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, openErr) } handles = append(handles, child) } var errs []error + // An ancestor vanished mid-descent, so the target went with it. + targetRemoved = depth < needed // 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)) + } else { + targetRemoved = true } } // Chain[i] is removed through handles[i], so the deepest one that can be @@ -607,9 +637,16 @@ func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization } 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)) + continue + } + // The deepest chain entry IS the ACL target for a directory + // materialization, and it is the leaf's parent when a racer made the leaf, + // so removing it means the target is gone either way. + if !materialization.FileMade && index == len(materialization.Chain)-1 { + targetRemoved = true } } - return errors.Join(errs...) + return targetRemoved, errors.Join(errs...) } // windowsACLMaterializeSwapHook is a test seam and nothing else. It fires inside diff --git a/internal/sandbox/windows_acl_materialize_swap_windows_test.go b/internal/sandbox/windows_acl_materialize_swap_windows_test.go index 8776b3427..4c4d2192d 100644 --- a/internal/sandbox/windows_acl_materialize_swap_windows_test.go +++ b/internal/sandbox/windows_acl_materialize_swap_windows_test.go @@ -217,7 +217,7 @@ func TestRollbackDoesNotFollowAnAncestorSwappedAfterCreation(t *testing.T) { // 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) + _, 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) @@ -261,7 +261,7 @@ func TestRollbackRefusesAnAnchorReplacedByARealDirectory(t *testing.T) { t.Fatalf("plant the decoy child: %v", err) } - err = rollbackWindowsACLMaterialization(created) + _, 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") { @@ -289,7 +289,7 @@ func TestRollbackLeavesDirectoriesItDidNotCreate(t *testing.T) { if created.AnchorPath != existing { t.Fatalf("anchor = %q, want the deepest pre-existing directory %q", created.AnchorPath, existing) } - if err := rollbackWindowsACLMaterialization(created); err != nil { + if _, err := rollbackWindowsACLMaterialization(created); err != nil { t.Fatalf("rollbackWindowsACLMaterialization: %v", err) } if _, err := os.Stat(filepath.Join(existing, "made")); !errors.Is(err, os.ErrNotExist) { @@ -354,7 +354,7 @@ func TestRollbackReportsWhatItCouldNotRemove(t *testing.T) { t.Fatalf("populate: %v", err) } - err = rollbackWindowsACLMaterialization(created) + _, err = rollbackWindowsACLMaterialization(created) if err == nil { t.Fatal("rollback reported success on a directory it could not empty, so callers cannot tell teardown failed") } diff --git a/internal/sandbox/windows_acl_raced_leaf_windows_test.go b/internal/sandbox/windows_acl_raced_leaf_windows_test.go new file mode 100644 index 000000000..65caf2078 --- /dev/null +++ b/internal/sandbox/windows_acl_raced_leaf_windows_test.go @@ -0,0 +1,124 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// racedLeafDenyMask reads the DENY mask a path carries for sid. +func racedLeafDenyMask(t *testing.T, path string, sid string) uint32 { + 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 the DACL of %s: %v", path, err) + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + t.Fatalf("read the DACL of %s: %v", path, err) + } + wanted, err := windows.StringToSid(sid) + if err != nil { + t.Fatalf("parse %q: %v", sid, err) + } + var mask uint32 + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if windows.GetAce(dacl, index, &ace) != nil { + continue + } + if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE { + continue + } + if !(*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(wanted) { + continue + } + mask |= uint32(ace.Mask) + } + return mask +} + +// OWNING THE PARENT IS NOT OWNING THE TARGET. +// +// rollbackWindowsACLSnapshots branched on createdAnything(), an OR across the +// whole materialization record. When this run creates the parent chain and a +// racer wins the leaf, that is true while the ACL-bearing file belongs to +// somebody else: Chain carries Made:true and FileMade is false. +// +// The rollback then could not remove the parent, because it is not empty, and +// the unconditional skip meant the existing no-follow, identity-validated +// restore never ran either. A file that existed before setup started was left +// wearing an aborted setup's DACL. +// +// Driven through applyWindowsACLPlan with the leaf created inside the swap hook, +// which fires after the anchor is pinned and before anything is made, so the +// race is deterministic rather than hoped for. +func TestAbortRestoresALeafARacerCreated(t *testing.T) { + workspace := t.TempDir() + parent := filepath.Join(workspace, "materialized") + leaf := filepath.Join(parent, "config") + + previous := windowsACLMaterializeSwapHook + t.Cleanup(func() { windowsACLMaterializeSwapHook = previous }) + windowsACLMaterializeSwapHook = func(string) { + // The racer wins the leaf, inside the parent this run is about to create. + if err := os.MkdirAll(parent, 0o700); err != nil { + return + } + _ = os.WriteFile(leaf, []byte("pre-existing content"), 0o600) + } + + sid := testPrincipalSID + before := racedLeafDenyMask(t, workspace, sid) + _ = before + + plan := WindowsACLPlan{Entries: []WindowsACLEntry{{ + Action: WindowsACLDenyWrite, + Path: leaf, + Capability: sid, + Materialize: true, + MaterializeFile: true, + }}} + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Skipf("cannot apply an ACL plan here: %v", err) + } + + // SETUP: the racer really won, and the apply really landed on its file. + if _, statErr := os.Stat(leaf); statErr != nil { + t.Skipf("SETUP: the racer's leaf is not present, so this is not the mixed-ownership case: %v", statErr) + } + applied := racedLeafDenyMask(t, leaf, sid) + if applied == 0 { + t.Skipf("SETUP: the apply did not put a deny ACE on the raced leaf, so there is nothing to restore") + } + + // The setup aborts. + if rollback != nil { + _ = rollback() + } + + // The racer's file must not be left wearing this run's ACL. + if got := racedLeafDenyMask(t, leaf, sid); got != 0 { + t.Fatalf("a file the racer created still carries the aborted setup's deny ACE (mask=%#x); rollback skipped its restore because an ancestor happened to be ours", got) + } + // And it must still exist: a raced leaf is never ours to delete. + body, readErr := os.ReadFile(leaf) + if readErr != nil { + t.Fatalf("rollback removed a file it did not create: %v", readErr) + } + if !strings.Contains(string(body), "pre-existing") { + t.Fatalf("the raced leaf's contents changed: %q", body) + } +} From 802f72687ff2aae0c16b5133ad08f181ee4a559e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 21:46:16 +0530 Subject: [PATCH 94/96] test(sandbox): reproduce the raced leaf at the instant it can actually happen The first version of this regression used the anchor-stage swap hook, which fires before the directory chain exists. Creating the parent there made the chain the RACER's, not this run's, so createdAnything() was false, the ordinary restore path ran, and the test passed with the fix reverted. The mixed ownership the finding is about needs the chain to be ours and only the leaf to be theirs, which is reachable at exactly one instant: after makeWindowsACLDirChainNoFollow returns and before createWindowsACLChildFile runs. windowsACLRacedLeafHook makes that instant addressable. Reverting the disposition check now fails it with the deny ACE still on the racer's file. --- internal/sandbox/windows_acl_apply_windows.go | 12 ++++++++++++ .../windows_acl_raced_leaf_windows_test.go | 18 +++++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 548ed7088..42c996265 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -516,6 +516,14 @@ func materializeWindowsACLTarget(path string, asFile bool) (windowsACLMaterializ if !asFile { return created, nil } + // windowsACLRacedLeafHook fires at the ONE instant a racer can win the leaf + // while this run already owns the chain above it: the directories exist and + // the file does not yet. That mixed ownership is what made the old rollback + // skip its restore, and it is unreachable from the anchor-stage hook, which + // fires before the chain exists at all. Always nil in production. + if windowsACLRacedLeafHook != nil { + windowsACLRacedLeafHook(path) + } // 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. @@ -656,6 +664,10 @@ func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization // is made addressable rather than hoped for. Always nil in production. var windowsACLMaterializeSwapHook func(anchor string) +// windowsACLRacedLeafHook fires after the directory chain is created and before +// the file leaf is. See materializeWindowsACLTarget. Always nil in production. +var windowsACLRacedLeafHook func(path string) + // makeWindowsACLDirChainNoFollow is an os.MkdirAll that never resolves a // pathname below its anchor. // diff --git a/internal/sandbox/windows_acl_raced_leaf_windows_test.go b/internal/sandbox/windows_acl_raced_leaf_windows_test.go index 65caf2078..6929d96e8 100644 --- a/internal/sandbox/windows_acl_raced_leaf_windows_test.go +++ b/internal/sandbox/windows_acl_raced_leaf_windows_test.go @@ -69,19 +69,20 @@ func TestAbortRestoresALeafARacerCreated(t *testing.T) { parent := filepath.Join(workspace, "materialized") leaf := filepath.Join(parent, "config") - previous := windowsACLMaterializeSwapHook - t.Cleanup(func() { windowsACLMaterializeSwapHook = previous }) - windowsACLMaterializeSwapHook = func(string) { - // The racer wins the leaf, inside the parent this run is about to create. - if err := os.MkdirAll(parent, 0o700); err != nil { + previous := windowsACLRacedLeafHook + t.Cleanup(func() { windowsACLRacedLeafHook = previous }) + raced := false + windowsACLRacedLeafHook = func(string) { + // The chain above already belongs to this run; the racer wins only the + // file. That mixed ownership is the whole case. + if raced { return } + raced = true _ = os.WriteFile(leaf, []byte("pre-existing content"), 0o600) } sid := testPrincipalSID - before := racedLeafDenyMask(t, workspace, sid) - _ = before plan := WindowsACLPlan{Entries: []WindowsACLEntry{{ Action: WindowsACLDenyWrite, @@ -95,6 +96,9 @@ func TestAbortRestoresALeafARacerCreated(t *testing.T) { t.Skipf("cannot apply an ACL plan here: %v", err) } + if !raced { + t.Fatal("SETUP INVALID: the leaf hook never fired, so the mixed-ownership case was not reproduced") + } // SETUP: the racer really won, and the apply really landed on its file. if _, statErr := os.Stat(leaf); statErr != nil { t.Skipf("SETUP: the racer's leaf is not present, so this is not the mixed-ownership case: %v", statErr) From 659140f6be93e5f7ab2e7bc1a84122925765725c Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 21:49:58 +0530 Subject: [PATCH 95/96] fix(cli): terminate the sandboxed command when the wrapper is cancelled runSandboxPlannedCommand started the backend wrapper with a bare exec.Command().Run(): no context, no signal forwarding, no shutdown path. A terminal masks that, because terminals signal the whole foreground process group, but a supervisor or task runner sending SIGTERM to the `zero sandbox exec` PID killed only Zero. The wrapper and everything under it kept running, doing filesystem and network work after the caller considered the task cancelled, and the deferred plan cleanup never ran because the process was gone. The CLI's shared shutdown context is threaded in, Cancel kills the tree, and WaitDelay bounds how long a child that ignores the first signal can hold the wrapper open and skip cleanup. DELIBERATELY WITHOUT execution.ConfigureProcessGroup, which the obvious fix would add. Putting the child in its own process group closes this hole and opens a worse one: it severs every kernel-delivered group signal, so a supervisor escalating to a group kill, and a terminal delivering Ctrl+C to its foreground group, would stop reaching the child. That was measured, not assumed: with the group change a group-directed kill left both the child and its grandchild alive, where today it reaps them. Keeping the child in Zero's group leaves group delivery exactly as it is, while the directed-signal case, which reached nothing at all before, now reaches the child. The remaining gap is a grandchild on the directed-signal path, which the group path still covers. Closing that too needs a job object on Windows and a group Zero owns on Unix, which is the trade above and a separate decision. The existing 128+signal mapping for a child that exits by signal is untouched; that is a different direction of signalling. --- internal/cli/sandbox_exec.go | 41 ++++++++++++- internal/cli/sandbox_exec_cancel_test.go | 78 ++++++++++++++++++++++++ internal/cli/sandbox_exec_env_test.go | 5 +- 3 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 internal/cli/sandbox_exec_cancel_test.go diff --git a/internal/cli/sandbox_exec.go b/internal/cli/sandbox_exec.go index 17cd0db86..e6fd92a34 100644 --- a/internal/cli/sandbox_exec.go +++ b/internal/cli/sandbox_exec.go @@ -1,14 +1,17 @@ package cli import ( + "context" "errors" "fmt" "io" "os" "os/exec" "strings" + "time" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/execution" zeroSandbox "github.com/Gitlawb/zero/internal/sandbox" ) @@ -109,11 +112,43 @@ func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appD fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason) } - return runSandboxPlannedCommand(plan, stdout, stderr) + // The CLI's shared shutdown context, so Ctrl+C and a directed SIGTERM both + // arrive here rather than only at whatever the terminal happens to signal. + runCtx, stopSignals := signalContext() + defer stopSignals() + return runSandboxPlannedCommand(runCtx, plan, stdout, stderr) } -func runSandboxPlannedCommand(plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int { - process := exec.Command(plan.Name, plan.Args...) +// sandboxExecShutdownGrace bounds how long a cancelled child may take to exit +// before it is killed outright, so cleanup still runs. +const sandboxExecShutdownGrace = 5 * time.Second + +func runSandboxPlannedCommand(ctx context.Context, plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int { + // CANCELLING THE WRAPPER HAS TO REACH THE COMMAND. + // + // This started the backend wrapper with a bare exec.Command().Run(): no + // context, no forwarding, no shutdown path. A terminal masks it, because + // terminals signal the whole foreground process group, but a supervisor or + // task runner that sends SIGTERM to the zero sandbox exec PID killed only + // Zero. The wrapper and everything under it kept running, doing filesystem + // and network work after the caller considered the task cancelled, and the + // deferred plan cleanup never ran because the process was gone. + // + // DELIBERATELY NOT execution.ConfigureProcessGroup. Putting the child in its + // own process group closes this hole and opens a worse one: it severs every + // kernel-delivered group signal, so a supervisor escalating to a group kill, + // and a terminal delivering Ctrl+C to its foreground group, would stop + // reaching the child. That trades a path that works today for one that does + // not. Keeping the child in Zero's group leaves group delivery unchanged, + // while the directed-signal case, which reached nothing at all before, now + // reaches the child. + process := exec.CommandContext(ctx, plan.Name, plan.Args...) + // Kill the tree rather than the root: on Windows that is taskkill /T, and on + // Unix the child plus its group when it leads one. + process.Cancel = func() error { return execution.KillProcessTree(process.Process.Pid) } + // Bounded, so a child ignoring the first signal cannot hold the wrapper open + // forever and skip cleanup anyway. + process.WaitDelay = sandboxExecShutdownGrace process.Dir = plan.Dir if process.Dir == "" { process.Dir = plan.WorkspaceRoot diff --git a/internal/cli/sandbox_exec_cancel_test.go b/internal/cli/sandbox_exec_cancel_test.go new file mode 100644 index 000000000..96916b882 --- /dev/null +++ b/internal/cli/sandbox_exec_cancel_test.go @@ -0,0 +1,78 @@ +package cli + +import ( + "context" + "io" + "os" + "runtime" + "testing" + "time" + + zeroSandbox "github.com/Gitlawb/zero/internal/sandbox" +) + +// longLivedPlan returns a plan whose child outlives the test unless something +// terminates it. +func longLivedPlan(t *testing.T) zeroSandbox.CommandPlan { + t.Helper() + plan := zeroSandbox.CommandPlan{Dir: t.TempDir()} + if runtime.GOOS == "windows" { + plan.Name = "cmd.exe" + plan.Args = []string{"/c", "ping -n 120 127.0.0.1 >NUL"} + return plan + } + plan.Name = "/bin/sh" + plan.Args = []string{"-c", "sleep 120"} + return plan +} + +// CANCELLING THE WRAPPER HAS TO REACH THE COMMAND. +// +// The sandboxed command was started with a bare exec.Command().Run(): no +// context, no forwarding, no shutdown path. A terminal masks that, because it +// signals the whole foreground process group, but a supervisor or task runner +// that sends SIGTERM to the wrapper's PID killed only Zero. The command kept +// running, doing filesystem and network work after the caller considered the +// task cancelled, and the deferred plan cleanup never ran. +// +// Driven with a real long-lived child and a real cancellation, asserting that +// the call actually returns rather than that a field is set. +func TestCancellingTheWrapperTerminatesTheSandboxedCommand(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan int, 1) + go func() { + done <- runSandboxPlannedCommand(ctx, longLivedPlan(t), io.Discard, io.Discard) + }() + + // Let the child actually start, or cancelling proves nothing. + time.Sleep(300 * time.Millisecond) + select { + case <-done: + t.Fatal("SETUP INVALID: the child exited on its own, so cancellation was not exercised") + default: + } + + cancel() + select { + case <-done: + case <-time.After(sandboxExecShutdownGrace + 10*time.Second): + t.Fatal("cancelling the wrapper did not terminate the sandboxed command; it would keep running after the caller gave up") + } +} + +// And an uncancelled command still runs to completion and reports its own +// status, or the fix above would be "kill everything immediately". +func TestAnUncancelledSandboxedCommandStillReportsItsStatus(t *testing.T) { + plan := zeroSandbox.CommandPlan{Dir: t.TempDir()} + if runtime.GOOS == "windows" { + plan.Name = "cmd.exe" + plan.Args = []string{"/c", "exit 3"} + } else { + plan.Name = "/bin/sh" + plan.Args = []string{"-c", "exit 3"} + } + if code := runSandboxPlannedCommand(context.Background(), plan, io.Discard, os.Stderr); code != 3 { + t.Fatalf("exit code = %d, want the child's own 3", code) + } +} diff --git a/internal/cli/sandbox_exec_env_test.go b/internal/cli/sandbox_exec_env_test.go index 327b854cf..17624671c 100644 --- a/internal/cli/sandbox_exec_env_test.go +++ b/internal/cli/sandbox_exec_env_test.go @@ -1,6 +1,7 @@ package cli import ( + "context" "os" "runtime" "strings" @@ -44,7 +45,7 @@ func TestAnEmptyPlannedEnvironmentDoesNotInherit(t *testing.T) { var out strings.Builder // Specified, and deliberately empty. - code := runSandboxPlannedCommand(envPrinterPlan(t, []string{}), &out, os.Stderr) + code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, []string{}), &out, os.Stderr) if code != 0 { t.Fatalf("child exited %d: %s", code, out.String()) } @@ -61,7 +62,7 @@ func TestANilPlannedEnvironmentStillInherits(t *testing.T) { t.Setenv(marker, "inherited-value") var out strings.Builder - code := runSandboxPlannedCommand(envPrinterPlan(t, nil), &out, os.Stderr) + code := runSandboxPlannedCommand(context.Background(), envPrinterPlan(t, nil), &out, os.Stderr) if code != 0 { t.Fatalf("child exited %d: %s", code, out.String()) } From 7e9a5241463c6c532e2b0706af367d6af491068f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 4 Sep 2026 22:13:07 +0530 Subject: [PATCH 96/96] fix(sandbox): decide the runtime base on a canonical spelling The Windows Smoke leg failed on my own new tests with "has no operator-owned base to descend from" for a perfectly ordinary tree. runtimeCandidateBase decides ownership with a containment test, and containment runs on spellings. A runner's temp is C:\Users\RUNNER~1\..., an 8.3 short name that compares unequal to the long form of the same directory the cache root resolves to. The same class of mismatch prepareSandboxRuntime already documents for its own comparison. ensureRuntimeTreeDirs now canonicalizes before asking, the same way the rest of the runtime state does. The tests were the other half of it: a bare t.TempDir() only lands under the user cache directory on a machine where temp happens to sit there, which is true on my box and false on the runner. They now pin the cache root themselves, so they are about the descent rather than about where temp lives. --- internal/sandbox/runtime_tree.go | 8 +++++++ .../runtime_tree_redirect_windows_test.go | 22 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/internal/sandbox/runtime_tree.go b/internal/sandbox/runtime_tree.go index e70b71705..4df3da27c 100644 --- a/internal/sandbox/runtime_tree.go +++ b/internal/sandbox/runtime_tree.go @@ -28,6 +28,14 @@ import ( // the grant exists to prevent. This descent validates and creates; it does not // re-secure. func ensureRuntimeTreeDirs(root string, directories []string) error { + // Canonicalized the same way the rest of the runtime state is, because + // runtimeCandidateBase decides ownership with a containment test and that + // test runs on spellings. An 8.3 short name (C:UsersRUNNER~1) or a + // symlinked temp compares unequal to the long form of the same directory, + // which reads as "no operator-owned base" for a perfectly ordinary tree. + if canonical := canonicalSandboxWorkspaceRoot(root); canonical != "" { + root = canonical + } base, ok := runtimeCandidateBase(root) if !ok || strings.TrimSpace(base) == "" { return fmt.Errorf("sandbox runtime root %s has no operator-owned base to descend from", root) diff --git a/internal/sandbox/runtime_tree_redirect_windows_test.go b/internal/sandbox/runtime_tree_redirect_windows_test.go index 14e7ad2b0..55c0e8ee0 100644 --- a/internal/sandbox/runtime_tree_redirect_windows_test.go +++ b/internal/sandbox/runtime_tree_redirect_windows_test.go @@ -25,7 +25,7 @@ import ( // A junction needs no privilege, so the previous command's half runs here on an // ordinary unelevated box. func TestRuntimeTreePreparationRefusesARedirectedDescendant(t *testing.T) { - base := t.TempDir() + base := runtimeTreeTestBase(t) target := t.TempDir() root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789") cache := filepath.Join(root, "cache") @@ -69,7 +69,7 @@ func TestRuntimeTreePreparationRefusesARedirectedDescendant(t *testing.T) { // And an ordinary tree is still created, or the refusal above would be satisfied // by a preparation that refuses everything. func TestRuntimeTreePreparationStillCreatesAnOrdinaryTree(t *testing.T) { - base := t.TempDir() + base := runtimeTreeTestBase(t) root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789") cache := filepath.Join(root, "cache") npm := filepath.Join(cache, "npm") @@ -87,7 +87,7 @@ func TestRuntimeTreePreparationStillCreatesAnOrdinaryTree(t *testing.T) { // Reusing an existing tree is the common case and must not be refused. func TestRuntimeTreePreparationIsIdempotent(t *testing.T) { - base := t.TempDir() + base := runtimeTreeTestBase(t) root := filepath.Join(base, "zero", "runtime", "v1", "abcdef0123456789") dirs := []string{root, filepath.Join(root, "cache"), filepath.Join(root, "cache", "npm")} if err := ensureRuntimeTreeDirs(root, dirs); err != nil { @@ -173,3 +173,19 @@ func TestPrepareSandboxRuntimeStillPreparesAnOrdinaryTree(t *testing.T) { } } } + +// runtimeTreeTestBase makes the operator-owned base deterministic. +// +// runtimeCandidateBase answers by containment against the user cache directory, +// so a bare t.TempDir() only works where temp happens to sit under it. On a CI +// runner it does not, or its 8.3 short name compares unequal to the long form, +// and the tree is reported as having no owned base. Stubbing the cache root is +// what makes these tests about the descent rather than about where temp lives. +func runtimeTreeTestBase(t *testing.T) string { + t.Helper() + base := canonicalSandboxWorkspaceRoot(t.TempDir()) + previous := sandboxUserCacheDir + t.Cleanup(func() { sandboxUserCacheDir = previous }) + sandboxUserCacheDir = func() (string, error) { return base, nil } + return base +}