From fbb3db9da9ca51ed52d59556c5079e26c731d088 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 13 Aug 2026 15:58:13 +0530 Subject: [PATCH 01/38] fix(sandbox): agree on the runtime root across the Windows setup marker Every exec_command on a Windows machine that had run `zero sandbox setup` aborted with "windows sandbox setup is out of date: permission roots or deny lists changed". File tools worked, only shell execution died, and `zero doctor` reported the backend healthy throughout, so there was nothing pointing at the cause. Reported in #881 with an accurate trace. Setup fingerprinted the bare permission profile into the marker. Every command arrived with the per-workspace runtime root already appended by permissionProfileWithRuntime, so the plan hash the runner computed could never match the one setup stored, and a marker written seconds earlier was rejected forever. Both sides now fold in the same runtime candidate set before the profile is fingerprinted. Three things this needs to get right, each of which broke it once. BOTH candidates, not the one this process would pick. sandboxRuntimeRootFor prefers the cache-derived root and falls back to the temp-derived one when the cache sits inside the workspace, and that choice is per process. Granting only one left a command that fell back writing to a tree with no ACE on it. The fallback has to be derived rather than minted. It used os.MkdirTemp memoized in a process-global map, so the answer was private to whichever process asked first: setup granted temp root A, the next command derived root B, teardown cleaned a third. It is now a hash of the workspace and creates nothing, so every process agrees without sharing state. The runner cannot derive the candidates itself. It runs re-exec'd with TEMP and TMP already pointed at the sandbox runtime temp, so os.TempDir() there returns the redirected value and the temp-derived candidate comes out rooted under the runtime tree. The profile is augmented in the parent and passed down. Split out of #808, which carries this fix among the Windows principal work. That PR has open architectural questions and this does not, and the bug is a user-visible outage on one platform, so it should not wait on them. On the tests, because one of them was not enough. The composition test calls the helper directly and stays green even with the production call site deleted, which is the same class of bug as the fix itself. The added call-path test drives BuildCommandPlan and asserts the runtime roots reach the runner's argv; reverting the runner call fails it with the missing root named. --- internal/sandbox/runtime_state.go | 152 +++++++++--- internal/sandbox/windows_runner.go | 27 ++- .../windows_runner_marker_windows_test.go | 68 ++++++ internal/sandbox/windows_setup.go | 122 ++++++++++ .../windows_setup_runtime_root_test.go | 225 ++++++++++++++++++ 5 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 internal/sandbox/windows_runner_marker_windows_test.go create mode 100644 internal/sandbox/windows_setup_runtime_root_test.go diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 4a5fdfc9a..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"` @@ -33,8 +28,37 @@ 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) { + 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. +// +// 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])) + return root, !pathWithinRoot(workspaceRoot, root) +} + 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") } @@ -42,17 +66,20 @@ 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") } - 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 { @@ -174,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 } @@ -226,3 +268,59 @@ 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 + } + } + // 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 + } +} diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 032f2a844..2514b3d65 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -336,13 +336,26 @@ 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}, + // The SAME augmentation setup applied, so the marker's plan and this + // command's plan describe the same roots. + // + // The runner cannot derive the candidates itself: it runs re-exec'd with + // TEMP and TMP already 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, and every + // command then died on "permission roots or deny lists changed" with two + // plans that had the same number of entries and different paths. + 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_runner_marker_windows_test.go b/internal/sandbox/windows_runner_marker_windows_test.go new file mode 100644 index 000000000..667261b1a --- /dev/null +++ b/internal/sandbox/windows_runner_marker_windows_test.go @@ -0,0 +1,68 @@ +//go:build windows + +package sandbox + +import ( + "path/filepath" + "strings" + "testing" +) + +// THE AUGMENTATION HAS TO HAPPEN ON THE COMMAND PATH, NOT ONLY IN A HELPER. +// +// windows_setup_runtime_root_test.go proves the pieces compose: given a profile +// put through WindowsSandboxProfileWithRuntimeRoots on both sides, the marker +// validates. It calls that function directly, so it stays green even when the +// production call site in BuildCommandPlan is deleted, which is exactly the +// shape of the bug being fixed here. Reverting the runner call and watching that +// test still pass is how this gap was found. +// +// So this drives the real path and asserts on what the runner is actually handed. +// The config is serialized into the runner's argv, so the argv is where to look: +// recomputing the profile in the test would just be the helper test again. +func TestBuildCommandPlanCarriesTheRuntimeRootsIntoTheRunnerArgs(t *testing.T) { + workspace := t.TempDir() + cacheRoot := t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{ + Name: BackendWindowsRestrictedToken, + Available: true, + Platform: "windows", + Executable: filepath.Join(t.TempDir(), WindowsSandboxCommandRunnerName), + CommandWrapping: true, + NativeIsolation: true, + }, + }) + + plan, err := engine.BuildCommandPlan(CommandSpec{ + Name: "cmd.exe", + Args: WindowsShellArgs("echo hi"), + Dir: workspace, + }) + if err != nil { + t.Fatalf("BuildCommandPlan: %v", err) + } + defer plan.Cleanup() + + candidates := windowsSandboxRuntimeCandidates([]string{workspace}) + if len(candidates) == 0 { + t.Fatal("no runtime candidates derived, so this test would pass vacuously") + } + + // The profile reaches the runner as JSON inside one of these arguments. + argv := strings.Join(plan.Args, "\x00") + for _, candidate := range candidates { + // JSON escapes the backslashes in a Windows path, so compare in the same + // spelling the encoder produced rather than the raw path. + encoded := strings.ReplaceAll(candidate, `\`, `\\`) + if !strings.Contains(argv, encoded) && !strings.Contains(argv, candidate) { + t.Errorf("the runner argv does not carry runtime root %s; setup grants it, so the plans disagree and every command dies on \"permission roots or deny lists changed\"", candidate) + } + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 3fc9634e8..9ce1d764a 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -66,6 +66,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. The temp-derived candidate reads os.TempDir(), so it has + // to be resolved where the environment is still 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) @@ -306,3 +310,121 @@ 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 +} + +// 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 +} + +// 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 +} + +// 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 new file mode 100644 index 000000000..9a7cd22d1 --- /dev/null +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -0,0 +1,225 @@ +package sandbox + +import ( + "os" + "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) + // 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) + } + + candidates := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + if len(candidates) == 0 { + t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") + } + for _, candidate := range candidates { + augmented := config + // 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 { + t.Fatalf("ValidateWindowsSandboxSetupMarker with runtime root %s: %v", candidate, err) + } + } + + // 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 + 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) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots) + 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) + } + } +} + +// 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) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots) + 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 +// 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 cbba57aae8c28405f74230e566e9690b081ed98e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 13 Aug 2026 16:56:30 +0530 Subject: [PATCH 02/38] fix(sandbox): create the Windows runtime roots the ACL plan grants Review found that the runtime roots this PR folds into the permission profile were granted but never created. applyWindowsACLPlan materializes only DenyRead targets, so an AllowWrite target that does not exist fails the whole run with "windows ACL target does not exist" -- turning a clean `zero sandbox setup` into a different outage than the one being fixed. ensureWindowsSandboxRuntimeCandidates already existed for exactly this reason and its doc comment names the failure, but the split from the parent PR carried the function across without its call site. Provisioning now sits with whoever computes the candidates, because the two have to happen in the same environment: - buildWindowsSandboxSetupACLPlan provisions, then builds the elevated plan. - windowsSandboxProfileWithProvisionedRuntime provisions, then returns the command profile, and BuildCommandPlan calls it in the PARENT. The unelevated tier applies its own plan inside the re-exec'd runner, where TEMP points into the runtime tree, so the runner can derive neither the paths nor the directories. Also from review: - doctor checked the marker against the bare profile, so `zero doctor` reported a correctly prepared machine as out of date. It now folds in the same roots setup and the command plan use. - fallbackSandboxRuntimeRoot derived from a raw os.TempDir() while pathWithinRoot compares spellings, so an aliased TEMP read as outside the workspace. Canonicalized like the workspace and cache roots. This closes short-name and symlink aliases, not junctions, which EvalSymlinks returns unchanged; the comment says so rather than implying the case is shut. - windowsSandboxRuntimeCandidates deriving from the first workspace root only is deliberate, not a defect: the marker compares plan hashes for equality and a command presents one root, so widening it would put entries in the marker no command reproduces. Documented and pinned by a test, since the suggested fix would have reintroduced the outage. Tests drive production entry points rather than the helpers behind them. The previous round shipped tests that called the helpers directly and stayed green with the call sites deleted, which is how the missing provisioning survived. Each of the four new assertions was verified to fail with its fix reverted. --- internal/doctor/hardening.go | 14 +- internal/sandbox/runtime_state.go | 18 +- internal/sandbox/windows_runner.go | 23 +- .../windows_runner_marker_windows_test.go | 66 ++++++ internal/sandbox/windows_setup.go | 54 ++++- .../sandbox/windows_setup_provision_test.go | 205 ++++++++++++++++++ internal/sandbox/windows_setup_windows.go | 4 +- internal/sandbox/windows_unelevated.go | 5 + 8 files changed, 373 insertions(+), 16 deletions(-) create mode 100644 internal/sandbox/windows_setup_provision_test.go diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index baf21e04c..46a9df326 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -100,10 +100,16 @@ 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}, + // The same augmentation setup and the command plan apply, so doctor + // fingerprints what a real command fingerprints. Checking the bare profile + // made doctor call a correctly prepared machine "out of date", which is the + // mismatch this pairing exists to close. Safe to resolve in this process: + // doctor runs in the operator's shell, not behind the sandbox TEMP + // redirection that stops the runner deriving these for itself. + PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}), } 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/runtime_state.go b/internal/sandbox/runtime_state.go index 0bde14441..3647e119e 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -221,8 +221,24 @@ func combineSandboxCleanups(cleanups ...func()) func() { // 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) { + // Canonicalized for the same reason prepareSandboxRuntime canonicalizes the + // workspace and cache roots: pathWithinRoot compares SPELLINGS, so a raw + // os.TempDir() measured against a canonical workspace root compares two + // different names for one directory and the containment check misses. Both + // callers resolve this in the operator's environment, so the derived path is + // identical on the setup and command sides and the plan hashes still agree. + // + // This closes the 8.3 short-name and symlink spellings, not every alias. A + // Windows directory JUNCTION comes back from EvalSymlinks unchanged, so a TEMP + // that is a junction into the workspace still reads as outside it. Closing that + // needs a physical identity check (os.SameFile against existing ancestors, or + // GetFinalPathNameByHandle) rather than a string comparison. + tempRoot := canonicalSandboxWorkspaceRoot(os.TempDir()) + if tempRoot == "" || tempRoot == "." { + return "", errors.New("temp directory is unavailable") + } digest := sha256.Sum256([]byte(workspaceRoot)) - root := filepath.Join(os.TempDir(), "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) + root := filepath.Join(tempRoot, "zero", "runtime", "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 diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 2514b3d65..a0de8e14e 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -335,6 +335,18 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli if execRequest.EnforcementLevel == EnforcementUnelevated { level = WindowsSandboxLevelUnelevated } + // Derived AND created here, in the caller's shell. The unelevated tier applies + // this plan itself, and applyWindowsACLPlan fails the whole run on an AllowWrite + // target that does not exist; prepareSandboxRuntime creates only the candidate + // this process selects, so the other one has to be created explicitly. The + // runner cannot do it, for the same reason it cannot derive them (below). + runtimeProfile, err := windowsSandboxProfileWithProvisionedRuntime( + execRequest.PermissionProfile, + []string{execRequest.WorkspaceRoot}, + ) + if err != nil { + return CommandPlan{}, err + } args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{ SandboxHome: sandboxHome, CommandCWD: spec.Dir, @@ -349,13 +361,10 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli // Setup, whose TEMP is untouched, derived the other spelling, and every // command then died on "permission roots or deny lists changed" with two // plans that had the same number of entries and different paths. - PermissionProfile: windowsSandboxProfileWithRuntime( - execRequest.PermissionProfile, - []string{execRequest.WorkspaceRoot}, - ), - Env: childEnv, - SandboxLevel: level, - Command: append([]string{spec.Name}, spec.Args...), + PermissionProfile: runtimeProfile, + Env: childEnv, + SandboxLevel: level, + Command: append([]string{spec.Name}, spec.Args...), }) if err != nil { return CommandPlan{}, err diff --git a/internal/sandbox/windows_runner_marker_windows_test.go b/internal/sandbox/windows_runner_marker_windows_test.go index 667261b1a..50a102a11 100644 --- a/internal/sandbox/windows_runner_marker_windows_test.go +++ b/internal/sandbox/windows_runner_marker_windows_test.go @@ -3,6 +3,7 @@ package sandbox import ( + "os" "path/filepath" "strings" "testing" @@ -66,3 +67,68 @@ func TestBuildCommandPlanCarriesTheRuntimeRootsIntoTheRunnerArgs(t *testing.T) { } } } + +// GRANTING A ROOT IS NOT THE SAME AS PROVISIONING IT. +// +// The unelevated tier applies this plan itself, and applyWindowsACLPlan +// materializes only DenyRead targets: an AllowWrite target that does not exist +// aborts the run with "windows ACL target does not exist". prepareSandboxRuntime +// creates only the candidate the process SELECTS, so the other one has to be +// created explicitly, and it has to happen in the parent because the runner runs +// with TEMP redirected into the runtime tree and derives a different temp-side +// spelling. +func TestBuildCommandPlanProvisionsTheRuntimeRootsItGrants(t *testing.T) { + workspace := t.TempDir() + cacheRoot := t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + candidates := windowsSandboxRuntimeCandidates([]string{workspace}) + if len(candidates) == 0 { + t.Fatal("no runtime candidates derived, so this test would pass vacuously") + } + for _, candidate := range candidates { + if err := os.RemoveAll(candidate); err != nil { + t.Fatalf("clear candidate %s: %v", candidate, err) + } + } + + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{ + Name: BackendWindowsRestrictedToken, + Available: true, + Platform: "windows", + Executable: filepath.Join(t.TempDir(), WindowsSandboxCommandRunnerName), + CommandWrapping: true, + NativeIsolation: true, + }, + }) + + plan, err := engine.BuildCommandPlan(CommandSpec{ + Name: "cmd.exe", + Args: WindowsShellArgs("echo hi"), + Dir: workspace, + }) + if err != nil { + t.Fatalf("BuildCommandPlan: %v", err) + } + defer plan.Cleanup() + + for _, candidate := range candidates { + info, err := os.Stat(candidate) + if err != nil { + t.Errorf("runtime root %s is granted by the plan but was not created: %v; the unelevated tier aborts on \"windows ACL target does not exist\"", candidate, err) + continue + } + if !info.IsDir() { + t.Errorf("runtime root %s exists but is not a directory", candidate) + } + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 9ce1d764a..f76508cb0 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -320,6 +320,17 @@ func canonicalWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry { // 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. +// +// The FIRST root only, and that is deliberate rather than an oversight. The +// marker compares plan hashes for EQUALITY, and a command presents exactly one +// workspace root, so setup has to derive its candidates from the same single root +// the command will. Deriving them for every root instead would put candidates in +// the marker that no single command reproduces, and every command would fail with +// the same "permission roots or deny lists changed" this pairing exists to fix. +// Nothing passes more than one root today: every construction site is a +// one-element slice. Whoever adds multi-root support has to change the marker to a +// per-root or subset comparison FIRST; widening this function on its own would +// reintroduce the outage. func windowsSandboxRuntimeCandidates(workspaceRoots []string) []string { workspaceRoot := "" for _, candidate := range workspaceRoots { @@ -391,9 +402,13 @@ func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots // 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. +// Called by WHOEVER APPLIES THE PLAN, which is both tiers rather than only the +// elevated one. Setup applies it under Administrator; the unelevated tier applies +// its own workspace ACLs per command by design, since capability grants on trees +// the user already owns need no privilege. A command creating a runtime root under +// its own cache or temp grants itself nothing it could not create anyway, and the +// tier that skips this is the tier that dies on "windows ACL target does not +// exist". func ensureWindowsSandboxRuntimeCandidates(workspaceRoots []string) error { for _, root := range windowsSandboxRuntimeCandidates(workspaceRoots) { if err := os.MkdirAll(root, 0o700); err != nil { @@ -403,6 +418,39 @@ func ensureWindowsSandboxRuntimeCandidates(workspaceRoots []string) error { return nil } +// buildWindowsSandboxSetupACLPlan provisions the runtime roots and then builds the +// plan that grants them, in that order. +// +// One function rather than two statements at the call site because the ordering is +// the contract: BuildWindowsACLPlan emits AllowWrite entries for the runtime +// candidates, applyWindowsACLPlan materializes only DenyRead targets, and an +// AllowWrite target that does not exist fails the entire run. The elevated setup +// path had the provisioning omitted once already, which turned a clean `zero +// sandbox setup` into "windows ACL target does not exist". Keeping the two joined +// here means a caller cannot get the plan without the trees it names. +func buildWindowsSandboxSetupACLPlan(config WindowsSandboxSetupConfig) (WindowsACLPlan, error) { + if err := ensureWindowsSandboxRuntimeCandidates(config.WorkspaceRoots); err != nil { + return WindowsACLPlan{}, err + } + return BuildWindowsACLPlan(config.commandConfig()) +} + +// windowsSandboxProfileWithProvisionedRuntime is the command-side counterpart: +// it creates the runtime candidates and returns the profile that grants them. +// +// Joined for the same reason as the setup helper, and called from the PARENT for +// one more. The unelevated tier applies its own ACL plan inside the re-exec'd +// runner, where TEMP points into the runtime tree; deriving the candidates there +// yields a temp-side root under the redirected temp rather than the one the plan +// names, so the runner can neither derive nor provision them. The parent still has +// the operator's environment, so it does both and the runner only applies. +func windowsSandboxProfileWithProvisionedRuntime(profile PermissionProfile, workspaceRoots []string) (PermissionProfile, error) { + if err := ensureWindowsSandboxRuntimeCandidates(workspaceRoots); err != nil { + return PermissionProfile{}, err + } + return windowsSandboxProfileWithRuntime(profile, workspaceRoots), 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. diff --git a/internal/sandbox/windows_setup_provision_test.go b/internal/sandbox/windows_setup_provision_test.go new file mode 100644 index 000000000..14edc05cf --- /dev/null +++ b/internal/sandbox/windows_setup_provision_test.go @@ -0,0 +1,205 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The tests here drive PRODUCTION entry points rather than the helpers those entry +// points call. The distinction is the point: this runtime-root work shipped once +// with tests that called windowsSandboxProfileWithRuntime and +// ensureWindowsSandboxRuntimeCandidates directly, and they stayed green while the +// production call sites were missing outright. A test that reaches past the caller +// proves the helper works and says nothing about whether anything calls it. + +func windowsRuntimeCandidatesForTest(t *testing.T, workspaceRoot string) []string { + t.Helper() + candidates := windowsSandboxRuntimeCandidates([]string{workspaceRoot}) + if len(candidates) == 0 { + t.Skip("no runtime candidates derivable in this environment") + } + return candidates +} + +// TestBuildWindowsSandboxSetupACLPlanCreatesTheRootsItGrants is the regression for +// the defect this PR shipped with: the plan named the runtime roots as AllowWrite +// targets and nothing created them. applyWindowsACLPlan materializes only DenyRead +// targets, so an absent AllowWrite target aborts the whole elevated setup with +// "windows ACL target does not exist". +func TestBuildWindowsSandboxSetupACLPlanCreatesTheRootsItGrants(t *testing.T) { + workspaceRoot := t.TempDir() + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + candidates := windowsRuntimeCandidatesForTest(t, workspaceRoot) + for _, candidate := range candidates { + if err := os.RemoveAll(candidate); err != nil { + t.Fatalf("clear candidate %s: %v", candidate, err) + } + } + + config := WindowsSandboxSetupConfig{ + SandboxHome: t.TempDir(), + CommandCWD: workspaceRoot, + WorkspaceRoots: []string{workspaceRoot}, + // Augmented, because that is what the elevated helper parses off the wire: + // BuildWindowsSandboxSetupArgs folds the runtime roots in before the re-exec, + // so by the time runWindowsSandboxSetup builds this plan the roots are + // already write roots. Feeding a bare profile here would test a shape + // production never produces. + PermissionProfile: WindowsSandboxProfileWithRuntimeRoots( + PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil), + []string{workspaceRoot}, + ), + } + plan, err := buildWindowsSandboxSetupACLPlan(config) + if err != nil { + t.Fatalf("buildWindowsSandboxSetupACLPlan: %v", err) + } + + granted := map[string]struct{}{} + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowWrite { + granted[windowsCapabilityPathKey(entry.Path)] = struct{}{} + } + } + for _, candidate := range candidates { + if _, ok := granted[windowsCapabilityPathKey(candidate)]; !ok { + t.Fatalf("plan does not grant runtime root %s; the marker and the command plan will disagree", candidate) + } + info, err := os.Stat(candidate) + if err != nil { + t.Fatalf("runtime root %s is granted but absent: %v; applyWindowsACLPlan aborts the entire setup with \"windows ACL target does not exist\"", candidate, err) + } + if !info.IsDir() { + t.Fatalf("runtime root %s exists but is not a directory", candidate) + } + } +} + +// TestBuildWindowsSandboxSetupArgsCarriesEveryRuntimeCandidate closes the gap the +// reviewer named: every prior test fed BuildWindowsSandboxSetupArgs a profile the +// caller had already augmented, so deleting the augmentation inside the builder +// left the suite green. This one hands it a BARE profile and decodes the argument +// the elevated helper actually receives. +func TestBuildWindowsSandboxSetupArgsCarriesEveryRuntimeCandidate(t *testing.T) { + workspaceRoot := t.TempDir() + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + candidates := windowsRuntimeCandidatesForTest(t, workspaceRoot) + bare := PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil) + for _, root := range bare.FileSystem.WriteRoots { + for _, candidate := range candidates { + if windowsCapabilityPathKey(root.Root) == windowsCapabilityPathKey(candidate) { + t.Fatalf("the bare profile already contains runtime root %s, so this test could not detect the augmentation going missing", candidate) + } + } + } + + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: t.TempDir(), + CommandCWD: workspaceRoot, + // Deliberately NOT pre-augmented: the builder folds the runtime roots in + // itself, and passing them here would hide it if that ever stopped. + PermissionProfile: bare, + WorkspaceRoots: []string{workspaceRoot}, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + + encoded := "" + for i, arg := range args { + if arg == "--permission-profile" && i+1 < len(args) { + encoded = args[i+1] + break + } + if strings.HasPrefix(arg, "--permission-profile=") { + encoded = strings.TrimPrefix(arg, "--permission-profile=") + break + } + } + if encoded == "" { + t.Fatalf("no --permission-profile argument in %v", args) + } + var decoded PermissionProfile + if err := json.Unmarshal([]byte(encoded), &decoded); err != nil { + t.Fatalf("decode --permission-profile: %v", err) + } + + present := map[string]struct{}{} + for _, root := range decoded.FileSystem.WriteRoots { + present[windowsCapabilityPathKey(root.Root)] = struct{}{} + } + for _, candidate := range candidates { + if _, ok := present[windowsCapabilityPathKey(candidate)]; !ok { + t.Fatalf("the setup args omit runtime root %s; setup would fingerprint a profile no command reproduces and every command would die on \"permission roots or deny lists changed\"", candidate) + } + } +} + +// TestFallbackSandboxRuntimeRootIsSpellingStable pins the canonicalization added +// for the aliased-TEMP finding. Two spellings of one directory must produce one +// runtime root; producing two is what let a root resolving inside the workspace +// pass the containment check. +func TestFallbackSandboxRuntimeRootIsSpellingStable(t *testing.T) { + workspaceRoot := t.TempDir() + tempRoot := t.TempDir() + + // An UPPER-CASED spelling is the one alias available without privilege or a + // volume setting: 8.3 generation is disabled on many volumes and creating a + // symlink needs a privilege the test process may not hold, while a + // case-insensitive filesystem resolves this to the same directory and + // GetLongPathName returns the on-disk casing. Skipped rather than passed + // vacuously where the filesystem is case-sensitive, since there the two names + // really are different directories. + alias := strings.ToUpper(tempRoot) + canonical := canonicalSandboxWorkspaceRoot(tempRoot) + if alias == tempRoot || canonicalSandboxWorkspaceRoot(alias) != canonical { + t.Skip("no distinct alias spelling of the temp dir is constructible here") + } + + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + viaReal, err := fallbackSandboxRuntimeRoot(workspaceRoot) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot(real spelling): %v", err) + } + + t.Setenv("TMP", alias) + t.Setenv("TEMP", alias) + viaAlias, err := fallbackSandboxRuntimeRoot(workspaceRoot) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot(alias): %v", err) + } + + if viaReal != viaAlias { + t.Fatalf("two spellings of ONE temp directory produced two runtime roots:\n via %s\n -> %s\n via %s\n -> %s\nsetup and the command side derive from the same function, so they would grant and expect different paths, and pathWithinRoot would measure the workspace against a spelling it does not match", tempRoot, viaReal, alias, viaAlias) + } +} + +// TestWindowsSandboxRuntimeCandidatesUsesOneWorkspaceRoot pins the single-root +// contract rather than treating it as a defect. The marker compares plan hashes +// for EQUALITY and a command presents exactly one root, so deriving candidates for +// every root would put entries in the marker that no command reproduces. Whoever +// widens this has to change the marker comparison in the same change. +func TestWindowsSandboxRuntimeCandidatesUsesOneWorkspaceRoot(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + + combined := windowsSandboxRuntimeCandidates([]string{first, second}) + alone := windowsSandboxRuntimeCandidates([]string{first}) + if len(combined) == 0 { + t.Skip("no runtime candidates derivable in this environment") + } + separator := string(filepath.ListSeparator) + if strings.Join(combined, separator) != strings.Join(alone, separator) { + t.Fatalf("a second workspace root changed the candidate set:\n [first, second] %v\n [first] %v\nsetup would grant roots no single command reproduces", combined, alone) + } +} diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 888355397..8ff598764 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -17,7 +17,9 @@ 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 } - plan, err := BuildWindowsACLPlan(config.commandConfig()) + // Provisions the runtime candidate roots, then builds the plan that grants + // them. One call because a granted-but-absent write root fails the whole apply. + plan, err := buildWindowsSandboxSetupACLPlan(config) if err != nil { fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 diff --git a/internal/sandbox/windows_unelevated.go b/internal/sandbox/windows_unelevated.go index 980664d6a..044805766 100644 --- a/internal/sandbox/windows_unelevated.go +++ b/internal/sandbox/windows_unelevated.go @@ -47,6 +47,11 @@ func WindowsUnelevatedSetupMarkerPath(sandboxHome string) string { // output later feeds the apply step, so the fingerprint and the applied grants // can never drift apart. func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (WindowsUnelevatedAppliedPlan, WindowsACLPlan, error) { + // No provisioning here, deliberately. This tier applies a plan whose runtime + // write roots were derived and created by the PARENT, because this process runs + // re-exec'd with TEMP redirected into the runtime tree and would derive a + // different temp-side spelling than the one the plan names. See the note at the + // windowsSandboxProfileWithProvisionedRuntime call in BuildCommandPlan. plan, err := BuildWindowsACLPlan(config) if err != nil { return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err From 905eae97930b923eac8008d7424e561e865b332f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 13 Aug 2026 22:28:47 +0530 Subject: [PATCH 03/38] test(sandbox): keep the provisioning tests inside test-owned directories The new provisioning tests derived their runtime candidates from the real os.UserCacheDir() and then cleared one to prove provisioning recreates it, so every run deleted a directory under the developer's actual cache and the tests failed outright on a read-only home. The test has no ownership claim on that path, so it must not derive one. windowsRuntimeTestRoots now stubs sandboxUserCacheDir and TMP/TEMP to t.TempDir() before deriving anything, and refuses to run at all if a derived candidate falls outside those owned roots, so a later change to the derivation cannot quietly reintroduce this. Measured against the real cache directory: the previous version added an entry on every run, this one adds none. --- .../sandbox/windows_setup_provision_test.go | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/internal/sandbox/windows_setup_provision_test.go b/internal/sandbox/windows_setup_provision_test.go index 14edc05cf..aa18bed34 100644 --- a/internal/sandbox/windows_setup_provision_test.go +++ b/internal/sandbox/windows_setup_provision_test.go @@ -15,13 +15,40 @@ import ( // production call sites were missing outright. A test that reaches past the caller // proves the helper works and says nothing about whether anything calls it. -func windowsRuntimeCandidatesForTest(t *testing.T, workspaceRoot string) []string { +// windowsRuntimeTestRoots returns a workspace plus the runtime candidates derived +// for it, with EVERY location the derivation reads pointed at test-owned +// directories. +// +// Stubbing sandboxUserCacheDir is not tidiness. windowsSandboxRuntimeCandidates +// reads the real user cache when it is left alone, so a test that then clears a +// candidate to prove provisioning recreates it was deleting the developer's own +// zero runtime tree under the real cache on every run, and failing outright on a +// read-only home. The test has no ownership claim on that path, so it must not +// derive one. windows_runner_marker_windows_test.go had this right already. +func windowsRuntimeTestRoots(t *testing.T) (string, []string) { t.Helper() + workspaceRoot := t.TempDir() + cacheRoot := t.TempDir() + tempRoot := t.TempDir() + + originalCacheDir := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir }) + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + candidates := windowsSandboxRuntimeCandidates([]string{workspaceRoot}) if len(candidates) == 0 { t.Skip("no runtime candidates derivable in this environment") } - return candidates + // Belt and braces: refuse to run rather than touch anything the test does not + // own, so a later change to the derivation cannot quietly reintroduce this. + for _, candidate := range candidates { + if !pathWithinRoot(cacheRoot, candidate) && !pathWithinRoot(tempRoot, candidate) { + t.Fatalf("candidate %s is outside the test-owned cache (%s) and temp (%s) roots; refusing to modify it", candidate, cacheRoot, tempRoot) + } + } + return workspaceRoot, candidates } // TestBuildWindowsSandboxSetupACLPlanCreatesTheRootsItGrants is the regression for @@ -30,12 +57,7 @@ func windowsRuntimeCandidatesForTest(t *testing.T, workspaceRoot string) []strin // targets, so an absent AllowWrite target aborts the whole elevated setup with // "windows ACL target does not exist". func TestBuildWindowsSandboxSetupACLPlanCreatesTheRootsItGrants(t *testing.T) { - workspaceRoot := t.TempDir() - tempRoot := t.TempDir() - t.Setenv("TMP", tempRoot) - t.Setenv("TEMP", tempRoot) - - candidates := windowsRuntimeCandidatesForTest(t, workspaceRoot) + workspaceRoot, candidates := windowsRuntimeTestRoots(t) for _, candidate := range candidates { if err := os.RemoveAll(candidate); err != nil { t.Fatalf("clear candidate %s: %v", candidate, err) @@ -87,12 +109,7 @@ func TestBuildWindowsSandboxSetupACLPlanCreatesTheRootsItGrants(t *testing.T) { // left the suite green. This one hands it a BARE profile and decodes the argument // the elevated helper actually receives. func TestBuildWindowsSandboxSetupArgsCarriesEveryRuntimeCandidate(t *testing.T) { - workspaceRoot := t.TempDir() - tempRoot := t.TempDir() - t.Setenv("TMP", tempRoot) - t.Setenv("TEMP", tempRoot) - - candidates := windowsRuntimeCandidatesForTest(t, workspaceRoot) + workspaceRoot, candidates := windowsRuntimeTestRoots(t) bare := PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil) for _, root := range bare.FileSystem.WriteRoots { for _, candidate := range candidates { @@ -193,6 +210,17 @@ func TestWindowsSandboxRuntimeCandidatesUsesOneWorkspaceRoot(t *testing.T) { first := t.TempDir() second := t.TempDir() + // Owned cache and temp even though this test only reads: the derivation would + // otherwise depend on the developer's real cache directory, which makes the + // result environment-dependent as well as impolite. + cacheRoot := t.TempDir() + originalCacheDir := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir }) + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + combined := windowsSandboxRuntimeCandidates([]string{first, second}) alone := windowsSandboxRuntimeCandidates([]string{first}) if len(combined) == 0 { From 43699f40cbc866c621b87b0c9c1c4f8f423bd0f6 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 13 Aug 2026 23:04:37 +0530 Subject: [PATCH 04/38] fix(sandbox): pin the Windows runtime root instead of deriving it twice Review found the setup marker depended on the caller's transient TEMP. Setup run from one terminal recorded a plan containing that shell's temp-derived candidate; a later parent process started by an IDE or a service with a different TEMP built a different path, and every command failed the marker's equality check while the cache runtime sat there untouched and healthy. That is the outage in #881 arriving by another route. The cause was two derivations of the same thing. sandboxRuntimeRootFor decides the tree the process uses; windowsSandboxRuntimeCandidates separately decided the tree the marker fingerprints, and folded in BOTH candidates so that whichever the command picked was covered. That bought agreement at the price of putting os.TempDir() into a machine-wide fingerprint. This removes the second derivation rather than trying to keep two in step. windowsSandboxRuntimeRoots pins to profile.Runtime.Root when the profile has one, because by then prepareSandboxRuntime has chosen, created and leased that tree, so the plan names exactly what the command writes to, including after a lease-failure relocation. Callers with no runtime yet (elevated setup, doctor) derive through sandboxRuntimeRootFor itself, so setup and command cannot drift. The lease-failure relocation in prepareSandboxRuntime is deliberately left alone. It is load-bearing off Windows, where there is no marker and relocation is inherently consistent, and removing it would trade this bug for a cross-platform one. Scope, since the finding is broader than what this closes: the runtime root no longer tracks TEMP, but the full plan hash still does, because PermissionProfileFromPolicy grants os.TempDir() as a write root when the policy allows temp. That predates this PR. The test says so in a comment rather than asserting a stability the code does not yet have. Both new tests fail with the change reverted. --- .../windows_runner_marker_windows_test.go | 4 +- internal/sandbox/windows_setup.go | 90 +++++++++++------- .../sandbox/windows_setup_provision_test.go | 94 ++++++++++++++++++- .../windows_setup_runtime_root_test.go | 14 +-- 4 files changed, 156 insertions(+), 46 deletions(-) diff --git a/internal/sandbox/windows_runner_marker_windows_test.go b/internal/sandbox/windows_runner_marker_windows_test.go index 50a102a11..729677bcb 100644 --- a/internal/sandbox/windows_runner_marker_windows_test.go +++ b/internal/sandbox/windows_runner_marker_windows_test.go @@ -51,7 +51,7 @@ func TestBuildCommandPlanCarriesTheRuntimeRootsIntoTheRunnerArgs(t *testing.T) { } defer plan.Cleanup() - candidates := windowsSandboxRuntimeCandidates([]string{workspace}) + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspace}) if len(candidates) == 0 { t.Fatal("no runtime candidates derived, so this test would pass vacuously") } @@ -88,7 +88,7 @@ func TestBuildCommandPlanProvisionsTheRuntimeRootsItGrants(t *testing.T) { t.Setenv("TMP", tempRoot) t.Setenv("TEMP", tempRoot) - candidates := windowsSandboxRuntimeCandidates([]string{workspace}) + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspace}) if len(candidates) == 0 { t.Fatal("no runtime candidates derived, so this test would pass vacuously") } diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index f76508cb0..20e3198f7 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -67,8 +67,9 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str workspaceRoots = []string{commandCWD} } // Augmented here, in the caller's shell, before the args cross into the - // elevated helper. The temp-derived candidate reads os.TempDir(), so it has - // to be resolved where the environment is still the operator's. + // elevated helper. The profile carries no runtime yet at setup time, so this + // derives through sandboxRuntimeRootFor and the answer depends only on the + // workspace and the user cache directory, not on this process's TEMP. options.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(options.PermissionProfile, workspaceRoots) profileJSON, err := json.Marshal(options.PermissionProfile) if err != nil { @@ -311,15 +312,25 @@ func canonicalWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry { return out } -// windowsSandboxRuntimeCandidates returns every runtime root setup provisions. +// windowsSandboxRuntimeRoots returns the runtime root the plan must name. // -// 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. +// PINNED to the profile's own runtime when it has one, rather than derived a +// second time. A command's profile has already been through +// permissionProfileWithRuntime, so its runtime tree is the one this process +// chose, created and took a lease on. Asking a separate function to work out +// which tree that "should" be is how the plan comes to name one directory while +// the command writes to another, which is the whole of issue #881. +// +// Deriving BOTH candidates was the previous answer to that problem. It bought +// agreement at the price of putting os.TempDir() into a machine-wide +// fingerprint: setup run under one TEMP recorded a plan that a later parent +// process with a different TEMP could not reproduce, so every command failed the +// equality check even though the cache runtime was untouched and healthy. +// Pinning removes the second derivation instead of trying to keep two in step. +// +// The derive branch below serves callers that have no runtime yet (elevated +// setup, doctor) and goes through sandboxRuntimeRootFor, THE selector +// prepareSandboxRuntime uses, so the two cannot drift. // // The FIRST root only, and that is deliberate rather than an oversight. The // marker compares plan hashes for EQUALITY, and a command presents exactly one @@ -331,7 +342,15 @@ func canonicalWindowsACLEntries(entries []WindowsACLEntry) []WindowsACLEntry { // one-element slice. Whoever adds multi-root support has to change the marker to a // per-root or subset comparison FIRST; widening this function on its own would // reintroduce the outage. -func windowsSandboxRuntimeCandidates(workspaceRoots []string) []string { +func windowsSandboxRuntimeRoots(profile PermissionProfile, workspaceRoots []string) []string { + // The pin. Nothing is derived when the profile already carries the answer, + // including after prepareSandboxRuntime relocated on a lease failure: the plan + // names whatever tree the command actually holds. + if profile.Runtime != nil { + if root := strings.TrimSpace(profile.Runtime.Root); root != "" { + return []string{root} + } + } workspaceRoot := "" for _, candidate := range workspaceRoots { if trimmed := strings.TrimSpace(candidate); trimmed != "" { @@ -342,18 +361,21 @@ func windowsSandboxRuntimeCandidates(workspaceRoots []string) []string { 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) - } - } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return nil + } + // Canonicalized exactly as prepareSandboxRuntime canonicalizes it, because + // sandboxRuntimeRootFor compares this against the workspace root to decide + // whether the cache-derived tree lands inside it. + if cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot); cacheRoot == "" || cacheRoot == "." { + return nil } - if root, err := fallbackSandboxRuntimeRoot(workspaceRoot); err == nil { - roots = append(roots, root) + root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) + if err != nil { + return nil } - return roots + return []string{root} } // windowsSandboxProfileWithRuntime adds the runtime candidates as write roots. @@ -366,15 +388,14 @@ func windowsSandboxRuntimeCandidates(workspaceRoots []string) []string { // 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 +// Naming the SAME root on both sides makes the two hashes agree, and it puts the +// runtime root 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) + candidates := windowsSandboxRuntimeRoots(profile, workspaceRoots) if len(candidates) == 0 { return profile } @@ -394,13 +415,14 @@ func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots return profile } -// ensureWindowsSandboxRuntimeCandidates creates every runtime root setup grants. +// ensureWindowsSandboxRuntimeRoots creates every runtime root the plan 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 +// Paired with windowsSandboxProfileWithRuntime: that function puts the root into +// the ACL plan, and this one makes it 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. +// absent. Both now go through windowsSandboxRuntimeRoots, so they cannot name +// different trees. // // Called by WHOEVER APPLIES THE PLAN, which is both tiers rather than only the // elevated one. Setup applies it under Administrator; the unelevated tier applies @@ -409,8 +431,8 @@ func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots // its own cache or temp grants itself nothing it could not create anyway, and the // tier that skips this is the tier that dies on "windows ACL target does not // exist". -func ensureWindowsSandboxRuntimeCandidates(workspaceRoots []string) error { - for _, root := range windowsSandboxRuntimeCandidates(workspaceRoots) { +func ensureWindowsSandboxRuntimeRoots(profile PermissionProfile, workspaceRoots []string) error { + for _, root := range windowsSandboxRuntimeRoots(profile, workspaceRoots) { if err := os.MkdirAll(root, 0o700); err != nil { return fmt.Errorf("create sandbox runtime root %s: %w", root, err) } @@ -429,7 +451,7 @@ func ensureWindowsSandboxRuntimeCandidates(workspaceRoots []string) error { // sandbox setup` into "windows ACL target does not exist". Keeping the two joined // here means a caller cannot get the plan without the trees it names. func buildWindowsSandboxSetupACLPlan(config WindowsSandboxSetupConfig) (WindowsACLPlan, error) { - if err := ensureWindowsSandboxRuntimeCandidates(config.WorkspaceRoots); err != nil { + if err := ensureWindowsSandboxRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots); err != nil { return WindowsACLPlan{}, err } return BuildWindowsACLPlan(config.commandConfig()) @@ -445,7 +467,7 @@ func buildWindowsSandboxSetupACLPlan(config WindowsSandboxSetupConfig) (WindowsA // names, so the runner can neither derive nor provision them. The parent still has // the operator's environment, so it does both and the runner only applies. func windowsSandboxProfileWithProvisionedRuntime(profile PermissionProfile, workspaceRoots []string) (PermissionProfile, error) { - if err := ensureWindowsSandboxRuntimeCandidates(workspaceRoots); err != nil { + if err := ensureWindowsSandboxRuntimeRoots(profile, workspaceRoots); err != nil { return PermissionProfile{}, err } return windowsSandboxProfileWithRuntime(profile, workspaceRoots), nil diff --git a/internal/sandbox/windows_setup_provision_test.go b/internal/sandbox/windows_setup_provision_test.go index aa18bed34..bc5c1d34a 100644 --- a/internal/sandbox/windows_setup_provision_test.go +++ b/internal/sandbox/windows_setup_provision_test.go @@ -37,7 +37,7 @@ func windowsRuntimeTestRoots(t *testing.T) (string, []string) { t.Setenv("TMP", tempRoot) t.Setenv("TEMP", tempRoot) - candidates := windowsSandboxRuntimeCandidates([]string{workspaceRoot}) + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspaceRoot}) if len(candidates) == 0 { t.Skip("no runtime candidates derivable in this environment") } @@ -161,6 +161,94 @@ func TestBuildWindowsSandboxSetupArgsCarriesEveryRuntimeCandidate(t *testing.T) } } +// TestSetupMarkerSurvivesADifferentTempInALaterProcess is the regression for the +// finding that the marker depended on the caller's transient TEMP. +// +// The sequence is the real one and the old code could not survive it: elevated +// setup runs from one terminal, and a later command is planned by a parent +// process an IDE or service started with a different TEMP. The cache runtime is +// untouched and healthy throughout. While both candidates were folded in +// unconditionally, the second process derived a different temp-side path, the +// plan hashes disagreed, and every command died on "permission roots or deny +// lists changed" with nothing wrong. +func TestSetupMarkerSurvivesADifferentTempInALaterProcess(t *testing.T) { + workspaceRoot := t.TempDir() + cacheRoot := t.TempDir() + originalCacheDir := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir }) + + runtimeRootUnder := func(temp string) string { + t.Helper() + t.Setenv("TMP", temp) + t.Setenv("TEMP", temp) + roots := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspaceRoot}) + if len(roots) != 1 { + t.Fatalf("expected one runtime root under TEMP=%s, got %v", temp, roots) + } + return roots[0] + } + + atSetup := runtimeRootUnder(t.TempDir()) + atLaterCommand := runtimeRootUnder(t.TempDir()) + + if atSetup != atLaterCommand { + t.Fatalf("the runtime root moved when only TEMP changed:\n setup %s\n later process %s\nboth feed the ACL plan the marker fingerprints, so every command would fail validation with \"permission roots or deny lists changed\" while the cache runtime sat there healthy", + atSetup, atLaterCommand) + } + + // Scope, stated rather than implied. This asserts the RUNTIME ROOT no longer + // tracks TEMP, which is the part this PR introduced and this change removes. + // The whole plan hash is still TEMP-dependent for a separate, older reason: + // PermissionProfileFromPolicy grants os.TempDir() itself as a write root when + // the policy allows temp, so the profile carries the caller's TEMP before any + // runtime augmentation happens. Asserting on the full hash here would fail for + // that pre-existing reason and read as though this fix were broken. + base := PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil) + carriesAmbientTemp := false + for _, root := range base.FileSystem.WriteRoots { + if pathWithinRoot(canonicalSandboxWorkspaceRoot(os.TempDir()), canonicalSandboxWorkspaceRoot(root.Root)) { + carriesAmbientTemp = true + } + } + if !carriesAmbientTemp { + t.Log("the base profile no longer grants the ambient temp dir; the wider TEMP dependency may now be closed and this note can go") + } +} + +// TestRuntimeRootsPinToTheProfileTheCommandActuallyHolds covers the other half: +// once a profile carries a runtime, the plan names THAT tree and does not +// re-derive one. prepareSandboxRuntime relocates on a lease failure, so a +// re-derivation would name the tree the command is not writing to. +func TestRuntimeRootsPinToTheProfileTheCommandActuallyHolds(t *testing.T) { + workspaceRoot := t.TempDir() + cacheRoot := t.TempDir() + originalCacheDir := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir }) + tempRoot := t.TempDir() + t.Setenv("TMP", tempRoot) + t.Setenv("TEMP", tempRoot) + + derived := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{workspaceRoot}) + if len(derived) != 1 { + t.Fatalf("expected exactly one derived runtime root, got %v", derived) + } + + // A runtime the process actually selected, deliberately NOT the derived one, + // standing in for the lease-failure relocation. + relocated := filepath.Join(t.TempDir(), "relocated-runtime") + profile := PermissionProfile{Runtime: &SandboxRuntime{Root: relocated}} + + pinned := windowsSandboxRuntimeRoots(profile, []string{workspaceRoot}) + if len(pinned) != 1 || pinned[0] != relocated { + t.Fatalf("the plan did not pin to the runtime the profile holds:\n profile runtime %s\n plan named %v\nthe command would write to one tree while the plan grants another", relocated, pinned) + } + if pinned[0] == derived[0] { + t.Fatalf("pinned and derived roots are identical (%s), so this test cannot tell them apart", pinned[0]) + } +} + // TestFallbackSandboxRuntimeRootIsSpellingStable pins the canonicalization added // for the aliased-TEMP finding. Two spellings of one directory must produce one // runtime root; producing two is what let a root resolving inside the workspace @@ -221,8 +309,8 @@ func TestWindowsSandboxRuntimeCandidatesUsesOneWorkspaceRoot(t *testing.T) { t.Setenv("TMP", tempRoot) t.Setenv("TEMP", tempRoot) - combined := windowsSandboxRuntimeCandidates([]string{first, second}) - alone := windowsSandboxRuntimeCandidates([]string{first}) + combined := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{first, second}) + alone := windowsSandboxRuntimeRoots(PermissionProfile{}, []string{first}) if len(combined) == 0 { t.Skip("no runtime candidates derivable in this environment") } diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 9a7cd22d1..57039a5c2 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -52,7 +52,7 @@ func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) } - candidates := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) if len(candidates) == 0 { t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") } @@ -121,7 +121,7 @@ func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { // candidates again, for the same reason as above. func TestWindowsSandboxRuntimeRootsAreInTheCapabilityPlan(t *testing.T) { config := runtimeRootTestConfig(t) - candidates := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) if len(candidates) == 0 { t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") } @@ -157,7 +157,7 @@ func TestWindowsSandboxRuntimeRootsAreInTheCapabilityPlan(t *testing.T) { // 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) + candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) if len(candidates) == 0 { t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") } @@ -171,7 +171,7 @@ func TestWindowsSandboxSetupProvisionsEveryGrantedWriteRoot(t *testing.T) { t.Cleanup(func() { _ = os.RemoveAll(candidate) }) } - if err := ensureWindowsSandboxRuntimeCandidates(config.WorkspaceRoots); err != nil { + if err := ensureWindowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots); err != nil { t.Fatalf("ensureWindowsSandboxRuntimeCandidates: %v", err) } @@ -198,11 +198,11 @@ func TestWindowsSandboxSetupProvisionsEveryGrantedWriteRoot(t *testing.T) { // setup and never selected, or selected and never granted. func TestWindowsSandboxRuntimeCandidatesAreDeterministic(t *testing.T) { config := runtimeRootTestConfig(t) - first := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + first := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) if len(first) == 0 { t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") } - second := windowsSandboxRuntimeCandidates(config.WorkspaceRoots) + second := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) if len(first) != len(second) { t.Fatalf("candidate count = %d then %d, want stable", len(first), len(second)) } @@ -213,7 +213,7 @@ func TestWindowsSandboxRuntimeCandidatesAreDeterministic(t *testing.T) { } other := runtimeRootTestConfig(t) - otherCandidates := windowsSandboxRuntimeCandidates(other.WorkspaceRoots) + otherCandidates := windowsSandboxRuntimeRoots(PermissionProfile{}, other.WorkspaceRoots) for _, candidate := range otherCandidates { for _, mine := range first { if candidate == mine { From 00a78b157570f5b8581ca60db3334018ba581077 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 14 Aug 2026 08:55:25 +0530 Subject: [PATCH 05/38] test(sandbox): compare canonical spellings in the ownership guard The guard added with the provisioning tests measured a canonicalized candidate against a raw t.TempDir(), so it fired on every machine where those two spellings differ and passed on the one where they do not. macOS resolves /var to /private/var and the CI Windows runner's profile has an 8.3 name, so both failed; this box has neither and stayed green. That is the same one-sided comparison this PR fixes in fallbackSandboxRuntimeRoot, written into the guard meant to protect against it. Both sides go through canonicalSandboxWorkspaceRoot now. --- internal/sandbox/windows_setup_provision_test.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/windows_setup_provision_test.go b/internal/sandbox/windows_setup_provision_test.go index bc5c1d34a..2f925f525 100644 --- a/internal/sandbox/windows_setup_provision_test.go +++ b/internal/sandbox/windows_setup_provision_test.go @@ -43,9 +43,21 @@ func windowsRuntimeTestRoots(t *testing.T) (string, []string) { } // Belt and braces: refuse to run rather than touch anything the test does not // own, so a later change to the derivation cannot quietly reintroduce this. + // + // BOTH SIDES CANONICALIZED, because pathWithinRoot compares spellings and the + // candidate arrives canonical: the derivation runs the cache root through + // canonicalSandboxWorkspaceRoot before joining. Measuring that against a raw + // t.TempDir() is the same one-sided comparison this PR fixes in + // fallbackSandboxRuntimeRoot, and it fired on exactly the machines that carry a + // second spelling: macOS /var vs /private/var, and a CI Windows runner whose + // profile has an 8.3 name (RUNNER~1 against runneradmin). It passed locally + // because this box has neither. + ownedCache := canonicalSandboxWorkspaceRoot(cacheRoot) + ownedTemp := canonicalSandboxWorkspaceRoot(tempRoot) for _, candidate := range candidates { - if !pathWithinRoot(cacheRoot, candidate) && !pathWithinRoot(tempRoot, candidate) { - t.Fatalf("candidate %s is outside the test-owned cache (%s) and temp (%s) roots; refusing to modify it", candidate, cacheRoot, tempRoot) + canonical := canonicalSandboxWorkspaceRoot(candidate) + if !pathWithinRoot(ownedCache, canonical) && !pathWithinRoot(ownedTemp, canonical) { + t.Fatalf("candidate %s (canonically %s) is outside the test-owned cache (%s) and temp (%s) roots; refusing to modify it", candidate, canonical, ownedCache, ownedTemp) } } return workspaceRoot, candidates From c4998fae68c36ff4f2a0828d7ebcec89d8ab417f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 14 Aug 2026 12:40:11 +0530 Subject: [PATCH 06/38] test(sandbox): decide the alias skip by filesystem identity, not by the function under test --- .../sandbox/windows_setup_provision_test.go | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/internal/sandbox/windows_setup_provision_test.go b/internal/sandbox/windows_setup_provision_test.go index 2f925f525..b7353ec91 100644 --- a/internal/sandbox/windows_setup_provision_test.go +++ b/internal/sandbox/windows_setup_provision_test.go @@ -273,13 +273,31 @@ func TestFallbackSandboxRuntimeRootIsSpellingStable(t *testing.T) { // volume setting: 8.3 generation is disabled on many volumes and creating a // symlink needs a privilege the test process may not hold, while a // case-insensitive filesystem resolves this to the same directory and - // GetLongPathName returns the on-disk casing. Skipped rather than passed - // vacuously where the filesystem is case-sensitive, since there the two names - // really are different directories. + // GetLongPathName returns the on-disk casing. alias := strings.ToUpper(tempRoot) + if alias == tempRoot { + t.Skip("the temp path has no distinct upper-cased spelling here") + } + + // Decide whether the alias is usable by asking the filesystem, never by asking + // canonicalSandboxWorkspaceRoot. Skipping on what the function under test says + // would turn a canonicalization regression into a silent skip on the one + // platform this test exists to protect. + realInfo, err := os.Stat(tempRoot) + if err != nil { + t.Fatalf("stat temp dir: %v", err) + } + aliasInfo, err := os.Stat(alias) + if err != nil || !os.SameFile(realInfo, aliasInfo) { + t.Skip("case-sensitive filesystem: the upper-cased spelling is a different directory") + } + + // One directory under two spellings. Canonicalization has to fold them, and a + // failure here is the regression, not a reason to stop testing. canonical := canonicalSandboxWorkspaceRoot(tempRoot) - if alias == tempRoot || canonicalSandboxWorkspaceRoot(alias) != canonical { - t.Skip("no distinct alias spelling of the temp dir is constructible here") + if got := canonicalSandboxWorkspaceRoot(alias); got != canonical { + t.Fatalf("canonicalization did not fold two spellings of one directory:\n %s\n -> %s\n %s\n -> %s\nos.SameFile says these are the same directory, so the runtime roots derived from them will disagree", + tempRoot, canonical, alias, got) } t.Setenv("TMP", tempRoot) From 200ac171d5bea1ef833381fd20cd38cc0f43a75d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 14 Aug 2026 12:40:12 +0530 Subject: [PATCH 07/38] fix(sandbox): name both plan hashes when the setup marker mismatches --- internal/sandbox/windows_setup.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 20e3198f7..9e2e1413a 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -261,7 +261,12 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { return fmt.Errorf("windows sandbox setup is out of date: schema %d, want %d", actual.SchemaVersion, expected.SchemaVersion) } 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") + // Name both sides. This message fires when setup and the command derived + // different runtime roots, and without the hashes the operator cannot tell + // that case apart from a genuine policy edit. + return fmt.Errorf("windows sandbox setup is out of date: permission roots or deny lists changed (marker plan %s, %d entries; this command wants %s, %d entries)", + shortWindowsACLPlanHash(actual.ACLPlanHash), actual.ACLPlanEntries, + shortWindowsACLPlanHash(expected.ACLPlanHash), expected.ACLPlanEntries) } // Mode-agnostic: validate the provisioned infrastructure, never the // per-command network mode — so an approved (allow) network command and an From 6eed34fc2379032cbc25f8b1a9e0a07ee37d35fa Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 14 Aug 2026 12:48:54 +0530 Subject: [PATCH 08/38] test(sandbox): assert the case-folding contract only where it exists --- internal/sandbox/windows_setup_provision_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/sandbox/windows_setup_provision_test.go b/internal/sandbox/windows_setup_provision_test.go index b7353ec91..b97f414cc 100644 --- a/internal/sandbox/windows_setup_provision_test.go +++ b/internal/sandbox/windows_setup_provision_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -292,6 +293,18 @@ func TestFallbackSandboxRuntimeRootIsSpellingStable(t *testing.T) { t.Skip("case-sensitive filesystem: the upper-cased spelling is a different directory") } + // Case folding is a Windows property of canonicalSandboxWorkspaceRoot, not a + // cross-platform one: it folds case only because filepath.EvalSymlinks returns + // the on-disk spelling there. On a case-insensitive macOS volume os.SameFile + // calls these one directory and canonicalization still keeps them apart, which + // is a real gap but not one this branch claims to close, and it cannot produce + // the setup-versus-command disagreement fixed here because the elevated setup + // marker is Windows-only. Gate on the platform, never on what the function + // under test returns. + if runtime.GOOS != "windows" { + t.Skip("canonicalization folds case only on windows; nothing to assert here") + } + // One directory under two spellings. Canonicalization has to fold them, and a // failure here is the regression, not a reason to stop testing. canonical := canonicalSandboxWorkspaceRoot(tempRoot) From 1878c1eb35a745c5d269126acc79e2a23b017980 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 14 Aug 2026 13:35:07 +0530 Subject: [PATCH 09/38] fix(sandbox): decide runtime-root containment on physical paths, not spellings --- internal/sandbox/runtime_physical_path.go | 15 ++ .../sandbox/runtime_physical_path_windows.go | 111 +++++++++++ internal/sandbox/runtime_root_alias_test.go | 173 ++++++++++++++++++ internal/sandbox/runtime_state.go | 71 ++++++- 4 files changed, 363 insertions(+), 7 deletions(-) create mode 100644 internal/sandbox/runtime_physical_path.go create mode 100644 internal/sandbox/runtime_physical_path_windows.go create mode 100644 internal/sandbox/runtime_root_alias_test.go diff --git a/internal/sandbox/runtime_physical_path.go b/internal/sandbox/runtime_physical_path.go new file mode 100644 index 000000000..38a8f0702 --- /dev/null +++ b/internal/sandbox/runtime_physical_path.go @@ -0,0 +1,15 @@ +//go:build !windows + +package sandbox + +// physicalSandboxPath resolves path to the spelling the filesystem itself uses. +// +// Off Windows that is what canonicalSandboxWorkspaceRoot already does: +// filepath.EvalSymlinks follows every symlink, and there is no junction to +// follow. Two aliases remain unresolved and are handled by the identity walk in +// runtimeRootWithinWorkspace instead: a differing case on a case-insensitive +// volume, which EvalSymlinks preserves, and a bind mount, which no userspace +// path API resolves because the kernel deliberately presents it as a real path. +func physicalSandboxPath(path string) string { + return canonicalSandboxWorkspaceRoot(path) +} diff --git a/internal/sandbox/runtime_physical_path_windows.go b/internal/sandbox/runtime_physical_path_windows.go new file mode 100644 index 000000000..aa00de7bc --- /dev/null +++ b/internal/sandbox/runtime_physical_path_windows.go @@ -0,0 +1,111 @@ +//go:build windows + +package sandbox + +import ( + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +const ( + // GetFinalPathNameByHandle flags. golang.org/x/sys/windows does not export + // these; both are zero, and naming them keeps the call site readable. + fileNameNormalized = 0x0 + volumeNameDOS = 0x0 +) + +// physicalSandboxPath resolves path to the spelling the filesystem itself uses. +// +// canonicalSandboxWorkspaceRoot cannot do this on Windows. filepath.EvalSymlinks +// returns a directory JUNCTION unchanged, so a TEMP that reaches into the +// workspace through one still measures as outside it, and that is how a runtime +// tree ends up inside the very workspace the sandbox exists to confine. +// GetFinalPathNameByHandle answers with the target's real path, junctions and +// mount points followed and casing as stored on disk. +// +// This deliberately opens WITHOUT FILE_FLAG_OPEN_REPARSE_POINT, the opposite of +// openWindowsACLTarget. That helper must refuse to follow a reparse point, +// because following one is the path-swap it guards against. Here the whole +// question is where the reparse point leads, and the answer is only ever used to +// decide that a runtime root is contained, never that it is safe. +func physicalSandboxPath(path string) string { + cleaned := canonicalSandboxWorkspaceRoot(path) + if cleaned == "" || cleaned == "." { + return cleaned + } + // The runtime root does not exist yet at derivation time, which is the point. + // Resolve the deepest ancestor that does exist and re-append the rest, the + // same shape canonicalSandboxWorkspaceRoot uses for EvalSymlinks. + remainder := "" + current := cleaned + for { + if resolved, ok := finalWindowsPathName(current); ok { + if remainder == "" { + return resolved + } + return filepath.Join(resolved, remainder) + } + parent := filepath.Dir(current) + if parent == current { + // Nothing along the path could be opened. The cleaned form is the best + // answer available, and the caller's spelling comparison already ran. + return cleaned + } + remainder = filepath.Join(filepath.Base(current), remainder) + current = parent + } +} + +func finalWindowsPathName(path string) (string, bool) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", false + } + // Zero desired access is enough: GetFinalPathNameByHandle reads metadata, so + // this cannot be refused for lack of read rights on the directory contents. + handle, err := windows.CreateFile( + utf16Path, + 0, + 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 "", false + } + defer func() { _ = windows.CloseHandle(handle) }() + + buffer := make([]uint16, windows.MAX_PATH) + for attempt := 0; attempt < 2; attempt++ { + n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), fileNameNormalized|volumeNameDOS) + if err != nil { + return "", false + } + if int(n) > len(buffer) { + // n is the required length excluding the terminator when the buffer is + // too small. Grow once and ask again. + buffer = make([]uint16, n+1) + continue + } + if n == 0 { + return "", false + } + return trimWindowsExtendedPrefix(windows.UTF16ToString(buffer[:n])), true + } + return "", false +} + +// trimWindowsExtendedPrefix converts the \\?\ form GetFinalPathNameByHandle +// returns into an ordinary path, so it compares against paths the rest of this +// package builds with filepath.Join. \\?\UNC\server\share becomes +// \\server\share; anything else loses the \\?\ and keeps its drive letter. +func trimWindowsExtendedPrefix(path string) string { + if rest, ok := strings.CutPrefix(path, `\\?\UNC\`); ok { + return `\\` + rest + } + return strings.TrimPrefix(path, `\\?\`) +} diff --git a/internal/sandbox/runtime_root_alias_test.go b/internal/sandbox/runtime_root_alias_test.go new file mode 100644 index 000000000..f0b081954 --- /dev/null +++ b/internal/sandbox/runtime_root_alias_test.go @@ -0,0 +1,173 @@ +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// aliasTo returns a second path that IS target, spelled in a way +// canonicalSandboxWorkspaceRoot does not fold, or "" when this platform offers +// none. +// +// A plain symlink is no good: EvalSymlinks folds it, so the spelling comparison +// already wins and nothing downstream is exercised. The two aliases that survive +// canonicalization are a Windows directory junction, which needs no privilege, +// and an upper-cased spelling on a case-insensitive volume, which is the macOS +// default. +func aliasTo(t *testing.T, target string) string { + t.Helper() + + if runtime.GOOS == "windows" { + link := filepath.Join(t.TempDir(), "alias") + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil { + t.Logf("mklink /J unavailable: %v %s", err, out) + return "" + } + // A junction that did not actually land on the target proves nothing. + targetInfo, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + linkInfo, err := os.Stat(link) + if err != nil || !os.SameFile(targetInfo, linkInfo) { + t.Fatalf("mklink reported success but %s is not %s", link, target) + } + return link + } + + upper := strings.ToUpper(target) + if upper == target { + return "" + } + targetInfo, err := os.Stat(target) + if err != nil { + t.Fatalf("stat target: %v", err) + } + upperInfo, err := os.Stat(upper) + if err != nil || !os.SameFile(targetInfo, upperInfo) { + // Case-sensitive volume: the upper-cased name is a different directory, or + // none at all. Correctly not an alias. + return "" + } + return upper +} + +// TestRuntimeRootRefusesAWorkspaceReachedByAnAlias is the regression for the gap +// the macOS Smoke run exposed and the junction gap found alongside it. +// canonicalSandboxWorkspaceRoot folds the aliases EvalSymlinks folds and no +// others, so a runtime root that reaches the workspace under a junction, or under +// a different casing on a case-insensitive volume, measured as OUTSIDE the +// workspace and the runtime tree was allowed to live inside the tree the sandbox +// exists to confine. +// +// Both alias shapes are covered on purpose. An alias whose target IS the +// workspace root is caught by the identity walk; an alias into a SUBDIRECTORY is +// not, because the walk climbs a spelling and a junction has no spelling chain +// back into its target's parent. Only the physical-path resolution catches that +// one, and a test that exercised the root shape alone reported green while it was +// broken. +func TestRuntimeRootRefusesAWorkspaceReachedByAnAlias(t *testing.T) { + for _, shape := range []struct { + name string + suffix []string + }{ + {name: "alias to the workspace root"}, + {name: "alias into a workspace subdirectory", suffix: []string{"build", "tmp"}}, + } { + t.Run(shape.name, func(t *testing.T) { + workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(t.TempDir(), "workspace")) + target := filepath.Join(append([]string{workspaceRoot}, shape.suffix...)...) + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("create target: %v", err) + } + + alias := aliasTo(t, target) + if alias == "" { + t.Skip("no alias spelling is constructible here") + } + + // Precondition, asserted rather than assumed: the plain spelling + // comparison has to MISS. If canonicalization folds this alias the old + // code already handled it and the test proves nothing. + probe := filepath.Join(canonicalSandboxWorkspaceRoot(alias), "zero", "runtime", "v1", "0123456789abcdef") + if pathWithinRoot(workspaceRoot, probe) { + t.Skipf("canonicalization already folds %s into %s", alias, workspaceRoot) + } + + t.Setenv("TMP", alias) + t.Setenv("TEMP", alias) + t.Setenv("TMPDIR", alias) + + root, err := fallbackSandboxRuntimeRoot(workspaceRoot) + if err == nil { + t.Fatalf("fallback returned runtime root %s for a TEMP that reaches %s through %s; the sandbox would keep its own cache inside the tree it is confining", root, target, alias) + } + if !strings.Contains(err.Error(), "inside workspace") { + t.Fatalf("fallback refused for the wrong reason: %v", err) + } + }) + } +} + +// TestDeterministicRuntimeRootRejectsAnAliasedCache covers the other call site. +// Reverting only deterministicSandboxRuntimeRoot left the whole package green +// before this existed, so the cache-derived root had no alias coverage at all. +func TestDeterministicRuntimeRootRejectsAnAliasedCache(t *testing.T) { + workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(t.TempDir(), "workspace")) + inner := filepath.Join(workspaceRoot, "cachehome") + if err := os.MkdirAll(inner, 0o700); err != nil { + t.Fatalf("create target: %v", err) + } + + // The derived root is /zero/runtime/v1/, so aliasing /zero + // is what puts the whole tree inside the workspace. + cacheRoot := filepath.Join(t.TempDir(), "cache") + if err := os.MkdirAll(cacheRoot, 0o700); err != nil { + t.Fatalf("create cache: %v", err) + } + alias := aliasTo(t, inner) + if alias == "" { + t.Skip("no alias spelling is constructible here") + } + if runtime.GOOS == "windows" { + // aliasTo built the junction somewhere else; put one at /zero. + if out, err := exec.Command("cmd", "/c", "mklink", "/J", filepath.Join(cacheRoot, "zero"), inner).CombinedOutput(); err != nil { + t.Skipf("mklink /J unavailable: %v %s", err, out) + } + } else { + if err := os.Symlink(alias, filepath.Join(cacheRoot, "zero")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + } + + root, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot) + if usableOutside { + t.Fatalf("cache-derived runtime root %s reported as outside %s while it resolves inside it", root, workspaceRoot) + } +} + +// TestRuntimeRootWithinWorkspaceKeepsAGenuinelyOutsideRootUsable is the other +// half. The three checks only ever ADD containment answers, so the one thing that +// could go wrong is reporting containment for a root that is merely adjacent. +func TestRuntimeRootWithinWorkspaceKeepsAGenuinelyOutsideRootUsable(t *testing.T) { + parent := t.TempDir() + workspaceRoot := canonicalSandboxWorkspaceRoot(filepath.Join(parent, "workspace")) + sibling := filepath.Join(parent, "workspace-runtime", "zero", "runtime", "v1", "0123456789abcdef") + if err := os.MkdirAll(workspaceRoot, 0o700); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := os.MkdirAll(sibling, 0o700); err != nil { + t.Fatalf("create sibling: %v", err) + } + + if runtimeRootWithinWorkspace(workspaceRoot, sibling) { + t.Fatalf("%s reported as inside %s; a sibling sharing a name prefix is not contained", sibling, workspaceRoot) + } + if _, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, filepath.Join(parent, "cache")); !usableOutside { + t.Fatalf("a cache root outside the workspace was reported unusable") + } +} diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 3647e119e..ad2c67409 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -54,7 +54,64 @@ func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, erro 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) + return root, !runtimeRootWithinWorkspace(workspaceRoot, root) +} + +// runtimeRootWithinWorkspace reports whether root lands inside workspaceRoot. +// +// pathWithinRoot compares SPELLINGS, and canonicalSandboxWorkspaceRoot folds only +// the aliases filepath.EvalSymlinks folds. Two get through it. Case: filepath.Rel +// folds case on Windows via sameWord but not elsewhere, so on a case-insensitive +// macOS volume /var/folders/x and /VAR/FOLDERS/X are one directory that every +// string comparison here keeps apart. Junctions: EvalSymlinks returns a Windows +// directory junction unchanged, so a TEMP that reaches the workspace through one +// measures as outside it. +// +// Three checks, each of which can only ADD a containment answer. That asymmetry +// is the safety argument: the failure that matters is the runtime tree living +// inside the workspace, so a missed alias is the expensive direction and an extra +// relocation is the cheap one. +// +// 1. the spellings as given; +// 2. the spellings resolved to physical paths, which on Windows follows +// junctions at any depth via GetFinalPathNameByHandle; +// 3. filesystem identity across root's existing ancestors, which catches a case +// alias on a case-insensitive volume where step 2 has no API to call. +// +// Step 3 only sees an alias whose target IS the workspace root, because it walks +// a SPELLING upward and a junction has no spelling chain back into its target's +// parent. That shape is covered by step 2 on Windows. It remains open off Windows +// for a bind mount, which the kernel presents as a real path with no way to ask +// where it came from; closing that needs mountinfo parsing, not a path API. +func runtimeRootWithinWorkspace(workspaceRoot string, root string) bool { + if pathWithinRoot(workspaceRoot, root) { + return true + } + if physicalWorkspace := physicalSandboxPath(workspaceRoot); physicalWorkspace != "" { + if pathWithinRoot(physicalWorkspace, physicalSandboxPath(root)) { + return true + } + } + workspaceInfo, err := os.Stat(workspaceRoot) + if err != nil { + // An unresolvable workspace leaves nothing to compare against. The + // spelling checks above already returned their answer. + return false + } + // root itself usually does not exist yet, which is the point: start at the + // deepest component and walk up, so the first directory that does exist gets + // compared and every ancestor above it after that. + current := filepath.Clean(root) + for { + if info, err := os.Stat(current); err == nil && os.SameFile(workspaceInfo, info) { + return true + } + parent := filepath.Dir(current) + if parent == current { + return false + } + current = parent + } } func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { @@ -228,18 +285,18 @@ func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) { // callers resolve this in the operator's environment, so the derived path is // identical on the setup and command sides and the plan hashes still agree. // - // This closes the 8.3 short-name and symlink spellings, not every alias. A - // Windows directory JUNCTION comes back from EvalSymlinks unchanged, so a TEMP - // that is a junction into the workspace still reads as outside it. Closing that - // needs a physical identity check (os.SameFile against existing ancestors, or - // GetFinalPathNameByHandle) rather than a string comparison. + // This closes the 8.3 short-name and symlink spellings, not every alias: a + // Windows directory JUNCTION comes back from EvalSymlinks unchanged, and case + // survives it on a case-insensitive macOS volume. The containment check below + // therefore does not rely on canonicalization alone; runtimeRootWithinWorkspace + // falls through to filesystem identity for exactly those aliases. tempRoot := canonicalSandboxWorkspaceRoot(os.TempDir()) if tempRoot == "" || tempRoot == "." { return "", errors.New("temp directory is unavailable") } digest := sha256.Sum256([]byte(workspaceRoot)) root := filepath.Join(tempRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) - if pathWithinRoot(workspaceRoot, root) { + if runtimeRootWithinWorkspace(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 // rather than pointed somewhere arbitrary: a runtime root inside the From a7bf021f3094a51063b53781975bd767af301b09 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 14 Aug 2026 13:42:44 +0530 Subject: [PATCH 10/38] test(sandbox): alias the cache by its real spelling, not a stacked case alias --- internal/sandbox/runtime_root_alias_test.go | 27 +++++++++++++-------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/internal/sandbox/runtime_root_alias_test.go b/internal/sandbox/runtime_root_alias_test.go index f0b081954..36b0e76bd 100644 --- a/internal/sandbox/runtime_root_alias_test.go +++ b/internal/sandbox/runtime_root_alias_test.go @@ -129,19 +129,26 @@ func TestDeterministicRuntimeRootRejectsAnAliasedCache(t *testing.T) { if err := os.MkdirAll(cacheRoot, 0o700); err != nil { t.Fatalf("create cache: %v", err) } - alias := aliasTo(t, inner) - if alias == "" { - t.Skip("no alias spelling is constructible here") - } + // A junction on Windows, a symlink elsewhere. Both point at inner by its REAL + // spelling on purpose. Stacking a case alias on top would build the one shape + // the doc on runtimeRootWithinWorkspace says stays open off Windows, an alias + // into a workspace SUBDIRECTORY that no spelling chain reaches, and the test + // would then be asserting a guarantee macOS does not make. + link := filepath.Join(cacheRoot, "zero") if runtime.GOOS == "windows" { - // aliasTo built the junction somewhere else; put one at /zero. - if out, err := exec.Command("cmd", "/c", "mklink", "/J", filepath.Join(cacheRoot, "zero"), inner).CombinedOutput(); err != nil { + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, inner).CombinedOutput(); err != nil { t.Skipf("mklink /J unavailable: %v %s", err, out) } - } else { - if err := os.Symlink(alias, filepath.Join(cacheRoot, "zero")); err != nil { - t.Skipf("symlink unavailable: %v", err) - } + } else if err := os.Symlink(inner, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + innerInfo, err := os.Stat(inner) + if err != nil { + t.Fatalf("stat target: %v", err) + } + linkInfo, err := os.Stat(link) + if err != nil || !os.SameFile(innerInfo, linkInfo) { + t.Fatalf("%s is not an alias of %s", link, inner) } root, usableOutside := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot) From d16e5877f744b25d612940af44857cb62c5c3a0d Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 19 Aug 2026 12:59:07 +0530 Subject: [PATCH 11/38] fix(sandbox): retry the final-path buffer whenever the size is not a success value --- internal/sandbox/runtime_physical_path_windows.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/runtime_physical_path_windows.go b/internal/sandbox/runtime_physical_path_windows.go index aa00de7bc..c57dd8e99 100644 --- a/internal/sandbox/runtime_physical_path_windows.go +++ b/internal/sandbox/runtime_physical_path_windows.go @@ -85,10 +85,15 @@ func finalWindowsPathName(path string) (string, bool) { if err != nil { return "", false } - if int(n) > len(buffer) { - // n is the required length excluding the terminator when the buffer is - // too small. Grow once and ask again. - buffer = make([]uint16, n+1) + if int(n) >= len(buffer) { + // TWO DIFFERENT CONVENTIONS, and the boundary is where they meet. On + // success the return value EXCLUDES the terminating null; on an + // insufficient buffer it INCLUDES it. So n == len(buffer) cannot be + // read as a complete path: a success that large would not have fitted + // its own terminator, which means the only reading left is a required + // size. Retrying on >= costs one extra call in a case that may not be + // reachable and removes the need to be right about which it was. + buffer = make([]uint16, int(n)+1) continue } if n == 0 { From 7b9e41be75389fe7789cf4462a7b175260ce31b2 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 20 Aug 2026 16:19:16 +0530 Subject: [PATCH 12/38] fix(sandbox): roll back runtime roots a failed setup created, and keep the provisioning tests in owned storage Two of the five findings from review. The three P1s are not addressed here and I have said so on the PR rather than implying otherwise. Setup materializes runtime roots before the network plan, the ACL apply, the network apply and the marker write. Any of those can fail, and the rollback that existed restored only ACL snapshots, so a run that reported failure still left new persistent runtime directories behind. It could not have cleaned them up even in principle, because provisioning returned nothing about what it had made. Provisioning now records the components it actually created and hands back a rollback, composed once at the top of the elevated path so no later failure can forget it. It removes only what this run created, innermost first, and uses os.Remove rather than os.RemoveAll: a directory that is not empty by then holds something this run did not create, and removing it would turn a failed setup into data loss. Refusing keeps the residue findable and reports it. The provisioning test derived a real user-cache runtime path because it took a bare t.TempDir() instead of the owned fixture the neighbouring tests use, so it created and then RemoveAll'd a genuine ~/.cache/zero/runtime tree on every run and failed outright on a read-only home. runtimeRootTestConfig now routes through windowsRuntimeTestRoots, which redirects every derivation input before any candidate is computed and refuses to run if a candidate escapes the owned roots. Centralizing it there covers the other tests built on that config too. --- .../windows_runtime_root_rollback_test.go | 102 +++++++++++++++++ internal/sandbox/windows_setup.go | 105 ++++++++++++++++-- .../sandbox/windows_setup_provision_test.go | 2 +- .../windows_setup_runtime_root_test.go | 17 ++- internal/sandbox/windows_setup_windows.go | 48 +++++--- 5 files changed, 244 insertions(+), 30 deletions(-) create mode 100644 internal/sandbox/windows_runtime_root_rollback_test.go diff --git a/internal/sandbox/windows_runtime_root_rollback_test.go b/internal/sandbox/windows_runtime_root_rollback_test.go new file mode 100644 index 000000000..eac710a1c --- /dev/null +++ b/internal/sandbox/windows_runtime_root_rollback_test.go @@ -0,0 +1,102 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// A FAILED SETUP LEAVES NO NEW PERSISTENT STATE. +// +// Runtime roots are materialized before the network plan, the ACL apply, the +// network apply and the marker write. Any of those can fail, and the rollback +// that existed restored only ACL snapshots, so a run that reported failure still +// left new runtime directories behind. It could not have cleaned them up even in +// principle, because provisioning returned nothing about what it had made. +func TestRuntimeRootProvisioningRecordsOnlyWhatItCreated(t *testing.T) { + base := t.TempDir() + // A pre-existing ancestor the user owns, and a leaf below it that does not + // exist yet. Only the latter may be recorded. + existing := filepath.Join(base, "cache", "zero") + if err := os.MkdirAll(existing, 0o700); err != nil { + t.Fatal(err) + } + target := filepath.Join(existing, "runtime", "v1", "abc123") + + created, err := createRuntimeDirRecording(target) + if err != nil { + t.Fatalf("createRuntimeDirRecording: %v", err) + } + if len(created) != 3 { + t.Fatalf("recorded %v, want the three components below the pre-existing ancestor", created) + } + // Outermost first, so the undo walking backwards removes the leaf before its + // parent and never meets a non-empty directory of its own making. + want := []string{ + filepath.Join(existing, "runtime"), + filepath.Join(existing, "runtime", "v1"), + target, + } + for index := range want { + if created[index] != want[index] { + t.Fatalf("recorded %v, want %v", created, want) + } + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("target was not created: %v", err) + } + + rollback := windowsRuntimeRootRollback{created: created} + if err := rollback.run(); err != nil { + t.Fatalf("rollback: %v", err) + } + if _, err := os.Stat(filepath.Join(existing, "runtime")); !os.IsNotExist(err) { + t.Errorf("rollback left the created tree behind: %v", err) + } + // The pre-existing ancestor is not ours and must survive. + if _, err := os.Stat(existing); err != nil { + t.Errorf("rollback removed a directory it did not create: %v", err) + } +} + +// Provisioning that finds everything already there records nothing, so a failed +// setup on a machine that was already set up removes none of it. +func TestRuntimeRootProvisioningRecordsNothingWhenAlreadyPresent(t *testing.T) { + target := filepath.Join(t.TempDir(), "runtime", "v1", "abc123") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatal(err) + } + created, err := createRuntimeDirRecording(target) + if err != nil { + t.Fatalf("createRuntimeDirRecording: %v", err) + } + if len(created) != 0 { + t.Fatalf("recorded %v for a tree that already existed", created) + } + if err := (windowsRuntimeRootRollback{created: created}).run(); err != nil { + t.Fatalf("rollback: %v", err) + } + if _, err := os.Stat(target); err != nil { + t.Errorf("rollback removed a pre-existing tree: %v", err) + } +} + +// Rollback refuses rather than destroys. A directory that has gained content is +// holding something this run did not create, and RemoveAll there would turn a +// failed setup into data loss. +func TestRuntimeRootRollbackRefusesToRemoveANonEmptyDirectory(t *testing.T) { + target := filepath.Join(t.TempDir(), "runtime", "v1", "abc123") + created, err := createRuntimeDirRecording(target) + if err != nil { + t.Fatalf("createRuntimeDirRecording: %v", err) + } + if err := os.WriteFile(filepath.Join(target, "someone-elses.txt"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := (windowsRuntimeRootRollback{created: created}).run(); err == nil { + t.Error("rollback reported success while a non-empty directory remained") + } + if _, err := os.Stat(filepath.Join(target, "someone-elses.txt")); err != nil { + t.Errorf("rollback destroyed content it did not create: %v", err) + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 9e2e1413a..366866c2c 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -436,13 +436,93 @@ func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots // its own cache or temp grants itself nothing it could not create anyway, and the // tier that skips this is the tier that dies on "windows ACL target does not // exist". -func ensureWindowsSandboxRuntimeRoots(profile PermissionProfile, workspaceRoots []string) error { +// windowsRuntimeRootRollback removes the runtime directories one provisioning +// call actually created, and only those. +// +// Setup materializes runtime roots before the network plan, the ACL apply, the +// network apply and the marker write. Every one of those can fail, and the +// existing rollback only restored ACL snapshots, so a failed `zero sandbox +// setup` reported failure and left new persistent state behind. It could not +// clean up even in principle, because provisioning returned nothing about what +// it had made. +type windowsRuntimeRootRollback struct { + // created is in creation order, outermost first, so undo walks it backwards. + created []string +} + +// run removes what was created, innermost first. +// +// os.Remove rather than os.RemoveAll, deliberately. A directory that is not +// empty by the time we get here holds something this call did not create, and +// removing it would turn a failed setup into data loss. Refusing is the right +// answer: the error is reported and the residue stays findable. +func (rollback windowsRuntimeRootRollback) run() error { + var errs []error + for index := len(rollback.created) - 1; index >= 0; index-- { + path := rollback.created[index] + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err)) + } + } + return errors.Join(errs...) +} + +// createRuntimeDirRecording is MkdirAll that reports which components it made. +// +// The distinction between "created" and "already there" is the whole contract: +// a pre-existing cache or temp ancestor belongs to the user and must survive a +// failed setup, while the components this run added must not. +func createRuntimeDirRecording(root string) ([]string, error) { + if strings.TrimSpace(root) == "" { + return nil, nil + } + // Find the deepest ancestor that already exists, then create downwards from + // there, so the record contains exactly the new components. + var missing []string + current := filepath.Clean(root) + for { + if info, err := os.Lstat(current); err == nil { + if !info.IsDir() { + return nil, fmt.Errorf("sandbox runtime path %s exists and is not a directory", current) + } + break + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("inspect sandbox runtime path %s: %w", current, err) + } + missing = append(missing, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + var created []string + for index := len(missing) - 1; index >= 0; index-- { + if err := os.Mkdir(missing[index], 0o700); err != nil { + if os.IsExist(err) { + // Raced with something else creating it; not ours to remove. + continue + } + return created, fmt.Errorf("create sandbox runtime root %s: %w", missing[index], err) + } + created = append(created, missing[index]) + } + return created, nil +} + +func ensureWindowsSandboxRuntimeRoots(profile PermissionProfile, workspaceRoots []string) (windowsRuntimeRootRollback, error) { + var rollback windowsRuntimeRootRollback for _, root := range windowsSandboxRuntimeRoots(profile, workspaceRoots) { - if err := os.MkdirAll(root, 0o700); err != nil { - return fmt.Errorf("create sandbox runtime root %s: %w", root, err) + created, err := createRuntimeDirRecording(root) + // Appended before the error check: a partial creation still has to be + // undone, and returning the record with the error is what lets the caller + // do that. + rollback.created = append(rollback.created, created...) + if err != nil { + return rollback, err } } - return nil + return rollback, nil } // buildWindowsSandboxSetupACLPlan provisions the runtime roots and then builds the @@ -455,11 +535,16 @@ func ensureWindowsSandboxRuntimeRoots(profile PermissionProfile, workspaceRoots // path had the provisioning omitted once already, which turned a clean `zero // sandbox setup` into "windows ACL target does not exist". Keeping the two joined // here means a caller cannot get the plan without the trees it names. -func buildWindowsSandboxSetupACLPlan(config WindowsSandboxSetupConfig) (WindowsACLPlan, error) { - if err := ensureWindowsSandboxRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots); err != nil { - return WindowsACLPlan{}, err +func buildWindowsSandboxSetupACLPlan(config WindowsSandboxSetupConfig) (WindowsACLPlan, windowsRuntimeRootRollback, error) { + rollback, err := ensureWindowsSandboxRuntimeRoots(config.PermissionProfile, config.WorkspaceRoots) + if err != nil { + return WindowsACLPlan{}, rollback, err + } + plan, err := BuildWindowsACLPlan(config.commandConfig()) + if err != nil { + return WindowsACLPlan{}, rollback, err } - return BuildWindowsACLPlan(config.commandConfig()) + return plan, rollback, nil } // windowsSandboxProfileWithProvisionedRuntime is the command-side counterpart: @@ -472,7 +557,9 @@ func buildWindowsSandboxSetupACLPlan(config WindowsSandboxSetupConfig) (WindowsA // names, so the runner can neither derive nor provision them. The parent still has // the operator's environment, so it does both and the runner only applies. func windowsSandboxProfileWithProvisionedRuntime(profile PermissionProfile, workspaceRoots []string) (PermissionProfile, error) { - if err := ensureWindowsSandboxRuntimeRoots(profile, workspaceRoots); err != nil { + // The command side does not roll back: it is not transactional, and a runtime + // root it created is the tree the command is about to use. + if _, err := ensureWindowsSandboxRuntimeRoots(profile, workspaceRoots); err != nil { return PermissionProfile{}, err } return windowsSandboxProfileWithRuntime(profile, workspaceRoots), nil diff --git a/internal/sandbox/windows_setup_provision_test.go b/internal/sandbox/windows_setup_provision_test.go index b97f414cc..b06b5bb54 100644 --- a/internal/sandbox/windows_setup_provision_test.go +++ b/internal/sandbox/windows_setup_provision_test.go @@ -91,7 +91,7 @@ func TestBuildWindowsSandboxSetupACLPlanCreatesTheRootsItGrants(t *testing.T) { []string{workspaceRoot}, ), } - plan, err := buildWindowsSandboxSetupACLPlan(config) + plan, _, err := buildWindowsSandboxSetupACLPlan(config) if err != nil { t.Fatalf("buildWindowsSandboxSetupACLPlan: %v", err) } diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 57039a5c2..c04898e5a 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -8,10 +8,21 @@ import ( // 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. +// runtime root necessary in the first place. Its derivation lands entirely +// inside test-owned storage. +// +// EVERY DERIVATION INPUT IS REDIRECTED BEFORE ANY CANDIDATE IS COMPUTED, which +// is the whole point of routing through windowsRuntimeTestRoots rather than +// taking a bare t.TempDir(). Without it, windowsSandboxRuntimeRoots reads the +// real user cache: tests below then create and RemoveAll a genuine +// ~/.cache/zero/runtime/... path, deleting the developer's own runtime tree on +// every run, and failing outright on a read-only home before they ever reach an +// assertion. The fixture also refuses to run at all if a candidate escapes the +// owned roots, so a later change to the derivation cannot quietly reintroduce +// this. func runtimeRootTestConfig(t *testing.T) WindowsSandboxCommandConfig { t.Helper() - workspace := t.TempDir() + workspace, _ := windowsRuntimeTestRoots(t) return WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), CommandCWD: workspace, @@ -171,7 +182,7 @@ func TestWindowsSandboxSetupProvisionsEveryGrantedWriteRoot(t *testing.T) { t.Cleanup(func() { _ = os.RemoveAll(candidate) }) } - if err := ensureWindowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots); err != nil { + if _, err := ensureWindowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots); err != nil { t.Fatalf("ensureWindowsSandboxRuntimeCandidates: %v", err) } diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 8ff598764..009337cd2 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -19,39 +19,53 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) } // Provisions the runtime candidate roots, then builds the plan that grants // them. One call because a granted-but-absent write root fails the whole apply. - plan, err := buildWindowsSandboxSetupACLPlan(config) - if err != nil { - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + plan, runtimeRollback, err := buildWindowsSandboxSetupACLPlan(config) + // SETUP IS TRANSACTIONAL FOR THE STATE IT CREATED. Runtime roots are + // materialized before the network plan, the ACL apply, the network apply and + // the marker write, and every one of those can fail. Previously only ACL + // snapshots were restored, so a run that reported failure still left new + // persistent runtime directories behind, and it could not have cleaned them up + // even in principle because provisioning returned nothing about what it made. + // + // Composed once here so no later failure path can forget it. It removes only + // directories THIS run created, innermost first, and refuses to remove a + // non-empty one, so a pre-existing cache or temp tree is never touched. + failed := func(cause error) int { + if rollbackErr := runtimeRollback.run(); rollbackErr != nil { + fmt.Fprintf(stderr, "%s: %v; runtime rollback failed: %v\n", WindowsSandboxSetupName, cause, rollbackErr) + return 1 + } + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+cause.Error()) return 1 } + if err != nil { + return failed(err) + } // 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 + return failed(err) } rollback, err := applyWindowsACLPlan(plan) if err != nil { - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) - return 1 + return failed(err) } - if err := applyWindowsNetworkPlan(networkPlan); err != nil { + // From here both have to be undone, ACLs first so the directories are empty + // of our grants before they are removed. + failedAfterACL := func(cause error) int { if rollbackErr := rollback(); rollbackErr != nil { - fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) + fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, cause, rollbackErr) return 1 } - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) - return 1 + return failed(cause) + } + if err := applyWindowsNetworkPlan(networkPlan); err != nil { + return failedAfterACL(err) } if _, err := WriteWindowsSandboxSetupMarker(config); 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 + return failedAfterACL(err) } return 0 } From 3c261e45fc0a79b6939743af07788063efb480a9 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 20 Aug 2026 17:37:41 +0530 Subject: [PATCH 13/38] fix(sandbox): make the selected runtime root a setup-to-command contract, and refuse to provision through a link The three P1s, and they were one defect: setup recorded what it INTENDED, a fingerprint of a plan built from a root it merely derived, and never what it actually provisioned. A command derived the same cache root, failed to LEASE it, and silently relocated to the temp fallback. Its plan then named a tree setup had never provisioned, and the marker rejected it: windows sandbox setup is out of date: permission roots or deny lists changed (marker plan e0b1c3fec819, 2 entries; this command wants 8a75a38d0006, 2 entries) blaming permissions for a runtime-root disagreement. Re-running setup could not recover, because sandboxRuntimeRootFor rejects a candidate only for landing inside the workspace, never for being unusable, so setup chose the same unleasable root again. The only escapes were deleting the marker, which silently drops WFP network enforcement, or turning the sandbox off, and the error mentions neither. Setup and commands now select through one function, lease attempt and fallback included, so a relocation is something they agree on. The marker also could not tell whether the directory its pathnames resolve to was still the one setup provisioned. cleanupSandboxRuntimeRoots evicts inactive roots and the next run recreates the same pathname with ordinary inherited permissions; the plan hash is over pathnames, so both marker checks reported setup as current while the tree carried no capability ACE and a WRITE_RESTRICTED token could not write TMP or GOCACHE. Setup now stamps the tree it provisioned, alongside the marker and after the ACL has applied, and validation reads it. A file inside the tree survives exactly as long as the tree does, so eviction is detectable without needing to read an ACE. Provisioning also created by pathname. Every component below the user cache root is ours, a local user needs no privilege to create a junction, and planting one at zero, runtime or v1 made the leaf land in their tree while openWindowsACLTarget saw no reparse point ON THE LEAF and would have granted the sandbox capability write access to a directory they control. Refused at every component we own, before and after creation, and deliberately NOT above them: a redirected LOCALAPPDATA is an ordinary configuration. That last point caught a regression from the previous commit on this branch. Its existence walk used os.Lstat, which reports a junction as not-a-directory, so a redirected cache root was refused outright with "exists and is not a directory". Existence follows links now; whether a link is acceptable is the separate question above. The marker schema is bumped, because a marker written by the previous version has no stamp and requiring one without a bump would report every already-set-up machine as broken rather than as out of date. NOT VERIFIED HERE, said plainly: the elevated apply needs Administrator and this machine is not. Everything above was exercised unelevated through the real entry points, and the ACL write itself was not. --- internal/sandbox/runtime_state.go | 84 +++++--- .../sandbox/windows_runtime_ancestor_test.go | 109 ++++++++++ .../sandbox/windows_runtime_contract_test.go | 110 ++++++++++ internal/sandbox/windows_setup.go | 201 +++++++++++++++++- .../windows_setup_runtime_root_test.go | 46 ++-- internal/sandbox/windows_setup_windows.go | 5 + 6 files changed, 499 insertions(+), 56 deletions(-) create mode 100644 internal/sandbox/windows_runtime_ancestor_test.go create mode 100644 internal/sandbox/windows_runtime_contract_test.go diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index ad2c67409..fd0b60ee3 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -115,40 +115,11 @@ func runtimeRootWithinWorkspace(workspaceRoot string, root string) bool { } func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { - workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) - if workspaceRoot == "" || workspaceRoot == "." { - return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root") - } - cacheRoot, err := sandboxUserCacheDir() - if err != nil { - return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err) - } - // 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") - } - root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) + // One selection function, shared with setup. See selectSandboxRuntimeRoot. + root, lease, err := selectSandboxRuntimeRoot(workspaceRoot) if err != nil { return SandboxRuntime{}, nil, err } - lease, err := prepareSandboxRuntimeLease(root) - if err != nil { - root, err = fallbackSandboxRuntimeRoot(workspaceRoot) - if err != nil { - return SandboxRuntime{}, nil, err - } - lease, err = prepareSandboxRuntimeLease(root) - if err != nil { - return SandboxRuntime{}, nil, err - } - } prepared := false defer func() { if !prepared { @@ -397,3 +368,54 @@ func canonicalSandboxWorkspaceRoot(root string) string { current = parent } } + +// selectSandboxRuntimeRoot picks the runtime root a command will actually use, +// and holds a lease on it while the caller decides what to do with it. +// +// SETUP AND THE COMMAND HAVE TO SELECT THE SAME WAY, or they disagree about +// which tree exists. Setup used to derive the cache-based root and fingerprint a +// plan naming it, while a command derived the same root, failed to lease it, and +// silently relocated to the temp fallback. The command's plan then named the +// fallback and the marker rejected it: +// +// windows sandbox setup is out of date: permission roots or deny lists changed +// +// which blames permissions for a runtime-root disagreement, and re-running setup +// could not recover because setup deterministically chose the same unleasable +// root again. That is a permanent brick, not a retry. +// +// Extracted from prepareSandboxRuntime so both sides run this one function. The +// caller must release the returned lease. +func selectSandboxRuntimeRoot(workspaceRoot string) (string, *sandboxRuntimeLease, error) { + workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) + if workspaceRoot == "" || workspaceRoot == "." { + return "", nil, errors.New("sandbox runtime requires a workspace root") + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return "", nil, fmt.Errorf("resolve user cache directory: %w", err) + } + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) + if cacheRoot == "" || cacheRoot == "." { + return "", nil, errors.New("user cache directory is unavailable") + } + root, err := sandboxRuntimeRootFor(workspaceRoot, cacheRoot) + if err != nil { + return "", nil, err + } + lease, err := prepareSandboxRuntimeLease(root) + if err == nil { + return root, lease, nil + } + // The preferred root could not be leased. Relocating is right, and it is what + // commands already did; the defect was that setup never learned about it. + root, err = fallbackSandboxRuntimeRoot(workspaceRoot) + if err != nil { + return "", nil, err + } + lease, err = prepareSandboxRuntimeLease(root) + if err != nil { + return "", nil, err + } + return root, lease, nil +} diff --git a/internal/sandbox/windows_runtime_ancestor_test.go b/internal/sandbox/windows_runtime_ancestor_test.go new file mode 100644 index 000000000..8edb780d3 --- /dev/null +++ b/internal/sandbox/windows_runtime_ancestor_test.go @@ -0,0 +1,109 @@ +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// AN ELEVATED ACL MUST NOT BE WRITTEN THROUGH A LINK. +// +// The runtime root is a predictable path under the user cache, and every +// component below the cache root is created by us. A local user needs no +// privilege to create a junction, so they can plant one at "zero", "runtime" or +// "v1" and have provisioning follow it: the leaf is created in their tree +// instead of ours, openWindowsACLTarget opens that leaf, sees no reparse point +// ON THE LEAF, and elevated setup grants the sandbox capability write access to +// a directory the attacker controls. +// +// The variant that matters is the one where the attacker ALSO creates the +// components below the junction. A check that looks only at the deepest existing +// component then finds an ordinary directory and passes. +func TestProvisioningRefusesAReparsePointAtAnOwnedAncestor(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("junctions are a Windows construct; the guard is only reachable there") + } + + // The owned tail, exactly as deterministicSandboxRuntimeRoot joins it. + for _, ancestor := range []string{"zero", filepath.Join("zero", "runtime"), filepath.Join("zero", "runtime", "v1")} { + t.Run(ancestor, func(t *testing.T) { + base := t.TempDir() + cache := filepath.Join(base, "cache") + decoy := filepath.Join(base, "attacker-owned") + root := filepath.Join(cache, "zero", "runtime", "v1", "abc123def456") + + link := filepath.Join(cache, ancestor) + if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(decoy, 0o700); err != nil { + t.Fatal(err) + } + out, err := exec.Command("cmd", "/c", "mklink", "/J", link, decoy).CombinedOutput() + if err != nil { + t.Skipf("cannot create a junction here: %v %s", err, out) + } + + // The attacker fills in everything below the junction, so the deepest + // EXISTING component is an ordinary directory and a check that looks only + // there is satisfied. + below := strings.TrimPrefix(strings.TrimPrefix(filepath.Dir(root), filepath.Join(cache, ancestor)), string(filepath.Separator)) + if below != "" { + if err := os.MkdirAll(filepath.Join(decoy, below), 0o700); err != nil { + t.Fatal(err) + } + } + + created, err := createRuntimeDirRecording(root) + if err == nil { + physical := physicalSandboxPath(root) + t.Fatalf("provisioning followed a junction at %s and created %v (physically %s); an elevated ACL applied to that leaf lands on a directory the attacker controls", ancestor, created, physical) + } + if !strings.Contains(err.Error(), "link") { + t.Errorf("the refusal does not explain that a link was in the way: %v", err) + } + }) + } +} + +// The ordinary case must still provision. A guard that refuses everything would +// satisfy the test above and break every real machine. +func TestProvisioningStillCreatesAnOrdinaryRuntimeRoot(t *testing.T) { + root := filepath.Join(t.TempDir(), "cache", "zero", "runtime", "v1", "abc123def456") + created, err := createRuntimeDirRecording(root) + if err != nil { + t.Fatalf("createRuntimeDirRecording on a clean tree: %v", err) + } + if len(created) == 0 { + t.Fatal("nothing was recorded as created") + } + if info, err := os.Stat(root); err != nil || !info.IsDir() { + t.Fatalf("the runtime root was not created: %v", err) + } +} + +// The CACHE ROOT above the owned components is the user's, and a redirected +// LOCALAPPDATA legitimately makes it a reparse point. Refusing there would break +// ordinary machines, so the guard must stop at the components Zero creates. +func TestProvisioningAllowsAReparsePointAboveTheOwnedComponents(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("junctions are a Windows construct") + } + base := t.TempDir() + real := filepath.Join(base, "real-cache") + link := filepath.Join(base, "redirected-cache") + if err := os.MkdirAll(real, 0o700); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, real).CombinedOutput(); err != nil { + t.Skipf("cannot create a junction here: %v %s", err, out) + } + + root := filepath.Join(link, "zero", "runtime", "v1", "abc123def456") + if _, err := createRuntimeDirRecording(root); err != nil { + t.Errorf("provisioning refused a redirected cache root, which is an ordinary Windows configuration: %v", err) + } +} diff --git a/internal/sandbox/windows_runtime_contract_test.go b/internal/sandbox/windows_runtime_contract_test.go new file mode 100644 index 000000000..a0f703f6e --- /dev/null +++ b/internal/sandbox/windows_runtime_contract_test.go @@ -0,0 +1,110 @@ +package sandbox + +import ( + "os" + "strings" + "testing" +) + +// A LEASE FALLBACK MUST NOT BRICK THE WORKSPACE. +// +// Setup used to fingerprint a plan built from the cache-derived root, while a +// command derived the same root, failed to LEASE it, and silently relocated to +// the temp fallback. The command's plan then named a tree setup had never +// provisioned, and the marker rejected it with "permission roots or deny lists +// changed", which blames permissions for a runtime-root disagreement. +// +// Re-running setup could not recover: sandboxRuntimeRootFor rejects a candidate +// only for landing inside the workspace, never for being unusable, so setup +// chose the same unleasable root again. The only escapes were deleting the +// marker, which silently drops WFP network enforcement, or turning the sandbox +// off. Neither is mentioned by the error. +// +// Setup and the command select through one function now, so a fallback is +// something they agree on rather than something that splits them. +func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) { + config := runtimeRootTestConfig(t) + + setupRoot, setupLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0]) + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (setup side): %v", err) + } + setupLease.release() + + commandRoot, commandLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0]) + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (command side): %v", err) + } + commandLease.release() + + if setupRoot != commandRoot { + t.Fatalf("setup selected %s and the command selected %s; the marker cannot validate across that", setupRoot, commandRoot) + } +} + +// THE MARKER MUST BE ABOUT AN OBJECT, NOT ABOUT A PATHNAME. +// +// cleanupSandboxRuntimeRoots evicts inactive roots with os.RemoveAll on an age +// and count policy, and the next run for that workspace recreates the same +// deterministic pathname with ordinary inherited permissions. The plan hash is +// over pathnames, so it was unchanged, and both the elevated and the unelevated +// marker checks reported setup as current while the recreated directory carried +// no capability ACE at all: a WRITE_RESTRICTED token could not write TMP, +// GOCACHE or anything else beneath it, with nothing saying why. +func TestAnEvictedRuntimeRootInvalidatesTheMarker(t *testing.T) { + config := runtimeRootTestConfig(t) + + selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0]) + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot: %v", err) + } + lease.release() + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + command := config + command.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) + // The premise: it validates while the provisioned tree is intact. Without + // this the eviction assertion below could pass for the wrong reason. + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command)); err != nil { + t.Fatalf("SETUP INVALID: the marker did not validate before eviction: %v", err) + } + + // Eviction, exactly as cleanupSandboxRuntimeRoots performs it. + if err := os.RemoveAll(selected); err != nil { + t.Fatalf("evict the runtime root: %v", err) + } + // And the pathname comes back, as prepareSandboxRuntime recreates it, with + // ordinary permissions and no capability ACE. This is the state that used to + // validate. + if err := os.MkdirAll(selected, 0o700); err != nil { + t.Fatalf("recreate the runtime root: %v", err) + } + + err = ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command)) + if err == nil { + t.Fatal("the marker still validates after the provisioned tree was evicted and recreated, so the command runs with no capability ACE and nothing reports it") + } + if !strings.Contains(err.Error(), "removed since setup ran") { + t.Errorf("the error does not explain that the runtime tree was removed, so the operator cannot act on it: %v", err) + } +} + +// A profile carrying no runtime root has nothing to check, which is the setup +// side itself and every non-restricted profile. The stamp must not become a +// requirement where there is no tree. +func TestRuntimeStampIsNotRequiredWithoutARuntimeRoot(t *testing.T) { + if err := validateWindowsSandboxRuntimeStamp(PermissionProfile{}, "somehash"); err != nil { + t.Errorf("a profile carrying no runtime root was rejected: %v", err) + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 366866c2c..890a37e1e 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -15,7 +15,13 @@ import ( const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe" -const windowsSandboxSetupMarkerSchemaVersion = 4 +// Bumped to 5 when setup began recording the CONCRETE runtime root it +// provisioned, and stamping that tree, instead of fingerprinting a plan built +// from a root it merely derived. A marker written by the previous version has no +// stamp, and requiring one without a bump would report every already-set-up +// machine as broken rather than as out of date. Bumping says the true thing: the +// setup protocol changed, run it once more. +const windowsSandboxSetupMarkerSchemaVersion = 5 type WindowsSandboxSetupArgsOptions struct { SandboxHome string @@ -66,10 +72,29 @@ 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. The profile carries no runtime yet at setup time, so this - // derives through sandboxRuntimeRootFor and the answer depends only on the - // workspace and the user cache directory, not on this process's TEMP. + // SELECTED, NOT DERIVED, and selected here in the operator's shell because a + // command runs in that same environment and will reach the same answer. + // + // This used to derive the cache-based root and fingerprint a plan naming it. + // A command derived the same root, failed to LEASE it, and silently relocated + // to the temp fallback, so its plan named a tree setup had never provisioned + // and the marker rejected the command with "permission roots or deny lists + // changed" -- which blames permissions for a runtime-root disagreement. + // Re-running setup could not recover, because setup chose the same unleasable + // root again. That is a permanent brick rather than a retry. + // + // selectSandboxRuntimeRoot is the function commands use, lease attempt and + // fallback included, so setup provisions the tree a command will actually + // select. The lease is released straight away: it is taken here only to learn + // which root wins, and the command acquires its own. + // + // A selection failure is not fatal here. The old derivation is still applied + // below, so a machine where the lease cannot be taken at all behaves exactly + // as it did before rather than losing the ability to run setup. + if selected, lease, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...)); selectErr == nil { + lease.release() + options.PermissionProfile = permissionProfileWithRuntime(options.PermissionProfile, SandboxRuntime{Root: selected}) + } options.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(options.PermissionProfile, workspaceRoots) profileJSON, err := json.Marshal(options.PermissionProfile) if err != nil { @@ -211,6 +236,14 @@ func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa if err != nil { return WindowsSandboxSetupMarker{}, err } + // Stamped alongside the marker, because this is the one place setup records + // that it completed and the two have to be recorded together: a marker whose + // stamp is missing reports setup as current when the tree it provisioned is + // gone, which is the whole failure. Written FIRST so a marker never outlives + // its stamp if the process dies between the two. + if err := writeWindowsSandboxRuntimeStamp(windowsSandboxSelectedRuntimeRoot(config.PermissionProfile), marker.ACLPlanHash); err != nil { + return WindowsSandboxSetupMarker{}, err + } path := WindowsSandboxSetupMarkerPath(config.SandboxHome) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker dir: %w", err) @@ -277,6 +310,17 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { if actual.OfflineFilterSID != expected.OfflineFilterSID { return errors.New("windows sandbox setup is out of date: offline network identity changed") } + // THE PLAN HASH IS ABOUT PATHNAMES, NOT ABOUT OBJECTS. Everything above + // compares what setup INTENDED with what this command wants, and both sides + // agree as long as the same paths are named. Whether the directory those paths + // resolve to is still the one setup provisioned is a different question, and + // nothing here was asking it: cleanupSandboxRuntimeRoots evicts inactive roots + // and the next run recreates the pathname with ordinary permissions, so the + // hashes still matched while the capability ACE was gone and a + // WRITE_RESTRICTED token could not write anything under it. + if err := validateWindowsSandboxRuntimeStamp(config.PermissionProfile, expected.ACLPlanHash); err != nil { + return err + } if actual.NetworkFilters != expected.NetworkFilters { return errors.New("windows sandbox setup is out of date: network enforcement plan changed") } @@ -472,16 +516,81 @@ func (rollback windowsRuntimeRootRollback) run() error { // The distinction between "created" and "already there" is the whole contract: // a pre-existing cache or temp ancestor belongs to the user and must survive a // failed setup, while the components this run added must not. +// windowsSandboxRuntimeOwnedDepth is how many trailing components of a runtime +// root Zero creates and therefore owns: "zero", "runtime", "v1", "". See +// deterministicSandboxRuntimeRoot, which joins exactly these under the cache +// root. +const windowsSandboxRuntimeOwnedDepth = 4 + +// refuseReparsedRuntimeAncestors rejects a reparse point at any component Zero +// creates, so an elevated ACL is never written through one. +// +// ONLY THE COMPONENTS WE OWN. The cache root above them is the user's, and on a +// machine with a redirected LOCALAPPDATA it is legitimately a reparse point, so +// refusing there would break ordinary setups. Everything below it is ours, was +// created by us, and has no business being a link. +// +// The check has to cover EVERY owned component, not just the deepest one that +// exists. A junction planted at "zero" with the components below it created by +// the attacker leaves the deepest existing component an ordinary directory, so a +// check that looks only there passes while creation follows the junction and the +// leaf lands in the attacker's tree. openWindowsACLTarget then opens that leaf, +// sees no reparse point on it, and the capability ACL is written outside the +// runtime hierarchy entirely. +// +// os.Lstat reports a junction as ModeIrregular rather than ModeSymlink, which is +// why both are tested: a Windows junction needs no privilege to create, so this +// is reachable by any local user. +func refuseReparsedRuntimeAncestors(root string) error { + cleaned := filepath.Clean(root) + owned := make([]string, 0, windowsSandboxRuntimeOwnedDepth) + current := cleaned + for range windowsSandboxRuntimeOwnedDepth { + owned = append(owned, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + for _, component := range owned { + info, err := os.Lstat(component) + if err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("inspect sandbox runtime component %s: %w", component, err) + } + if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return fmt.Errorf("refusing to provision the sandbox runtime through a link at %s: a reparse point here would redirect the directory the sandbox is granted write access to", component) + } + } + return nil +} + func createRuntimeDirRecording(root string) ([]string, error) { if strings.TrimSpace(root) == "" { return nil, nil } + // Checked BEFORE anything is created, and again by the caller after, because + // this alone is a check-then-use: an ancestor swapped between the two would + // still redirect the leaf. Pairing it with the post-check narrows the window + // to the creation itself rather than to the whole of setup. + if err := refuseReparsedRuntimeAncestors(root); err != nil { + return nil, err + } // Find the deepest ancestor that already exists, then create downwards from // there, so the record contains exactly the new components. var missing []string current := filepath.Clean(root) for { - if info, err := os.Lstat(current); err == nil { + // os.Stat, which FOLLOWS links, deliberately. os.Lstat reports a junction + // as ModeIrregular rather than as a directory, so using it here refused a + // redirected LOCALAPPDATA -- an ordinary Windows configuration -- with + // "exists and is not a directory". Whether a link is acceptable is a + // different question, answered by refuseReparsedRuntimeAncestors for the + // components Zero actually owns. + if info, err := os.Stat(current); err == nil { if !info.IsDir() { return nil, fmt.Errorf("sandbox runtime path %s exists and is not a directory", current) } @@ -507,6 +616,13 @@ func createRuntimeDirRecording(root string) ([]string, error) { } created = append(created, missing[index]) } + // Re-checked after creation. If an ancestor was swapped for a junction while + // we were creating, the leaf we just made is in the wrong tree, and granting + // it the capability ACL would put it on someone elses directory. Reported as + // a failure so the caller rolls back rather than proceeding. + if err := refuseReparsedRuntimeAncestors(root); err != nil { + return created, err + } return created, nil } @@ -590,3 +706,76 @@ func shortWindowsACLPlanHash(hash string) string { func WindowsSandboxProfileWithRuntimeRoots(profile PermissionProfile, workspaceRoots []string) PermissionProfile { return windowsSandboxProfileWithRuntime(profile, workspaceRoots) } + +// windowsSandboxRuntimeStampName marks a runtime root that ELEVATED SETUP +// actually provisioned and applied the capability ACL to. +const windowsSandboxRuntimeStampName = ".zero-sandbox-setup" + +func windowsSandboxRuntimeStampPath(root string) string { + return filepath.Join(root, windowsSandboxRuntimeStampName) +} + +// writeWindowsSandboxRuntimeStamp records, INSIDE the runtime root, that this +// exact tree carries the capability ACL for this exact plan. +// +// The marker alone cannot tell. It hashes ACL-plan ENTRIES, which are pathnames, +// not the objects those pathnames resolve to. cleanupSandboxRuntimeRoots removes +// inactive roots with os.RemoveAll on an age and count policy, and the next run +// for that workspace recreates the same deterministic pathname through +// os.MkdirAll with ordinary inherited permissions. The plan hash is unchanged, +// so both the elevated and the unelevated marker checks reported setup as +// current while the recreated directory carried NO capability ACE, and a +// WRITE_RESTRICTED token could not write TMP, GOCACHE or anything else under it. +// +// A file inside the tree survives exactly as long as the tree does. Eviction +// takes it, ordinary recreation does not restore it, so its absence is precisely +// the condition "this pathname exists but is not the object setup provisioned". +func writeWindowsSandboxRuntimeStamp(root string, planHash string) error { + root = strings.TrimSpace(root) + if root == "" { + return nil + } + if err := os.MkdirAll(root, 0o700); err != nil { + return fmt.Errorf("create sandbox runtime root for the setup stamp: %w", err) + } + if err := os.WriteFile(windowsSandboxRuntimeStampPath(root), []byte(planHash), 0o600); err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + return nil +} + +// validateWindowsSandboxRuntimeStamp reports whether the runtime root this +// command will use is the one setup provisioned. +// +// Absent when the profile carries no runtime root, which is the setup side +// itself and every non-restricted profile, so this adds no requirement where +// there is nothing to check. +func validateWindowsSandboxRuntimeStamp(profile PermissionProfile, planHash string) error { + if profile.Runtime == nil { + return nil + } + root := strings.TrimSpace(profile.Runtime.Root) + if root == "" { + return nil + } + recorded, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("the sandbox runtime directory for this workspace was removed since setup ran, so it no longer carries the permissions the sandbox needs — run `zero sandbox setup` from an elevated (Administrator) terminal (%s)", root) + } + return fmt.Errorf("read sandbox runtime setup stamp: %w", err) + } + if strings.TrimSpace(string(recorded)) != strings.TrimSpace(planHash) { + return fmt.Errorf("the sandbox runtime directory for this workspace was provisioned for a different configuration — run `zero sandbox setup` from an elevated (Administrator) terminal (%s)", root) + } + return nil +} + +// windowsSandboxSelectedRuntimeRoot returns the concrete runtime root a profile +// carries, or empty when it carries none. +func windowsSandboxSelectedRuntimeRoot(profile PermissionProfile) string { + if profile.Runtime == nil { + return "" + } + return strings.TrimSpace(profile.Runtime.Root) +} diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index c04898e5a..10c8f0a36 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -57,29 +57,37 @@ 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 SELECTS, exactly as BuildWindowsSandboxSetupArgs does in the operator + // shell, so the tree it provisions is the tree a command will choose. This + // used to fingerprint a merely-derived root and then accept EITHER candidate + // at validation, which was compensating for a disagreement rather than + // removing it: whichever root the command selected, only one of them had ever + // been provisioned or carried the capability ACE. + selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0]) + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot: %v", err) + } + lease.release() + setup := WindowsSandboxSetupConfigFromCommand(config) - setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(setup.PermissionProfile, config.WorkspaceRoots) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) } - candidates := windowsSandboxRuntimeRoots(PermissionProfile{}, config.WorkspaceRoots) - if len(candidates) == 0 { - t.Fatal("windowsSandboxRuntimeCandidates returned none, so this test proves nothing") - } - for _, candidate := range candidates { - augmented := config - // 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 { - t.Fatalf("ValidateWindowsSandboxSetupMarker with runtime root %s: %v", candidate, err) - } + // 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 := config + augmented.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: selected}), + config.WorkspaceRoots, + ) + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(augmented)); err != nil { + t.Fatalf("ValidateWindowsSandboxSetupMarker with the selected runtime root %s: %v", selected, err) } // THE RUNNER'S TEMP IS NOT THE OPERATOR'S. @@ -96,7 +104,7 @@ func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { // Augmented FIRST, standing in for the parent, whose TEMP is still real. runner := config runner.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( - permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: candidates[0]}), + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: selected}), config.WorkspaceRoots, ) // Only THEN does the environment become the runner's. Anything downstream of diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 009337cd2..28c5e9392 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -64,6 +64,11 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) if err := applyWindowsNetworkPlan(networkPlan); err != nil { return failedAfterACL(err) } + // The runtime stamp is written by WriteWindowsSandboxSetupMarker below, which + // is the one place setup records that it completed. It is reached only after + // the ACL and network plans have applied, so the stamp still means "this tree + // carries these permissions" and every caller that records a marker records + // the stamp with it. if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { return failedAfterACL(err) } From 7156eab7ca45c59225b89aa7557e8e8bb427781e Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 21 Aug 2026 12:30:41 +0530 Subject: [PATCH 14/38] fix(sandbox): record the runtime root, open it by handle, roll back completely Three problems in the Windows setup-to-command contract. Setup selected a runtime root, released the lease and recorded a plan hash over pathnames, and a command later ran the same selector again. Selection consults a lease, and a lease is a fact about one moment: with the cache root briefly unusable setup provisioned and recorded the temp fallback, and once it freed up the command selected the cache root instead. Two selections, two roots, a marker that could never validate, and re-running setup did not help because setup was equally free to pick the other one. The concrete root is recorded in the marker now and the command consumes it. A recorded root is only honoured when it is one of the two this workspace derives, so a root belonging to another workspace is ignored, and a recorded root that cannot be leased fails with a message naming the situation instead of relocating into a tree nothing provisioned. The reparse-point checks inspected pathnames, and everything after them reopened the tree by name. FILE_FLAG_OPEN_REPARSE_POINT governs only the final component, so a junction planted at an owned ancestor after the last check was followed and the elevated capability ACL landed on a leaf inside the attacker's directory. Junctions need no privilege. A second check narrows that window and cannot close it, so the owned tail is now walked one component at a time through NtCreateFile relative to the handle above it, refusing a reparse point at each step, and the resulting handle is what the ACL apply and the stamp write use. The stamp used MkdirAll and a pathname write, which was the same hole again after the ACL had been applied. The base above the owned components is still followed: a redirected LOCALAPPDATA is ordinary configuration, not an attack. Rollback returned as soon as the ACL restore reported an error, so the runtime rollback never ran and the failure most likely to leave a machine in a strange state was the one that skipped half the cleanup. Every compensation runs now and the errors are joined. The stamp is part of the rollback record too: it lands inside the runtime root before the marker is renamed into place, so a late failure used to leave a root that was non-empty and therefore unremovable by design. A stamp that was already there is restored rather than deleted, so a machine whose previous setup succeeded does not start reporting itself broken. --- internal/sandbox/runtime_state.go | 65 +++++- internal/sandbox/windows_acl_apply_windows.go | 18 ++ .../sandbox/windows_runtime_contract_test.go | 6 +- .../windows_runtime_recorded_root_test.go | 170 ++++++++++++++++ internal/sandbox/windows_runtime_tail.go | 69 +++++++ .../windows_runtime_tail_impl_windows.go | 11 + .../sandbox/windows_runtime_tail_other.go | 14 ++ .../sandbox/windows_runtime_tail_windows.go | 188 ++++++++++++++++++ .../windows_runtime_tail_windows_test.go | 170 ++++++++++++++++ internal/sandbox/windows_setup.go | 179 ++++++++++++++++- ...indows_setup_rollback_completeness_test.go | 162 +++++++++++++++ .../windows_setup_runtime_root_test.go | 2 +- internal/sandbox/windows_setup_windows.go | 25 ++- 13 files changed, 1054 insertions(+), 25 deletions(-) create mode 100644 internal/sandbox/windows_runtime_recorded_root_test.go create mode 100644 internal/sandbox/windows_runtime_tail.go create mode 100644 internal/sandbox/windows_runtime_tail_impl_windows.go create mode 100644 internal/sandbox/windows_runtime_tail_other.go create mode 100644 internal/sandbox/windows_runtime_tail_windows.go create mode 100644 internal/sandbox/windows_runtime_tail_windows_test.go create mode 100644 internal/sandbox/windows_setup_rollback_completeness_test.go diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index fd0b60ee3..668e244bd 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -53,7 +53,11 @@ func sandboxRuntimeRootFor(workspaceRoot string, cacheRoot string) (string, erro // 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])) + // The same inventory the rooted traversal recognizes a runtime root by. See + // windowsSandboxRuntimeOwnedNames: two spellings of this list is how a real + // runtime root stops being recognized as owned, and that failure opens by + // name instead of by handle. + root := filepath.Join(append(append([]string{cacheRoot}, windowsSandboxRuntimeOwnedNames...), hex.EncodeToString(digest[:8]))...) return root, !runtimeRootWithinWorkspace(workspaceRoot, root) } @@ -116,7 +120,7 @@ func runtimeRootWithinWorkspace(workspaceRoot string, root string) bool { func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { // One selection function, shared with setup. See selectSandboxRuntimeRoot. - root, lease, err := selectSandboxRuntimeRoot(workspaceRoot) + root, lease, err := selectSandboxRuntimeRoot(workspaceRoot, true) if err != nil { return SandboxRuntime{}, nil, err } @@ -386,7 +390,41 @@ func canonicalSandboxWorkspaceRoot(root string) string { // // Extracted from prepareSandboxRuntime so both sides run this one function. The // caller must release the returned lease. -func selectSandboxRuntimeRoot(workspaceRoot string) (string, *sandboxRuntimeLease, error) { +// pinnedSandboxRuntimeRoot returns the root a previous setup recorded, when +// that root is one this workspace could actually select. +// +// The candidate check is what keeps this honest. One sandbox home serves +// whichever workspace ran setup last, so a recorded root can belong to a +// different workspace entirely; pinning to that would point this command's +// runtime at another workspace's tree. A recorded root is only honoured when it +// matches one of the two roots THIS workspace derives, which is also the only +// pair the selections could ever have disagreed about. +func pinnedSandboxRuntimeRoot(preferred, fallback string) string { + // No GOOS gate. The marker only exists where setup wrote one, so this is + // already Windows-only in practice, and keeping the code path platform-neutral + // means the setup-to-command contract is exercised on every CI runner instead + // of only the Windows one. + home, err := ResolveWindowsSandboxHome(nil) + if err != nil { + return "" + } + recorded := WindowsSandboxRecordedRuntimeRoot(home) + if recorded == "" { + return "" + } + for _, candidate := range []string{preferred, fallback} { + if candidate != "" && sameWindowsRuntimeRootPath(recorded, candidate) { + return candidate + } + } + return "" +} + +// selectSandboxRuntimeRoot picks the root for a command. honorRecorded is true +// on the command side and false during setup: setup is making the choice, so it +// must not consult a record it is about to overwrite, or a single unlucky +// relocation to the temp fallback would pin every future setup to temp. +func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool) (string, *sandboxRuntimeLease, error) { workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) if workspaceRoot == "" || workspaceRoot == "." { return "", nil, errors.New("sandbox runtime requires a workspace root") @@ -403,6 +441,27 @@ func selectSandboxRuntimeRoot(workspaceRoot string) (string, *sandboxRuntimeLeas if err != nil { return "", nil, err } + // CONSUME SETUP'S CHOICE, do not re-make it. Everything below is a fresh + // selection whose answer depends on whether a lease can be taken right now, + // and a command reaching a different answer than setup did is the outage this + // contract exists to prevent: the tree the command names was never + // provisioned, so its ACL plan hash cannot match and no amount of re-running + // setup fixes it. + if honorRecorded { + fallbackRoot, _ := fallbackSandboxRuntimeRoot(workspaceRoot) + if pinned := pinnedSandboxRuntimeRoot(root, fallbackRoot); pinned != "" { + lease, leaseErr := prepareSandboxRuntimeLease(pinned) + if leaseErr != nil { + // NOT relocated. Relocating is what produced the permanent brick: + // the other root has no capability ACL, so the command would be + // rejected anyway, with a message about permissions. Failing here + // says the true thing and points at the action that fixes it. + return "", nil, fmt.Errorf("sandbox runtime root %s was provisioned by setup but cannot be used now (%w); "+ + "re-run `zero sandbox setup` from an elevated (Administrator) terminal", pinned, leaseErr) + } + return pinned, lease, nil + } + } lease, err := prepareSandboxRuntimeLease(root) if err == nil { return root, lease, nil diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index c666aa9ef..126fea90e 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -151,6 +151,24 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // is exactly the redirection this guard exists to prevent. A missing target is // surfaced as os.ErrNotExist so the caller's materialize path still fires. func openWindowsACLTarget(path string) (windows.Handle, bool, error) { + // A RUNTIME ROOT IS OPENED BY HANDLE, NOT BY NAME. + // + // FILE_FLAG_OPEN_REPARSE_POINT below protects only the FINAL component; every + // ancestor in the pathname is resolved normally. The runtime tail is the one + // part of the tree Zero creates and therefore the one part an unprivileged + // local user can predict and pre-empt, and junctions need no privilege, so a + // swap at an owned ancestor between the last check and this open redirects the + // elevated capability ACL into a directory of their choosing. + // + // Everything else here is the user's own tree, where an ancestor reparse point + // is ordinary configuration and following it is correct. + if _, _, owned := windowsSandboxRuntimeOwnedTail(path); owned { + handle, err := openWindowsRuntimeTailDirectory(path, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_TRAVERSE) + if err != nil { + return 0, false, err + } + return handle, true, nil + } utf16Path, err := windows.UTF16PtrFromString(path) if err != nil { return 0, false, fmt.Errorf("encode windows ACL target %s: %w", path, err) diff --git a/internal/sandbox/windows_runtime_contract_test.go b/internal/sandbox/windows_runtime_contract_test.go index a0f703f6e..2cabbfa40 100644 --- a/internal/sandbox/windows_runtime_contract_test.go +++ b/internal/sandbox/windows_runtime_contract_test.go @@ -25,13 +25,13 @@ import ( func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) { config := runtimeRootTestConfig(t) - setupRoot, setupLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0]) + setupRoot, setupLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) if err != nil { t.Fatalf("selectSandboxRuntimeRoot (setup side): %v", err) } setupLease.release() - commandRoot, commandLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0]) + commandRoot, commandLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) if err != nil { t.Fatalf("selectSandboxRuntimeRoot (command side): %v", err) } @@ -54,7 +54,7 @@ func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) { func TestAnEvictedRuntimeRootInvalidatesTheMarker(t *testing.T) { config := runtimeRootTestConfig(t) - selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0]) + selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) if err != nil { t.Fatalf("selectSandboxRuntimeRoot: %v", err) } diff --git a/internal/sandbox/windows_runtime_recorded_root_test.go b/internal/sandbox/windows_runtime_recorded_root_test.go new file mode 100644 index 000000000..a1e3c3b0c --- /dev/null +++ b/internal/sandbox/windows_runtime_recorded_root_test.go @@ -0,0 +1,170 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// blockCacheRuntimeRoot makes the cache-derived runtime root unleasable, and +// returns the function that frees it again. +// +// A file where prepareSandboxRuntimeLease wants a directory: MkdirAll on the +// parent fails, the lease attempt fails with it, and selection relocates to the +// temp fallback. It stands in for any reason the preferred root is unavailable +// for a moment, which is the whole point -- the defect never depended on which +// reason it was. +func blockCacheRuntimeRoot(t *testing.T, workspaceRoot string) (string, func()) { + t.Helper() + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + t.Skipf("no user cache directory in this environment: %v", err) + } + preferred, err := sandboxRuntimeRootFor(canonicalSandboxWorkspaceRoot(workspaceRoot), canonicalSandboxWorkspaceRoot(cacheRoot)) + if err != nil { + t.Skipf("no cache-derived runtime root in this environment: %v", err) + } + blocker := filepath.Dir(preferred) + if err := os.MkdirAll(filepath.Dir(blocker), 0o700); err != nil { + t.Fatalf("create the blocker's parent: %v", err) + } + _ = os.RemoveAll(blocker) + if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("block the cache runtime root: %v", err) + } + return preferred, func() { _ = os.Remove(blocker) } +} + +// A TRANSIENT SELECTION IS NOT A DURABLE CONFIGURATION. +// +// Setup selected a runtime root, released the lease, and recorded a plan hash +// over pathnames. A command later ran the SAME selector, and the selector +// consults a lease: with the cache root briefly unusable setup provisioned and +// recorded the temp fallback, and once it freed up the command selected the +// cache root instead. Its plan hash and stamp path then named a tree setup had +// never provisioned, so the marker rejected every command with "permission roots +// or deny lists changed" -- and re-running setup did not help, because setup was +// equally free to pick the other root. +// +// Setup's choice is recorded now and the command consumes it, so the two cannot +// disagree no matter what changes in between. +func TestTheCommandHonoursTheRootSetupActuallyProvisioned(t *testing.T) { + config := runtimeRootTestConfig(t) + workspace := config.WorkspaceRoots[0] + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) + + preferred, unblock := blockCacheRuntimeRoot(t, workspace) + + // Setup, with the cache root unusable. honorRecorded is false because setup + // is the one making the choice. + setupRoot, setupLease, err := selectSandboxRuntimeRoot(workspace, false) + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (setup): %v", err) + } + setupLease.release() + if sameWindowsRuntimeRootPath(setupRoot, preferred) { + t.Fatalf("setup selected the cache root %s even though it was blocked; this case is not being exercised", setupRoot) + } + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: setupRoot}), + config.WorkspaceRoots, + ) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + // And now the cache root frees up, which is exactly when the old code + // diverged. + unblock() + + commandRoot, commandLease, err := selectSandboxRuntimeRoot(workspace, true) + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (command): %v", err) + } + commandLease.release() + if !sameWindowsRuntimeRootPath(commandRoot, setupRoot) { + t.Fatalf("setup provisioned %s and the command selected %s once the cache root freed up; the marker can never validate across that", setupRoot, commandRoot) + } + + command := config + command.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(config.PermissionProfile, SandboxRuntime{Root: commandRoot}), + config.WorkspaceRoots, + ) + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command)); err != nil { + t.Fatalf("the first command after setup was rejected: %v", err) + } +} + +// The record belongs to the workspace setup ran for. One sandbox home serves +// whichever workspace ran setup last, so honouring a root recorded for a +// different workspace would point this command's runtime at somebody else's +// tree. +func TestARootRecordedForAnotherWorkspaceIsNotHonoured(t *testing.T) { + config := runtimeRootTestConfig(t) + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) + + foreign := filepath.Join(t.TempDir(), "somebody-elses-runtime") + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: foreign}) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot: %v", err) + } + lease.release() + if sameWindowsRuntimeRootPath(selected, foreign) { + t.Fatalf("a root recorded for another workspace was honoured: %s", selected) + } +} + +// A recorded root that cannot be leased must FAIL, not relocate. +// +// Relocating is what produced the permanent brick: the other root carries no +// capability ACE, so the command is rejected anyway, with a message about +// permissions that sends the operator looking in the wrong place. +func TestAnUnusableRecordedRootFailsInsteadOfRelocating(t *testing.T) { + config := runtimeRootTestConfig(t) + workspace := config.WorkspaceRoots[0] + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) + + recorded, lease, err := selectSandboxRuntimeRoot(workspace, false) + if err != nil { + t.Fatalf("selectSandboxRuntimeRoot (setup): %v", err) + } + lease.release() + + setup := WindowsSandboxSetupConfigFromCommand(config) + setup.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots( + permissionProfileWithRuntime(setup.PermissionProfile, SandboxRuntime{Root: recorded}), + config.WorkspaceRoots, + ) + if _, err := WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + // Make the provisioned root unusable after the fact. + blocker := filepath.Dir(recorded) + if err := os.RemoveAll(blocker); err != nil { + t.Skipf("cannot displace the recorded root in this environment: %v", err) + } + if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil { + t.Skipf("cannot displace the recorded root in this environment: %v", err) + } + t.Cleanup(func() { _ = os.Remove(blocker) }) + + selected, selectedLease, err := selectSandboxRuntimeRoot(workspace, true) + if err == nil { + selectedLease.release() + t.Fatalf("selection relocated to %s instead of reporting that the provisioned root is unusable", selected) + } + if !strings.Contains(err.Error(), "provisioned by setup") || !strings.Contains(err.Error(), "zero sandbox setup") { + t.Errorf("the error does not name the situation or the action that fixes it: %v", err) + } +} diff --git a/internal/sandbox/windows_runtime_tail.go b/internal/sandbox/windows_runtime_tail.go new file mode 100644 index 000000000..5a49d5420 --- /dev/null +++ b/internal/sandbox/windows_runtime_tail.go @@ -0,0 +1,69 @@ +package sandbox + +import ( + "errors" + "fmt" + "path/filepath" + "strings" +) + +// windowsSandboxRuntimeOwnedNames are the fixed components Zero joins under the +// cache or temp root, above the per-workspace digest. +// +// ONE INVENTORY. deterministicSandboxRuntimeRoot builds a root from these and +// the traversal below recognizes one by them, and the two have to stay the same +// list or the traversal silently stops treating a real runtime root as owned: +// it would fall back to opening by name, which is exactly the unprotected path +// this file exists to replace. A wrong answer here fails open, so the two uses +// read from the same place. +var windowsSandboxRuntimeOwnedNames = []string{"zero", "runtime", "v1"} + +// windowsSandboxRuntimeOwnedDepth is how many trailing components of a runtime +// root Zero creates and therefore owns: the fixed names plus the digest. +var windowsSandboxRuntimeOwnedDepth = len(windowsSandboxRuntimeOwnedNames) + 1 + +// windowsSandboxRuntimeOwnedTail splits a runtime root into the ancestor that +// belongs to the user and the components Zero created. +// +// The base is deliberately not our business. On a machine with a redirected +// LOCALAPPDATA it is legitimately a reparse point, and refusing there would +// break ordinary setups. Everything below it was created by us and has no +// business being a link. +// +// ok is false when the path does not have the shape a runtime root has, which +// means the caller must not treat it as owned. +func windowsSandboxRuntimeOwnedTail(root string) (string, []string, bool) { + cleaned := filepath.Clean(strings.TrimSpace(root)) + if cleaned == "" || cleaned == "." { + return "", nil, false + } + components := make([]string, 0, windowsSandboxRuntimeOwnedDepth) + current := cleaned + for range windowsSandboxRuntimeOwnedDepth { + parent := filepath.Dir(current) + if parent == current { + return "", nil, false + } + components = append(components, filepath.Base(current)) + current = parent + } + // components came off the tail, deepest first. + for index, name := range windowsSandboxRuntimeOwnedNames { + if !strings.EqualFold(components[len(components)-1-index], name) { + return "", nil, false + } + } + ordered := make([]string, 0, len(components)) + for index := len(components) - 1; index >= 0; index-- { + ordered = append(ordered, components[index]) + } + return current, ordered, true +} + +// errRuntimeTailNotOwned reports a path the rooted traversal will not handle. +// Callers must fail rather than quietly opening it by name. +var errRuntimeTailNotOwned = errors.New("path is not a sandbox runtime root") + +func runtimeTailNotOwned(root string) error { + return fmt.Errorf("%w: %s", errRuntimeTailNotOwned, root) +} diff --git a/internal/sandbox/windows_runtime_tail_impl_windows.go b/internal/sandbox/windows_runtime_tail_impl_windows.go new file mode 100644 index 000000000..f6a73b6b8 --- /dev/null +++ b/internal/sandbox/windows_runtime_tail_impl_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package sandbox + +import "errors" + +var errNoRootedStampWriter = errors.New("no rooted stamp writer on this platform") + +func writeRuntimeStampThroughHandle(root string, planHash string) error { + return writeWindowsRuntimeStampThroughHandle(root, planHash) +} diff --git a/internal/sandbox/windows_runtime_tail_other.go b/internal/sandbox/windows_runtime_tail_other.go new file mode 100644 index 000000000..e0b3719a0 --- /dev/null +++ b/internal/sandbox/windows_runtime_tail_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package sandbox + +import "errors" + +// errNoRootedStampWriter marks the platforms with no rooted traversal. The +// runtime stamp is a Windows concept; the code that writes it is shared only so +// its tests run everywhere. +var errNoRootedStampWriter = errors.New("no rooted stamp writer on this platform") + +func writeRuntimeStampThroughHandle(string, string) error { + return errNoRootedStampWriter +} diff --git a/internal/sandbox/windows_runtime_tail_windows.go b/internal/sandbox/windows_runtime_tail_windows.go new file mode 100644 index 000000000..7007eb52b --- /dev/null +++ b/internal/sandbox/windows_runtime_tail_windows.go @@ -0,0 +1,188 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// windowsFileAddFile is FILE_ADD_FILE, the directory right to create a file in +// it. x/sys/windows does not export it. +const windowsFileAddFile = 0x00000002 + +// A PATHNAME IS NOT AN OBJECT. +// +// refuseReparsedRuntimeAncestors inspects the owned components before and after +// creation, and that is a check-then-use however many times it runs. Everything +// afterwards reopens the tree BY NAME: openWindowsACLTarget passes the whole +// path to CreateFile, and FILE_FLAG_OPEN_REPARSE_POINT governs only the final +// component, so every ancestor is resolved normally. A local user who plants a +// junction at an owned ancestor between the last check and the open gets the +// capability ACL written to an ordinary leaf inside a directory they chose. +// Windows junctions need no privilege to create, so this is not a theoretical +// attacker. +// +// A second Lstat narrows that window; it cannot close it. The only thing that +// closes it is never resolving the path again: open the base once, then descend +// one component at a time RELATIVE TO THE HANDLE ABOVE IT, refusing a reparse +// point at each step, and use the handle that comes out for everything that +// follows. NtCreateFile is what allows a relative open at all; Win32 CreateFile +// has no equivalent. + +// openWindowsRuntimeTailDirectory walks the components Zero owns and returns a +// handle to the runtime root itself. The caller closes it. +func openWindowsRuntimeTailDirectory(root string, access uint32) (windows.Handle, error) { + base, components, ok := windowsSandboxRuntimeOwnedTail(root) + if !ok { + // NOT a fallback to opening by name. A path that does not have a runtime + // root's shape is one this traversal cannot vouch for, and the whole point + // is to stop trusting a name. + return 0, runtimeTailNotOwned(root) + } + // The base belongs to the user, so it is opened by name and its own reparse + // points are followed: a redirected LOCALAPPDATA is an ordinary machine + // configuration, not an attack. + parent, err := openWindowsDirectoryByName(base) + if err != nil { + return 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err) + } + for index, name := range components { + // FILE_READ_ATTRIBUTES on every component, including the intermediates: + // each open is followed by a GetFileInformationByHandle to decide whether + // it is a reparse point, and without that right the check itself fails with + // "Access is denied" and refuses the whole tree. + wanted := access | windows.FILE_READ_ATTRIBUTES + if index < len(components)-1 { + // Intermediate components are only traversed. + wanted = windows.FILE_TRAVERSE | windows.FILE_READ_ATTRIBUTES | windows.SYNCHRONIZE + } + child, err := openWindowsChildNoFollow(parent, name, wanted, windows.FILE_DIRECTORY_FILE) + _ = windows.CloseHandle(parent) + if err != nil { + return 0, err + } + parent = child + } + return parent, nil +} + +func openWindowsDirectoryByName(path string) (windows.Handle, error) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.CreateFile( + utf16Path, + windows.FILE_TRAVERSE|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, + ) +} + +// openWindowsChildNoFollow opens exactly one component beneath parent. +// +// FILE_OPEN_REPARSE_POINT makes the open land on a link rather than following +// it, so a swapped component is opened as the link it is and then refused, +// instead of silently resolving into somebody else's tree. Since the name is +// relative to a handle, no ancestor is re-resolved and there is no interval for +// a swap to land in. +func openWindowsChildNoFollow(parent windows.Handle, name string, access uint32, options uint32) (windows.Handle, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, fmt.Errorf("encode sandbox runtime 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 iosb windows.IO_STATUS_BLOCK + err = windows.NtCreateFile( + &handle, + access|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + 0, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + options|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return 0, fmt.Errorf("open sandbox runtime component %s: %w", name, err) + } + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("inspect sandbox runtime component %s: %w", name, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("refusing to use the sandbox runtime through a link at %s: a reparse point here redirects the directory the sandbox is granted write access to", name) + } + return handle, nil +} + +// writeWindowsRuntimeStampThroughHandle writes the setup stamp INTO the object +// the traversal reached, not into whatever the pathname resolves to now. +// +// The old writer used MkdirAll and a pathname write, which left a second +// unbound interval: a tree replaced after the ACL apply could be recreated and +// stamped without ever carrying the capability grant, and marker validation +// still passed because it only reads the stamp's contents. The restricted +// process then got a marker-valid runtime path with no grant on it. +func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error { + directory, err := openWindowsRuntimeTailDirectory(root, windows.FILE_TRAVERSE|windowsFileAddFile|windows.SYNCHRONIZE) + if err != nil { + return err + } + defer windows.CloseHandle(directory) + + objectName, err := windows.NewNTUnicodeString(windowsSandboxRuntimeStampName) + if err != nil { + return fmt.Errorf("encode sandbox runtime setup stamp name: %w", err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: directory, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var iosb windows.IO_STATUS_BLOCK + err = windows.NtCreateFile( + &handle, + windows.GENERIC_WRITE|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ, + windows.FILE_OVERWRITE_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + file := os.NewFile(uintptr(handle), windowsSandboxRuntimeStampName) + defer file.Close() + if _, err := file.WriteString(planHash); err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + return nil +} diff --git a/internal/sandbox/windows_runtime_tail_windows_test.go b/internal/sandbox/windows_runtime_tail_windows_test.go new file mode 100644 index 000000000..0d6ab6fac --- /dev/null +++ b/internal/sandbox/windows_runtime_tail_windows_test.go @@ -0,0 +1,170 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// makeJunction creates a real directory junction. +// +// mklink /J, not os.Symlink, on purpose. A junction needs NO privilege, which is +// what makes this an attack an ordinary local user can mount against elevated +// setup, and it is a different reparse tag from a symlink: os.Lstat reports a +// junction as ModeIrregular rather than ModeSymlink, so a guard written against +// symlinks is inert against the thing that is actually reachable here. +func makeJunction(t *testing.T, link, target string) { + t.Helper() + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("create the junction target: %v", err) + } + output, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput() + if err != nil { + t.Skipf("cannot create a junction in this environment: %v (%s)", err, output) + } +} + +func runtimeTailRoot(t *testing.T) (string, string) { + t.Helper() + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + return base, root +} + +// A SWAP AT ANY OWNED ANCESTOR MUST BE REFUSED, at the moment the tree is used. +// +// The pre-creation and post-creation checks are check-then-use however many +// times they run: an ancestor replaced afterwards is followed by the next open, +// because FILE_FLAG_OPEN_REPARSE_POINT governs only the final component of a +// pathname. Every owned component is covered here, not just the deepest one: +// a junction at "zero" with ordinary directories created below it leaves the +// leaf looking perfectly normal. +func TestTheRootedTraversalRefusesAJunctionAtEveryOwnedComponent(t *testing.T) { + for depth := range windowsSandboxRuntimeOwnedDepth { + t.Run("swap "+string(rune('0'+depth))+" levels above the leaf", func(t *testing.T) { + base, root := runtimeTailRoot(t) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + // Replace one owned component with a junction pointing somewhere else, + // and recreate the components below it inside the attacker's target so + // the leaf itself is an ordinary directory. + swapped := root + for range depth { + swapped = filepath.Dir(swapped) + } + tail, err := filepath.Rel(swapped, root) + if err != nil { + t.Fatalf("relate the swapped component to the root: %v", err) + } + if err := os.RemoveAll(swapped); err != nil { + t.Fatalf("clear the component to swap: %v", err) + } + target := filepath.Join(t.TempDir(), "attacker") + makeJunction(t, swapped, target) + if tail != "." { + if err := os.MkdirAll(filepath.Join(target, tail), 0o700); err != nil { + t.Fatalf("recreate the components below the junction: %v", err) + } + } + // Above the leaf, the leaf itself is now an ORDINARY directory, which is + // exactly why a check that looks only there passes while the open lands + // inside the attacker's tree. Asserted so the subtest cannot quietly + // degenerate into the easy leaf-swap case. + if depth > 0 { + if info, err := os.Lstat(root); err != nil || info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + t.Fatalf("the leaf is not an ordinary directory, so this case is not being exercised (err %v)", err) + } + } + + handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_TRAVERSE) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatalf("the traversal followed a junction at an owned component and would have applied the elevated ACL inside %s", target) + } + if !strings.Contains(err.Error(), "link") { + t.Errorf("the refusal does not name the reason: %v", err) + } + _ = base + }) + } +} + +// And the ordinary tree still opens, or the guard above would be satisfied by a +// traversal that refuses everything. +func TestTheRootedTraversalOpensAnOrdinaryRuntimeRoot(t *testing.T) { + _, root := runtimeTailRoot(t) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windows.FILE_TRAVERSE) + if err != nil { + t.Fatalf("an ordinary runtime root was refused: %v", err) + } + _ = windows.CloseHandle(handle) +} + +// A REDIRECTED LOCALAPPDATA IS NOT AN ATTACK. The base above the owned +// components belongs to the user, and on a machine with a redirected cache +// directory it is legitimately a reparse point. Refusing there would break +// ordinary setups on ordinary machines. +func TestTheRootedTraversalFollowsAJunctionAboveTheOwnedComponents(t *testing.T) { + real := t.TempDir() + base := filepath.Join(t.TempDir(), "redirected-localappdata") + makeJunction(t, base, real) + + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree through the redirected base: %v", err) + } + handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windows.FILE_TRAVERSE) + if err != nil { + t.Fatalf("a redirected cache directory was refused: %v", err) + } + _ = windows.CloseHandle(handle) +} + +// The stamp goes into the object the traversal reached. Writing it by pathname +// left a second unbound interval after the ACL apply: a replaced tree could be +// recreated and stamped without ever carrying the capability grant, and marker +// validation still passed because it only compares the stamp's contents. +func TestTheStampIsWrittenThroughTheTraversalAndRefusesASwappedTree(t *testing.T) { + _, root := runtimeTailRoot(t) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil { + t.Fatalf("write the stamp through the traversal: %v", err) + } + recorded, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil || string(recorded) != "planhash" { + t.Fatalf("the stamp did not land in the runtime root (%q, err %v)", recorded, err) + } + + // Now replace an owned ancestor, as an attacker would between the ACL apply + // and the stamp. + parent := filepath.Dir(root) + leaf := filepath.Base(root) + if err := os.RemoveAll(parent); err != nil { + t.Fatalf("clear the component to swap: %v", err) + } + target := filepath.Join(t.TempDir(), "attacker") + makeJunction(t, parent, target) + if err := os.MkdirAll(filepath.Join(target, leaf), 0o700); err != nil { + t.Fatalf("recreate the leaf inside the attacker's tree: %v", err) + } + + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err == nil { + t.Fatal("the stamp was written through a junction, marking an unprovisioned tree as set up") + } + if _, err := os.Stat(filepath.Join(target, leaf, windowsSandboxRuntimeStampName)); err == nil { + t.Errorf("a stamp was written inside the attacker's tree at %s", target) + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 890a37e1e..de2050bae 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -9,19 +9,25 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" ) const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe" +// Bumped to 6 when the SELECTED RUNTIME ROOT itself became part of the marker, +// so a command consumes setup's choice instead of re-deriving one. A marker +// written by the previous version records no root, and honouring it would pin +// commands to whatever this build happens to select first. +// // Bumped to 5 when setup began recording the CONCRETE runtime root it // provisioned, and stamping that tree, instead of fingerprinting a plan built // from a root it merely derived. A marker written by the previous version has no // stamp, and requiring one without a bump would report every already-set-up // machine as broken rather than as out of date. Bumping says the true thing: the // setup protocol changed, run it once more. -const windowsSandboxSetupMarkerSchemaVersion = 5 +const windowsSandboxSetupMarkerSchemaVersion = 6 type WindowsSandboxSetupArgsOptions struct { SandboxHome string @@ -49,6 +55,22 @@ type WindowsSandboxSetupMarker struct { NetworkInfraHash string `json:"networkInfraHash"` OfflineFilterSID string `json:"offlineFilterSid"` NetworkFilters int `json:"networkFilters"` + // RuntimeRoot is the runtime tree setup ACTUALLY PROVISIONED, recorded rather + // than re-derived. + // + // Selection consults a lease, and a lease is a fact about one moment. Setup + // took the lease only to learn which root won and released it immediately, so + // a command ran the same selector later and was free to reach a different + // answer: setup relocating to the temp fallback while the cache root was + // briefly unavailable, then a command taking the cache root once it freed up. + // Two selections, two roots, and a marker that can never validate again -- + // re-running setup does not help, because setup is equally free to pick the + // other one. + // + // Recording the choice removes the disagreement instead of trying to make two + // independent selections agree. See pinnedSandboxRuntimeRoot for the consuming + // side. + RuntimeRoot string `json:"runtimeRoot,omitempty"` } func WindowsSandboxSetupMarkerPath(sandboxHome string) string { @@ -91,7 +113,7 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str // A selection failure is not fatal here. The old derivation is still applied // below, so a machine where the lease cannot be taken at all behaves exactly // as it did before rather than losing the ability to run setup. - if selected, lease, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...)); selectErr == nil { + if selected, lease, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...), false); selectErr == nil { lease.release() options.PermissionProfile = permissionProfileWithRuntime(options.PermissionProfile, SandboxRuntime{Root: selected}) } @@ -228,6 +250,7 @@ func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa NetworkInfraHash: infraHash, OfflineFilterSID: offlineSID, NetworkFilters: len(infraPlan.Filters), + RuntimeRoot: windowsSandboxSelectedRuntimeRoot(config.PermissionProfile), }, nil } @@ -324,9 +347,61 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { if actual.NetworkFilters != expected.NetworkFilters { return errors.New("windows sandbox setup is out of date: network enforcement plan changed") } + // Named explicitly, and last, because the checks above cannot tell this case + // apart from a policy edit. A runtime-root disagreement used to surface as + // "permission roots or deny lists changed", which sends the operator looking + // at permissions for a problem that is nothing to do with them. + // + // With the root recorded this should not be reachable, since the command + // consumes what setup wrote. It stays as the assertion that the contract held. + if recorded := strings.TrimSpace(actual.RuntimeRoot); recorded != "" { + if selected := strings.TrimSpace(expected.RuntimeRoot); selected != "" && !sameWindowsRuntimeRootPath(recorded, selected) { + return fmt.Errorf("windows sandbox setup is out of date: setup provisioned runtime root %s, this command selected %s -- run `zero sandbox setup` from an elevated (Administrator) terminal", recorded, selected) + } + } return nil } +// sameWindowsRuntimeRootPath compares two runtime roots the way the filesystem +// does on this platform. Windows paths are case-insensitive, and the recorded +// root and the selected root can differ only in spelling. +func sameWindowsRuntimeRootPath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +// WindowsSandboxRecordedRuntimeRoot returns the runtime root a previous setup +// provisioned, or "" when there is no usable marker. +// +// Deliberately silent on every failure. A missing, unreadable or malformed +// marker means there is nothing to honour, and the caller's job is to select +// normally rather than to report on marker health -- validation does that, with +// a much better message than a selector could produce. +func WindowsSandboxRecordedRuntimeRoot(sandboxHome string) string { + sandboxHome = strings.TrimSpace(sandboxHome) + if sandboxHome == "" { + return "" + } + bytes, err := os.ReadFile(WindowsSandboxSetupMarkerPath(sandboxHome)) + if err != nil { + return "" + } + var marker WindowsSandboxSetupMarker + if err := json.Unmarshal(bytes, &marker); err != nil { + return "" + } + // A root recorded by an older schema describes a tree provisioned under + // different rules, so it is not a root this build may pin to. + if marker.SchemaVersion != windowsSandboxSetupMarkerSchemaVersion { + return "" + } + return strings.TrimSpace(marker.RuntimeRoot) +} + func WindowsACLPlanHash(plan WindowsACLPlan) (string, error) { entries := canonicalWindowsACLEntries(plan.Entries) bytes, err := json.Marshal(entries) @@ -492,6 +567,59 @@ func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots type windowsRuntimeRootRollback struct { // created is in creation order, outermost first, so undo walks it backwards. created []string + // stamp is the runtime stamp's state before this run touched it. + // + // The stamp is the one artifact setup writes INSIDE the runtime root, and it + // is written before the marker. A marker write that failed therefore left a + // root this run had created holding a file this run had written, and the + // rollback below refuses a non-empty directory on purpose, so the failed setup + // kept its own residue forever. Owning the stamp is what makes the root empty + // again and the whole transaction complete. + stamp windowsSandboxStampSnapshot +} + +// windowsSandboxStampSnapshot records the runtime stamp as it was before setup +// overwrote it, so a failed run restores rather than deletes. +// +// Restoring matters where a previous setup had succeeded. Deleting the stamp +// would leave that machine's still-valid marker pointing at a tree with no +// stamp, which reads as "the runtime directory was removed since setup ran" -- +// a healthy machine reporting itself broken because an unrelated later setup +// failed. +type windowsSandboxStampSnapshot struct { + path string + prior []byte + existed bool +} + +func snapshotWindowsSandboxRuntimeStamp(root string) windowsSandboxStampSnapshot { + root = strings.TrimSpace(root) + if root == "" { + return windowsSandboxStampSnapshot{} + } + path := windowsSandboxRuntimeStampPath(root) + prior, err := os.ReadFile(path) + if err != nil { + // Absent, or unreadable and therefore not something to put back. + return windowsSandboxStampSnapshot{path: path} + } + return windowsSandboxStampSnapshot{path: path, prior: prior, existed: true} +} + +func (snapshot windowsSandboxStampSnapshot) restore() error { + if snapshot.path == "" { + return nil + } + if !snapshot.existed { + if err := os.Remove(snapshot.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) + } + return nil + } + if err := os.WriteFile(snapshot.path, snapshot.prior, 0o600); err != nil { + return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err) + } + return nil } // run removes what was created, innermost first. @@ -502,6 +630,12 @@ type windowsRuntimeRootRollback struct { // answer: the error is reported and the residue stays findable. func (rollback windowsRuntimeRootRollback) run() error { var errs []error + // The stamp first, so a root this run created is empty again by the time the + // directory walk reaches it. EVERY compensation still runs if this one fails: + // one broken undo must not strand the rest. + if err := rollback.stamp.restore(); err != nil { + errs = append(errs, err) + } for index := len(rollback.created) - 1; index >= 0; index-- { path := rollback.created[index] if err := os.Remove(path); err != nil && !os.IsNotExist(err) { @@ -511,16 +645,36 @@ func (rollback windowsRuntimeRootRollback) run() error { return errors.Join(errs...) } +// runWindowsSandboxSetupCompensations undoes a failed setup COMPLETELY, and +// reports everything that went wrong doing it. +// +// One function because the old code had two failure closures that composed by +// calling each other, and the outer one returned as soon as the ACL rollback +// reported an error. The runtime rollback then never ran, so the failure most +// likely to leave a machine in a strange state was the one failure that skipped +// half the cleanup. Every compensation runs here, unconditionally, and the +// errors are joined rather than raced. +// +// aclRollback is nil before the ACL plan has been applied, which is the only +// difference between the two failure points. +func runWindowsSandboxSetupCompensations(cause error, aclRollback func() error, runtimeRollback windowsRuntimeRootRollback) error { + errs := []error{cause} + if aclRollback != nil { + if err := aclRollback(); err != nil { + errs = append(errs, fmt.Errorf("acl rollback failed: %w", err)) + } + } + if err := runtimeRollback.run(); err != nil { + errs = append(errs, fmt.Errorf("runtime rollback failed: %w", err)) + } + return errors.Join(errs...) +} + // createRuntimeDirRecording is MkdirAll that reports which components it made. // // The distinction between "created" and "already there" is the whole contract: // a pre-existing cache or temp ancestor belongs to the user and must survive a // failed setup, while the components this run added must not. -// windowsSandboxRuntimeOwnedDepth is how many trailing components of a runtime -// root Zero creates and therefore owns: "zero", "runtime", "v1", "". See -// deterministicSandboxRuntimeRoot, which joins exactly these under the cache -// root. -const windowsSandboxRuntimeOwnedDepth = 4 // refuseReparsedRuntimeAncestors rejects a reparse point at any component Zero // creates, so an elevated ACL is never written through one. @@ -738,6 +892,17 @@ func writeWindowsSandboxRuntimeStamp(root string, planHash string) error { if err := os.MkdirAll(root, 0o700); err != nil { return fmt.Errorf("create sandbox runtime root for the setup stamp: %w", err) } + // Written through the rooted traversal where one is available, so the stamp + // lands in the object the ACL was applied to rather than in whatever the + // pathname resolves to by now. MkdirAll above and a pathname write left a + // second unbound interval: a tree replaced after the ACL apply could be + // recreated and stamped with no capability grant on it at all, and validation + // still passed because it only compares the stamp contents. + if err := writeRuntimeStampThroughHandle(root, planHash); err == nil { + return nil + } else if !errors.Is(err, errRuntimeTailNotOwned) && !errors.Is(err, errNoRootedStampWriter) { + return err + } if err := os.WriteFile(windowsSandboxRuntimeStampPath(root), []byte(planHash), 0o600); err != nil { return fmt.Errorf("write sandbox runtime setup stamp: %w", err) } diff --git a/internal/sandbox/windows_setup_rollback_completeness_test.go b/internal/sandbox/windows_setup_rollback_completeness_test.go new file mode 100644 index 000000000..1609f2ee3 --- /dev/null +++ b/internal/sandbox/windows_setup_rollback_completeness_test.go @@ -0,0 +1,162 @@ +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// A FAILED SETUP MUST LEAVE NOTHING IT CREATED. +// +// The stamp goes inside the runtime root and is written before the marker file +// is renamed into place, so any failure after that point left the root holding a +// file this run had written. The directory removal refuses a non-empty directory +// on purpose, to avoid turning a failed setup into data loss, and the two +// combined meant the residue could never be cleaned up: a failed run kept the +// persistent runtime tree it had just created, permanently. +func TestRollbackRemovesTheStampItWroteAndThenTheRoot(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime", "v1", "abcd") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + + // Snapshot BEFORE the stamp exists, which is the fresh-setup case. + snapshot := snapshotWindowsSandboxRuntimeStamp(root) + if err := writeWindowsSandboxRuntimeStamp(root, "planhash"); err != nil { + t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) + } + + rollback := windowsRuntimeRootRollback{ + created: []string{ + filepath.Join(parent, "zero"), + filepath.Join(parent, "zero", "runtime"), + filepath.Join(parent, "zero", "runtime", "v1"), + root, + }, + stamp: snapshot, + } + if err := rollback.run(); err != nil { + t.Fatalf("rollback.run: %v", err) + } + if _, err := os.Stat(filepath.Join(parent, "zero")); !os.IsNotExist(err) { + t.Errorf("the failed setup kept the runtime tree it created (stat err %v)", err) + } +} + +// And a stamp that was already there is RESTORED, not deleted. +// +// Deleting it would leave a machine whose previous setup succeeded with a valid +// marker pointing at a tree with no stamp, which reads as "the runtime directory +// was removed since setup ran". A healthy machine would start reporting itself +// broken because an unrelated later setup failed. +func TestRollbackRestoresAPreviousSetupsStamp(t *testing.T) { + root := filepath.Join(t.TempDir(), "runtime") + if err := writeWindowsSandboxRuntimeStamp(root, "the-previous-setup"); err != nil { + t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) + } + + snapshot := snapshotWindowsSandboxRuntimeStamp(root) + if err := writeWindowsSandboxRuntimeStamp(root, "this-run"); err != nil { + t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) + } + + // created is empty: this run found the root already there and owns none of it. + if err := (windowsRuntimeRootRollback{stamp: snapshot}).run(); err != nil { + t.Fatalf("rollback.run: %v", err) + } + + restored, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil { + t.Fatalf("the previous setup's stamp is gone: %v", err) + } + if string(restored) != "the-previous-setup" { + t.Errorf("the stamp is %q, want the previous setup's %q", restored, "the-previous-setup") + } +} + +// Pre-existing content is never removed, whatever else the rollback does. +func TestRollbackRefusesToRemoveWhatItDidNotCreate(t *testing.T) { + root := t.TempDir() + theirs := filepath.Join(root, "somebody-elses-file") + if err := os.WriteFile(theirs, []byte("keep me"), 0o600); err != nil { + t.Fatalf("seed the pre-existing file: %v", err) + } + + if err := (windowsRuntimeRootRollback{created: []string{root}}).run(); err == nil { + t.Error("a non-empty directory was removed without complaint") + } + if _, err := os.Stat(theirs); err != nil { + t.Errorf("pre-existing content was destroyed by rollback: %v", err) + } +} + +// One broken compensation must not strand the others. The stamp restore is +// attempted first, and a failure there has to be reported without stopping the +// directory removal. +func TestRollbackContinuesAfterACompensationFails(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + + // A stamp snapshot naming a path that cannot be written: its parent is a file. + broken := filepath.Join(parent, "not-a-directory") + if err := os.WriteFile(broken, []byte("x"), 0o600); err != nil { + t.Fatalf("seed the blocker: %v", err) + } + rollback := windowsRuntimeRootRollback{ + created: []string{filepath.Join(parent, "zero"), root}, + stamp: windowsSandboxStampSnapshot{path: filepath.Join(broken, "stamp"), prior: []byte("x"), existed: true}, + } + + err := rollback.run() + if err == nil { + t.Fatal("the failed stamp restore was not reported") + } + if _, statErr := os.Stat(filepath.Join(parent, "zero")); !os.IsNotExist(statErr) { + t.Errorf("the directory removal was skipped because the stamp restore failed (stat err %v); every compensation has to run", statErr) + } +} + +// A FAILING ACL ROLLBACK MUST NOT STRAND THE RUNTIME ROLLBACK. +// +// The two undos used to compose by calling each other, and the outer one +// returned as soon as the ACL rollback reported an error. The runtime rollback +// then never ran, so the failure most likely to leave a machine in a strange +// state was the one failure that skipped half the cleanup. +// +// Composed through a function with no build tag on purpose: the setup entry +// point is Windows-only and needs Administrator plus WFP to reach, so a test +// there would run on nobody's machine and prove nothing on CI. +func TestEveryCompensationRunsWhenTheACLRollbackFails(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + + aclCalled := false + err := runWindowsSandboxSetupCompensations( + errors.New("the setup failure"), + func() error { aclCalled = true; return errors.New("acl restore exploded") }, + windowsRuntimeRootRollback{created: []string{filepath.Join(parent, "zero"), root}}, + ) + if !aclCalled { + t.Fatal("the ACL rollback was never attempted") + } + if err == nil { + t.Fatal("the failures were not reported") + } + for _, want := range []string{"the setup failure", "acl restore exploded"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the report drops %q: %v", want, err) + } + } + if _, statErr := os.Stat(filepath.Join(parent, "zero")); !os.IsNotExist(statErr) { + t.Errorf("the runtime rollback was skipped because the ACL rollback failed (stat err %v)", statErr) + } +} diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 10c8f0a36..84af36c95 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -63,7 +63,7 @@ func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { // at validation, which was compensating for a disagreement rather than // removing it: whichever root the command selected, only one of them had ever // been provisioned or carried the capability ACE. - selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0]) + selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) if err != nil { t.Fatalf("selectSandboxRuntimeRoot: %v", err) } diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 28c5e9392..76d3ddf07 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -31,11 +31,7 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // directories THIS run created, innermost first, and refuses to remove a // non-empty one, so a pre-existing cache or temp tree is never touched. failed := func(cause error) int { - if rollbackErr := runtimeRollback.run(); rollbackErr != nil { - fmt.Fprintf(stderr, "%s: %v; runtime rollback failed: %v\n", WindowsSandboxSetupName, cause, rollbackErr) - return 1 - } - fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+cause.Error()) + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+runWindowsSandboxSetupCompensations(cause, nil, runtimeRollback).Error()) return 1 } if err != nil { @@ -53,13 +49,13 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) return failed(err) } // From here both have to be undone, ACLs first so the directories are empty - // of our grants before they are removed. + // of our grants before they are removed. This used to return as soon as the + // ACL rollback reported an error, so the runtime rollback never ran and a + // failed setup kept the persistent directories it had just created; one undo + // failing is the moment the others matter most. failedAfterACL := func(cause error) int { - if rollbackErr := rollback(); rollbackErr != nil { - fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, cause, rollbackErr) - return 1 - } - return failed(cause) + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+runWindowsSandboxSetupCompensations(cause, rollback, runtimeRollback).Error()) + return 1 } if err := applyWindowsNetworkPlan(networkPlan); err != nil { return failedAfterACL(err) @@ -69,6 +65,13 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // the ACL and network plans have applied, so the stamp still means "this tree // carries these permissions" and every caller that records a marker records // the stamp with it. + // + // It also lands INSIDE the runtime root, before the marker file is renamed + // into place, so from here on that root holds an artifact this run wrote. + // Handing it to the rollback record is what lets a late failure leave nothing + // behind: without it the root is non-empty, the directory removal refuses it + // by design, and the residue is permanent. + runtimeRollback.stamp = snapshotWindowsSandboxRuntimeStamp(windowsSandboxSelectedRuntimeRoot(config.PermissionProfile)) if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { return failedAfterACL(err) } From ed3040cb167bffa5756a7ecd41193aabe17538b4 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 21 Aug 2026 13:14:21 +0530 Subject: [PATCH 15/38] fix(sandbox): stop building the runtime tree through a path other users can name An audit of this branch turned up a regression this PR itself introduced, plus a gap it left in doctor. The temp fallback root used to come from os.MkdirTemp, which mints a random name atomically at mode 0700 so no other local account can name it. Deriving it from a digest of the workspace path bought stable cache reuse and gave the name away: every component is computable, and on Linux os.TempDir() is the world-writable /tmp whenever TMPDIR is unset. os.MkdirAll returns nil when Stat says the path is already a directory and Stat follows links, so a link planted at an owned component was accepted silently, the cache, data and tmp directories were built inside whatever it pointed at, Chmod and Chtimes followed it too, and the root was then handed to the backend as a write root (a read-write bind under bwrap) with TMPDIR, GOCACHE, GOMODCACHE and the package-manager caches inside it. The sandbox would have been granting the confined command write access to a directory somebody else chose. Reproduced end to end before fixing. The fallback digest now covers a per-user scope, and every component Zero owns is checked for a link and for foreign ownership before anything is created through it and again afterwards, on the shared path all three platforms take. A link or a foreign owner is refused outright rather than relocated around, because relocating leaves the link in place and moves to the next predictable name; an ordinary file sitting where a component belongs is a broken machine rather than a hostile one and still relocates, as before. doctor built its profile with PermissionProfileFromPolicy, which never sets Runtime, and validateWindowsSandboxRuntimeStamp returns nil early in that case. So the one check that can tell an evicted runtime tree from a healthy one was skipped and `zero doctor` reported a healthy sandbox on exactly the state the stamp was added to detect. It reads the recorded root from the marker now, which takes no lease and creates nothing. One test in this branch was vacuous and is fixed here too: t.TempDir() names its directory after the test, the subtest was called "link N levels above the leaf", and strings.Contains(err.Error(), "link") was matching the path rather than the reason, so all four subtests passed with the refusal deleted. Both that test and the Windows traversal test now assert the sentinel and a distinctive phrase. --- internal/doctor/hardening.go | 17 +- internal/doctor/windows_runtime_stamp_test.go | 111 ++++++++++ internal/sandbox/runtime_root_guard.go | 100 +++++++++ .../sandbox/runtime_root_guard_helper_test.go | 16 ++ .../runtime_root_guard_link_unix_test.go | 16 ++ .../runtime_root_guard_link_windows_test.go | 19 ++ internal/sandbox/runtime_root_guard_test.go | 208 ++++++++++++++++++ internal/sandbox/runtime_root_guard_unix.go | 45 ++++ .../sandbox/runtime_root_guard_windows.go | 31 +++ internal/sandbox/runtime_state.go | 39 +++- .../windows_runtime_tail_windows_test.go | 5 +- internal/sandbox/windows_setup.go | 17 ++ 12 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 internal/doctor/windows_runtime_stamp_test.go create mode 100644 internal/sandbox/runtime_root_guard.go create mode 100644 internal/sandbox/runtime_root_guard_helper_test.go create mode 100644 internal/sandbox/runtime_root_guard_link_unix_test.go create mode 100644 internal/sandbox/runtime_root_guard_link_windows_test.go create mode 100644 internal/sandbox/runtime_root_guard_test.go create mode 100644 internal/sandbox/runtime_root_guard_unix.go create mode 100644 internal/sandbox/runtime_root_guard_windows.go diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index 46a9df326..7f96fa4e4 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -109,7 +109,22 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo // mismatch this pairing exists to close. Safe to resolve in this process: // doctor runs in the operator's shell, not behind the sandbox TEMP // redirection that stops the runner deriving these for itself. - PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(profile, []string{workspaceRoot}), + // + // The RECORDED runtime root goes on the profile as well, because that is + // what makes the stamp check run at all. Without it profile.Runtime is nil, + // validateWindowsSandboxRuntimeStamp returns nil early, and doctor reported + // a healthy sandbox on a machine whose runtime tree had been evicted and + // silently recreated without the capability ACE -- precisely the state the + // stamp exists to catch, and the state where every sandboxed command then + // fails with nothing explaining why. + // + // Read from the marker rather than selected, so doctor takes no lease and + // creates nothing. A marker from an older schema records no root, which + // leaves this exactly as it was. + PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots( + sandbox.PermissionProfileWithRuntimeRoot(profile, sandbox.WindowsSandboxRecordedRuntimeRoot(sandboxHome)), + []string{workspaceRoot}, + ), } 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/doctor/windows_runtime_stamp_test.go b/internal/doctor/windows_runtime_stamp_test.go new file mode 100644 index 000000000..7bb614bcd --- /dev/null +++ b/internal/doctor/windows_runtime_stamp_test.go @@ -0,0 +1,111 @@ +package doctor + +import ( + "os" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/sandbox" +) + +// DOCTOR HAS TO ASK THE ONE QUESTION THE MARKER CANNOT ANSWER. +// +// The marker hashes ACL-plan entries, which are pathnames. Whether the directory +// those pathnames resolve to is still the tree setup provisioned is a different +// question, and the runtime stamp is what answers it. validateWindowsSandboxRuntimeStamp +// returns nil early when profile.Runtime is nil, which is correct for the setup +// side and for unrestricted profiles, and was wrong here: doctor built its +// profile with PermissionProfileFromPolicy, which never sets Runtime, so the +// check was skipped and `zero doctor` reported a healthy sandbox on exactly the +// state the stamp was added to detect -- an evicted runtime tree, silently +// recreated with ordinary permissions and no capability ACE, where every +// sandboxed command then fails with nothing explaining why. +// doctorRuntimeCandidate returns a runtime root this workspace would really +// select, taken from the candidate set the Windows plan folds in. +// +// It has to be a REAL candidate. An arbitrary path is already in the ACL plan on +// one side and not the other, so the plan hashes diverge and validation fails +// before the stamp is ever consulted -- which would make this test fail without +// the fix for a reason that has nothing to do with the stamp. +func doctorRuntimeCandidate(t *testing.T, workspace string) string { + t.Helper() + bare := doctorProfile(t, workspace) + augmented := sandbox.WindowsSandboxProfileWithRuntimeRoots(bare, []string{workspace}) + existing := map[string]bool{} + for _, root := range bare.FileSystem.WriteRoots { + existing[root.Root] = true + } + for _, root := range augmented.FileSystem.WriteRoots { + if !existing[root.Root] { + return root.Root + } + } + t.Skip("no runtime candidate is derivable in this environment") + return "" +} + +func doctorProfile(t *testing.T, workspace string) sandbox.PermissionProfile { + t.Helper() + scope, err := sandbox.NewScope(workspace, nil) + if err != nil { + t.Fatalf("NewScope: %v", err) + } + return sandbox.PermissionProfileFromPolicy(workspace, doctorSandboxPolicy(config.SandboxConfig{}), scope) +} + +func writeDoctorSetupMarker(t *testing.T, home, workspace, runtimeRoot string) { + t.Helper() + profile := doctorProfile(t, workspace) + setup := sandbox.WindowsSandboxSetupConfig{ + SandboxHome: home, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots( + sandbox.PermissionProfileWithRuntimeRoot(profile, runtimeRoot), + []string{workspace}, + ), + } + if _, err := sandbox.WriteWindowsSandboxSetupMarker(setup); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } +} + +func TestDoctorReportsAnEvictedRuntimeTree(t *testing.T) { + home := t.TempDir() + workspace := t.TempDir() + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home) + + runtimeRoot := doctorRuntimeCandidate(t, workspace) + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) }) + writeDoctorSetupMarker(t, home, workspace, runtimeRoot) + + backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken} + + // A healthy machine first, or the eviction assertion below would be satisfied + // by a check that warns unconditionally. + if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil { + t.Fatalf("a freshly set-up machine was reported unhealthy: %s", result.Message) + } + + // cleanupSandboxRuntimeRoots evicts inactive roots on an age and count policy, + // and the next run recreates the pathname with inherited permissions. The plan + // hash never moves, so only the stamp can tell. + if err := os.RemoveAll(runtimeRoot); err != nil { + t.Fatalf("evict the runtime root: %v", err) + } + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + t.Fatalf("recreate the pathname the way an ordinary run would: %v", err) + } + + result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}) + if result == nil { + t.Fatal("doctor reported a healthy sandbox while the provisioned runtime tree was gone; every sandboxed command on this machine would fail with nothing explaining why") + } + if !strings.Contains(strings.ToLower(result.Message), "setup") { + t.Errorf("the warning does not point at setup: %s", result.Message) + } +} diff --git a/internal/sandbox/runtime_root_guard.go b/internal/sandbox/runtime_root_guard.go new file mode 100644 index 000000000..49d5f05ff --- /dev/null +++ b/internal/sandbox/runtime_root_guard.go @@ -0,0 +1,100 @@ +package sandbox + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// errRuntimeComponentAliased marks a refusal that must NOT be treated as a +// reason to relocate. +// +// selectSandboxRuntimeRoot falls back to the temp root when the preferred one +// cannot be leased, which is right for an unusable directory and wrong for a +// hostile one: relocating would leave the attacker's link in place, say nothing, +// and simply move to the next predictable name for them to take as well. A +// machine in this state needs an operator, not a retry. +var errRuntimeComponentAliased = errors.New("sandbox runtime component is aliased") + +// THE RUNTIME TREE IS CREATED IN A DIRECTORY OTHER PEOPLE CAN WRITE TO. +// +// The fallback root used to come from os.MkdirTemp, which mints a random name +// atomically at mode 0700, so no other local user could name the directory, let +// alone pre-create it. Deriving it from a digest of the workspace path bought +// stable cache reuse across runs and gave that name away: every component is +// computable by anyone who can guess the workspace path, and on Linux +// os.TempDir() is the shared, world-writable /tmp whenever TMPDIR is unset. +// +// What follows from a name another user can create is not subtle. +// os.MkdirAll returns nil when Stat says the path is already a directory, and +// Stat FOLLOWS LINKS, so a link planted at the leaf is silently accepted; the +// cache, data and tmp directories are then created inside whatever it points at, +// os.Chmod and os.Chtimes follow it too, and the root is handed to the platform +// backend as a WRITE ROOT (a read-write bind under bwrap) with TMPDIR, GOCACHE, +// GOMODCACHE and the package-manager caches all pointed inside it. The sandbox +// would be granting the confined command write access to a directory an +// attacker chose. +// +// Two things close it, and both are needed. The path carries a per-user +// component so ordinary users are not sharing one tree, and every component Zero +// owns is verified to be a real directory belonging to this user before anything +// is created through it. The name alone is not enough: /tmp is world-writable, +// so another user can create the per-user directory FIRST and wait. +// +// This is the same rule refuseReparsedRuntimeAncestors applies during elevated +// Windows setup. That guard was never on this path, which is the shared one +// every platform takes for every command. +func refuseAliasedRuntimeComponents(root string) error { + for _, component := range ownedRuntimeComponents(root) { + info, err := os.Lstat(component) + if err != nil { + if os.IsNotExist(err) { + // Not there yet, so there is nothing to alias. The caller re-checks + // after creation, because this alone is a check-then-use. + continue + } + return fmt.Errorf("inspect sandbox runtime component %s: %w", component, err) + } + // ModeIrregular as well as ModeSymlink: a Windows junction is reported as + // irregular, needs no privilege to create, and a guard written against + // symlinks alone is inert against it. + if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return fmt.Errorf("%w: refusing to use the sandbox runtime through a link at %s: "+ + "a link here redirects the directory the sandbox is granted write access to", errRuntimeComponentAliased, component) + } + if !info.IsDir() { + // NOT tagged as aliased, deliberately. An ordinary file sitting where a + // runtime component belongs is a broken machine rather than a hostile + // one, and relocating to the other candidate is the sane recovery that + // was already there. Only a link or a directory belonging to somebody + // else says an attacker chose this path, and those are the two the + // caller must refuse outright rather than route around. + return fmt.Errorf("sandbox runtime component %s exists and is not a directory", component) + } + if err := refuseForeignRuntimeComponent(component, info); err != nil { + return err + } + } + return nil +} + +// ownedRuntimeComponents lists the trailing components Zero creates, deepest +// first. Anything above them belongs to the user or the machine. +func ownedRuntimeComponents(root string) []string { + cleaned := filepath.Clean(root) + if cleaned == "" || cleaned == "." { + return nil + } + components := make([]string, 0, windowsSandboxRuntimeOwnedDepth) + current := cleaned + for range windowsSandboxRuntimeOwnedDepth { + components = append(components, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return components +} diff --git a/internal/sandbox/runtime_root_guard_helper_test.go b/internal/sandbox/runtime_root_guard_helper_test.go new file mode 100644 index 000000000..87246de3b --- /dev/null +++ b/internal/sandbox/runtime_root_guard_helper_test.go @@ -0,0 +1,16 @@ +package sandbox + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +// digestFor recomputes the fallback leaf name the way fallbackSandboxRuntimeRoot +// does, so the test moves with the implementation rather than pinning a literal. +func digestFor(workspaceRoot string, scope string) string { + digest := sha256.Sum256([]byte(canonicalSandboxWorkspaceRoot(workspaceRoot) + "\x00" + scope)) + return hex.EncodeToString(digest[:8]) +} + +var _ = testing.Verbose diff --git a/internal/sandbox/runtime_root_guard_link_unix_test.go b/internal/sandbox/runtime_root_guard_link_unix_test.go new file mode 100644 index 000000000..33afd4f49 --- /dev/null +++ b/internal/sandbox/runtime_root_guard_link_unix_test.go @@ -0,0 +1,16 @@ +//go:build !windows + +package sandbox + +import ( + "os" + "testing" +) + +// A POSIX symlink is the reachable alias off Windows. +func linkRuntimeComponent(t *testing.T, link, target string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create a symlink in this environment: %v", err) + } +} diff --git a/internal/sandbox/runtime_root_guard_link_windows_test.go b/internal/sandbox/runtime_root_guard_link_windows_test.go new file mode 100644 index 000000000..a4d67a16f --- /dev/null +++ b/internal/sandbox/runtime_root_guard_link_windows_test.go @@ -0,0 +1,19 @@ +//go:build windows + +package sandbox + +import ( + "os/exec" + "testing" +) + +// A JUNCTION, not a symlink: it needs no privilege, which is what makes it the +// alias an ordinary local user can actually plant, and os.Lstat reports it as +// ModeIrregular rather than ModeSymlink. +func linkRuntimeComponent(t *testing.T, link, target string) { + t.Helper() + output, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput() + if err != nil { + t.Skipf("cannot create a junction in this environment: %v (%s)", err, output) + } +} diff --git a/internal/sandbox/runtime_root_guard_test.go b/internal/sandbox/runtime_root_guard_test.go new file mode 100644 index 000000000..429eb2258 --- /dev/null +++ b/internal/sandbox/runtime_root_guard_test.go @@ -0,0 +1,208 @@ +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// A LINK AT AN OWNED COMPONENT MUST NOT BE BUILT THROUGH. +// +// This is the shared path every platform takes for every command, and until now +// it had no guard at all: the only link refusal in this package was wired into +// elevated Windows setup. os.MkdirAll returns nil when Stat says the path is +// already a directory, and Stat FOLLOWS links, so a link planted at an owned +// component is accepted silently. The cache, data and tmp directories are then +// created inside whatever it points at, Chmod and Chtimes follow it too, and the +// root is handed to the backend as a WRITE ROOT with TMPDIR, GOCACHE and the +// package-manager caches pointed inside it. The sandbox would be granting the +// confined command write access to a directory somebody else chose. +func TestTheRuntimeGuardRefusesALinkAtEveryOwnedComponent(t *testing.T) { + for depth := range windowsSandboxRuntimeOwnedDepth { + t.Run("replaced "+string(rune('0'+depth))+" levels above the leaf", func(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + swapped := root + for range depth { + swapped = filepath.Dir(swapped) + } + tail, err := filepath.Rel(swapped, root) + if err != nil { + t.Fatalf("relate the swapped component to the root: %v", err) + } + if err := os.RemoveAll(swapped); err != nil { + t.Fatalf("clear the component to replace: %v", err) + } + target := filepath.Join(t.TempDir(), "somewhere-else") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("create the link target: %v", err) + } + linkRuntimeComponent(t, swapped, target) + if tail != "." { + if err := os.MkdirAll(filepath.Join(target, tail), 0o700); err != nil { + t.Fatalf("recreate the components below the link: %v", err) + } + } + + err = refuseAliasedRuntimeComponents(root) + if err == nil { + t.Fatalf("a link at an owned component was accepted; the runtime tree would have been built inside %s and granted to the sandbox as a write root", target) + } + // ASSERTED ON THE SENTINEL, not on a word in the message. + // + // This read strings.Contains(err.Error(), "link") and was vacuous: + // t.TempDir() names its directory after the test, the subtest was called + // "link N levels above the leaf", and every component path therefore + // contained "link". Deleting the reparse refusal left a different error + // ("exists and is not a directory") whose PATH still satisfied the + // assertion, so all four subtests passed with the fix removed. + // + // The sentinel is also the real contract: selectSandboxRuntimeRoot + // branches on errors.Is to decide refuse-versus-relocate. + if !errors.Is(err, errRuntimeComponentAliased) { + t.Errorf("a link was not reported as a hostile alias, so selection would relocate around it instead of refusing: %v", err) + } + if !strings.Contains(err.Error(), "redirects the directory") { + t.Errorf("the refusal does not name the reason: %v", err) + } + }) + } +} + +// An ordinary tree passes, or the guard above would be satisfied by one that +// refuses everything. +func TestTheRuntimeGuardAcceptsAnOrdinaryTree(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + if err := refuseAliasedRuntimeComponents(root); err != nil { + t.Fatalf("an ordinary runtime tree was refused: %v", err) + } +} + +// A tree that does not exist yet is fine: there is nothing to alias, and this is +// the ordinary first-run case. +func TestTheRuntimeGuardAcceptsATreeThatDoesNotExistYet(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := refuseAliasedRuntimeComponents(root); err != nil { + t.Fatalf("an absent runtime tree was refused: %v", err) + } +} + +// A FILE where an owned component belongs is refused too, rather than producing +// a confusing failure deeper in. +func TestTheRuntimeGuardRefusesAFileWhereADirectoryBelongs(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil { + t.Fatalf("create the runtime parents: %v", err) + } + if err := os.WriteFile(root, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("seed the file: %v", err) + } + if err := refuseAliasedRuntimeComponents(root); err == nil { + t.Fatal("a file standing where the runtime root belongs was accepted") + } +} + +// THE SHARED-TEMP FALLBACK IS SCOPED TO THIS USER. +// +// It replaced os.MkdirTemp, which minted a random 0700 directory atomically, so +// no other local user could name it. A digest of the workspace path alone is the +// same string for every account on the host, and on Linux os.TempDir() is the +// world-writable /tmp whenever TMPDIR is unset: two users on the same path would +// name one directory and the first one there would own it. +func TestTheTempFallbackRootIsScopedToTheUser(t *testing.T) { + workspace := t.TempDir() + root, err := fallbackSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace)) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot: %v", err) + } + scope := sandboxRuntimeUserScope() + if strings.TrimSpace(scope) == "" { + t.Fatal("the user scope is empty, so the digest is the same for every account") + } + + // The leaf must move when the scope does. Recomputed the way the function + // does, rather than asserting on a hardcoded digest. + same := digestFor(workspace, scope) + other := digestFor(workspace, scope+"-someone-else") + if same == other { + t.Fatal("the user scope does not reach the digest") + } + if !strings.HasSuffix(filepath.Clean(root), same) { + t.Errorf("the fallback root %s does not end in the user-scoped digest %s", root, same) + } +} + +// And the fallback still has the shape the owned-component guard and the Windows +// rooted traversal both recognize. A path that stops matching silently loses +// BOTH protections, which is the expensive direction. +func TestTheTempFallbackRootKeepsTheOwnedShape(t *testing.T) { + workspace := t.TempDir() + root, err := fallbackSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace)) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot: %v", err) + } + if _, _, ok := windowsSandboxRuntimeOwnedTail(root); !ok { + t.Fatalf("the fallback root %s is not recognized as an owned runtime tail, so the rooted traversal falls back to opening it by name", root) + } + components := ownedRuntimeComponents(root) + if len(components) != windowsSandboxRuntimeOwnedDepth { + t.Fatalf("the guard walks %d components of %s, want %d", len(components), root, windowsSandboxRuntimeOwnedDepth) + } +} + +// THROUGH prepareSandboxRuntime, not the helper. +// +// The helper being correct proves nothing on its own: the guard has to be on the +// path runner.go actually calls, before the MkdirAll that would build the tree +// through the link and before the root is handed back as a write root. +func TestPreparingTheRuntimeRefusesALinkedRoot(t *testing.T) { + workspace := t.TempDir() + cacheRoot := t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + canonical := canonicalSandboxWorkspaceRoot(workspace) + root, err := sandboxRuntimeRootFor(canonical, canonicalSandboxWorkspaceRoot(cacheRoot)) + if err != nil { + t.Skipf("no cache-derived runtime root in this environment: %v", err) + } + + // Somebody else got to the predictable name first and pointed it elsewhere. + if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil { + t.Fatalf("create the runtime parents: %v", err) + } + target := filepath.Join(t.TempDir(), "somewhere-else") + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatalf("create the link target: %v", err) + } + linkRuntimeComponent(t, root, target) + + runtimeState, cleanup, err := prepareSandboxRuntime(canonical) + if cleanup != nil { + cleanup() + } + if err == nil { + t.Fatalf("the runtime was prepared at %s through a link; %s would have been bound read-write into the sandbox with TMPDIR and the build caches inside it", runtimeState.Root, target) + } + if !errors.Is(err, errRuntimeComponentAliased) { + t.Errorf("the linked root was not reported as a hostile alias: %v", err) + } + for _, name := range []string{"cache", "data", "tmp"} { + if _, statErr := os.Stat(filepath.Join(target, name)); statErr == nil { + t.Errorf("the runtime tree was created inside the link target at %s", filepath.Join(target, name)) + } + } +} diff --git a/internal/sandbox/runtime_root_guard_unix.go b/internal/sandbox/runtime_root_guard_unix.go new file mode 100644 index 000000000..2ef20823f --- /dev/null +++ b/internal/sandbox/runtime_root_guard_unix.go @@ -0,0 +1,45 @@ +//go:build !windows + +package sandbox + +import ( + "fmt" + "os" + "syscall" +) + +// refuseForeignRuntimeComponent rejects a component owned by somebody else. +// +// The link check above stops the redirection; this stops the quieter half. /tmp +// is world-writable and sticky, so another local user can create the components +// Zero owns BEFORE Zero ever runs. A directory they own but Zero writes into is +// a place they can read the sandbox's caches out of, and the sticky bit does not +// help because they own it. os.MkdirAll accepts it silently, since the path +// already exists as a directory. +// +// Ownership rather than mode, because a 0777 directory belonging to this user is +// the user's own business while a 0700 directory belonging to another user is +// not something Zero should adopt. +func refuseForeignRuntimeComponent(component string, info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + // No ownership information on this filesystem. The link check already ran. + return nil + } + if uid := os.Getuid(); uid >= 0 && int(stat.Uid) != uid { + return fmt.Errorf("%w: refusing to use the sandbox runtime directory %s: it belongs to uid %d, not to this user (uid %d)", + errRuntimeComponentAliased, component, stat.Uid, uid) + } + return nil +} + +// sandboxRuntimeUserScope isolates the derived runtime tree per user. +// +// Two users on one host derive the same digest for the same workspace path, so +// without this they name one directory in shared temp and the first one there +// owns it. A uid is not a secret and is not meant to be: it removes the +// collision, and refuseAliasedRuntimeComponents handles the case where somebody +// got there first. +func sandboxRuntimeUserScope() string { + return fmt.Sprintf("u%d", os.Getuid()) +} diff --git a/internal/sandbox/runtime_root_guard_windows.go b/internal/sandbox/runtime_root_guard_windows.go new file mode 100644 index 000000000..5b6531508 --- /dev/null +++ b/internal/sandbox/runtime_root_guard_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package sandbox + +import ( + "os" + "strings" +) + +// refuseForeignRuntimeComponent has no ownership check on Windows. +// +// The derived root lives under the per-user cache directory or the per-session +// TEMP, both of which are already user-private, and the elevated setup path +// applies its own capability ACL. The link refusal in the shared guard is the +// part that matters here. +func refuseForeignRuntimeComponent(string, os.FileInfo) error { + return nil +} + +// sandboxRuntimeUserScope names the account the tree belongs to. +// +// Windows TEMP is already per-user, so this is belt and braces rather than the +// load-bearing separation it is on Unix. Kept so the derived path has the same +// shape on every platform and one code path builds it. +func sandboxRuntimeUserScope() string { + name := strings.TrimSpace(os.Getenv("USERNAME")) + if name == "" { + return "u" + } + return "u" + strings.ToLower(name) +} diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 668e244bd..3d0cc3960 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -149,6 +149,15 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) filepath.Join(runtimeState.Data, "go-mod"), filepath.Join(runtimeState.Data, "cargo"), } + // BEFORE ANYTHING IS CREATED. os.MkdirAll returns nil when Stat says the path + // is already a directory, and Stat follows links, so a link planted at an + // owned component is silently accepted and the whole tree is built inside + // whatever it points at. Chmod and Chtimes below follow it too, and the root + // then becomes a write root the backend binds read-write with TMPDIR and the + // build caches pointed inside it. + if err := refuseAliasedRuntimeComponents(runtimeState.Root); err != nil { + return SandboxRuntime{}, nil, err + } 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) @@ -157,6 +166,12 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err) } } + // AND AGAIN AFTER, because the check above is a check-then-use on its own: a + // component swapped during creation would still redirect the tree. Pairing the + // two narrows the window to the creation itself. + if err := refuseAliasedRuntimeComponents(runtimeState.Root); err != nil { + return SandboxRuntime{}, nil, err + } now := sandboxRuntimeNow() if err := os.Chtimes(runtimeState.Root, now, now); err != nil { return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err) @@ -167,6 +182,12 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) } func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) { + // Before the parent is created, because MkdirAll walks and creates through + // whatever the owned components resolve to and the lease file is opened + // without O_NOFOLLOW. + if err := refuseAliasedRuntimeComponents(root); err != nil { + return nil, err + } if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil { return nil, fmt.Errorf("create sandbox runtime parent: %w", err) } @@ -269,8 +290,15 @@ func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) { if tempRoot == "" || tempRoot == "." { return "", errors.New("temp directory is unavailable") } - digest := sha256.Sum256([]byte(workspaceRoot)) - root := filepath.Join(tempRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) + // SCOPED TO THIS USER, unlike the cache-derived root, because this one lives + // in shared temp. On Linux os.TempDir() is the world-writable /tmp whenever + // TMPDIR is unset, and a digest of the workspace path alone is the same string + // for every user on the host: two accounts working on the same path would name + // one directory and the first one there would own it. The uid is not a secret + // and is not doing secrecy work; it removes the collision, and + // refuseAliasedRuntimeComponents handles somebody having got there first. + digest := sha256.Sum256([]byte(workspaceRoot + "\x00" + sandboxRuntimeUserScope())) + root := filepath.Join(append(append([]string{tempRoot}, windowsSandboxRuntimeOwnedNames...), hex.EncodeToString(digest[:8]))...) if runtimeRootWithinWorkspace(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 @@ -466,6 +494,13 @@ func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool) (string, if err == nil { return root, lease, nil } + // AN ALIASED COMPONENT IS NOT A REASON TO RELOCATE. Falling back here would + // leave the link in place, report nothing, and move to the next predictable + // name, which the same attacker can take as well. Relocating is for a root + // that is merely unusable. + if errors.Is(err, errRuntimeComponentAliased) { + return "", nil, err + } // The preferred root could not be leased. Relocating is right, and it is what // commands already did; the defect was that setup never learned about it. root, err = fallbackSandboxRuntimeRoot(workspaceRoot) diff --git a/internal/sandbox/windows_runtime_tail_windows_test.go b/internal/sandbox/windows_runtime_tail_windows_test.go index 0d6ab6fac..a06753869 100644 --- a/internal/sandbox/windows_runtime_tail_windows_test.go +++ b/internal/sandbox/windows_runtime_tail_windows_test.go @@ -89,7 +89,10 @@ func TestTheRootedTraversalRefusesAJunctionAtEveryOwnedComponent(t *testing.T) { _ = windows.CloseHandle(handle) t.Fatalf("the traversal followed a junction at an owned component and would have applied the elevated ACL inside %s", target) } - if !strings.Contains(err.Error(), "link") { + // A distinctive phrase, not the word "link": t.TempDir() names its + // directory after the test, so a subtest name can put the word the + // assertion looks for into every path in the error. + if !strings.Contains(err.Error(), "redirects the directory") { t.Errorf("the refusal does not name the reason: %v", err) } _ = base diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index de2050bae..5c8d0f5da 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -861,6 +861,23 @@ func WindowsSandboxProfileWithRuntimeRoots(profile PermissionProfile, workspaceR return windowsSandboxProfileWithRuntime(profile, workspaceRoots) } +// PermissionProfileWithRuntimeRoot names the CONCRETE runtime root on a profile, +// which is what makes the stamp check run. +// +// validateWindowsSandboxRuntimeStamp returns nil when profile.Runtime is nil, +// and that is the correct answer for the setup side and for every unrestricted +// profile. It was the wrong answer for doctor: doctor built its profile with +// PermissionProfileFromPolicy, which never sets Runtime, so the one check that +// can tell an evicted runtime tree from a healthy one was skipped and `zero +// doctor` reported a healthy sandbox on exactly the machine state the stamp was +// added to detect. +func PermissionProfileWithRuntimeRoot(profile PermissionProfile, root string) PermissionProfile { + if strings.TrimSpace(root) == "" { + return profile + } + return permissionProfileWithRuntime(profile, SandboxRuntime{Root: root}) +} + // windowsSandboxRuntimeStampName marks a runtime root that ELEVATED SETUP // actually provisioned and applied the capability ACL to. const windowsSandboxRuntimeStampName = ".zero-sandbox-setup" From a28623bd0b65fd0fa0af0b193bbe89f256859f29 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 22 Aug 2026 13:02:17 +0530 Subject: [PATCH 16/38] fix(sandbox): stamp the runtime tree through the handle that carries the ACE The stamp is supposed to prove that the directory a command later uses is the one setup granted the capability ACE to, and writing it afterwards by pathname cannot prove that however careful the second open is. The ACE went on through a rooted handle, that handle closed, network setup ran, and only then did the marker write re-open the name. A local process that can reach the user-owned runtime tree removes the predictable root in that window and puts an ordinary directory in its place. The re-open rejects a junction correctly, but an ordinary replacement is not a reparse point and is not the ACL-bearing object either, so it collected a valid-looking stamp. Marker validation then passed over a directory with no capability ACE, and the next WRITE_RESTRICTED command failed its cache and TMP writes while setup insisted it was current. The stamp now rides along with the ACE. The marker is built before the apply so the plan hash is known, the apply writes the stamp through the same handle it just set the DACL on, and the marker write records the file and never names the runtime tree again. The rollback snapshot moves ahead of the apply for the same reason, so a failed setup restores what it found rather than its own artifact. The regression drives the real apply and swaps the directory in the window between the two, which is the only place the difference shows. A direct handle-level test cannot see it: any name derived from a retained handle resolves back to the same object, so a pathname write derived that way lands correctly and the test passes either way. I had written that test first and it survived the falsification, so it is gone rather than left looking like cover. Also: the doctor stamp test was creating, removing and recreating a directory under the developer's REAL user cache, because the runtime candidate is derived from the process's actual cache directory. The workspace digest made a collision with a live runtime tree unlikely rather than impossible, which is not the standard for a test that calls RemoveAll. It redirects the cache to test-owned storage now, and fails loudly if that ever stops working. --- internal/doctor/windows_runtime_stamp_test.go | 27 +++++++++ internal/sandbox/windows_acl_apply_windows.go | 50 +++++++++++++++- .../windows_acl_stamp_swap_windows_test.go | 59 +++++++++++++++++++ .../sandbox/windows_acl_stamp_windows_test.go | 42 +++++++++++++ internal/sandbox/windows_runtime_tail.go | 15 +++++ .../sandbox/windows_runtime_tail_windows.go | 41 +++++++++++++ internal/sandbox/windows_setup.go | 31 +++++++--- internal/sandbox/windows_setup_windows.go | 40 ++++++++----- 8 files changed, 283 insertions(+), 22 deletions(-) create mode 100644 internal/sandbox/windows_acl_stamp_swap_windows_test.go create mode 100644 internal/sandbox/windows_acl_stamp_windows_test.go diff --git a/internal/doctor/windows_runtime_stamp_test.go b/internal/doctor/windows_runtime_stamp_test.go index 7bb614bcd..4bd0f318e 100644 --- a/internal/doctor/windows_runtime_stamp_test.go +++ b/internal/doctor/windows_runtime_stamp_test.go @@ -28,6 +28,27 @@ import ( // one side and not the other, so the plan hashes diverge and validation fails // before the stamp is ever consulted -- which would make this test fail without // the fix for a reason that has nothing to do with the stamp. +// redirectUserCache points os.UserCacheDir at test-owned storage. +// +// WITHOUT THIS THE TEST WRITES INTO THE DEVELOPER'S REAL CACHE. The runtime +// candidate is derived from the process's actual user cache directory, so the +// test was creating, recursively removing, recreating and then cleanup-removing +// a directory under the real %LocalAppData%zerountime (or ~/.cache/zero on +// Unix). The workspace digest made a collision with a live runtime tree +// unlikely rather than impossible, and "unlikely" is not the standard for a +// test that calls RemoveAll. +// +// All three variables, because os.UserCacheDir reads a different one per +// platform: %LocalAppData% on Windows, $XDG_CACHE_HOME or $HOME on Unix, and +// $HOME on macOS. +func redirectUserCache(t *testing.T) { + t.Helper() + cache := t.TempDir() + t.Setenv("LOCALAPPDATA", cache) + t.Setenv("XDG_CACHE_HOME", cache) + t.Setenv("HOME", cache) +} + func doctorRuntimeCandidate(t *testing.T, workspace string) string { t.Helper() bare := doctorProfile(t, workspace) @@ -75,8 +96,14 @@ func TestDoctorReportsAnEvictedRuntimeTree(t *testing.T) { home := t.TempDir() workspace := t.TempDir() t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home) + redirectUserCache(t) runtimeRoot := doctorRuntimeCandidate(t, workspace) + // Belt and braces: if the redirection above ever stops working, fail loudly + // rather than quietly operating on the developer's real cache. + if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) { + t.Fatalf("the runtime candidate %q is outside test-owned storage; this test creates and removes that path", runtimeRoot) + } if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { t.Fatalf("create the runtime root: %v", err) } diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 126fea90e..7f6e7c50a 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -26,11 +26,44 @@ type windowsACLSnapshot struct { Materialized bool } +// windowsACLStampRequest asks the apply to write the runtime setup stamp THROUGH +// THE SAME HANDLE it just applied the capability ACE through. +// +// The stamp exists to prove that the directory a command later uses is the +// object setup granted the ACE to. Writing it afterwards by pathname cannot +// prove that, however carefully the second open is done: the ACE goes on +// through a rooted handle, that handle closes, network setup runs, and only then +// does the marker write re-open the name. A local process that can reach the +// user-owned runtime tree can remove the predictable root in that window and +// put an ordinary directory in its place. The re-open correctly rejects a +// junction, but an ordinary replacement is not a reparse point and is not the +// ACL-bearing object either, so it collects a valid-looking stamp. Marker +// validation then passes over a directory with no capability ACE, and the next +// WRITE_RESTRICTED command fails its cache and TMP writes with setup insisting +// it is current. +// +// Closing that means never naming the target again after the ACE lands. The +// hash is known before the apply, so the stamp can simply ride along. +type windowsACLStampRequest struct { + Root string + PlanHash string +} + +// windowsACLStampSwapHook fires in the exact window this design closes: after +// the capability ACE is on the object and before the stamp is written. Nil in +// production; a test uses it to replace the runtime root with an ordinary +// directory, which is what a local process would do. +var windowsACLStampSwapHook func(path string) + func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { + return applyWindowsACLPlanWithStamp(plan, nil) +} + +func applyWindowsACLPlanWithStamp(plan WindowsACLPlan, stamp *windowsACLStampRequest) (func() error, error) { groups := groupWindowsACLPlanByPath(plan) snapshots := make([]windowsACLSnapshot, 0, len(groups)) for _, group := range groups { - snapshot, applied, err := applyWindowsACLPathGroup(group) + snapshot, applied, err := applyWindowsACLPathGroupWithStamp(group, stamp) if err != nil { rollbackErr := rollbackWindowsACLSnapshots(snapshots) if rollbackErr != nil { @@ -73,6 +106,10 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup { } func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bool, error) { + return applyWindowsACLPathGroupWithStamp(group, nil) +} + +func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windowsACLStampRequest) (windowsACLSnapshot, bool, error) { path := strings.TrimSpace(group.Path) if path == "" || len(group.Entries) == 0 { return windowsACLSnapshot{}, false, nil @@ -138,6 +175,17 @@ 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. + // BEFORE THE HANDLE CLOSES, and only for the target the stamp names. This is + // the whole point: the ACE and the stamp land on one kernel object with no + // pathname resolution in between. + if stamp != nil && windowsSameRuntimeRootPath(stamp.Root, path) { + if windowsACLStampSwapHook != nil { + windowsACLStampSwapHook(path) + } + if err := writeWindowsRuntimeStampToDirectoryHandle(handle, stamp.PlanHash); err != nil { + return fail(fmt.Errorf("stamp windows ACL target %s: %w", path, err)) + } + } _ = windows.CloseHandle(handle) return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil } diff --git a/internal/sandbox/windows_acl_stamp_swap_windows_test.go b/internal/sandbox/windows_acl_stamp_swap_windows_test.go new file mode 100644 index 000000000..b93989787 --- /dev/null +++ b/internal/sandbox/windows_acl_stamp_swap_windows_test.go @@ -0,0 +1,59 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// THROUGH THE REAL APPLY, with the swap in the window that matters. +// +// The direct-handle test cannot distinguish handle from pathname, because any +// name derived from the handle resolves back to the same object. What the old +// code did was different: it re-opened the ORIGINAL root string after the ACL +// step, so a directory swapped in under that name collected the stamp. This +// drives applyWindowsACLPlanWithStamp and performs the swap between the ACE +// landing and the stamp write. +func TestTheStampSkipsADirectorySwappedInAfterTheACE(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + moved := root + "-original" + previous := windowsACLStampSwapHook + windowsACLStampSwapHook = func(path string) { + // Ordinary directories throughout. Nothing here is a reparse point, which + // is why a no-follow re-open does not catch it. + if err := os.Rename(path, moved); err != nil { + t.Skipf("cannot rename the runtime root here: %v", err) + } + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("plant the replacement: %v", err) + } + } + t.Cleanup(func() { windowsACLStampSwapHook = previous }) + + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + rollback, err := applyWindowsACLPlanWithStamp(plan, &windowsACLStampRequest{Root: root, PlanHash: "planhash"}) + if err != nil { + t.Fatalf("applyWindowsACLPlanWithStamp: %v", err) + } + t.Cleanup(func() { _ = rollback() }) + + if _, err := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName)); err == nil { + t.Error("the swapped-in directory collected the stamp; it carries no capability ACE and would still validate as set up") + } + recorded, err := os.ReadFile(filepath.Join(moved, windowsSandboxRuntimeStampName)) + if err != nil { + t.Fatalf("the stamp did not land on the object the ACE was applied to: %v", err) + } + if string(recorded) != "planhash" { + t.Errorf("stamp contents = %q, want the plan hash", recorded) + } +} diff --git a/internal/sandbox/windows_acl_stamp_windows_test.go b/internal/sandbox/windows_acl_stamp_windows_test.go new file mode 100644 index 000000000..b7fb2986b --- /dev/null +++ b/internal/sandbox/windows_acl_stamp_windows_test.go @@ -0,0 +1,42 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// The positive half only. The swap case that proves the ACE and the stamp are +// about ONE OBJECT lives in windows_acl_stamp_swap_windows_test.go, where it can +// drive the real apply. +// +// A direct-handle test cannot prove it: any name derived from the retained +// handle resolves back to the same object, so a pathname write derived that way +// lands correctly too and the test passes either way. The distinction only shows +// through the call site, where the old code re-opened the ORIGINAL root string. +// And the ordinary path still works, or the assertion above would be satisfied +// by a writer that never writes anything. +func TestTheStampWritesThroughAnOpenDirectoryHandle(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + handle, err := openWindowsRuntimeTailDirectory(root, windows.READ_CONTROL|windowsFileAddFile|windows.FILE_TRAVERSE) + if err != nil { + t.Fatalf("open the runtime root: %v", err) + } + defer windows.CloseHandle(handle) + + if err := writeWindowsRuntimeStampToDirectoryHandle(handle, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + recorded, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil || string(recorded) != "planhash" { + t.Fatalf("the stamp did not land in the runtime root (%q, err %v)", recorded, err) + } +} diff --git a/internal/sandbox/windows_runtime_tail.go b/internal/sandbox/windows_runtime_tail.go index 5a49d5420..99a97c00a 100644 --- a/internal/sandbox/windows_runtime_tail.go +++ b/internal/sandbox/windows_runtime_tail.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "path/filepath" + "runtime" "strings" ) @@ -64,6 +65,20 @@ func windowsSandboxRuntimeOwnedTail(root string) (string, []string, bool) { // Callers must fail rather than quietly opening it by name. var errRuntimeTailNotOwned = errors.New("path is not a sandbox runtime root") +// windowsSameRuntimeRootPath compares two runtime roots the way the filesystem +// does, so the stamp rides along with the right target regardless of spelling. +func windowsSameRuntimeRootPath(left, right string) bool { + left = filepath.Clean(strings.TrimSpace(left)) + right = filepath.Clean(strings.TrimSpace(right)) + if left == "" || right == "" { + return false + } + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + func runtimeTailNotOwned(root string) error { return fmt.Errorf("%w: %s", errRuntimeTailNotOwned, root) } diff --git a/internal/sandbox/windows_runtime_tail_windows.go b/internal/sandbox/windows_runtime_tail_windows.go index 7007eb52b..5e2e94181 100644 --- a/internal/sandbox/windows_runtime_tail_windows.go +++ b/internal/sandbox/windows_runtime_tail_windows.go @@ -143,6 +143,47 @@ func openWindowsChildNoFollow(parent windows.Handle, name string, access uint32, // stamped without ever carrying the capability grant, and marker validation // still passed because it only reads the stamp's contents. The restricted // process then got a marker-valid runtime path with no grant on it. +// writeWindowsRuntimeStampToDirectoryHandle writes the stamp into an ALREADY +// OPEN directory, naming nothing. The caller holds the handle the capability ACE +// was applied through, so the stamp cannot land anywhere else. +func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, planHash string) error { + objectName, err := windows.NewNTUnicodeString(windowsSandboxRuntimeStampName) + if err != nil { + return fmt.Errorf("encode sandbox runtime setup stamp name: %w", err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: directory, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var iosb windows.IO_STATUS_BLOCK + err = windows.NtCreateFile( + &handle, + windows.GENERIC_WRITE|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ, + windows.FILE_OVERWRITE_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + file := os.NewFile(uintptr(handle), windowsSandboxRuntimeStampName) + defer file.Close() + if _, err := file.WriteString(planHash); err != nil { + return fmt.Errorf("write sandbox runtime setup stamp: %w", err) + } + return nil +} + func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error { directory, err := openWindowsRuntimeTailDirectory(root, windows.FILE_TRAVERSE|windowsFileAddFile|windows.SYNCHRONIZE) if err != nil { diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 5c8d0f5da..88c0de793 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -254,6 +254,14 @@ func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa }, nil } +// WriteWindowsSandboxSetupMarker builds the marker, stamps the runtime tree and +// records the marker file. +// +// The elevated setup path does NOT use this. It splits the two, because the +// stamp has to ride along with the capability ACE through one handle rather than +// re-open the runtime root by name afterwards. See windowsACLStampRequest. This +// entry point remains for callers that record a marker without applying an ACL +// plan, where there is no handle to ride. func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) { marker, err := BuildWindowsSandboxSetupMarker(config) if err != nil { @@ -267,33 +275,42 @@ func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa if err := writeWindowsSandboxRuntimeStamp(windowsSandboxSelectedRuntimeRoot(config.PermissionProfile), marker.ACLPlanHash); err != nil { return WindowsSandboxSetupMarker{}, err } + if err := writeWindowsSandboxSetupMarkerFile(config, marker); err != nil { + return WindowsSandboxSetupMarker{}, err + } + return marker, nil +} + +// writeWindowsSandboxSetupMarkerFile records an already-built marker and touches +// nothing else. It never names the runtime tree. +func writeWindowsSandboxSetupMarkerFile(config WindowsSandboxSetupConfig, marker WindowsSandboxSetupMarker) error { path := WindowsSandboxSetupMarkerPath(config.SandboxHome) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker dir: %w", err) + return fmt.Errorf("create windows sandbox setup marker dir: %w", err) } bytes, err := json.MarshalIndent(marker, "", " ") if err != nil { - return WindowsSandboxSetupMarker{}, fmt.Errorf("marshal windows sandbox setup marker: %w", err) + return fmt.Errorf("marshal windows sandbox setup marker: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-setup-*.tmp") if err != nil { - return WindowsSandboxSetupMarker{}, fmt.Errorf("create windows sandbox setup marker temp file: %w", err) + return fmt.Errorf("create windows sandbox setup marker temp file: %w", err) } tmpPath := tmp.Name() if _, err := tmp.Write(bytes); err != nil { _ = tmp.Close() _ = os.Remove(tmpPath) - return WindowsSandboxSetupMarker{}, fmt.Errorf("write windows sandbox setup marker temp file: %w", err) + return fmt.Errorf("write windows sandbox setup marker temp file: %w", err) } if err := tmp.Close(); err != nil { _ = os.Remove(tmpPath) - return WindowsSandboxSetupMarker{}, fmt.Errorf("close windows sandbox setup marker temp file: %w", err) + return fmt.Errorf("close windows sandbox setup marker temp file: %w", err) } if err := os.Rename(tmpPath, path); err != nil { _ = os.Remove(tmpPath) - return WindowsSandboxSetupMarker{}, fmt.Errorf("replace windows sandbox setup marker: %w", err) + return fmt.Errorf("replace windows sandbox setup marker: %w", err) } - return marker, nil + return nil } func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 76d3ddf07..6e6bb75ea 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -5,6 +5,7 @@ package sandbox import ( "fmt" "io" + "strings" "golang.org/x/sys/windows" ) @@ -44,7 +45,26 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) if err != nil { return failed(err) } - rollback, err := applyWindowsACLPlan(plan) + // THE STAMP RIDES WITH THE ACE. Computed before the apply so the apply can + // write it through the very handle it grants the capability on, which is the + // only way the two are provably about one object. Writing it afterwards by + // pathname left a window in which the predictable root could be replaced by an + // ordinary directory that then collected a valid-looking stamp while carrying + // no ACE at all. + marker, err := BuildWindowsSandboxSetupMarker(config) + if err != nil { + return failed(err) + } + var stamp *windowsACLStampRequest + if root := windowsSandboxSelectedRuntimeRoot(config.PermissionProfile); strings.TrimSpace(root) != "" { + stamp = &windowsACLStampRequest{Root: root, PlanHash: marker.ACLPlanHash} + // Snapshotted BEFORE the apply, because the apply is now what writes the + // stamp. Taking it afterwards would record this run's own stamp as the + // state to restore, so a failed setup would put its own artifact back + // rather than what it found. + runtimeRollback.stamp = snapshotWindowsSandboxRuntimeStamp(root) + } + rollback, err := applyWindowsACLPlanWithStamp(plan, stamp) if err != nil { return failed(err) } @@ -60,19 +80,11 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) if err := applyWindowsNetworkPlan(networkPlan); err != nil { return failedAfterACL(err) } - // The runtime stamp is written by WriteWindowsSandboxSetupMarker below, which - // is the one place setup records that it completed. It is reached only after - // the ACL and network plans have applied, so the stamp still means "this tree - // carries these permissions" and every caller that records a marker records - // the stamp with it. - // - // It also lands INSIDE the runtime root, before the marker file is renamed - // into place, so from here on that root holds an artifact this run wrote. - // Handing it to the rollback record is what lets a late failure leave nothing - // behind: without it the root is non-empty, the directory removal refuses it - // by design, and the residue is permanent. - runtimeRollback.stamp = snapshotWindowsSandboxRuntimeStamp(windowsSandboxSelectedRuntimeRoot(config.PermissionProfile)) - if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { + // The stamp is already on disk, written through the handle the capability ACE + // was applied on, so this records the marker only and never names the runtime + // tree again. The rollback already owns that stamp, snapshotted above, so a + // failure here still leaves the root removable. + if err := writeWindowsSandboxSetupMarkerFile(config, marker); err != nil { return failedAfterACL(err) } return 0 From ffb16fa480ce5ef1a99f406bfc8590df71c49d3b Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sun, 23 Aug 2026 01:03:39 +0530 Subject: [PATCH 17/38] fix(sandbox): stop the sandbox rewriting the attestation about itself The runtime root grants the capability SID FILE_GENERIC_WRITE with SUB_CONTAINERS_AND_OBJECTS_INHERIT, which is what lets a sandboxed command write TMP, GOCACHE, GOMODCACHE and the package-manager caches beneath it. The stamp is created inside that root, so it inherited the same grant. A restricted command carrying the capability could open and overwrite the file attesting its own setup after passing its own pre-launch validation. Its command would continue, and every later elevated command and zero doctor would then reject the altered plan hash until an Administrator re-ran setup: a sandboxed process bricking the sandbox for everything after it. Moving the attestation outside the tree is the other way to close it and is worse. The stamp works precisely because it dies with the tree, which is how eviction is detectable without reading an ACE. Keeping it inside with inheritance switched off preserves that property. The stamp now gets an explicit DACL naming only CREATOR OWNER, LocalSystem and Administrators, applied with PROTECTED_DACL_SECURITY_INFORMATION. Protected rather than merely explicit, because without that bit the inherited capability ACE stays in the DACL beside whatever is set. Both writers do it, and both open with WRITE_DAC now, which the regression caught: SetSecurityInfo needs it on the handle and the first version failed with "Access is denied". The regression grants a stand-in capability SID an inheritable write on a real root, asserts an ordinary descendant DOES inherit it so the case is genuinely exercised, then asserts the stamp neither grants that SID nor leaves its DACL unprotected, and that setup can still read back what it wrote. --- .../sandbox/windows_runtime_tail_windows.go | 78 ++++++++++- .../windows_stamp_protection_windows_test.go | 124 ++++++++++++++++++ 2 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 internal/sandbox/windows_stamp_protection_windows_test.go diff --git a/internal/sandbox/windows_runtime_tail_windows.go b/internal/sandbox/windows_runtime_tail_windows.go index 5e2e94181..7946055f8 100644 --- a/internal/sandbox/windows_runtime_tail_windows.go +++ b/internal/sandbox/windows_runtime_tail_windows.go @@ -162,7 +162,7 @@ func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, planHas var iosb windows.IO_STATUS_BLOCK err = windows.NtCreateFile( &handle, - windows.GENERIC_WRITE|windows.SYNCHRONIZE, + windows.GENERIC_WRITE|windows.READ_CONTROL|windows.WRITE_DAC|windows.SYNCHRONIZE, &attributes, &iosb, nil, @@ -178,12 +178,80 @@ func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, planHas } file := os.NewFile(uintptr(handle), windowsSandboxRuntimeStampName) defer file.Close() + // PROTECTED BEFORE ANYTHING IS WRITTEN, because the stamp lives inside the + // tree it attests. See protectWindowsRuntimeStamp. + if err := protectWindowsRuntimeStamp(windows.Handle(file.Fd())); err != nil { + return err + } if _, err := file.WriteString(planHash); err != nil { return fmt.Errorf("write sandbox runtime setup stamp: %w", err) } return nil } +// protectWindowsRuntimeStamp gives the stamp its own DACL, excluding the +// capability SID the sandboxed command runs with. +// +// THE ATTESTATION CANNOT LIVE IN THE SUBJECT'S OWN WRITABLE NAMESPACE. The +// runtime root carries an AllowWrite entry for the capability SID with +// SUB_CONTAINERS_AND_OBJECTS_INHERIT, which is exactly what lets a sandboxed +// command write TMP, GOCACHE and the package-manager caches under it. A file +// created inside that root inherits the same grant, so the restricted command +// could open the stamp and overwrite it after passing its own pre-launch +// validation. Its current command would continue, and every later elevated +// command and zero doctor would then reject the altered plan hash until an +// Administrator re-ran setup: a sandboxed process bricking the sandbox. +// +// Moving the stamp outside the tree is the other way to fix it and is worse: +// the stamp works precisely because it dies with the tree, so eviction is +// detectable without reading an ACE. Keeping it inside with inheritance +// switched off preserves that and closes the hole. +// +// PROTECTED, not merely explicit: without SE_DACL_PROTECTED the inherited +// capability ACE stays in the DACL alongside whatever is set here. +func protectWindowsRuntimeStamp(handle windows.Handle) error { + owner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid) + if err != nil { + return fmt.Errorf("resolve owner SID for the sandbox runtime stamp: %w", err) + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return fmt.Errorf("resolve LocalSystem SID for the sandbox runtime stamp: %w", err) + } + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return fmt.Errorf("resolve Administrators SID for the sandbox runtime stamp: %w", err) + } + // Setup writes it, doctor and the elevated command read it; nothing else + // needs to reach it, and the capability SID is deliberately absent. + entries := make([]windows.EXPLICIT_ACCESS, 0, 3) + for _, sid := range []*windows.SID{owner, system, administrators} { + entries = append(entries, windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + }) + } + dacl, err := windows.ACLFromEntries(entries, nil) + if err != nil { + return fmt.Errorf("build the sandbox runtime stamp DACL: %w", err) + } + if err := windows.SetSecurityInfo( + handle, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, nil, dacl, nil, + ); err != nil { + return fmt.Errorf("protect the sandbox runtime setup stamp: %w", err) + } + return nil +} + func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error { directory, err := openWindowsRuntimeTailDirectory(root, windows.FILE_TRAVERSE|windowsFileAddFile|windows.SYNCHRONIZE) if err != nil { @@ -206,7 +274,7 @@ func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error { var iosb windows.IO_STATUS_BLOCK err = windows.NtCreateFile( &handle, - windows.GENERIC_WRITE|windows.SYNCHRONIZE, + windows.GENERIC_WRITE|windows.READ_CONTROL|windows.WRITE_DAC|windows.SYNCHRONIZE, &attributes, &iosb, nil, @@ -222,6 +290,12 @@ func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error { } file := os.NewFile(uintptr(handle), windowsSandboxRuntimeStampName) defer file.Close() + // Both writers protect. This one is the fallback path, and a stamp written + // here would inherit the same capability grant as one written through the + // ACL handle. + if err := protectWindowsRuntimeStamp(windows.Handle(file.Fd())); err != nil { + return err + } if _, err := file.WriteString(planHash); err != nil { return fmt.Errorf("write sandbox runtime setup stamp: %w", err) } diff --git a/internal/sandbox/windows_stamp_protection_windows_test.go b/internal/sandbox/windows_stamp_protection_windows_test.go new file mode 100644 index 000000000..71ef3ed94 --- /dev/null +++ b/internal/sandbox/windows_stamp_protection_windows_test.go @@ -0,0 +1,124 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// stampDACLGrants reports whether the stamp's DACL grants the named SID, and +// whether it still inherits from the runtime root. +func stampDACLGrants(t *testing.T, path string, sid *windows.SID) (granted bool, inherits bool) { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the stamp security descriptor: %v", err) + } + control, _, err := descriptor.Control() + if err != nil { + t.Fatalf("read the stamp descriptor control bits: %v", err) + } + inherits = control&windows.SE_DACL_PROTECTED == 0 + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("read the stamp DACL: %v", err) + } + if dacl == nil { + return false, inherits + } + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + t.Fatalf("read ACE %d: %v", index, err) + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + if (*windows.SID)(unsafePointerOfSID(ace)).Equals(sid) { + granted = true + } + } + return granted, inherits +} + +// THE ATTESTATION MUST NOT SIT IN THE SUBJECT'S OWN WRITABLE NAMESPACE. +// +// The runtime root grants the capability SID FILE_GENERIC_WRITE with +// SUB_CONTAINERS_AND_OBJECTS_INHERIT, which is what lets a sandboxed command +// write TMP, GOCACHE and the package caches beneath it. A stamp created inside +// that root inherits the same grant, so the restricted command could overwrite +// the file attesting its own setup: its current command would continue, and +// every later elevated command and zero doctor would reject the altered plan +// hash until an Administrator re-ran setup. +func TestTheStampDoesNotInheritTheCapabilityGrant(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + // Grant a capability SID write on the root, inheritable, exactly as the ACL + // plan does for a real runtime root. + capability, err := windows.StringToSid("S-1-5-32-546") + if err != nil { + t.Fatalf("resolve the stand-in capability SID: %v", err) + } + entries := []windows.EXPLICIT_ACCESS{{ + AccessPermissions: windows.GENERIC_WRITE | windows.GENERIC_READ, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(capability), + }, + }} + dacl, err := windows.ACLFromEntries(entries, nil) + if err != nil { + t.Fatalf("build the root DACL: %v", err) + } + if err := windows.SetNamedSecurityInfo(root, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Skipf("cannot set an inheritable ACL here: %v", err) + } + + // An ordinary runtime descendant DOES inherit it: that is the grant the + // sandbox needs, and the precondition that makes this test meaningful. + cache := filepath.Join(root, "cache") + if err := os.MkdirAll(cache, 0o700); err != nil { + t.Fatalf("create the runtime cache: %v", err) + } + if granted, _ := stampDACLGrants(t, cache, capability); !granted { + t.Skip("the inheritable grant did not reach an ordinary descendant here, so this case is not being exercised") + } + + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + stamp := windowsSandboxRuntimeStampPath(root) + + granted, inherits := stampDACLGrants(t, stamp, capability) + if granted { + t.Error("the stamp grants the capability SID; a sandboxed command could overwrite the attestation about its own setup") + } + if inherits { + t.Error("the stamp DACL is not protected, so the root's inheritable capability grant still applies to it") + } + + // And setup can still read what it wrote, or the protection would have + // locked out doctor and every later elevated command. + body, err := os.ReadFile(stamp) + if err != nil || string(body) != "planhash" { + t.Fatalf("the stamp is unreadable by its own writer (%q, err %v)", body, err) + } +} + +func unsafePointerOfSID(ace *windows.ACCESS_ALLOWED_ACE) unsafe.Pointer { + return unsafe.Pointer(&ace.SidStart) +} From 421b8c7eab6e2b344bd4adeb8eaf9b3e22e1a2f7 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 24 Aug 2026 14:43:26 +0530 Subject: [PATCH 18/38] fix(sandbox): make the ACL and its stamp one transaction, and fail setup without a runtime root Three things, all on the elevated Windows setup path. The capability ACL is committed by SetSecurityInfo before the ride-along stamp is written, and the apply returns a rollback closure only on success, so a stamp failure left the caller with nothing to compensate with. Setup reported failure while leaving the grant on a pre-existing runtime root. The captured descriptor is now restored through the same bound handle before the error goes back, and a test hook makes that path reachable. Runtime-root selection was best effort in BuildWindowsSandboxSetupArgs: on failure setup carried on and wrote a marker recording no runtime root, having already applied the ACLs. A later command selects a concrete root of its own, finds no stamp on it and refuses, and re-running setup takes the same branch again. That is the permanent brick the recorded-root contract exists to prevent, reached by the one path allowed to skip it. Selection failure now fails setup before any ACL or marker state is persisted. The fallback-runtime test stubbed only the cache derivation, so the temp derivation still read the process environment and the deterministic root landed in the developer's real temp directory and stayed there. Both inputs are now test owned, and the test asserts the redirect took rather than assuming it. --- internal/sandbox/runtime_state_test.go | 21 +++++- internal/sandbox/windows_acl_apply_windows.go | 38 ++++++++++- ...windows_acl_stamp_rollback_windows_test.go | 64 +++++++++++++++++ internal/sandbox/windows_setup.go | 26 +++++-- .../windows_setup_runtime_selection_test.go | 68 +++++++++++++++++++ 5 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 internal/sandbox/windows_acl_stamp_rollback_windows_test.go create mode 100644 internal/sandbox/windows_setup_runtime_selection_test.go diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index b707f67f2..2f71f4e13 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -71,6 +71,16 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) { func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) { workspace := t.TempDir() + // BOTH derivation inputs belong to the test, not just the cache one. The + // fallback root is derived from os.TempDir(), which reads TMPDIR on Unix and + // TMP/TEMP on Windows, and its name is deterministic, so stubbing only the + // cache left this test creating a persistent tree in the developer real temp + // directory that later runs then found already present. + tempHome := t.TempDir() + t.Setenv("TMPDIR", tempHome) + t.Setenv("TMP", tempHome) + t.Setenv("TEMP", tempHome) + original := sandboxUserCacheDir sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil } t.Cleanup(func() { sandboxUserCacheDir = original }) @@ -79,7 +89,16 @@ func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } - defer release() + defer func() { + release() + // Only the root this test created. + _ = os.RemoveAll(runtimeState.Root) + }() + // And the redirect actually took, or the cleanup above removes one tree while + // the real one is still left behind somewhere else. + if !pathWithinRoot(canonicalSandboxWorkspaceRoot(tempHome), runtimeState.Root) { + t.Fatalf("fallback runtime root %q is outside the test-owned temp directory %q", runtimeState.Root, tempHome) + } if pathWithinRoot(workspace, runtimeState.Root) { t.Fatalf("fallback runtime root %q must stay outside workspace %q", runtimeState.Root, workspace) } diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 7f6e7c50a..c5e7054b5 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -55,6 +55,31 @@ type windowsACLStampRequest struct { // directory, which is what a local process would do. var windowsACLStampSwapHook func(path string) +// windowsACLStampWriteHook replaces the ride-along stamp write. Nil in +// production; a test uses it to reach the post-commit failure path, which no +// ordinary input produces once the bound handle is already open. +var windowsACLStampWriteHook func(path string) error + +// writeRidingStamp writes the stamp through the handle the capability ACE was +// applied on, or through the test hook when one is installed. +func writeRidingStamp(handle windows.Handle, path string, planHash string) error { + if windowsACLStampWriteHook != nil { + return windowsACLStampWriteHook(path) + } + return writeWindowsRuntimeStampToDirectoryHandle(handle, planHash) +} + +// restoreWindowsACLThroughHandle puts a captured DACL back on the object the +// handle names, by handle rather than by pathname for the same reason the stamp +// rides along: after the apply, the name is no longer proof of the object. +func restoreWindowsACLThroughHandle(handle windows.Handle, descriptor *windows.SECURITY_DESCRIPTOR) error { + dacl, _, err := descriptor.DACL() + if err != nil { + return fmt.Errorf("read the captured windows DACL: %w", err) + } + return windows.SetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil) +} + func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { return applyWindowsACLPlanWithStamp(plan, nil) } @@ -182,7 +207,18 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows if windowsACLStampSwapHook != nil { windowsACLStampSwapHook(path) } - if err := writeWindowsRuntimeStampToDirectoryHandle(handle, stamp.PlanHash); err != nil { + if err := writeRidingStamp(handle, path, stamp.PlanHash); err != nil { + // THE ACE AND ITS STAMP ARE ONE TRANSACTION. + // + // SetSecurityInfo above has already committed, and this function + // returns no rollback closure on its error paths, so the caller has + // nothing to compensate with. Without this restore a failed setup + // reports failure while leaving the capability grant on a pre-existing + // runtime root: the tree stays writable by the restricted token and + // nothing on disk records that it should not be. + if restoreErr := restoreWindowsACLThroughHandle(handle, descriptor); restoreErr != nil { + return fail(fmt.Errorf("stamp windows ACL target %s: %w (the committed ACL could not be restored either: %v)", path, err, restoreErr)) + } return fail(fmt.Errorf("stamp windows ACL target %s: %w", path, err)) } } diff --git a/internal/sandbox/windows_acl_stamp_rollback_windows_test.go b/internal/sandbox/windows_acl_stamp_rollback_windows_test.go new file mode 100644 index 000000000..ce75cff28 --- /dev/null +++ b/internal/sandbox/windows_acl_stamp_rollback_windows_test.go @@ -0,0 +1,64 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func daclOf(t *testing.T, path string) string { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the DACL of %s: %v", path, err) + } + return descriptor.String() +} + +// THE ACE AND ITS STAMP ARE ONE TRANSACTION. +// +// SetSecurityInfo commits before the stamp is written, and the apply returns no +// rollback closure on its error paths, so the caller's compensations have +// nothing to undo. A failed setup therefore reported failure while leaving the +// capability grant on a pre-existing runtime root: the tree stays writable by +// the restricted token and nothing on disk records that it should not be. +func TestAStampFailureRestoresTheCommittedACL(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + before := daclOf(t, root) + + previous := windowsACLStampWriteHook + windowsACLStampWriteHook = func(string) error { return errors.New("disk full") } + t.Cleanup(func() { windowsACLStampWriteHook = previous }) + + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + rollback, err := applyWindowsACLPlanWithStamp(plan, &windowsACLStampRequest{Root: root, PlanHash: "planhash"}) + if err == nil { + if rollback != nil { + _ = rollback() + } + t.Fatal("the apply reported success even though the stamp could not be written") + } + if rollback != nil { + // A rollback closure here would be the other acceptable shape, but the + // caller only receives one on success, so it must not be relied on. + _ = rollback() + } + + if after := daclOf(t, root); after != before { + t.Errorf("the committed capability grant survived a failed setup:\nbefore %s\nafter %s", before, after) + } + if _, err := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName)); err == nil { + t.Error("a stamp exists even though the stamp step failed") + } +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 88c0de793..0c9d39f67 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -110,13 +110,25 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str // select. The lease is released straight away: it is taken here only to learn // which root wins, and the command acquires its own. // - // A selection failure is not fatal here. The old derivation is still applied - // below, so a machine where the lease cannot be taken at all behaves exactly - // as it did before rather than losing the ability to run setup. - if selected, lease, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...), false); selectErr == nil { - lease.release() - options.PermissionProfile = permissionProfileWithRuntime(options.PermissionProfile, SandboxRuntime{Root: selected}) - } + // A SELECTION FAILURE IS FATAL HERE, and continuing was the bug. + // + // Continuing left the profile with no runtime root while setup went on to + // provision a derived tree, apply the capability ACLs and write the marker. + // That marker records an empty runtime root, so it attests nothing about the + // tree. A later command makes its own concrete selection, finds no stamp on + // what it selected and refuses, and re-running setup reaches this same branch + // and records nothing again: the permanent brick described above, reached by + // the one path that was allowed to skip the fix. + // + // Failing now is failing before any ACL or marker state is persisted, so the + // operator is left in a state a retry can get out of, and the message names + // the step that actually failed. + selected, lease, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...), false) + if selectErr != nil { + return nil, fmt.Errorf("select the sandbox runtime root for setup: %w", selectErr) + } + lease.release() + options.PermissionProfile = permissionProfileWithRuntime(options.PermissionProfile, SandboxRuntime{Root: selected}) options.PermissionProfile = WindowsSandboxProfileWithRuntimeRoots(options.PermissionProfile, workspaceRoots) profileJSON, err := json.Marshal(options.PermissionProfile) if err != nil { diff --git a/internal/sandbox/windows_setup_runtime_selection_test.go b/internal/sandbox/windows_setup_runtime_selection_test.go new file mode 100644 index 000000000..d09e78695 --- /dev/null +++ b/internal/sandbox/windows_setup_runtime_selection_test.go @@ -0,0 +1,68 @@ +package sandbox + +import ( + "errors" + "strings" + "testing" +) + +// SETUP MUST NOT PERSIST STATE IT CANNOT ATTEST. +// +// The runtime-root selection used to be best effort here: if it failed, setup +// carried on and wrote a marker with no runtime root in it, having already +// applied the capability ACLs to a derived tree. A later command makes its own +// concrete selection, finds no stamp on what it picked and refuses, and +// re-running setup takes the same branch and records nothing again. That is the +// permanent brick the recorded-root contract exists to prevent, reached through +// the one path allowed to skip it. +func TestSetupArgsFailWhenNoRuntimeRootCanBeSelected(t *testing.T) { + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return "", errors.New("no cache directory on this machine") } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + CommandCWD: t.TempDir(), + SandboxHome: t.TempDir(), + }) + if err == nil { + t.Fatalf("setup args were built without a runtime root; the marker they produce attests nothing: %v", args) + } + if !strings.Contains(err.Error(), "runtime root") { + t.Errorf("the failure does not name the step that failed: %v", err) + } +} + +// And the ordinary path still records the concrete root it selected, so the +// test above is failing on the selection and not on some unrelated argument. +func TestSetupArgsRecordTheSelectedRuntimeRoot(t *testing.T) { + workspace := t.TempDir() + tempHome := t.TempDir() + t.Setenv("TMPDIR", tempHome) + t.Setenv("TMP", tempHome) + t.Setenv("TEMP", tempHome) + + cacheRoot := t.TempDir() + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + CommandCWD: workspace, + SandboxHome: t.TempDir(), + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + profile := "" + for index, arg := range args { + if arg == "--permission-profile" && index+1 < len(args) { + profile = args[index+1] + } + } + if profile == "" { + t.Fatalf("no permission profile in the setup args: %v", args) + } + if !strings.Contains(profile, "\"runtime\"") { + t.Errorf("the setup profile records no runtime root, so the marker cannot attest one: %s", profile) + } +} From bf879997f2c5d26976d368b093f80988ae48609a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 24 Aug 2026 17:36:32 +0530 Subject: [PATCH 19/38] test(sandbox): compare the DACL's entries, not the descriptor's SDDL The rollback test compared the full SDDL of the security descriptor before and after, which carries the owner, the group and the control flags as well as the access. It passed here and failed on the Windows runner with the three access entries byte-identical on both sides: SetSecurityInfo sets SE_DACL_AUTO_INHERITED when it writes a DACL, and GetNamedSecurityInfo did not report the owner and group the same way there. None of that grants anyone anything, and the test is about whether a capability grant survived a failed setup, so compare the access-control entries instead. The failure message now names the entry that survived rather than printing two SDDL strings to diff by eye. --- ...windows_acl_stamp_rollback_windows_test.go | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/windows_acl_stamp_rollback_windows_test.go b/internal/sandbox/windows_acl_stamp_rollback_windows_test.go index ce75cff28..09a1562d4 100644 --- a/internal/sandbox/windows_acl_stamp_rollback_windows_test.go +++ b/internal/sandbox/windows_acl_stamp_rollback_windows_test.go @@ -4,20 +4,49 @@ package sandbox import ( "errors" + "fmt" "os" "path/filepath" + "slices" + "strings" "testing" + "unsafe" "golang.org/x/sys/windows" ) -func daclOf(t *testing.T, path string) string { +// daclOf returns the DACL of path as one line per access-control entry. +// +// NOT the SDDL of the whole descriptor. That string also carries the owner, the +// group and the control flags, and none of those are what this test is about: +// SetSecurityInfo sets SE_DACL_AUTO_INHERITED when it writes a DACL, and +// GetNamedSecurityInfo does not report owner and group identically on every +// machine, so comparing full SDDL fails on a difference that grants nobody +// anything. The entries are the access, so the entries are what gets compared. +func daclOf(t *testing.T, path string) []string { t.Helper() descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) if err != nil { t.Fatalf("read the DACL of %s: %v", path, err) } - return descriptor.String() + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("read the DACL entries of %s: %v", path, err) + } + if dacl == nil { + return nil + } + entries := make([]string, 0, dacl.AceCount) + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + t.Fatalf("read ACE %d of %s: %v", index, path, err) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + entries = append(entries, fmt.Sprintf("type=%d flags=%#x mask=%#x sid=%s", + ace.Header.AceType, ace.Header.AceFlags, ace.Mask, sid.String())) + } + return entries } // THE ACE AND ITS STAMP ARE ONE TRANSACTION. @@ -55,8 +84,9 @@ func TestAStampFailureRestoresTheCommittedACL(t *testing.T) { _ = rollback() } - if after := daclOf(t, root); after != before { - t.Errorf("the committed capability grant survived a failed setup:\nbefore %s\nafter %s", before, after) + if after := daclOf(t, root); !slices.Equal(after, before) { + t.Errorf("the committed capability grant survived a failed setup:\nbefore %s\nafter %s", + strings.Join(before, " | "), strings.Join(after, " | ")) } if _, err := os.Stat(filepath.Join(root, windowsSandboxRuntimeStampName)); err == nil { t.Error("a stamp exists even though the stamp step failed") From d33740f8ffed04332cfe67e8cb21c8e7aeb9ed8b Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 26 Aug 2026 12:16:08 +0530 Subject: [PATCH 20/38] fix(sandbox): put the user boundary above every private fallback component The temp-derived runtime root scoped its LEAF to the user and left zero/runtime/v1 fixed above it. Runtime preparation creates and ownership-checks each of those at 0700, and on Unix os.TempDir() is shared whenever TMPDIR is unset, so the first account to use the fallback created a private directory every other account was then refused at: traversal fails on the mode, and relaxing the mode fails the ownership guard instead. The per-workspace digest never got the chance to separate them, and the fallback became first-user-wins on a shared host. The MkdirTemp layout this replaced did not have that problem, because each process sat under its own 0700 directory. The scope moves to the shallowest component, so every ancestor the guards create and validate is already inside one user's namespace. The workspace digest stays the leaf, so setup and the command still derive the same root for the same workspace. Windows keeps the fixed names deliberately: its temp root already resolves inside the user profile, and windowsSandboxRuntimeOwnedTail compares those names to recognise a root built by an elevated setup running as another account. The test pins both directions so neither platform's choice can drift into the other. --- .../runtime_fallback_user_scope_test.go | 106 ++++++++++++++++++ internal/sandbox/runtime_root_guard_unix.go | 22 ++++ .../sandbox/runtime_root_guard_windows.go | 10 ++ internal/sandbox/runtime_state.go | 2 +- 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/runtime_fallback_user_scope_test.go diff --git a/internal/sandbox/runtime_fallback_user_scope_test.go b/internal/sandbox/runtime_fallback_user_scope_test.go new file mode 100644 index 000000000..6d36f1ee6 --- /dev/null +++ b/internal/sandbox/runtime_fallback_user_scope_test.go @@ -0,0 +1,106 @@ +package sandbox + +import ( + "path/filepath" + "runtime" + "strings" + "testing" +) + +// EVERY OWNERSHIP-CHECKED ANCESTOR MUST ALREADY BE INSIDE ONE USER'S NAMESPACE. +// +// The temp-derived fallback lives under a directory that is shared on Unix +// whenever TMPDIR is unset. Runtime preparation creates and ownership-checks +// each component of the tail at 0700, so a fixed first component meant the +// first account to use the fallback created a private directory every other +// account was then refused at: traversal fails on the mode, and relaxing the +// mode fails the ownership guard instead. The per-workspace digest is the leaf, +// so it never got the chance to separate them. +// +// The invariant is therefore about the SHALLOWEST checked component, not the +// leaf: it has to carry the user scope, or the guards below it are guarding a +// namespace two users share. +func TestFallbackRuntimeRootScopesItsShallowestOwnedComponent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("os.TempDir() resolves inside the user profile on Windows, so the shared-temp collision cannot arise") + } + workspace := t.TempDir() + tempHome := t.TempDir() + t.Setenv("TMPDIR", tempHome) + + root, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatalf("fallbackSandboxRuntimeRoot: %v", err) + } + components := ownedRuntimeComponents(root) + if len(components) == 0 { + t.Fatal("no ownership-checked components derived for the fallback root") + } + // ownedRuntimeComponents walks upward, so the last entry is the shallowest + // directory the guards create and validate. + shallowest := filepath.Base(components[len(components)-1]) + scope := sandboxRuntimeUserScope() + if !strings.Contains(shallowest, scope) { + t.Errorf("the shallowest ownership-checked component is %q and does not carry the user scope %q; "+ + "two accounts on a shared temp would contend for it", shallowest, scope) + } +} + +// The workspace still decides the leaf, so setup and the command derive the same +// root for the same workspace. Scoping the top must not have moved that. +func TestFallbackRuntimeRootStaysStableForOneWorkspace(t *testing.T) { + workspace := t.TempDir() + tempHome := t.TempDir() + t.Setenv("TMPDIR", tempHome) + t.Setenv("TMP", tempHome) + t.Setenv("TEMP", tempHome) + + first, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatal(err) + } + second, err := fallbackSandboxRuntimeRoot(workspace) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Errorf("the fallback root is not stable for one workspace:\n %s\n %s", first, second) + } + + other, err := fallbackSandboxRuntimeRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if other == first { + t.Error("two different workspaces derived the same fallback root") + } + if filepath.Dir(other) != filepath.Dir(first) { + t.Errorf("two workspaces for one user should differ only in the leaf:\n %s\n %s", first, other) + } +} + +// And the Windows side is a deliberate choice, not an omission. Its temp root +// is already inside the user profile, and the names have to stay fixed so +// windowsSandboxRuntimeOwnedTail can still recognise a root that an elevated +// setup running as a different account built. +func TestFallbackOwnedNamesAreUnscopedOnWindowsOnly(t *testing.T) { + names := sandboxRuntimeFallbackOwnedNames() + if len(names) != len(windowsSandboxRuntimeOwnedNames) { + t.Fatalf("fallback names = %v, want the same depth as %v", names, windowsSandboxRuntimeOwnedNames) + } + // The components below the first are shared by both platforms, so a change + // to them would break the tail matcher on Windows. + for index := 1; index < len(names); index++ { + if names[index] != windowsSandboxRuntimeOwnedNames[index] { + t.Errorf("component %d = %q, want %q", index, names[index], windowsSandboxRuntimeOwnedNames[index]) + } + } + scoped := names[0] != windowsSandboxRuntimeOwnedNames[0] + if runtime.GOOS == "windows" && scoped { + t.Errorf("the Windows fallback scoped its first component to %q; the tail matcher compares fixed names", names[0]) + } + if runtime.GOOS != "windows" && !scoped { + t.Errorf("the first component is %q on %s, where the temp root can be shared between accounts", + names[0], runtime.GOOS) + } +} diff --git a/internal/sandbox/runtime_root_guard_unix.go b/internal/sandbox/runtime_root_guard_unix.go index 2ef20823f..68ed1cd19 100644 --- a/internal/sandbox/runtime_root_guard_unix.go +++ b/internal/sandbox/runtime_root_guard_unix.go @@ -43,3 +43,25 @@ func refuseForeignRuntimeComponent(component string, info os.FileInfo) error { func sandboxRuntimeUserScope() string { return fmt.Sprintf("u%d", os.Getuid()) } + +// sandboxRuntimeFallbackOwnedNames are the components the temp-derived runtime +// root is built from. +// +// THE USER BOUNDARY COMES FIRST, ABOVE EVERY PRIVATE COMPONENT. On Unix +// os.TempDir() is a SHARED directory whenever TMPDIR is unset, and runtime +// preparation creates and ownership-checks each of these components at 0700. A +// fixed first component therefore meant the first account to use the fallback +// created a private directory that every other account was then refused at: +// traversal fails on the mode, and relaxing the mode fails the ownership guard +// instead. The per-workspace digest is the leaf, so it never got the chance to +// separate them, and the fallback became first-user-wins on a shared host. +// +// Scoping the FIRST component keeps every ownership-checked ancestor inside a +// namespace that already belongs to one user, which is the property the guards +// below assume. The workspace digest stays the leaf so setup and the command +// still derive the same path for the same workspace. +func sandboxRuntimeFallbackOwnedNames() []string { + names := append([]string(nil), windowsSandboxRuntimeOwnedNames...) + names[0] = names[0] + "-" + sandboxRuntimeUserScope() + return names +} diff --git a/internal/sandbox/runtime_root_guard_windows.go b/internal/sandbox/runtime_root_guard_windows.go index 5b6531508..ecbd4c27c 100644 --- a/internal/sandbox/runtime_root_guard_windows.go +++ b/internal/sandbox/runtime_root_guard_windows.go @@ -29,3 +29,13 @@ func sandboxRuntimeUserScope() string { } return "u" + strings.ToLower(name) } + +// sandboxRuntimeFallbackOwnedNames are the components the temp-derived runtime +// root is built from. Unscoped on Windows: os.TempDir() already resolves inside +// the user's own profile, so the shared-temp collision the Unix build guards +// against cannot arise, and the fixed names keep windowsSandboxRuntimeOwnedTail +// able to recognise a root built by an elevated setup running as another +// account. +func sandboxRuntimeFallbackOwnedNames() []string { + return windowsSandboxRuntimeOwnedNames +} diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 3d0cc3960..9c7038066 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -298,7 +298,7 @@ func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) { // and is not doing secrecy work; it removes the collision, and // refuseAliasedRuntimeComponents handles somebody having got there first. digest := sha256.Sum256([]byte(workspaceRoot + "\x00" + sandboxRuntimeUserScope())) - root := filepath.Join(append(append([]string{tempRoot}, windowsSandboxRuntimeOwnedNames...), hex.EncodeToString(digest[:8]))...) + root := filepath.Join(append(append([]string{tempRoot}, sandboxRuntimeFallbackOwnedNames()...), hex.EncodeToString(digest[:8]))...) if runtimeRootWithinWorkspace(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 d4d9d5524789e2ea1b16323ca68e19b1289f2ddf Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 26 Aug 2026 12:19:15 +0530 Subject: [PATCH 21/38] fix(sandbox): attest the object before trusting the applied-plan marker The unelevated marker records a plan hash and entry count, both derived from pathnames and entries. That says what should be granted; it cannot establish that whatever directory currently answers to the name has received it. The runtime root makes the gap reachable rather than theoretical, because it is deterministic and disposable. Cleanup removes the tree, the next command's parent recreates the same pathname with ordinary inherited permissions, and the hash is unchanged, so the fast path skipped the apply and the WRITE_RESTRICTED child could not write TMP or its language and package caches. Nothing failed to say why. The fast path now reads the security descriptor of each allow entry and takes it only when the grant is actually present. That costs one descriptor read per entry on a path that is about to create a process, and it covers every reason a grant can be missing rather than only recreation by the parent: manual deletion, eviction between commands, a restored backup. Any failure reads as "not applied", because re-applying an existing grant is idempotent while skipping an absent one produces a sandbox that silently cannot write. --- .../sandbox/windows_acl_attest_windows.go | 81 +++++++++++++++++++ .../windows_acl_attest_windows_test.go | 79 ++++++++++++++++++ .../sandbox/windows_command_runner_windows.go | 6 +- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/windows_acl_attest_windows.go create mode 100644 internal/sandbox/windows_acl_attest_windows_test.go diff --git a/internal/sandbox/windows_acl_attest_windows.go b/internal/sandbox/windows_acl_attest_windows.go new file mode 100644 index 000000000..61eba721e --- /dev/null +++ b/internal/sandbox/windows_acl_attest_windows.go @@ -0,0 +1,81 @@ +//go:build windows + +package sandbox + +import ( + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// windowsACLPlanStillApplied reports whether the objects a plan names still +// carry the grants it describes. +// +// THE MARKER FINGERPRINTS A PLAN, NOT AN OBJECT. Its hash is computed from +// pathnames and entries, so it records what SHOULD be granted and can never +// establish that whatever directory currently answers to that name has received +// it. The runtime root makes the difference reachable rather than theoretical: +// it is deterministic and disposable, so cleanup removes the tree, the next +// command's parent recreates the same pathname with ordinary inherited +// permissions, and the plan hash is unchanged. The fast path then skipped the +// apply entirely and the WRITE_RESTRICTED child could not write TMP or its +// language and package caches, with nothing failing to say why. +// +// Attesting the object costs one security-descriptor read per allow entry, on a +// path that is about to create a process, and it covers every reason a grant +// can be missing rather than only recreation by the parent: manual deletion, +// an eviction between commands, a restored backup. +// +// Failure is treated as "not applied". Reapplying a grant that is already there +// is idempotent and cheap; skipping one that is absent produces a sandbox that +// silently cannot write. +func windowsACLPlanStillApplied(plan WindowsACLPlan) bool { + for _, entry := range plan.Entries { + if entry.Action != WindowsACLAllowWrite { + // Only the allow grants are load-bearing for the child's ability to + // run. A missing DENY is a weaker boundary rather than a broken one, + // and re-applying the whole plan is what fixes either. + continue + } + if strings.TrimSpace(entry.Path) == "" || strings.TrimSpace(entry.Capability) == "" { + continue + } + if !windowsPathGrantsTrustee(entry.Path, entry.Capability) { + return false + } + } + return true +} + +// windowsPathGrantsTrustee reports whether path's DACL carries an allow entry +// for the given SID string. +func windowsPathGrantsTrustee(path, trustee string) bool { + wanted, err := windows.StringToSid(trustee) + if err != nil { + return false + } + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + return false + } + 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 { + return false + } + ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(header)) + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if sid.Equals(wanted) { + return true + } + } + return false +} diff --git a/internal/sandbox/windows_acl_attest_windows_test.go b/internal/sandbox/windows_acl_attest_windows_test.go new file mode 100644 index 000000000..0efc2aaf8 --- /dev/null +++ b/internal/sandbox/windows_acl_attest_windows_test.go @@ -0,0 +1,79 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// A PLAN HASH ATTESTS A PATHNAME, NOT THE DIRECTORY THAT ANSWERS TO IT. +// +// The unelevated marker records the plan hash and its entry count, both derived +// from pathnames and entries. The runtime root is deterministic and disposable, +// so cleanup can remove the tree and the next command's parent recreates the +// same pathname with ordinary inherited permissions. The hash is unchanged, the +// marker still claims the plan was applied, and the replacement never received +// the capability ACE, leaving the WRITE_RESTRICTED child unable to write TMP or +// its caches with nothing failing to say why. +func TestPlanAttestationFailsAfterTheRootIsRecreated(t *testing.T) { + root := filepath.Join(t.TempDir(), "runtime") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + // Guests: a well-known SID that no ordinary object carries, standing in for + // the sandbox capability SID. + const capability = "S-1-5-32-546" + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: capability}, + }} + + if windowsACLPlanStillApplied(plan) { + t.Fatal("SETUP INVALID: the grant is reported as present before it was applied") + } + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + t.Cleanup(func() { _ = rollback() }) + + if !windowsACLPlanStillApplied(plan) { + t.Fatal("the grant was just applied and the attestation does not see it") + } + + // Exactly what cleanup plus the next command's parent does: same pathname, + // new directory object, ordinary inherited permissions. + if err := os.RemoveAll(root); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + if windowsACLPlanStillApplied(plan) { + t.Error("a recreated root is reported as still carrying the grant, so the apply would be skipped") + } +} + +// A missing path is not a grant either, and must not be read as one. +func TestPlanAttestationFailsWhenThePathIsGone(t *testing.T) { + root := filepath.Join(t.TempDir(), "absent") + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + if windowsACLPlanStillApplied(plan) { + t.Error("a path that does not exist was reported as carrying its grant") + } +} + +// Deny entries are not load-bearing for the child's ability to run, so their +// absence must not force a re-apply on every command. +func TestPlanAttestationIgnoresDenyEntries(t *testing.T) { + root := t.TempDir() + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLDenyWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + if !windowsACLPlanStillApplied(plan) { + t.Error("a plan of deny entries alone reported as unapplied, which would re-apply on every command") + } +} diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 0b9e8f64e..1c8e36060 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -108,7 +108,11 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { if err != nil { return err } - if marker.contains(applied) { + // The marker says this plan was applied; the object has to agree. See + // windowsACLPlanStillApplied: a deterministic runtime root can be removed and + // recreated between commands under the same pathname, which leaves the plan + // hash identical and the capability grant gone. + if marker.contains(applied) && windowsACLPlanStillApplied(plan) { return nil } if _, err := applyWindowsACLPlan(plan); err != nil { From ac181742e26e271c5949f4a8af11804a1ce5353f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Wed, 26 Aug 2026 12:49:29 +0530 Subject: [PATCH 22/38] fix(sandbox): make rollback prove it holds the object it changed The forward apply and its stamp go through one handle, so they are provably about one object. Compensation runs later, after a network or marker failure, and resolves those names again. Opening no-follow refuses a reparse point but accepts an ordinary directory moved into the name since the handle closed, so a rename plus a substitute made rollback restore the pre-apply DACL onto the substitute and report success, while the moved original kept this run's capability ACE. Setup claimed a completed rollback with the modified object still reachable elsewhere, having also mutated a directory the forward operation never touched. The snapshot now records the object identity read from the open handle, which is the last moment the name and the object are known to be the same thing, and the restore refuses to act unless the name still answers to it. On a mismatch the substitute is left alone and the error says the original is unrestored, rather than reporting success. Materialized targets are deliberately not identity-checked. A materialized target is one this run created, and its plan routinely denies Everyone read, which is what a protected metadata carve-out is, so the attributes identity needs cannot be read back even by the owner: requiring it turned rollback of every materialized directory into "Access is denied". Establishing identity there means holding the apply handle open to the last failure point, which is a larger change than this one and is called out rather than papered over. --- internal/sandbox/windows_acl_apply_windows.go | 105 ++++++++++++++++- ...dows_acl_rollback_identity_windows_test.go | 109 ++++++++++++++++++ 2 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/windows_acl_rollback_identity_windows_test.go diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index c5e7054b5..70c8a96f7 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -24,6 +24,39 @@ type windowsACLSnapshot struct { Path string Descriptor *windows.SECURITY_DESCRIPTOR Materialized bool + // Identity is the object the forward apply actually modified, captured from + // the open handle. Compensation reopens BY NAME, and a name is not an + // object: see rollbackWindowsACLSnapshots. + Identity windowsObjectIdentity +} + +// windowsObjectIdentity identifies a filesystem object independently of the +// name it currently answers to. +type windowsObjectIdentity struct { + volume uint32 + high uint32 + low uint32 + valid bool +} + +func windowsIdentityFromHandle(handle windows.Handle) windowsObjectIdentity { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return windowsObjectIdentity{} + } + return windowsObjectIdentity{ + volume: info.VolumeSerialNumber, + high: info.FileIndexHigh, + low: info.FileIndexLow, + valid: true, + } +} + +// matches is deliberately false when either side is unknown. A compensation +// that cannot prove it is acting on the object it changed must not act. +func (id windowsObjectIdentity) matches(other windowsObjectIdentity) bool { + return id.valid && other.valid && + id.volume == other.volume && id.high == other.high && id.low == other.low } // windowsACLStampRequest asks the apply to write the runtime setup stamp THROUGH @@ -222,8 +255,11 @@ func applyWindowsACLPathGroupWithStamp(group windowsACLPathGroup, stamp *windows return fail(fmt.Errorf("stamp windows ACL target %s: %w", path, err)) } } + // Captured while the handle is still open, because this is the last moment + // the object and the name are known to be the same thing. + identity := windowsIdentityFromHandle(handle) _ = windows.CloseHandle(handle) - return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil + return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized, Identity: identity}, true, nil } // openWindowsACLTarget opens path for reading and rewriting its DACL without @@ -339,12 +375,48 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { var errs []error for index := len(snapshots) - 1; index >= 0; index-- { snapshot := snapshots[index] + // A NAME IS NOT AN OBJECT ONCE THE APPLY HANDLE HAS CLOSED. + // + // The forward apply and its stamp go through one handle, so they are + // provably about one object. Compensation runs later, after a network or + // marker failure, and resolves these names again. Opening no-follow stops + // a reparse point but accepts an ORDINARY directory moved into the name + // since: the original is renamed aside, a substitute is created, and + // rollback then restores the pre-apply DACL onto the substitute, strips a + // stamp there, and reports success, while the moved original keeps this + // run's capability ACE and a valid stamp. Setup would claim a completed + // rollback with the modified object still reachable elsewhere, having + // also mutated something it never touched going forward. + // + // So every compensation proves it holds the object it changed, and + // otherwise leaves the substitute alone and says plainly what was left + // behind. if snapshot.Materialized { + // NOT identity-checked, and the reason is a real limit rather than an + // oversight. A materialized target is one this run created, and its + // plan routinely denies Everyone read (that is what a protected + // metadata carve-out IS), so the attributes identity needs cannot be + // read back even by the owner: the check turned rollback of every + // materialized directory into "Access is denied". Establishing + // identity here would mean holding the apply handle open until the + // last failure point, which is a larger change than this one. if err := os.RemoveAll(snapshot.Path); err != nil { errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err)) } continue } + current, err := windowsSnapshotIdentityNow(snapshot.Path) + if err != nil { + errs = append(errs, fmt.Errorf("identify windows ACL target %s for rollback: %w", snapshot.Path, err)) + continue + } + if !snapshot.Identity.matches(current) { + errs = append(errs, fmt.Errorf( + "windows ACL target %s is no longer the object this setup modified; "+ + "leaving the replacement untouched, and the original still carries this run's grant", + snapshot.Path)) + continue + } dacl, _, err := snapshot.Descriptor.DACL() if err != nil { errs = append(errs, fmt.Errorf("read rollback windows DACL for %s: %w", snapshot.Path, err)) @@ -367,3 +439,34 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { } return errors.Join(errs...) } + +// windowsSnapshotIdentityNow opens the recorded path no-follow and reports which +// object currently answers to it. +// +// MINIMAL ACCESS, deliberately. openWindowsACLTarget asks for WRITE_DAC because +// it is about to rewrite a descriptor, and that is not available on every target +// the forward apply materialized once its own grants are in place: asking for it +// here turned an identity CHECK into an access failure and broke rollback for +// materialized directories. Identity only needs the attributes, so this asks for +// nothing more, and keeps FILE_FLAG_OPEN_REPARSE_POINT so a link substituted at +// the final component is opened as the link it is rather than followed. +func windowsSnapshotIdentityNow(path string) (windowsObjectIdentity, error) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return windowsObjectIdentity{}, 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 { + return windowsObjectIdentity{}, err + } + defer windows.CloseHandle(handle) + return windowsIdentityFromHandle(handle), nil +} diff --git a/internal/sandbox/windows_acl_rollback_identity_windows_test.go b/internal/sandbox/windows_acl_rollback_identity_windows_test.go new file mode 100644 index 000000000..541c57d70 --- /dev/null +++ b/internal/sandbox/windows_acl_rollback_identity_windows_test.go @@ -0,0 +1,109 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// COMPENSATION MUST HOLD THE OBJECT IT CHANGED, NOT THE NAME IT USED. +// +// The forward apply and its stamp go through one handle, so they are provably +// about one object. Compensation runs later, after a network or marker failure, +// and resolves those names again. Opening no-follow refuses a reparse point but +// accepts an ORDINARY directory moved into the name since the handle closed. So +// a rename plus a substitute made rollback restore the pre-apply DACL onto the +// substitute and report success, while the moved original kept this run's +// capability ACE: a completed rollback with the modified object still reachable +// elsewhere, and a directory mutated that the forward operation never touched. +func TestRollbackRefusesASubstituteAndReportsTheOriginal(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "target") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + const capability = "S-1-5-32-546" + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: capability}, + }} + + rollback, err := applyWindowsACLPlanWithStamp(plan, nil) + if err != nil { + t.Fatalf("applyWindowsACLPlanWithStamp: %v", err) + } + if !windowsACLPlanStillApplied(plan) { + t.Fatal("SETUP INVALID: the grant is not present after a successful apply") + } + + // Exactly the swap the compensation cannot see by name: move the object that + // was modified aside, and put an ordinary directory where it was. + moved := filepath.Join(base, "moved") + if err := os.Rename(root, moved); err != nil { + t.Skipf("cannot rename the applied target here: %v", err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + substituteBefore := daclOf(t, root) + + err = rollback() + if err == nil { + t.Fatal("rollback reported success against a substitute directory") + } + if !strings.Contains(err.Error(), root) { + t.Errorf("the failure does not name the path left in an unrestored state: %v", err) + } + + // The substitute must be byte-for-byte the directory the test created. + if after := daclOf(t, root); !equalACEs(after, substituteBefore) { + t.Errorf("rollback mutated a directory it never modified:\nbefore %v\nafter %v", substituteBefore, after) + } + + // And the original is honestly residual: it still carries this run's grant, + // which is what the error is telling the operator. + movedPlan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: moved, Capability: capability}, + }} + if !windowsACLPlanStillApplied(movedPlan) { + t.Error("the moved original lost its grant, so the error over-reported what was left behind") + } +} + +// And an unswapped rollback still restores, or the guard would have disabled +// compensation rather than bounding it. +func TestRollbackStillRestoresTheObjectItModified(t *testing.T) { + root := filepath.Join(t.TempDir(), "target") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: "S-1-5-32-546"}, + }} + before := daclOf(t, root) + + rollback, err := applyWindowsACLPlanWithStamp(plan, nil) + if err != nil { + t.Fatalf("applyWindowsACLPlanWithStamp: %v", err) + } + if err := rollback(); err != nil { + t.Fatalf("rollback of an unswapped target failed: %v", err) + } + if after := daclOf(t, root); !equalACEs(after, before) { + t.Errorf("rollback did not restore the original DACL:\nbefore %v\nafter %v", before, after) + } +} + +func equalACEs(a, b []string) bool { + if len(a) != len(b) { + return false + } + for index := range a { + if a[index] != b[index] { + return false + } + } + return true +} From 343da39db6cf1669dbcdcb474d6e5f7321595575 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 12:21:36 +0530 Subject: [PATCH 23/38] fix(sandbox): recognise the user-scoped fallback as an owned runtime tail Scoping the fallback's first component to the user broke the shape the owned tail is matched by, because the matcher compares that position against the one fixed name. The fallback therefore stopped being recognised on Unix, which silently costs it both protections that depend on the shape: the rooted no-follow traversal falls back to opening the tree by name, and the owned-component guard no longer sees it. TestTheTempFallbackRootKeepsTheOwnedShape exists for exactly that reason and caught it on CI. The first position now accepts either spelling, the fixed one the cache root uses or the user-scoped one the fallback uses, and every component below it stays fixed so a tree Zero does not own is still refused. The scoped spelling does not exist on Windows, so the accepting branch could not be exercised there at all and a break in it would only ever surface on another platform's CI. The matcher reads the fallback names through a seam so the shape test can drive both spellings, including a different user's scope, on any platform. --- .../sandbox/runtime_owned_tail_scope_test.go | 49 +++++++++++++++++++ internal/sandbox/windows_runtime_tail.go | 33 ++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 internal/sandbox/runtime_owned_tail_scope_test.go diff --git a/internal/sandbox/runtime_owned_tail_scope_test.go b/internal/sandbox/runtime_owned_tail_scope_test.go new file mode 100644 index 000000000..e69391c52 --- /dev/null +++ b/internal/sandbox/runtime_owned_tail_scope_test.go @@ -0,0 +1,49 @@ +package sandbox + +import ( + "path/filepath" + "testing" +) + +// THE SHAPE HAS TO HOLD FOR BOTH SPELLINGS OF THE FIRST COMPONENT. +// +// The cache-derived root uses the fixed name; the temp-derived fallback scopes +// that component to the user wherever the temp root is shared. A tail that +// stops matching loses the rooted no-follow traversal AND the owned-component +// guard, and neither failure is visible where it happens. +// +// The scoped spelling does not exist on Windows, so without the seam this +// branch could only ever be exercised by another platform's CI. +func TestOwnedTailAcceptsBothFirstComponentSpellings(t *testing.T) { + previous := fallbackOwnedNamesForMatch + t.Cleanup(func() { fallbackOwnedNamesForMatch = previous }) + fallbackOwnedNamesForMatch = func() []string { + return []string{windowsSandboxRuntimeOwnedNames[0] + "-u1001", "runtime", "v1"} + } + + base := filepath.Join("C:", "shared") + for _, testCase := range []struct { + name string + first string + want bool + }{ + {"fixed cache spelling", windowsSandboxRuntimeOwnedNames[0], true}, + {"user-scoped fallback spelling", windowsSandboxRuntimeOwnedNames[0] + "-u1001", true}, + {"a different user's scope", windowsSandboxRuntimeOwnedNames[0] + "-u2002", false}, + {"an unrelated directory", "notzero", false}, + } { + t.Run(testCase.name, func(t *testing.T) { + root := filepath.Join(base, testCase.first, "runtime", "v1", "abcdef0123456789") + if _, _, ok := windowsSandboxRuntimeOwnedTail(root); ok != testCase.want { + t.Errorf("owned tail for %s = %v, want %v", root, ok, testCase.want) + } + }) + } + + // The components below the first stay fixed for both spellings, or the + // traversal would accept a tree Zero does not own. + wrong := filepath.Join(base, windowsSandboxRuntimeOwnedNames[0]+"-u1001", "elsewhere", "v1", "abcdef0123456789") + if _, _, ok := windowsSandboxRuntimeOwnedTail(wrong); ok { + t.Errorf("owned tail accepted %s, whose middle component is not one Zero owns", wrong) + } +} diff --git a/internal/sandbox/windows_runtime_tail.go b/internal/sandbox/windows_runtime_tail.go index 99a97c00a..cb36b1ed2 100644 --- a/internal/sandbox/windows_runtime_tail.go +++ b/internal/sandbox/windows_runtime_tail.go @@ -49,8 +49,8 @@ func windowsSandboxRuntimeOwnedTail(root string) (string, []string, bool) { current = parent } // components came off the tail, deepest first. - for index, name := range windowsSandboxRuntimeOwnedNames { - if !strings.EqualFold(components[len(components)-1-index], name) { + for index := range windowsSandboxRuntimeOwnedNames { + if !windowsRuntimeOwnedNameMatches(index, components[len(components)-1-index]) { return "", nil, false } } @@ -82,3 +82,32 @@ func windowsSameRuntimeRootPath(left, right string) bool { func runtimeTailNotOwned(root string) error { return fmt.Errorf("%w: %s", errRuntimeTailNotOwned, root) } + +// windowsRuntimeOwnedNameMatches reports whether one component of a candidate +// tail is a name Zero owns at that position. +// +// The FIRST position has two accepted spellings. The cache-derived root uses the +// fixed name; the temp-derived fallback scopes that component to the user on +// platforms where the temp root is shared between accounts, because every +// private ownership-checked ancestor has to sit inside one user's namespace. +// Both spellings are Zero's own and everything below them is fixed. +// +// Accepting only the fixed name silently cost the fallback BOTH protections it +// depends on: the rooted no-follow traversal fell back to opening the tree by +// name, and the shape guard stopped recognising it. Neither failure is visible +// at the point it happens, which is why the shape has a test of its own. +func windowsRuntimeOwnedNameMatches(index int, component string) bool { + if strings.EqualFold(component, windowsSandboxRuntimeOwnedNames[index]) { + return true + } + if index != 0 { + return false + } + return strings.EqualFold(component, fallbackOwnedNamesForMatch()[0]) +} + +// fallbackOwnedNamesForMatch is the seam the shape tests use. The scoped +// spelling only exists on platforms whose temp root is shared, so without it the +// accepting branch cannot be exercised at all on Windows, and a break there +// would only surface on another platform's CI. +var fallbackOwnedNamesForMatch = sandboxRuntimeFallbackOwnedNames From ee4c66f2d1b162fe5acafc5974ee24c81b03bbcc Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 12:25:18 +0530 Subject: [PATCH 24/38] fix(sandbox): attest the effective grant, not the presence of the SID The attestation accepted any allow ACE naming the capability. That is weaker than the contract the child depends on: windowsACLAccess grants FILE_GENERIC_READ|WRITE|EXECUTE, and a directory entry carries SUB_CONTAINERS_AND_OBJECTS_INHERIT so the grant reaches the TMP and cache descendants the child actually writes. So an ACE that still named the capability but had been reduced to a read-only or metadata mask, or that no longer propagated, attested as healthy. Both consumers skipped the idempotent re-apply and the restricted child reached a marker-valid runtime root and got ACCESS_DENIED on its first write, which is the silent unusable runtime the attestation was added to remove. Attestation now unions the allow masks of the ACEs that actually propagate and requires the plan's own mask, and a deny naming the capability over any required bit fails closed rather than being ignored. The regression drives the positive case plus four weakenings that all keep the SID: read+execute, metadata only, full mask without inheritance, and containers-only inheritance. Removing either half of the check fails the cases belonging to that half. --- .../sandbox/windows_acl_attest_windows.go | 74 ++++++++++++++----- .../windows_acl_attest_windows_test.go | 71 ++++++++++++++++++ 2 files changed, 127 insertions(+), 18 deletions(-) diff --git a/internal/sandbox/windows_acl_attest_windows.go b/internal/sandbox/windows_acl_attest_windows.go index 61eba721e..debea9aa5 100644 --- a/internal/sandbox/windows_acl_attest_windows.go +++ b/internal/sandbox/windows_acl_attest_windows.go @@ -3,6 +3,7 @@ package sandbox import ( + "os" "strings" "unsafe" @@ -22,39 +23,62 @@ import ( // apply entirely and the WRITE_RESTRICTED child could not write TMP or its // language and package caches, with nothing failing to say why. // -// Attesting the object costs one security-descriptor read per allow entry, on a -// path that is about to create a process, and it covers every reason a grant -// can be missing rather than only recreation by the parent: manual deletion, -// an eviction between commands, a restored backup. +// PRESENCE OF THE SID IS NOT THE CONTRACT. What the child needs is the grant +// windowsACLAccess actually creates, and on a directory it needs to reach +// descendants. An ACE that still names the capability SID but has been reduced +// to a metadata or read-only mask, or that no longer propagates, leaves a +// runtime root that attests as healthy and returns ACCESS_DENIED on the first +// write into TMP or a cache: the same silent unusable runtime the attestation +// exists to eliminate. So the check compares the effective grant. // -// Failure is treated as "not applied". Reapplying a grant that is already there -// is idempotent and cheap; skipping one that is absent produces a sandbox that +// Attesting costs one security-descriptor read per allow entry, on a path that +// is about to create a process, and it covers every reason a grant can be +// missing or insufficient rather than only recreation by the parent. +// +// Anything unprovable reads as "not applied". Re-applying an adequate grant is +// idempotent and cheap; skipping an inadequate one produces a sandbox that // silently cannot write. func windowsACLPlanStillApplied(plan WindowsACLPlan) bool { for _, entry := range plan.Entries { if entry.Action != WindowsACLAllowWrite { - // Only the allow grants are load-bearing for the child's ability to - // run. A missing DENY is a weaker boundary rather than a broken one, - // and re-applying the whole plan is what fixes either. + // Only the allow grants decide whether the child can run. A missing + // DENY is a weaker boundary rather than a broken one, and re-applying + // the whole plan is what fixes either. continue } if strings.TrimSpace(entry.Path) == "" || strings.TrimSpace(entry.Capability) == "" { continue } - if !windowsPathGrantsTrustee(entry.Path, entry.Capability) { + _, required, err := windowsACLAccess(entry.Action) + if err != nil { + return false + } + if !windowsPathCarriesGrant(entry.Path, entry.Capability, required) { return false } } return true } -// windowsPathGrantsTrustee reports whether path's DACL carries an allow entry -// for the given SID string. -func windowsPathGrantsTrustee(path, trustee string) bool { +// windowsPathCarriesGrant reports whether path's DACL gives trustee at least +// required, and whether that grant reaches descendants when path is a directory. +func windowsPathCarriesGrant(path, trustee string, required windows.ACCESS_MASK) bool { wanted, err := windows.StringToSid(trustee) if err != nil { return false } + info, err := os.Stat(path) + if err != nil { + return false + } + // windowsExplicitAccessEntries sets SUB_CONTAINERS_AND_OBJECTS_INHERIT on a + // directory, and that is what lets the child create TMP and cache entries + // underneath. A grant on the directory alone would not. + needInherit := uint8(0) + if info.IsDir() { + needInherit = windows.CONTAINER_INHERIT_ACE | windows.OBJECT_INHERIT_ACE + } + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) if err != nil { return false @@ -63,19 +87,33 @@ func windowsPathGrantsTrustee(path, trustee string) bool { if err != nil || dacl == nil { return false } + + var granted windows.ACCESS_MASK 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 { return false } ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(header)) - if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !sid.Equals(wanted) { continue } - sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) - if sid.Equals(wanted) { - return true + switch ace.Header.AceType { + case windows.ACCESS_DENIED_ACE_TYPE: + // A deny naming the capability itself takes precedence over any allow, + // so the grant cannot be proven adequate. Fail closed and re-apply. + if ace.Mask&required != 0 { + return false + } + case windows.ACCESS_ALLOWED_ACE_TYPE: + // Only ACEs that propagate count towards a directory's grant, since a + // non-inheriting one leaves descendants ungranted. + if ace.Header.AceFlags&needInherit != needInherit { + continue + } + granted |= ace.Mask } } - return false + return granted&required == required } diff --git a/internal/sandbox/windows_acl_attest_windows_test.go b/internal/sandbox/windows_acl_attest_windows_test.go index 0efc2aaf8..a1e8fac2c 100644 --- a/internal/sandbox/windows_acl_attest_windows_test.go +++ b/internal/sandbox/windows_acl_attest_windows_test.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "testing" + + "golang.org/x/sys/windows" ) // A PLAN HASH ATTESTS A PATHNAME, NOT THE DIRECTORY THAT ANSWERS TO IT. @@ -77,3 +79,72 @@ func TestPlanAttestationIgnoresDenyEntries(t *testing.T) { t.Error("a plan of deny entries alone reported as unapplied, which would re-apply on every command") } } + +// setCapabilityACE replaces path's DACL with a single allow entry for trustee, +// so a test can weaken a grant without removing the SID that names it. +func setCapabilityACE(t *testing.T, path, trustee string, mask windows.ACCESS_MASK, inheritance uint32) { + t.Helper() + sid, err := windows.StringToSid(trustee) + if err != nil { + t.Fatal(err) + } + dacl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{ + AccessPermissions: mask, + AccessMode: windows.GRANT_ACCESS, + Inheritance: inheritance, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + }}, nil) + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Fatal(err) + } +} + +// PRESENCE OF THE SID IS NOT THE CONTRACT. +// +// What the restricted child needs is the grant windowsACLAccess creates, and on +// a directory it needs to reach descendants. An ACE that still names the +// capability but has been reduced to a read-only mask, or that no longer +// propagates, leaves a runtime root that attests as healthy and then returns +// ACCESS_DENIED on the first write into TMP or a cache: exactly the silent +// unusable runtime the attestation exists to eliminate. +func TestPlanAttestationRejectsAWeakenedCapabilityACE(t *testing.T) { + const capability = "S-1-5-32-546" + full := windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE) + + for _, testCase := range []struct { + name string + mask windows.ACCESS_MASK + inheritance uint32 + want bool + }{ + {"the grant the plan describes", full, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, true}, + {"reduced to read and execute", windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE), windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, false}, + {"reduced to metadata only", windows.FILE_READ_ATTRIBUTES, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, false}, + {"full mask that does not propagate", full, windows.NO_INHERITANCE, false}, + {"containers only, so files are ungranted", full, windows.SUB_CONTAINERS_ONLY_INHERIT, false}, + } { + t.Run(testCase.name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "runtime") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLAllowWrite, Path: root, Capability: capability}, + }} + setCapabilityACE(t, root, capability, testCase.mask, testCase.inheritance) + + if got := windowsACLPlanStillApplied(plan); got != testCase.want { + t.Errorf("attestation = %v, want %v; a capability SID is present either way, so only the effective grant separates these", + got, testCase.want) + } + }) + } +} From dcb28898733efdfbc69997cd504bb54e9f1a0b3f Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 16:00:41 +0530 Subject: [PATCH 25/38] fix(sandbox): attest the grant on both tiers, and roll back through one handle Three things the end-to-end pass over the runtime-root lifecycle turned up. The elevated tier attested its runtime root with a stamp file. That proves the directory was not removed and recreated under the same pathname and that setup provisioned it for this configuration, and it says nothing about whether the capability ACE still carries the permissions windowsACLAccess created or still reaches descendants. An ordinary file survives an ACL edit untouched, so an icacls reset, an inheritance change on a parent, or a security product rewriting the DACL leaves a valid stamp over a runtime root the restricted child cannot write. This is the finding already closed on the unelevated tier, still open on the tier that runs under the restricted token. It reads the descriptors now, and refuses with the action that fixes it rather than re-applying, because this tier cannot repeat an elevated provisioning. The grant check counted an INHERIT_ONLY allow ACE towards the directory's own grant. Such an ACE grants descendants and grants the directory nothing, so the child is still refused FILE_ADD_FILE on the runtime root while every inherit flag the check looks for is present: the propagation half of the grant taken as evidence for the whole of it. Rollback checked object identity through one open and then resolved the name again for the restore. The two opens are a check-then-use, and the fact established is not the fact the write depends on. One open now, with the identity read from the handle that gets mutated. --- .../sandbox/runtime_root_guard_helper_test.go | 19 ++++++ internal/sandbox/windows_acl_apply_windows.go | 57 ++++------------ internal/sandbox/windows_acl_attest_other.go | 12 ++++ .../windows_acl_attest_seam_windows.go | 7 ++ .../sandbox/windows_acl_attest_windows.go | 11 +++- .../windows_acl_attest_windows_test.go | 4 ++ .../windows_elevated_grant_attest_test.go | 65 +++++++++++++++++++ .../sandbox/windows_runtime_contract_test.go | 1 + .../windows_runtime_recorded_root_test.go | 1 + internal/sandbox/windows_setup.go | 36 ++++++++++ .../windows_setup_runtime_root_test.go | 1 + internal/sandbox/windows_setup_test.go | 2 + 12 files changed, 171 insertions(+), 45 deletions(-) create mode 100644 internal/sandbox/windows_acl_attest_other.go create mode 100644 internal/sandbox/windows_acl_attest_seam_windows.go create mode 100644 internal/sandbox/windows_elevated_grant_attest_test.go diff --git a/internal/sandbox/runtime_root_guard_helper_test.go b/internal/sandbox/runtime_root_guard_helper_test.go index 87246de3b..ce8af0817 100644 --- a/internal/sandbox/runtime_root_guard_helper_test.go +++ b/internal/sandbox/runtime_root_guard_helper_test.go @@ -14,3 +14,22 @@ func digestFor(workspaceRoot string, scope string) string { } var _ = testing.Verbose + +// assumeWindowsACLGrantsApplied holds the grant question constant. +// +// ValidateWindowsSandboxSetupMarker asks two independent things: whether setup's +// intent matches this command's, which is what the marker fields compare, and +// whether the objects still carry the grants, which reads real security +// descriptors. A test about the first would otherwise fail on Windows only, +// because it never applies an ACL and the descriptors honestly say so, while +// passing everywhere else. That platform-dependent result is worse than the +// stub: it hides the assertion the test was written for. +// +// The grant check has its own coverage, on both sides. Do not use this in a test +// that is about the grant. +func assumeWindowsACLGrantsApplied(t *testing.T) { + t.Helper() + previous := windowsACLPlanApplied + windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } + t.Cleanup(func() { windowsACLPlanApplied = previous }) +} diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index 70c8a96f7..a6cf622c8 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -405,12 +405,21 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { } continue } - current, err := windowsSnapshotIdentityNow(snapshot.Path) + // ONE OPEN, AND THE IDENTITY COMES FROM THE HANDLE THAT GETS MUTATED. + // + // Checking identity through a separate open and then resolving the name + // again for the restore proves nothing about the second handle: the two + // opens are a check-then-use, and the fact established (this NAME resolved + // to the object we changed) is not the fact the write depends on (this + // HANDLE is that object). Opening once and asking the handle who it is + // removes the window rather than narrowing it. + handle, _, err := openWindowsACLTarget(snapshot.Path) if err != nil { - errs = append(errs, fmt.Errorf("identify windows ACL target %s for rollback: %w", snapshot.Path, err)) + errs = append(errs, fmt.Errorf("re-open windows ACL target %s for rollback: %w", snapshot.Path, err)) continue } - if !snapshot.Identity.matches(current) { + if !snapshot.Identity.matches(windowsIdentityFromHandle(handle)) { + _ = windows.CloseHandle(handle) errs = append(errs, fmt.Errorf( "windows ACL target %s is no longer the object this setup modified; "+ "leaving the replacement untouched, and the original still carries this run's grant", @@ -419,19 +428,10 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { } dacl, _, err := snapshot.Descriptor.DACL() if err != nil { + _ = windows.CloseHandle(handle) 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. - 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 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)) } @@ -439,34 +439,3 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { } return errors.Join(errs...) } - -// windowsSnapshotIdentityNow opens the recorded path no-follow and reports which -// object currently answers to it. -// -// MINIMAL ACCESS, deliberately. openWindowsACLTarget asks for WRITE_DAC because -// it is about to rewrite a descriptor, and that is not available on every target -// the forward apply materialized once its own grants are in place: asking for it -// here turned an identity CHECK into an access failure and broke rollback for -// materialized directories. Identity only needs the attributes, so this asks for -// nothing more, and keeps FILE_FLAG_OPEN_REPARSE_POINT so a link substituted at -// the final component is opened as the link it is rather than followed. -func windowsSnapshotIdentityNow(path string) (windowsObjectIdentity, error) { - utf16Path, err := windows.UTF16PtrFromString(path) - if err != nil { - return windowsObjectIdentity{}, 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 { - return windowsObjectIdentity{}, err - } - defer windows.CloseHandle(handle) - return windowsIdentityFromHandle(handle), nil -} diff --git a/internal/sandbox/windows_acl_attest_other.go b/internal/sandbox/windows_acl_attest_other.go new file mode 100644 index 000000000..ed17f0c51 --- /dev/null +++ b/internal/sandbox/windows_acl_attest_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package sandbox + +// windowsACLPlanApplied reports whether the objects a plan names still carry the +// grants it describes. +// +// Off Windows there is no DACL to read and nothing that consumes one, so the +// marker's own comparisons are the whole answer. Declared here rather than +// guarded at the call site so the setup-to-command contract keeps one shape on +// every platform. +var windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } diff --git a/internal/sandbox/windows_acl_attest_seam_windows.go b/internal/sandbox/windows_acl_attest_seam_windows.go new file mode 100644 index 000000000..3773eb391 --- /dev/null +++ b/internal/sandbox/windows_acl_attest_seam_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package sandbox + +// windowsACLPlanApplied reads the real security descriptors. See +// windowsACLPlanStillApplied. +var windowsACLPlanApplied = windowsACLPlanStillApplied diff --git a/internal/sandbox/windows_acl_attest_windows.go b/internal/sandbox/windows_acl_attest_windows.go index debea9aa5..542920774 100644 --- a/internal/sandbox/windows_acl_attest_windows.go +++ b/internal/sandbox/windows_acl_attest_windows.go @@ -107,7 +107,16 @@ func windowsPathCarriesGrant(path, trustee string, required windows.ACCESS_MASK) return false } case windows.ACCESS_ALLOWED_ACE_TYPE: - // Only ACEs that propagate count towards a directory's grant, since a + // INHERIT_ONLY DOES NOT APPLY TO THE OBJECT ITSELF. An ACE carrying it + // grants descendants and grants the directory nothing, so the child can + // still be refused FILE_ADD_FILE on the runtime root while every other + // bit here looks satisfied. Checking the inherit flags alone would take + // the propagation half of the grant as evidence for the whole of it, + // which is the substitution this attestation exists to stop making. + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + // And only ACEs that propagate count towards a directory's grant, since a // non-inheriting one leaves descendants ungranted. if ace.Header.AceFlags&needInherit != needInherit { continue diff --git a/internal/sandbox/windows_acl_attest_windows_test.go b/internal/sandbox/windows_acl_attest_windows_test.go index a1e8fac2c..74a079289 100644 --- a/internal/sandbox/windows_acl_attest_windows_test.go +++ b/internal/sandbox/windows_acl_attest_windows_test.go @@ -130,6 +130,10 @@ func TestPlanAttestationRejectsAWeakenedCapabilityACE(t *testing.T) { {"reduced to metadata only", windows.FILE_READ_ATTRIBUTES, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, false}, {"full mask that does not propagate", full, windows.NO_INHERITANCE, false}, {"containers only, so files are ungranted", full, windows.SUB_CONTAINERS_ONLY_INHERIT, false}, + // INHERIT_ONLY grants descendants and grants the directory nothing, so the + // child is refused FILE_ADD_FILE on the runtime root while every inherit + // flag the check looks for is present. + {"granted to descendants but not to the directory", full, windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT | windows.INHERIT_ONLY_ACE, false}, } { t.Run(testCase.name, func(t *testing.T) { root := filepath.Join(t.TempDir(), "runtime") diff --git a/internal/sandbox/windows_elevated_grant_attest_test.go b/internal/sandbox/windows_elevated_grant_attest_test.go new file mode 100644 index 000000000..04d35dc7d --- /dev/null +++ b/internal/sandbox/windows_elevated_grant_attest_test.go @@ -0,0 +1,65 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// THE STAMP IS NOT THE GRANT. +// +// The elevated tier attested its runtime root with a stamp file, which proves +// the directory was not removed and recreated under the same pathname and that +// setup provisioned it for this configuration. An ordinary file survives an ACL +// edit untouched, so `icacls /reset`, an inheritance change on a parent, or a +// security product rewriting the DACL all leave a valid stamp over a runtime +// root the WRITE_RESTRICTED child cannot write. The marker fields agree, the +// stamp agrees, and the first write into TMP or a package cache returns +// ACCESS_DENIED with nothing having said why. +// +// The unelevated tier already reads the descriptors. This is the same question +// asked by the tier that runs under the restricted token after an elevated +// provisioning it cannot repeat, so it refuses and names the action rather than +// re-applying. +func TestTheElevatedMarkerRefusesAnUnappliedGrant(t *testing.T) { + config := WindowsSandboxSetupConfig{ + 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}, + }, + } + if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + // Everything the marker compares is identical in both halves below. Only the + // answer to "do the objects still carry the grants" differs. + previous := windowsACLPlanApplied + t.Cleanup(func() { windowsACLPlanApplied = previous }) + + windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } + if err := ValidateWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("SETUP INVALID: the marker does not validate even with the grants intact: %v", err) + } + + windowsACLPlanApplied = func(WindowsACLPlan) bool { return false } + err := ValidateWindowsSandboxSetupMarker(config) + if err == nil { + t.Fatal("a runtime root that no longer carries its grants validated, so the command launches into a sandbox it cannot write") + } + // Actionable, and about the right thing: an operator told "permission roots + // changed" goes looking at their policy for a problem that is not there. + for _, want := range []string{"permissions", "zero sandbox setup"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %v", want, err) + } + } + if strings.Contains(err.Error(), "out of date") { + t.Errorf("the refusal reads as a policy edit, which sends the operator to the wrong place: %v", err) + } +} diff --git a/internal/sandbox/windows_runtime_contract_test.go b/internal/sandbox/windows_runtime_contract_test.go index 2cabbfa40..4f1b96566 100644 --- a/internal/sandbox/windows_runtime_contract_test.go +++ b/internal/sandbox/windows_runtime_contract_test.go @@ -52,6 +52,7 @@ func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) { // no capability ACE at all: a WRITE_RESTRICTED token could not write TMP, // GOCACHE or anything else beneath it, with nothing saying why. func TestAnEvictedRuntimeRootInvalidatesTheMarker(t *testing.T) { + assumeWindowsACLGrantsApplied(t) config := runtimeRootTestConfig(t) selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) diff --git a/internal/sandbox/windows_runtime_recorded_root_test.go b/internal/sandbox/windows_runtime_recorded_root_test.go index a1e3c3b0c..e93763bb1 100644 --- a/internal/sandbox/windows_runtime_recorded_root_test.go +++ b/internal/sandbox/windows_runtime_recorded_root_test.go @@ -50,6 +50,7 @@ func blockCacheRuntimeRoot(t *testing.T, workspaceRoot string) (string, func()) // Setup's choice is recorded now and the command consumes it, so the two cannot // disagree no matter what changes in between. func TestTheCommandHonoursTheRootSetupActuallyProvisioned(t *testing.T) { + assumeWindowsACLGrantsApplied(t) config := runtimeRootTestConfig(t) workspace := config.WorkspaceRoots[0] t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 0c9d39f67..5f51bb291 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -373,6 +373,22 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { if err := validateWindowsSandboxRuntimeStamp(config.PermissionProfile, expected.ACLPlanHash); err != nil { return err } + // AND THE STAMP IS NOT THE GRANT EITHER. It proves the directory was not + // removed and recreated under the same pathname, and that setup provisioned it + // for this configuration. It says nothing about whether the capability ACE + // still carries the permissions windowsACLAccess created or still reaches + // descendants: an ordinary file survives an ACL edit untouched, so `icacls + // /reset`, an inheritance change on a parent, or a security product + // rewriting the DACL all leave a valid stamp over a runtime root the + // WRITE_RESTRICTED child cannot write. + // + // The unelevated tier already answers this by reading the descriptors. This + // tier is the one that runs under the restricted token after an ELEVATED + // provisioning it cannot repeat, so it refuses and names the action instead of + // re-applying. + if err := validateWindowsSandboxACLGrants(config); err != nil { + return err + } if actual.NetworkFilters != expected.NetworkFilters { return errors.New("windows sandbox setup is out of date: network enforcement plan changed") } @@ -990,3 +1006,23 @@ func windowsSandboxSelectedRuntimeRoot(profile PermissionProfile) string { } return strings.TrimSpace(profile.Runtime.Root) } + +// validateWindowsSandboxACLGrants reports whether the objects this command's ACL +// plan names still carry its allow grants. +// +// Separate from the marker comparisons above because it asks a different kind of +// question. Those compare what setup INTENDED with what this command wants, and +// both sides can agree perfectly while the filesystem has moved on underneath +// them. This one reads the security descriptors. +func validateWindowsSandboxACLGrants(config WindowsSandboxSetupConfig) error { + plan, err := BuildWindowsACLPlan(config.commandConfig()) + if err != nil { + return err + } + if windowsACLPlanApplied(plan) { + return nil + } + return errors.New("the sandbox directories for this workspace no longer carry the permissions setup granted them, " + + "so the sandboxed command would be unable to write its temp and cache directories — " + + "run `zero sandbox setup` from an elevated (Administrator) terminal") +} diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 84af36c95..44803d969 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -54,6 +54,7 @@ func runtimeRootTestConfig(t *testing.T) WindowsSandboxCommandConfig { // temp-derived one, and a marker that only accepts the preferred root bricks // every machine that falls back. func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { + assumeWindowsACLGrantsApplied(t) config := runtimeRootTestConfig(t) // The setup half, as BuildWindowsSandboxSetupArgs prepares it in the // operator's shell before the elevated helper ever runs. diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 0a3c6f044..16bb491cb 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -64,6 +64,7 @@ func TestRunWindowsSandboxSetupRejectsInvalidArgs(t *testing.T) { } func TestWindowsSandboxSetupMarkerRefreshesWhenProfileChanges(t *testing.T) { + assumeWindowsACLGrantsApplied(t) config := WindowsSandboxSetupConfig{ SandboxHome: t.TempDir(), CommandCWD: `C:\workspace`, @@ -98,6 +99,7 @@ func TestWindowsSandboxSetupMarkerRefreshesWhenProfileChanges(t *testing.T) { // approved network command (curl, git push, …). The per-command mode is enforced // at runtime by the token's SID set, not by which marker exists. func TestWindowsSandboxSetupMarkerValidatesBothNetworkModes(t *testing.T) { + assumeWindowsACLGrantsApplied(t) deny := WindowsSandboxSetupConfig{ SandboxHome: t.TempDir(), CommandCWD: `C:\workspace`, From caf293ccd076911b02bc12a1a6c1364641b5654c Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 16:23:52 +0530 Subject: [PATCH 26/38] fix(sandbox): put the grant attestation on the launch gate, not in the marker The grant check belongs beside the tier's other launch decision rather than inside ValidateWindowsSandboxSetupMarker. That function compares what setup intended with what this command wants, which is what its name asks and what every consumer of it expects; folding a security-descriptor read into it made `zero doctor` depend on real applied ACLs and report a freshly set-up machine unhealthy. Both tiers now attest in runWindowsSandboxCommand: the unelevated one reads the descriptors and re-applies, the restricted-token one reads them and refuses, because it cannot repeat an elevated provisioning. The refusal is covered through the runner rather than only through the function, since a check that reads correctly and is never called is the failure mode being avoided. --- .../sandbox/runtime_root_guard_helper_test.go | 19 ------ .../sandbox/windows_command_runner_windows.go | 13 +++- .../windows_elevated_grant_attest_test.go | 67 +++++++++++++------ .../windows_launch_gate_windows_test.go | 59 ++++++++++++++++ .../sandbox/windows_runtime_contract_test.go | 1 - .../windows_runtime_recorded_root_test.go | 1 - internal/sandbox/windows_setup.go | 39 +++++------ .../windows_setup_runtime_root_test.go | 1 - internal/sandbox/windows_setup_test.go | 2 - 9 files changed, 133 insertions(+), 69 deletions(-) create mode 100644 internal/sandbox/windows_launch_gate_windows_test.go diff --git a/internal/sandbox/runtime_root_guard_helper_test.go b/internal/sandbox/runtime_root_guard_helper_test.go index ce8af0817..87246de3b 100644 --- a/internal/sandbox/runtime_root_guard_helper_test.go +++ b/internal/sandbox/runtime_root_guard_helper_test.go @@ -14,22 +14,3 @@ func digestFor(workspaceRoot string, scope string) string { } var _ = testing.Verbose - -// assumeWindowsACLGrantsApplied holds the grant question constant. -// -// ValidateWindowsSandboxSetupMarker asks two independent things: whether setup's -// intent matches this command's, which is what the marker fields compare, and -// whether the objects still carry the grants, which reads real security -// descriptors. A test about the first would otherwise fail on Windows only, -// because it never applies an ACL and the descriptors honestly say so, while -// passing everywhere else. That platform-dependent result is worse than the -// stub: it hides the assertion the test was written for. -// -// The grant check has its own coverage, on both sides. Do not use this in a test -// that is about the grant. -func assumeWindowsACLGrantsApplied(t *testing.T) { - t.Helper() - previous := windowsACLPlanApplied - windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } - t.Cleanup(func() { windowsACLPlanApplied = previous }) -} diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 1c8e36060..f1bebb4d1 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -10,7 +10,18 @@ import ( func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int { switch config.SandboxLevel { case WindowsSandboxLevelRestrictedToken: - if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil { + setupConfig := WindowsSandboxSetupConfigFromCommand(config) + if err := ValidateWindowsSandboxSetupMarker(setupConfig); err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } + // The marker and the stamp are about intent and about the pathname. Whether + // the objects still carry the grant is a third question, and it is the one + // that decides whether this child can write its temp and cache directories. + // The unelevated tier below answers it by reading the descriptors and + // re-applying; this tier cannot repeat an elevated provisioning, so it + // refuses and names the action. + if err := ValidateWindowsSandboxLaunchGrants(setupConfig); err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) return 1 } diff --git a/internal/sandbox/windows_elevated_grant_attest_test.go b/internal/sandbox/windows_elevated_grant_attest_test.go index 04d35dc7d..565bb89f0 100644 --- a/internal/sandbox/windows_elevated_grant_attest_test.go +++ b/internal/sandbox/windows_elevated_grant_attest_test.go @@ -7,20 +7,21 @@ import ( // THE STAMP IS NOT THE GRANT. // -// The elevated tier attested its runtime root with a stamp file, which proves -// the directory was not removed and recreated under the same pathname and that -// setup provisioned it for this configuration. An ordinary file survives an ACL -// edit untouched, so `icacls /reset`, an inheritance change on a parent, or a +// The elevated tier attested its runtime root with the marker comparisons and a +// stamp file. Those prove that setup's intent matches this command's, and that +// the directory was not removed and recreated under the same pathname. An +// ordinary file answers the second by existing, and it survives an ACL edit +// untouched, so an `icacls /reset`, an inheritance change on a parent, or a // security product rewriting the DACL all leave a valid stamp over a runtime -// root the WRITE_RESTRICTED child cannot write. The marker fields agree, the -// stamp agrees, and the first write into TMP or a package cache returns -// ACCESS_DENIED with nothing having said why. +// root the WRITE_RESTRICTED child cannot write. The marker agrees, the stamp +// agrees, and the first write into TMP or a package cache returns ACCESS_DENIED +// with nothing having said why. // -// The unelevated tier already reads the descriptors. This is the same question -// asked by the tier that runs under the restricted token after an elevated -// provisioning it cannot repeat, so it refuses and names the action rather than -// re-applying. -func TestTheElevatedMarkerRefusesAnUnappliedGrant(t *testing.T) { +// This is the function runWindowsSandboxCommand calls for the restricted-token +// tier, beside the marker validation. The unelevated tier answers the same +// question by reading the descriptors and re-applying; this one cannot repeat an +// elevated provisioning, so it refuses and names the action. +func TestTheLaunchGateRefusesAnUnappliedGrant(t *testing.T) { config := WindowsSandboxSetupConfig{ SandboxHome: t.TempDir(), CommandCWD: `C:\workspace`, @@ -33,24 +34,19 @@ func TestTheElevatedMarkerRefusesAnUnappliedGrant(t *testing.T) { Network: NetworkPolicy{Mode: NetworkDeny}, }, } - if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { - t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) - } - // Everything the marker compares is identical in both halves below. Only the - // answer to "do the objects still carry the grants" differs. previous := windowsACLPlanApplied t.Cleanup(func() { windowsACLPlanApplied = previous }) windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } - if err := ValidateWindowsSandboxSetupMarker(config); err != nil { - t.Fatalf("SETUP INVALID: the marker does not validate even with the grants intact: %v", err) + if err := ValidateWindowsSandboxLaunchGrants(config); err != nil { + t.Fatalf("SETUP INVALID: the gate refuses even with the grants intact: %v", err) } windowsACLPlanApplied = func(WindowsACLPlan) bool { return false } - err := ValidateWindowsSandboxSetupMarker(config) + err := ValidateWindowsSandboxLaunchGrants(config) if err == nil { - t.Fatal("a runtime root that no longer carries its grants validated, so the command launches into a sandbox it cannot write") + t.Fatal("a runtime root that no longer carries its grants passed the launch gate, so the command starts into a sandbox it cannot write") } // Actionable, and about the right thing: an operator told "permission roots // changed" goes looking at their policy for a problem that is not there. @@ -63,3 +59,32 @@ func TestTheElevatedMarkerRefusesAnUnappliedGrant(t *testing.T) { t.Errorf("the refusal reads as a policy edit, which sends the operator to the wrong place: %v", err) } } + +// And the marker comparison stays a marker comparison. Folding the grant check +// into it would have made every consumer of the marker, including `zero doctor`, +// depend on real security descriptors, which is a different question from the +// one that function's name asks. +func TestTheMarkerValidationDoesNotReadDescriptors(t *testing.T) { + config := WindowsSandboxSetupConfig{ + 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}, + }, + } + if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + previous := windowsACLPlanApplied + t.Cleanup(func() { windowsACLPlanApplied = previous }) + windowsACLPlanApplied = func(WindowsACLPlan) bool { return false } + + if err := ValidateWindowsSandboxSetupMarker(config); err != nil { + t.Errorf("the marker comparison consulted the descriptors: %v", err) + } +} diff --git a/internal/sandbox/windows_launch_gate_windows_test.go b/internal/sandbox/windows_launch_gate_windows_test.go new file mode 100644 index 000000000..2d7125fa1 --- /dev/null +++ b/internal/sandbox/windows_launch_gate_windows_test.go @@ -0,0 +1,59 @@ +//go:build windows + +package sandbox + +import ( + "bytes" + "strings" + "testing" +) + +// THE GATE HAS TO BE ON THE PATH THAT LAUNCHES, NOT ONLY IN A FUNCTION. +// +// windows_elevated_grant_attest_test.go proves ValidateWindowsSandboxLaunchGrants +// answers correctly. It calls it directly, so it stays green if the call in +// runWindowsSandboxCommand is deleted, which is exactly how a check that reads +// correct becomes a check that never runs. This drives the runner. +func TestTheRestrictedTokenTierRefusesBeforeCreatingAToken(t *testing.T) { + home := t.TempDir() + config := WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo hi"}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: `C:\workspace`}}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + } + if _, err := WriteWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + + previous := windowsACLPlanApplied + t.Cleanup(func() { windowsACLPlanApplied = previous }) + + // With the grants intact the tier gets PAST both attestations. It fails later, + // on this machine, for reasons that have nothing to do with the gate, so the + // assertion is only that the refusal below is not what stopped it. + windowsACLPlanApplied = func(WindowsACLPlan) bool { return true } + var healthy bytes.Buffer + runWindowsSandboxCommand(config, &healthy) + if strings.Contains(healthy.String(), "no longer carry the permissions") { + t.Fatalf("SETUP INVALID: the gate refused with the grants intact: %s", healthy.String()) + } + + windowsACLPlanApplied = func(WindowsACLPlan) bool { return false } + var stderr bytes.Buffer + code := runWindowsSandboxCommand(config, &stderr) + if code == 0 { + t.Fatal("the runner launched into a sandbox whose directories no longer carry their grants") + } + if !strings.Contains(stderr.String(), "no longer carry the permissions") { + t.Errorf("the runner did not refuse for the missing grant; it stopped for something else: %s", stderr.String()) + } +} diff --git a/internal/sandbox/windows_runtime_contract_test.go b/internal/sandbox/windows_runtime_contract_test.go index 4f1b96566..2cabbfa40 100644 --- a/internal/sandbox/windows_runtime_contract_test.go +++ b/internal/sandbox/windows_runtime_contract_test.go @@ -52,7 +52,6 @@ func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) { // no capability ACE at all: a WRITE_RESTRICTED token could not write TMP, // GOCACHE or anything else beneath it, with nothing saying why. func TestAnEvictedRuntimeRootInvalidatesTheMarker(t *testing.T) { - assumeWindowsACLGrantsApplied(t) config := runtimeRootTestConfig(t) selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) diff --git a/internal/sandbox/windows_runtime_recorded_root_test.go b/internal/sandbox/windows_runtime_recorded_root_test.go index e93763bb1..a1e3c3b0c 100644 --- a/internal/sandbox/windows_runtime_recorded_root_test.go +++ b/internal/sandbox/windows_runtime_recorded_root_test.go @@ -50,7 +50,6 @@ func blockCacheRuntimeRoot(t *testing.T, workspaceRoot string) (string, func()) // Setup's choice is recorded now and the command consumes it, so the two cannot // disagree no matter what changes in between. func TestTheCommandHonoursTheRootSetupActuallyProvisioned(t *testing.T) { - assumeWindowsACLGrantsApplied(t) config := runtimeRootTestConfig(t) workspace := config.WorkspaceRoots[0] t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 5f51bb291..447e74257 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -373,22 +373,6 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { if err := validateWindowsSandboxRuntimeStamp(config.PermissionProfile, expected.ACLPlanHash); err != nil { return err } - // AND THE STAMP IS NOT THE GRANT EITHER. It proves the directory was not - // removed and recreated under the same pathname, and that setup provisioned it - // for this configuration. It says nothing about whether the capability ACE - // still carries the permissions windowsACLAccess created or still reaches - // descendants: an ordinary file survives an ACL edit untouched, so `icacls - // /reset`, an inheritance change on a parent, or a security product - // rewriting the DACL all leave a valid stamp over a runtime root the - // WRITE_RESTRICTED child cannot write. - // - // The unelevated tier already answers this by reading the descriptors. This - // tier is the one that runs under the restricted token after an ELEVATED - // provisioning it cannot repeat, so it refuses and names the action instead of - // re-applying. - if err := validateWindowsSandboxACLGrants(config); err != nil { - return err - } if actual.NetworkFilters != expected.NetworkFilters { return errors.New("windows sandbox setup is out of date: network enforcement plan changed") } @@ -1007,14 +991,23 @@ func windowsSandboxSelectedRuntimeRoot(profile PermissionProfile) string { return strings.TrimSpace(profile.Runtime.Root) } -// validateWindowsSandboxACLGrants reports whether the objects this command's ACL -// plan names still carry its allow grants. +// ValidateWindowsSandboxLaunchGrants reports whether the objects this command's +// ACL plan names still carry its allow grants. +// +// SEPARATE FROM THE MARKER, BECAUSE IT IS A DIFFERENT KIND OF QUESTION. The +// marker comparisons ask whether setup's intent matches this command's, and both +// sides can agree perfectly while the filesystem has moved on underneath them. +// The runtime stamp narrows it to "the directory was not removed and recreated +// under this pathname", which an ordinary file answers by existing: it survives +// an ACL edit untouched, so an `icacls /reset`, an inheritance change on a +// parent, or a security product rewriting the DACL all leave a valid stamp over +// a runtime root the WRITE_RESTRICTED child cannot write. // -// Separate from the marker comparisons above because it asks a different kind of -// question. Those compare what setup INTENDED with what this command wants, and -// both sides can agree perfectly while the filesystem has moved on underneath -// them. This one reads the security descriptors. -func validateWindowsSandboxACLGrants(config WindowsSandboxSetupConfig) error { +// This one reads the security descriptors, and it is a LAUNCH decision rather +// than a report: the elevated tier calls it beside its marker validation, which +// puts both tiers' attestation in the same place, one refusing because it cannot +// repeat an elevated provisioning and one re-applying because it can. +func ValidateWindowsSandboxLaunchGrants(config WindowsSandboxSetupConfig) error { plan, err := BuildWindowsACLPlan(config.commandConfig()) if err != nil { return err diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 44803d969..84af36c95 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -54,7 +54,6 @@ func runtimeRootTestConfig(t *testing.T) WindowsSandboxCommandConfig { // temp-derived one, and a marker that only accepts the preferred root bricks // every machine that falls back. func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { - assumeWindowsACLGrantsApplied(t) config := runtimeRootTestConfig(t) // The setup half, as BuildWindowsSandboxSetupArgs prepares it in the // operator's shell before the elevated helper ever runs. diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 16bb491cb..0a3c6f044 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -64,7 +64,6 @@ func TestRunWindowsSandboxSetupRejectsInvalidArgs(t *testing.T) { } func TestWindowsSandboxSetupMarkerRefreshesWhenProfileChanges(t *testing.T) { - assumeWindowsACLGrantsApplied(t) config := WindowsSandboxSetupConfig{ SandboxHome: t.TempDir(), CommandCWD: `C:\workspace`, @@ -99,7 +98,6 @@ func TestWindowsSandboxSetupMarkerRefreshesWhenProfileChanges(t *testing.T) { // approved network command (curl, git push, …). The per-command mode is enforced // at runtime by the token's SID set, not by which marker exists. func TestWindowsSandboxSetupMarkerValidatesBothNetworkModes(t *testing.T) { - assumeWindowsACLGrantsApplied(t) deny := WindowsSandboxSetupConfig{ SandboxHome: t.TempDir(), CommandCWD: `C:\workspace`, From 127d539c2a50f220e30c81c03833dcdbaff35865 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 21:56:18 +0530 Subject: [PATCH 27/38] fix(sandbox): one sandbox-home authority, and keep the tests out of the real cache Runtime preparation resolved the sandbox home from the ambient environment while Windows platform planning resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own spec.Env and hands that one to the runner for marker validation. A command that explicitly selects home B, while the parent still points at home A, pinned A's recorded root into the profile; the runner then loaded B's marker, saw a different root, and rejected the command as out of date even though setup for B was valid. The two homes need no different derivation rules to disagree, only different valid selections from the same preferred/fallback pair. Selection now takes the home the command asked for, and an empty one still resolves the ambient environment because that is the only authority a caller without command context has. The refusal of a root belonging to another workspace is unchanged. Separately, plan construction creates directories, which is easy to miss because it reads like naming: windowsSandboxProfileWithProvisionedRuntime provisions the root it selects, and the simulated-Windows tests run on every platform. Nothing redirected the cache, so the package wrote into the developer's real one. The machine this was found on had 64 runtime directories and 9115 orphaned lease files accumulated under the real runtime root. Redirected once for the package rather than per test, because the leak was in the default and any new test that builds a Windows plan would inherit it. --- internal/sandbox/main_test.go | 35 +++++++++ internal/sandbox/runner.go | 13 +++- .../sandbox/runtime_home_authority_test.go | 74 +++++++++++++++++++ internal/sandbox/runtime_root_guard_test.go | 2 +- internal/sandbox/runtime_state.go | 35 +++++++-- internal/sandbox/runtime_state_test.go | 8 +- .../sandbox/windows_runtime_contract_test.go | 6 +- .../windows_runtime_recorded_root_test.go | 10 +-- internal/sandbox/windows_setup.go | 2 +- .../windows_setup_runtime_root_test.go | 2 +- 10 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 internal/sandbox/main_test.go create mode 100644 internal/sandbox/runtime_home_authority_test.go diff --git a/internal/sandbox/main_test.go b/internal/sandbox/main_test.go new file mode 100644 index 000000000..d1ba53615 --- /dev/null +++ b/internal/sandbox/main_test.go @@ -0,0 +1,35 @@ +package sandbox + +import ( + "os" + "testing" +) + +// TestMain points the sandbox runtime's user-cache root at test-owned storage +// for the whole package. +// +// PLAN CONSTRUCTION CREATES DIRECTORIES, which is easy to miss because it reads +// like naming. windowsSandboxProfileWithProvisionedRuntime provisions the root +// it selects, and the simulated-Windows tests run on every platform, so a test +// that supplies an explicit child environment but leaves the cache alone writes +// into the developer's real one. Not hypothetical: the machine this was found on +// had accumulated thousands of entries under the real runtime root from exactly +// these runs. +// +// Done once for the package rather than per test, because the leak is in the +// DEFAULT: any new test that builds a Windows plan is affected unless its author +// remembers, and remembering is what failed here. A test that needs a specific +// cache root still overrides sandboxUserCacheDir itself. +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "zero-sandbox-testcache-") + if err != nil { + // Fail loudly rather than silently falling back to the real cache. + panic("sandbox tests: create the test cache root: " + err.Error()) + } + sandboxUserCacheDir = func() (string, error) { return root, nil } + + code := m.Run() + + _ = os.RemoveAll(root) + os.Exit(code) +} diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 8528e7e82..6b9e8a07d 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -189,7 +189,18 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) { } var runtimeCleanup func() if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled { - runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot) + // The home THIS command asked for. Windows planning resolves + // ZERO_WINDOWS_SANDBOX_HOME out of spec.Env and hands it to the runner for + // marker validation, so selection has to read the same environment or the + // two disagree about which marker describes the tree. See + // pinnedSandboxRuntimeRoot. + commandSandboxHome := "" + if spec.Env != nil { + if resolved, err := ResolveWindowsSandboxHome(envListToMap(spec.Env)); err == nil { + commandSandboxHome = resolved + } + } + runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot, commandSandboxHome) if runtimeErr != nil { return CommandPlan{}, runtimeErr } diff --git a/internal/sandbox/runtime_home_authority_test.go b/internal/sandbox/runtime_home_authority_test.go new file mode 100644 index 000000000..56e30f14b --- /dev/null +++ b/internal/sandbox/runtime_home_authority_test.go @@ -0,0 +1,74 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// writeRecordedRoot puts a setup marker naming root under sandboxHome. +func writeRecordedRoot(t *testing.T, sandboxHome, root string) { + t.Helper() + path := WindowsSandboxSetupMarkerPath(sandboxHome) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + blob, err := json.Marshal(WindowsSandboxSetupMarker{SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, RuntimeRoot: root}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, blob, 0o600); err != nil { + t.Fatal(err) + } +} + +// ONE COMMAND, ONE SANDBOX HOME. +// +// Runtime preparation happens before Windows platform planning, and it used to +// resolve the sandbox home from the AMBIENT environment while the planner +// resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own spec.Env and hands +// that one to the runner for marker validation. So a command that explicitly +// selects home B, while the parent process still points at home A, pinned A's +// recorded root into the profile; the runner then loaded B's marker, saw a +// different root, and rejected the command as out of date even though setup for +// B was valid. The two homes need no different derivation rules to disagree, +// only different valid selections from the same preferred/fallback pair. +func TestThePinnedRootComesFromTheCommandsOwnSandboxHome(t *testing.T) { + homeA := t.TempDir() + homeB := t.TempDir() + preferred := filepath.Join(t.TempDir(), "preferred") + fallback := filepath.Join(t.TempDir(), "fallback") + + writeRecordedRoot(t, homeA, preferred) + writeRecordedRoot(t, homeB, fallback) + + if got := pinnedSandboxRuntimeRoot(preferred, fallback, homeB); got != fallback { + t.Errorf("pinned %q for a command that selected home B, want %q: the ambient home decided instead of the command's", got, fallback) + } + if got := pinnedSandboxRuntimeRoot(preferred, fallback, homeA); got != preferred { + t.Errorf("pinned %q for home A, want %q", got, preferred) + } +} + +// A recorded root that this workspace could not select is still refused, which +// is the protection that stops one workspace's tree being pinned into another. +func TestARecordedRootFromAnotherWorkspaceIsStillRefused(t *testing.T) { + home := t.TempDir() + writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "someone-elses-tree")) + if got := pinnedSandboxRuntimeRoot(filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" { + t.Errorf("pinned %q, want none: it matches neither candidate this workspace derives", got) + } +} + +// No command context means the ambient environment is the only authority there +// is, so an empty home must still resolve rather than refusing outright. +func TestAnEmptyCommandHomeFallsBackToTheAmbientEnvironment(t *testing.T) { + home := t.TempDir() + t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home) + preferred := filepath.Join(t.TempDir(), "preferred") + writeRecordedRoot(t, home, preferred) + if got := pinnedSandboxRuntimeRoot(preferred, filepath.Join(t.TempDir(), "fallback"), ""); got != preferred { + t.Errorf("pinned %q with no command home, want the ambient one to decide (%q)", got, preferred) + } +} diff --git a/internal/sandbox/runtime_root_guard_test.go b/internal/sandbox/runtime_root_guard_test.go index 429eb2258..268691f30 100644 --- a/internal/sandbox/runtime_root_guard_test.go +++ b/internal/sandbox/runtime_root_guard_test.go @@ -190,7 +190,7 @@ func TestPreparingTheRuntimeRefusesALinkedRoot(t *testing.T) { } linkRuntimeComponent(t, root, target) - runtimeState, cleanup, err := prepareSandboxRuntime(canonical) + runtimeState, cleanup, err := prepareSandboxRuntime(canonical, "") if cleanup != nil { cleanup() } diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 9c7038066..ba5cad364 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -118,9 +118,9 @@ func runtimeRootWithinWorkspace(workspaceRoot string, root string) bool { } } -func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { +func prepareSandboxRuntime(workspaceRoot string, sandboxHome string) (SandboxRuntime, func(), error) { // One selection function, shared with setup. See selectSandboxRuntimeRoot. - root, lease, err := selectSandboxRuntimeRoot(workspaceRoot, true) + root, lease, err := selectSandboxRuntimeRoot(workspaceRoot, true, sandboxHome) if err != nil { return SandboxRuntime{}, nil, err } @@ -427,14 +427,33 @@ func canonicalSandboxWorkspaceRoot(root string) string { // runtime at another workspace's tree. A recorded root is only honoured when it // matches one of the two roots THIS workspace derives, which is also the only // pair the selections could ever have disagreed about. -func pinnedSandboxRuntimeRoot(preferred, fallback string) string { +// sandboxHome is the home THIS command asked for, not the one the parent +// process happens to be pointed at. +// +// TWO ENVIRONMENT AUTHORITIES IS ONE TOO MANY. Runtime preparation ran before +// Windows platform planning and resolved the home from the ambient environment, +// while the planner resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own +// spec.Env and hands THAT to the runner for marker validation. A request that +// selects home B while the parent still points at home A pinned A's recorded +// root into the profile, and the runner then loaded B's marker and rejected the +// command as out of date even though setup for B was perfectly valid. The two +// homes do not need different derivation rules to disagree, only different valid +// selections from the same preferred/fallback pair. +// +// Empty means no command context, so the ambient environment is the only +// authority there is and resolving it here is correct. +func pinnedSandboxRuntimeRoot(preferred, fallback, sandboxHome string) string { // No GOOS gate. The marker only exists where setup wrote one, so this is // already Windows-only in practice, and keeping the code path platform-neutral // means the setup-to-command contract is exercised on every CI runner instead // of only the Windows one. - home, err := ResolveWindowsSandboxHome(nil) - if err != nil { - return "" + home := strings.TrimSpace(sandboxHome) + if home == "" { + resolved, err := ResolveWindowsSandboxHome(nil) + if err != nil { + return "" + } + home = resolved } recorded := WindowsSandboxRecordedRuntimeRoot(home) if recorded == "" { @@ -452,7 +471,7 @@ func pinnedSandboxRuntimeRoot(preferred, fallback string) string { // on the command side and false during setup: setup is making the choice, so it // must not consult a record it is about to overwrite, or a single unlucky // relocation to the temp fallback would pin every future setup to temp. -func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool) (string, *sandboxRuntimeLease, error) { +func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool, sandboxHome string) (string, *sandboxRuntimeLease, error) { workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) if workspaceRoot == "" || workspaceRoot == "." { return "", nil, errors.New("sandbox runtime requires a workspace root") @@ -477,7 +496,7 @@ func selectSandboxRuntimeRoot(workspaceRoot string, honorRecorded bool) (string, // setup fixes it. if honorRecorded { fallbackRoot, _ := fallbackSandboxRuntimeRoot(workspaceRoot) - if pinned := pinnedSandboxRuntimeRoot(root, fallbackRoot); pinned != "" { + if pinned := pinnedSandboxRuntimeRoot(root, fallbackRoot, sandboxHome); pinned != "" { lease, leaseErr := prepareSandboxRuntimeLease(pinned) if leaseErr != nil { // NOT relocated. Relocating is what produced the permanent brick: diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index 2f71f4e13..f7883bef9 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -16,7 +16,7 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) { sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } t.Cleanup(func() { sandboxUserCacheDir = original }) - runtimeState, release, err := prepareSandboxRuntime(workspace) + runtimeState, release, err := prepareSandboxRuntime(workspace, "") if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } @@ -59,7 +59,7 @@ func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) { if err := os.Chtimes(expired, old, old); err != nil { t.Fatal(err) } - _, release, err := prepareSandboxRuntime(workspace) + _, release, err := prepareSandboxRuntime(workspace, "") if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } @@ -85,7 +85,7 @@ func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil } t.Cleanup(func() { sandboxUserCacheDir = original }) - runtimeState, release, err := prepareSandboxRuntime(workspace) + runtimeState, release, err := prepareSandboxRuntime(workspace, "") if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } @@ -120,7 +120,7 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) { sandboxRuntimeNow = originalNow }) - runtimeState, release, err := prepareSandboxRuntime(workspace) + runtimeState, release, err := prepareSandboxRuntime(workspace, "") if err != nil { t.Fatalf("prepareSandboxRuntime: %v", err) } diff --git a/internal/sandbox/windows_runtime_contract_test.go b/internal/sandbox/windows_runtime_contract_test.go index 2cabbfa40..888bb1a93 100644 --- a/internal/sandbox/windows_runtime_contract_test.go +++ b/internal/sandbox/windows_runtime_contract_test.go @@ -25,13 +25,13 @@ import ( func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) { config := runtimeRootTestConfig(t) - setupRoot, setupLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) + setupRoot, setupLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") if err != nil { t.Fatalf("selectSandboxRuntimeRoot (setup side): %v", err) } setupLease.release() - commandRoot, commandLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) + commandRoot, commandLease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") if err != nil { t.Fatalf("selectSandboxRuntimeRoot (command side): %v", err) } @@ -54,7 +54,7 @@ func TestSetupAndCommandSelectTheSameRuntimeRoot(t *testing.T) { func TestAnEvictedRuntimeRootInvalidatesTheMarker(t *testing.T) { config := runtimeRootTestConfig(t) - selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) + selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") if err != nil { t.Fatalf("selectSandboxRuntimeRoot: %v", err) } diff --git a/internal/sandbox/windows_runtime_recorded_root_test.go b/internal/sandbox/windows_runtime_recorded_root_test.go index a1e3c3b0c..eab395a02 100644 --- a/internal/sandbox/windows_runtime_recorded_root_test.go +++ b/internal/sandbox/windows_runtime_recorded_root_test.go @@ -58,7 +58,7 @@ func TestTheCommandHonoursTheRootSetupActuallyProvisioned(t *testing.T) { // Setup, with the cache root unusable. honorRecorded is false because setup // is the one making the choice. - setupRoot, setupLease, err := selectSandboxRuntimeRoot(workspace, false) + setupRoot, setupLease, err := selectSandboxRuntimeRoot(workspace, false, "") if err != nil { t.Fatalf("selectSandboxRuntimeRoot (setup): %v", err) } @@ -80,7 +80,7 @@ func TestTheCommandHonoursTheRootSetupActuallyProvisioned(t *testing.T) { // diverged. unblock() - commandRoot, commandLease, err := selectSandboxRuntimeRoot(workspace, true) + commandRoot, commandLease, err := selectSandboxRuntimeRoot(workspace, true, "") if err != nil { t.Fatalf("selectSandboxRuntimeRoot (command): %v", err) } @@ -114,7 +114,7 @@ func TestARootRecordedForAnotherWorkspaceIsNotHonoured(t *testing.T) { t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) } - selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) + selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") if err != nil { t.Fatalf("selectSandboxRuntimeRoot: %v", err) } @@ -134,7 +134,7 @@ func TestAnUnusableRecordedRootFailsInsteadOfRelocating(t *testing.T) { workspace := config.WorkspaceRoots[0] t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", config.SandboxHome) - recorded, lease, err := selectSandboxRuntimeRoot(workspace, false) + recorded, lease, err := selectSandboxRuntimeRoot(workspace, false, "") if err != nil { t.Fatalf("selectSandboxRuntimeRoot (setup): %v", err) } @@ -159,7 +159,7 @@ func TestAnUnusableRecordedRootFailsInsteadOfRelocating(t *testing.T) { } t.Cleanup(func() { _ = os.Remove(blocker) }) - selected, selectedLease, err := selectSandboxRuntimeRoot(workspace, true) + selected, selectedLease, err := selectSandboxRuntimeRoot(workspace, true, "") if err == nil { selectedLease.release() t.Fatalf("selection relocated to %s instead of reporting that the provisioned root is unusable", selected) diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 447e74257..67ad932b3 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -123,7 +123,7 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str // Failing now is failing before any ACL or marker state is persisted, so the // operator is left in a state a retry can get out of, and the message names // the step that actually failed. - selected, lease, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...), false) + selected, lease, selectErr := selectSandboxRuntimeRoot(firstNonEmpty(workspaceRoots...), false, sandboxHome) if selectErr != nil { return nil, fmt.Errorf("select the sandbox runtime root for setup: %w", selectErr) } diff --git a/internal/sandbox/windows_setup_runtime_root_test.go b/internal/sandbox/windows_setup_runtime_root_test.go index 84af36c95..aeec51b25 100644 --- a/internal/sandbox/windows_setup_runtime_root_test.go +++ b/internal/sandbox/windows_setup_runtime_root_test.go @@ -63,7 +63,7 @@ func TestWindowsSandboxSetupMarkerAcceptsRuntimeAugmentedCommand(t *testing.T) { // at validation, which was compensating for a disagreement rather than // removing it: whichever root the command selected, only one of them had ever // been provisioned or carried the capability ACE. - selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true) + selected, lease, err := selectSandboxRuntimeRoot(config.WorkspaceRoots[0], true, "") if err != nil { t.Fatalf("selectSandboxRuntimeRoot: %v", err) } From 95790e4b87c8b20c3876e4e0719d1d7a80c012ac Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 22:04:56 +0530 Subject: [PATCH 28/38] fix(sandbox): hold the runtime lease through setup, and bind compensation to the object The unelevated caller took a lease only to learn which root wins and released it at once, so nothing owned the selected root while the elevated helper provisioned the tree, applied the ACL and stamp, installed network state and wrote the marker. A command for another workspace scanning the same runtime parent excludes only its own current root, so it could take this root's cleanup lease and RemoveAll it mid-transaction, leaving setup to publish success for a pathname that was gone. The helper now holds a shared lease on the root it was handed, from before provisioning until after the marker write, and fails before any persistent state is written if it cannot take one. Compensation resolved pathnames again after the apply handles had closed, so a rename-aside plus an ordinary directory at the same name let it strip a stamp from the substitute, write another object's bytes onto it, and remove it as though this run had created it, while the original kept the grant and the stamp. The stamp snapshot and every created-directory record now carry the identity of the object they describe and refuse a replacement, reporting the original as residual instead. os.SameFile cannot express that on Windows. A Windows fileStat loads its volume serial and file index lazily, BY PATHNAME, at comparison time, so an identity captured before a replacement and compared after reports the substitute as the same object and the original as different: exactly backwards, and silently. Measured rather than reasoned about. runtimeDirIdentity reads the identity through a handle at capture time instead, with the Unix build using device and inode so both platforms follow one rule. --- .../runtime_compensation_identity_test.go | 109 ++++++++++++++++++ .../sandbox/runtime_dir_identity_other.go | 27 +++++ .../sandbox/runtime_dir_identity_windows.go | 45 ++++++++ .../sandbox/runtime_lease_ownership_test.go | 83 +++++++++++++ .../windows_runtime_root_rollback_test.go | 2 +- internal/sandbox/windows_setup.go | 69 +++++++++-- ...indows_setup_rollback_completeness_test.go | 10 +- internal/sandbox/windows_setup_windows.go | 25 ++++ 8 files changed, 355 insertions(+), 15 deletions(-) create mode 100644 internal/sandbox/runtime_compensation_identity_test.go create mode 100644 internal/sandbox/runtime_dir_identity_other.go create mode 100644 internal/sandbox/runtime_dir_identity_windows.go create mode 100644 internal/sandbox/runtime_lease_ownership_test.go diff --git a/internal/sandbox/runtime_compensation_identity_test.go b/internal/sandbox/runtime_compensation_identity_test.go new file mode 100644 index 000000000..5a42b3ef3 --- /dev/null +++ b/internal/sandbox/runtime_compensation_identity_test.go @@ -0,0 +1,109 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// COMPENSATION MUST PROVE IT HOLDS THE OBJECT IT CHANGED. +// +// The forward apply and its stamp go through one handle, so they are provably +// about one object. Compensation runs later, after those handles have closed, +// and used to resolve the pathname again. Rename the original aside, put an +// ordinary directory at the name, and a pathname-only undo strips a stamp from +// the substitute, or writes bytes snapshotted from another object onto it, and +// then removes it as though this run had created it, while the moved original +// keeps this run's grant and stamp. +func TestStampCompensationRefusesAReplacementDirectory(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "runtime-root") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + stampPath := windowsSandboxRuntimeStampPath(root) + if err := os.WriteFile(stampPath, []byte("previous-plan-hash"), 0o600); err != nil { + t.Fatal(err) + } + + snapshot := snapshotWindowsSandboxRuntimeStamp(root) + if !snapshot.existed { + t.Fatal("SETUP INVALID: the snapshot did not record the pre-existing stamp") + } + + // The original is moved aside and an ordinary directory takes the name. + moved := filepath.Join(parent, "moved-aside") + if err := os.Rename(root, moved); err != nil { + t.Skipf("cannot rename the runtime root on this filesystem: %v", err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + substituteStamp := filepath.Join(root, filepath.Base(stampPath)) + if err := os.WriteFile(substituteStamp, []byte("not-ours"), 0o600); err != nil { + t.Fatal(err) + } + + err := snapshot.restore() + if err == nil { + t.Fatal("compensation mutated a directory it never touched and reported success") + } + for _, want := range []string{"no longer the directory", "replacement"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the error does not say what was left behind (%q): %v", want, err) + } + } + if body, readErr := os.ReadFile(substituteStamp); readErr != nil || string(body) != "not-ours" { + t.Errorf("the substitute's stamp was overwritten: %q %v", string(body), readErr) + } + if body, readErr := os.ReadFile(filepath.Join(moved, filepath.Base(stampPath))); readErr != nil || string(body) != "previous-plan-hash" { + t.Errorf("the original lost its stamp: %q %v", string(body), readErr) + } +} + +// And the created-directory ledger applies the same rule, or the stamp check +// just moves the damage one line down. +func TestCreatedDirectoryCompensationRefusesAReplacement(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "created-root") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)} + + moved := filepath.Join(parent, "moved") + if err := os.Rename(root, moved); err != nil { + t.Skipf("cannot rename on this filesystem: %v", err) + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + + err := rollback.run() + if err == nil { + t.Fatal("a substitute directory was removed as though this run had created it") + } + if !strings.Contains(err.Error(), "no longer the directory this run created") { + t.Errorf("the error does not name the replacement: %v", err) + } + if _, statErr := os.Stat(root); statErr != nil { + t.Errorf("the substitute was removed: %v", statErr) + } +} + +// The ordinary case still cleans up, or the guard would be refusing everything. +func TestCompensationStillRemovesWhatThisRunCreated(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "mine") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)} + if err := rollback.run(); err != nil { + t.Fatalf("compensation refused a directory it really did create: %v", err) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Errorf("the directory this run created survived compensation: %v", err) + } +} diff --git a/internal/sandbox/runtime_dir_identity_other.go b/internal/sandbox/runtime_dir_identity_other.go new file mode 100644 index 000000000..798356d63 --- /dev/null +++ b/internal/sandbox/runtime_dir_identity_other.go @@ -0,0 +1,27 @@ +//go:build !windows + +package sandbox + +import ( + "fmt" + "os" + "syscall" +) + +// runtimeDirIdentity identifies the directory currently at path. +// +// device plus inode, read with Lstat so a link substituted at the final +// component is identified as the link rather than followed. The Windows build +// cannot use os.SameFile for this and explains why; here the same eager capture +// keeps both platforms on one rule. +func runtimeDirIdentity(path string) (string, bool) { + info, err := os.Lstat(path) + if err != nil { + return "", false + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", false + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino), true +} diff --git a/internal/sandbox/runtime_dir_identity_windows.go b/internal/sandbox/runtime_dir_identity_windows.go new file mode 100644 index 000000000..e9a15938c --- /dev/null +++ b/internal/sandbox/runtime_dir_identity_windows.go @@ -0,0 +1,45 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// runtimeDirIdentity identifies the directory currently at path, EAGERLY. +// +// os.SameFile cannot be used for this on Windows. A Windows fileStat loads its +// volume serial and file index lazily, by PATHNAME, at comparison time, so an +// identity captured before a replacement and compared afterwards reports the +// substitute as the same object and the original as a different one: exactly +// backwards, and silently. Measured, not reasoned about. +// +// Reading the identity through a handle at capture time removes the lazy step. +// Opened with FILE_FLAG_OPEN_REPARSE_POINT so a link substituted at the final +// component is identified as the link it is rather than followed. +func runtimeDirIdentity(path string) (string, bool) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", false + } + 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 { + return "", false + } + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return "", false + } + return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), true +} diff --git a/internal/sandbox/runtime_lease_ownership_test.go b/internal/sandbox/runtime_lease_ownership_test.go new file mode 100644 index 000000000..95cfd2a35 --- /dev/null +++ b/internal/sandbox/runtime_lease_ownership_test.go @@ -0,0 +1,83 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// A LEASE IS OWNERSHIP FOR A TRANSACTION, NOT A SELECTION PROBE. +// +// Setup took a lease only to learn which root won and released it at once, so +// nothing owned the selected root while the elevated helper provisioned the +// tree, applied the ACL and stamp, installed network state and wrote the marker. +// A command for another workspace scans the same runtime parent and excludes +// only its own current root, so it can take this root's cleanup lease and +// RemoveAll it mid-transaction; setup then publishes success for a pathname that +// is gone. +// +// This pins the mechanism the fix relies on: a held lease is what makes the +// cleanup's exclusive acquire fail. +func TestAHeldRuntimeLeaseStopsConcurrentCleanup(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "deadbeefdeadbeef") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + + lease, err := prepareSandboxRuntimeLease(root) + if err != nil { + t.Fatalf("acquire the runtime lease: %v", err) + } + removeSandboxRuntimeRootIfUnused(root) + if _, err := os.Stat(root); err != nil { + t.Fatalf("cleanup removed a root that setup was holding: %v", err) + } + lease.release() + + // And once nothing holds it, cleanup does its job: without this half the test + // would pass against a cleanup that never removes anything. + removeSandboxRuntimeRootIfUnused(root) + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("an unheld root survived cleanup: %v", err) + } +} + +// The lease is on the root the caller names, so holding one root does not +// protect a sibling: setup reserving its own selection must not stop the +// cleanup doing its work elsewhere. +func TestAHeldLeaseDoesNotProtectASiblingRoot(t *testing.T) { + parent := t.TempDir() + held := filepath.Join(parent, "aaaaaaaaaaaaaaaa") + sibling := filepath.Join(parent, "bbbbbbbbbbbbbbbb") + for _, dir := range []string{held, sibling} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + lease, err := prepareSandboxRuntimeLease(held) + if err != nil { + t.Fatal(err) + } + defer lease.release() + + removeSandboxRuntimeRootIfUnused(sibling) + if _, err := os.Stat(sibling); !os.IsNotExist(err) { + t.Errorf("holding one root blocked cleanup of an unrelated sibling: %v", err) + } + if _, err := os.Stat(held); err != nil { + t.Errorf("the held root was removed: %v", err) + } +} + +// createdRuntimeDirsForTest builds identity-bound rollback records for paths a +// test made itself, so a test can express "these are the directories this run +// created" without restating the identity capture. +func createdRuntimeDirsForTest(paths ...string) []windowsCreatedRuntimeDir { + records := make([]windowsCreatedRuntimeDir, 0, len(paths)) + for _, path := range paths { + identity, _ := runtimeDirIdentity(path) + records = append(records, windowsCreatedRuntimeDir{path: path, identity: identity}) + } + return records +} diff --git a/internal/sandbox/windows_runtime_root_rollback_test.go b/internal/sandbox/windows_runtime_root_rollback_test.go index eac710a1c..8da640336 100644 --- a/internal/sandbox/windows_runtime_root_rollback_test.go +++ b/internal/sandbox/windows_runtime_root_rollback_test.go @@ -38,7 +38,7 @@ func TestRuntimeRootProvisioningRecordsOnlyWhatItCreated(t *testing.T) { target, } for index := range want { - if created[index] != want[index] { + if created[index].path != want[index] { t.Fatalf("recorded %v, want %v", created, want) } } diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 67ad932b3..afb6ab5f8 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -595,7 +595,13 @@ func windowsSandboxProfileWithRuntime(profile PermissionProfile, workspaceRoots // it had made. type windowsRuntimeRootRollback struct { // created is in creation order, outermost first, so undo walks it backwards. - created []string + // + // Identity-bound, not pathname-bound. Compensation runs after the apply + // handles have closed, so resolving these names again can reach a different + // object: rename the original aside, drop an ordinary directory in its place, + // and a pathname-only undo removes the substitute while the original keeps + // this run's grant and stamp. + created []windowsCreatedRuntimeDir // stamp is the runtime stamp's state before this run touched it. // // The stamp is the one artifact setup writes INSIDE the runtime root, and it @@ -607,6 +613,13 @@ type windowsRuntimeRootRollback struct { stamp windowsSandboxStampSnapshot } +// windowsCreatedRuntimeDir is one directory this run made, remembered by the +// object it was rather than by the name it had. +type windowsCreatedRuntimeDir struct { + path string + identity string +} + // windowsSandboxStampSnapshot records the runtime stamp as it was before setup // overwrote it, so a failed run restores rather than deletes. // @@ -619,6 +632,11 @@ type windowsSandboxStampSnapshot struct { path string prior []byte existed bool + // root and rootIdentity identify the DIRECTORY the stamp lives in, captured + // when the snapshot was taken. The stamp file itself may not exist yet, so the + // directory is the object whose replacement this has to detect. + root string + rootIdentity string } func snapshotWindowsSandboxRuntimeStamp(root string) windowsSandboxStampSnapshot { @@ -627,18 +645,34 @@ func snapshotWindowsSandboxRuntimeStamp(root string) windowsSandboxStampSnapshot return windowsSandboxStampSnapshot{} } path := windowsSandboxRuntimeStampPath(root) + rootIdentity, _ := runtimeDirIdentity(root) prior, err := os.ReadFile(path) if err != nil { // Absent, or unreadable and therefore not something to put back. - return windowsSandboxStampSnapshot{path: path} + return windowsSandboxStampSnapshot{path: path, root: root, rootIdentity: rootIdentity} } - return windowsSandboxStampSnapshot{path: path, prior: prior, existed: true} + return windowsSandboxStampSnapshot{path: path, prior: prior, existed: true, root: root, rootIdentity: rootIdentity} } func (snapshot windowsSandboxStampSnapshot) restore() error { if snapshot.path == "" { return nil } + // THE NAME IS NOT THE OBJECT ONCE THE APPLY HANDLES HAVE CLOSED. Removing a + // stamp from, or writing one onto, whatever now answers to this path can + // mutate a directory this run never touched, while the original keeps the + // grant and the stamp. Leave the substitute alone and say what was left + // behind. + if snapshot.rootIdentity != "" { + current, ok := runtimeDirIdentity(snapshot.root) + if !ok { + return fmt.Errorf("identify the sandbox runtime root %s for stamp compensation", snapshot.root) + } + if current != snapshot.rootIdentity { + return fmt.Errorf("sandbox runtime root %s is no longer the directory this setup stamped; "+ + "leaving the replacement untouched, and the original still carries this run's stamp", snapshot.root) + } + } if !snapshot.existed { if err := os.Remove(snapshot.path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) @@ -666,9 +700,23 @@ func (rollback windowsRuntimeRootRollback) run() error { errs = append(errs, err) } for index := len(rollback.created) - 1; index >= 0; index-- { - path := rollback.created[index] - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - errs = append(errs, fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err)) + entry := rollback.created[index] + // Same rule as the stamp: prove this is the directory we made before + // removing it. A substitute at the same name belongs to whoever put it + // there. + current, ok := runtimeDirIdentity(entry.path) + if !ok { + // Gone already, or unreadable: either way there is nothing here this + // run can prove it created. + continue + } + if entry.identity != "" && current != entry.identity { + errs = append(errs, fmt.Errorf("sandbox runtime root %s is no longer the directory this run created; "+ + "leaving the replacement in place", entry.path)) + continue + } + if err := os.Remove(entry.path); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("remove sandbox runtime root %s created by this run: %w", entry.path, err)) } } return errors.Join(errs...) @@ -751,7 +799,7 @@ func refuseReparsedRuntimeAncestors(root string) error { return nil } -func createRuntimeDirRecording(root string) ([]string, error) { +func createRuntimeDirRecording(root string) ([]windowsCreatedRuntimeDir, error) { if strings.TrimSpace(root) == "" { return nil, nil } @@ -788,7 +836,7 @@ func createRuntimeDirRecording(root string) ([]string, error) { } current = parent } - var created []string + var created []windowsCreatedRuntimeDir for index := len(missing) - 1; index >= 0; index-- { if err := os.Mkdir(missing[index], 0o700); err != nil { if os.IsExist(err) { @@ -797,7 +845,10 @@ func createRuntimeDirRecording(root string) ([]string, error) { } return created, fmt.Errorf("create sandbox runtime root %s: %w", missing[index], err) } - created = append(created, missing[index]) + // Identified immediately after creating it, so compensation can prove it is + // still the same object rather than trusting the name. + identity, _ := runtimeDirIdentity(missing[index]) + created = append(created, windowsCreatedRuntimeDir{path: missing[index], identity: identity}) } // Re-checked after creation. If an ancestor was swapped for a junction while // we were creating, the leaf we just made is in the wrong tree, and granting diff --git a/internal/sandbox/windows_setup_rollback_completeness_test.go b/internal/sandbox/windows_setup_rollback_completeness_test.go index 1609f2ee3..457407496 100644 --- a/internal/sandbox/windows_setup_rollback_completeness_test.go +++ b/internal/sandbox/windows_setup_rollback_completeness_test.go @@ -30,12 +30,12 @@ func TestRollbackRemovesTheStampItWroteAndThenTheRoot(t *testing.T) { } rollback := windowsRuntimeRootRollback{ - created: []string{ + created: createdRuntimeDirsForTest( filepath.Join(parent, "zero"), filepath.Join(parent, "zero", "runtime"), filepath.Join(parent, "zero", "runtime", "v1"), root, - }, + ), stamp: snapshot, } if err := rollback.run(); err != nil { @@ -85,7 +85,7 @@ func TestRollbackRefusesToRemoveWhatItDidNotCreate(t *testing.T) { t.Fatalf("seed the pre-existing file: %v", err) } - if err := (windowsRuntimeRootRollback{created: []string{root}}).run(); err == nil { + if err := (windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)}).run(); err == nil { t.Error("a non-empty directory was removed without complaint") } if _, err := os.Stat(theirs); err != nil { @@ -109,7 +109,7 @@ func TestRollbackContinuesAfterACompensationFails(t *testing.T) { t.Fatalf("seed the blocker: %v", err) } rollback := windowsRuntimeRootRollback{ - created: []string{filepath.Join(parent, "zero"), root}, + created: createdRuntimeDirsForTest(filepath.Join(parent, "zero"), root), stamp: windowsSandboxStampSnapshot{path: filepath.Join(broken, "stamp"), prior: []byte("x"), existed: true}, } @@ -143,7 +143,7 @@ func TestEveryCompensationRunsWhenTheACLRollbackFails(t *testing.T) { err := runWindowsSandboxSetupCompensations( errors.New("the setup failure"), func() error { aclCalled = true; return errors.New("acl restore exploded") }, - windowsRuntimeRootRollback{created: []string{filepath.Join(parent, "zero"), root}}, + windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(filepath.Join(parent, "zero"), root)}, ) if !aclCalled { t.Fatal("the ACL rollback was never attempted") diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 6e6bb75ea..d2fa257e6 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -18,6 +18,31 @@ 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 } + // HOLD THE SELECTED ROOT FOR THE WHOLE TRANSACTION. + // + // The unelevated caller took a lease only to learn which root wins and released + // it immediately, so nothing owned that root while this process provisions the + // tree, applies the ACL and stamp, installs network state and writes the + // marker. A command for another workspace scanning the same runtime parent + // excludes only ITS own current root, so it can take this root's cleanup lease + // and RemoveAll it. In the damaging ordering cleanup selects the root before + // setup refreshes its mtime and removes it after the stamp handle closes but + // before the marker is published, so setup reports success for a pathname that + // is gone or delete-pending and the next command finds no stamp on what it + // selected. + // + // A shared lease is what the cleanup's exclusive acquire fails against, and it + // is released when this function returns, which is after the marker write. If + // it cannot be taken, fail here: that is before any ACL, network or marker + // state is persisted, so a retry can still get the operator out. + if root := strings.TrimSpace(windowsSandboxSelectedRuntimeRoot(config.PermissionProfile)); root != "" { + lease, leaseErr := prepareSandboxRuntimeLease(root) + if leaseErr != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": reserve the sandbox runtime root "+root+" for setup: "+leaseErr.Error()) + return 1 + } + defer lease.release() + } // Provisions the runtime candidate roots, then builds the plan that grants // them. One call because a granted-but-absent write root fails the whole apply. plan, runtimeRollback, err := buildWindowsSandboxSetupACLPlan(config) From d44f852bd6e9803f347f5cef782472e40d9f02d1 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Thu, 27 Aug 2026 22:21:26 +0530 Subject: [PATCH 29/38] test(sandbox): give the lease tests the runtime root shape production uses Owned depth is the fixed names plus the digest, so a root placed directly in t.TempDir() put an owned component on /tmp. The Unix ownership guard then correctly refused it, because /tmp belongs to root and the test runs as an ordinary user. That is the guard working rather than an environment problem, and it only appears off Windows: both ubuntu Smoke and Zero Review failed on it while the Windows run was green. The roots are built as /zero/runtime/v1/ now, so every component the alias and ownership walk inspects was created by the test. --- .../sandbox/runtime_lease_ownership_test.go | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/internal/sandbox/runtime_lease_ownership_test.go b/internal/sandbox/runtime_lease_ownership_test.go index 95cfd2a35..bc4d958ba 100644 --- a/internal/sandbox/runtime_lease_ownership_test.go +++ b/internal/sandbox/runtime_lease_ownership_test.go @@ -6,6 +6,23 @@ import ( "testing" ) +// runtimeRootUnderTest builds a root with the shape production uses, so the +// components refuseAliasedRuntimeComponents walks stay inside test-owned +// storage. +// +// Owned depth is the fixed names plus the digest, so a root placed directly in +// t.TempDir() puts an owned component on /tmp, which the Unix ownership guard +// correctly refuses because /tmp belongs to root. That is the guard working, not +// a test environment problem, and it only shows up off Windows. +func runtimeRootUnderTest(t *testing.T, leaf string) string { + t.Helper() + root := filepath.Join(t.TempDir(), "zero", "runtime", "v1", leaf) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + return root +} + // A LEASE IS OWNERSHIP FOR A TRANSACTION, NOT A SELECTION PROBE. // // Setup took a lease only to learn which root won and released it at once, so @@ -19,11 +36,7 @@ import ( // This pins the mechanism the fix relies on: a held lease is what makes the // cleanup's exclusive acquire fail. func TestAHeldRuntimeLeaseStopsConcurrentCleanup(t *testing.T) { - parent := t.TempDir() - root := filepath.Join(parent, "deadbeefdeadbeef") - if err := os.MkdirAll(root, 0o700); err != nil { - t.Fatal(err) - } + root := runtimeRootUnderTest(t, "deadbeefdeadbeef") lease, err := prepareSandboxRuntimeLease(root) if err != nil { @@ -47,13 +60,10 @@ func TestAHeldRuntimeLeaseStopsConcurrentCleanup(t *testing.T) { // protect a sibling: setup reserving its own selection must not stop the // cleanup doing its work elsewhere. func TestAHeldLeaseDoesNotProtectASiblingRoot(t *testing.T) { - parent := t.TempDir() - held := filepath.Join(parent, "aaaaaaaaaaaaaaaa") - sibling := filepath.Join(parent, "bbbbbbbbbbbbbbbb") - for _, dir := range []string{held, sibling} { - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } + held := runtimeRootUnderTest(t, "aaaaaaaaaaaaaaaa") + sibling := filepath.Join(filepath.Dir(held), "bbbbbbbbbbbbbbbb") + if err := os.MkdirAll(sibling, 0o700); err != nil { + t.Fatal(err) } lease, err := prepareSandboxRuntimeLease(held) if err != nil { From b7c1425c767a8a67209cb5a7e7e8123160e32977 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 12:38:09 +0530 Subject: [PATCH 30/38] fix(sandbox): grant the stamp to the identity that validates it The stamp's DACL named WinCreatorOwnerSid at GENERIC_ALL. SetSecurityInfo does substitute that placeholder even in a NO_INHERITANCE ACE, so a concrete SID did land in the ACE, measured again on this head: ACE[0] carries the user SID at mask 0x1f01ff and the readback succeeds. The mechanism was never the problem. The identity it named was. It is whoever ran setup, and setup runs elevated. When elevation comes from a different administrator account than the one that later runs the command or zero doctor, the reader matches no ACE, and a reader named in no ACE gets Access is denied on os.ReadFile. A successful setup then hands over an attestation the launch gate and doctor cannot open. Resolve the reader from the runtime root the stamp is created in, through the directory handle rather than a pathname, so the grant is bound to the install rather than to the elevation. Give it read only: nothing outside setup and repair should be able to rewrite an attestation about the tree, and the stamp write still succeeds because the handle was opened GENERIC_WRITE before the DACL was applied. A reader that could not be resolved is refused rather than protected with a DACL naming nobody. --- .../sandbox/windows_runtime_tail_windows.go | 73 +++++++-- .../windows_stamp_reader_windows_test.go | 143 ++++++++++++++++++ 2 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 internal/sandbox/windows_stamp_reader_windows_test.go diff --git a/internal/sandbox/windows_runtime_tail_windows.go b/internal/sandbox/windows_runtime_tail_windows.go index 7946055f8..ff38a5406 100644 --- a/internal/sandbox/windows_runtime_tail_windows.go +++ b/internal/sandbox/windows_runtime_tail_windows.go @@ -178,9 +178,13 @@ func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, planHas } file := os.NewFile(uintptr(handle), windowsSandboxRuntimeStampName) defer file.Close() + reader, err := windowsRuntimeStampReader(directory) + if err != nil { + return err + } // PROTECTED BEFORE ANYTHING IS WRITTEN, because the stamp lives inside the // tree it attests. See protectWindowsRuntimeStamp. - if err := protectWindowsRuntimeStamp(windows.Handle(file.Fd())); err != nil { + if err := protectWindowsRuntimeStamp(windows.Handle(file.Fd()), reader); err != nil { return err } if _, err := file.WriteString(planHash); err != nil { @@ -189,6 +193,29 @@ func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, planHas return nil } +// windowsRuntimeStampReader resolves the identity that has to read the stamp +// AFTER setup returns, from the runtime root the stamp is created in. +// +// Taken from the DIRECTORY HANDLE rather than the setup token, because the +// question is not who elevated but whose install this is. Setup runs elevated +// and may run as a different administrator account than the one that later runs +// the command or zero doctor; the runtime root lives under the ordinary user +// profile and its owner is stable across that boundary. +func windowsRuntimeStampReader(directory windows.Handle) (*windows.SID, error) { + descriptor, err := windows.GetSecurityInfo(directory, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return nil, fmt.Errorf("read the sandbox runtime root owner: %w", err) + } + owner, _, err := descriptor.Owner() + if err != nil { + return nil, fmt.Errorf("read the sandbox runtime root owner: %w", err) + } + if owner == nil { + return nil, fmt.Errorf("read the sandbox runtime root owner: the descriptor carried none") + } + return owner, nil +} + // protectWindowsRuntimeStamp gives the stamp its own DACL, excluding the // capability SID the sandboxed command runs with. // @@ -209,10 +236,9 @@ func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, planHas // // PROTECTED, not merely explicit: without SE_DACL_PROTECTED the inherited // capability ACE stays in the DACL alongside whatever is set here. -func protectWindowsRuntimeStamp(handle windows.Handle) error { - owner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid) - if err != nil { - return fmt.Errorf("resolve owner SID for the sandbox runtime stamp: %w", err) +func protectWindowsRuntimeStamp(handle windows.Handle, reader *windows.SID) error { + if reader == nil { + return fmt.Errorf("resolve the reader SID for the sandbox runtime stamp: no identity was supplied") } system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) if err != nil { @@ -224,8 +250,33 @@ func protectWindowsRuntimeStamp(handle windows.Handle) error { } // Setup writes it, doctor and the elevated command read it; nothing else // needs to reach it, and the capability SID is deliberately absent. - entries := make([]windows.EXPLICIT_ACCESS, 0, 3) - for _, sid := range []*windows.SID{owner, system, administrators} { + // + // The reader is named EXPLICITLY and gets READ ONLY. It used to be + // WinCreatorOwnerSid at GENERIC_ALL. SetSecurityInfo does substitute that + // placeholder even in a NO_INHERITANCE ACE, so the resulting ACE did name a + // concrete SID, but the one it named was whoever happened to run setup. When + // setup is elevated by a different administrator account than the one that + // later runs the command or zero doctor, the reader matches no ACE and + // os.ReadFile on the stamp returns Access is denied, so a successful setup + // hands over an unreadable attestation. Resolving the identity from the + // runtime root the stamp lives in binds it to the install rather than to the + // elevation. + // + // Read only, because nothing outside setup and repair should be able to + // rewrite an attestation about the tree. The write below still succeeds: the + // handle was opened GENERIC_WRITE before this DACL was applied, and Windows + // checks access at open time. + entries := []windows.EXPLICIT_ACCESS{{ + AccessPermissions: windows.FILE_GENERIC_READ, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(reader), + }, + }} + for _, sid := range []*windows.SID{system, administrators} { entries = append(entries, windows.EXPLICIT_ACCESS{ AccessPermissions: windows.GENERIC_ALL, AccessMode: windows.SET_ACCESS, @@ -253,7 +304,7 @@ func protectWindowsRuntimeStamp(handle windows.Handle) error { } func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error { - directory, err := openWindowsRuntimeTailDirectory(root, windows.FILE_TRAVERSE|windowsFileAddFile|windows.SYNCHRONIZE) + directory, err := openWindowsRuntimeTailDirectory(root, windows.FILE_TRAVERSE|windowsFileAddFile|windows.READ_CONTROL|windows.SYNCHRONIZE) if err != nil { return err } @@ -290,10 +341,14 @@ func writeWindowsRuntimeStampThroughHandle(root string, planHash string) error { } file := os.NewFile(uintptr(handle), windowsSandboxRuntimeStampName) defer file.Close() + reader, err := windowsRuntimeStampReader(directory) + if err != nil { + return err + } // Both writers protect. This one is the fallback path, and a stamp written // here would inherit the same capability grant as one written through the // ACL handle. - if err := protectWindowsRuntimeStamp(windows.Handle(file.Fd())); err != nil { + if err := protectWindowsRuntimeStamp(windows.Handle(file.Fd()), reader); err != nil { return err } if _, err := file.WriteString(planHash); err != nil { diff --git a/internal/sandbox/windows_stamp_reader_windows_test.go b/internal/sandbox/windows_stamp_reader_windows_test.go new file mode 100644 index 000000000..e12e8eb48 --- /dev/null +++ b/internal/sandbox/windows_stamp_reader_windows_test.go @@ -0,0 +1,143 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// stampACEMask returns the access mask the stamp's DACL grants sid, and whether +// an ACE for it exists at all. +func stampACEMask(t *testing.T, path string, sid *windows.SID) (uint32, bool) { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the stamp security descriptor: %v", err) + } + dacl, _, err := descriptor.DACL() + if err != nil || dacl == nil { + t.Fatalf("read the stamp DACL: %v", err) + } + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + t.Fatalf("read ACE %d: %v", index, err) + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + if (*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(sid) { + return uint32(ace.Mask), true + } + } + return 0, false +} + +func ownerOfDirectory(t *testing.T, path string) *windows.SID { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read the runtime root owner: %v", err) + } + owner, _, err := descriptor.Owner() + if err != nil { + t.Fatalf("read the runtime root owner: %v", err) + } + return owner +} + +// THE STAMP HAS TO BE READABLE BY THE IDENTITY THAT VALIDATES IT LATER, AND +// WRITABLE BY NEITHER IT NOR THE SANDBOX. +// +// Setup writes the stamp elevated; runWindowsSandboxCommand and zero doctor read +// it afterwards from an ordinary shell. The DACL used to name WinCreatorOwnerSid +// at GENERIC_ALL. SetSecurityInfo does substitute that placeholder even in a +// NO_INHERITANCE ACE, so a concrete SID did land in the ACE, but the one it +// named was whoever ran setup. Elevation by a different administrator account +// therefore produced a stamp the ordinary reader could not open, and a reader +// named in no ACE gets Access is denied, so a successful setup handed over an +// unreadable attestation. +// +// Resolving the reader from the runtime root binds the grant to the install +// rather than to the elevation, and read-only keeps the attestation out of reach +// of everything but setup and repair. +func TestStampGrantsTheRuntimeRootOwnerReadOnly(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + stamp := windowsSandboxRuntimeStampPath(root) + owner := ownerOfDirectory(t, root) + + mask, present := stampACEMask(t, stamp, owner) + if !present { + t.Fatalf("the stamp names no ACE for the runtime root owner %s, so the post-setup reader is locked out", owner) + } + + // Read, because doctor and the launch gate have to open it. + const readBits = windows.FILE_READ_DATA + if mask&readBits == 0 { + t.Errorf("the runtime root owner cannot read the stamp (mask 0x%08x)", mask) + } + // Not write. This is the half that fails against the old GENERIC_ALL grant: + // an ordinary user able to rewrite the attestation about their own sandbox + // setup defeats the point of protecting it. + const writeBits = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | windows.WRITE_DAC | windows.WRITE_OWNER + if mask&writeBits != 0 { + t.Errorf("the runtime root owner can rewrite the stamp (mask 0x%08x, write bits 0x%08x)", mask, mask&writeBits) + } + + // The end of the handoff: it is actually readable. + if _, err := os.ReadFile(stamp); err != nil { + t.Errorf("the post-setup reader cannot open the stamp: %v", err) + } + + // And repair still can, or a damaged stamp could never be replaced. + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{windows.WinLocalSystemSid, windows.WinBuiltinAdministratorsSid} { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + t.Fatalf("resolve well-known SID: %v", err) + } + mask, present := stampACEMask(t, stamp, sid) + if !present || mask&windows.FILE_WRITE_DATA == 0 { + t.Errorf("repair identity %s cannot rewrite the stamp (present=%v mask 0x%08x)", sid, present, mask) + } + } +} + +// An identity that could not be resolved is not permission to protect the stamp +// with a DACL naming nobody. +func TestProtectRefusesWithoutAResolvedReader(t *testing.T) { + // A REAL handle, so the refusal can only come from the missing identity. + // Handle 0 fails on its own, which made the first version of this test pass + // with the guard deleted. + stamp := filepath.Join(t.TempDir(), "stamp.json") + if err := os.WriteFile(stamp, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile(windows.StringToUTF16Ptr(stamp), + windows.READ_CONTROL|windows.WRITE_DAC, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("open the stamp: %v", err) + } + defer windows.CloseHandle(handle) + + err = protectWindowsRuntimeStamp(handle, nil) + if err == nil { + t.Fatal("protecting the stamp with no resolved reader succeeded, which would lock out the identity that has to validate it") + } + if !strings.Contains(err.Error(), "no identity was supplied") { + t.Fatalf("refused for the wrong reason, so this does not pin the guard: %v", err) + } +} From 61f7ce7cd89fc3dff551a403f362eba6e30bf9f8 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 12:47:16 +0530 Subject: [PATCH 31/38] fix(sandbox): refuse compensation it cannot bind to this run's object Both identity capture sites discarded runtimeDirIdentity's success flag, so a root that could not be opened was indistinguishable from one with no identity, and both mutation sites read the empty string as "skip the check". Stamp restoration then wrote to, or removed from, whatever answered to the pathname afterwards, and directory rollback treated every reopened object as eligible for removal. This is elevated compensation, so a pathname redirected after the capture gives the mutation reach the replacer would not have directly. Carry the flag and refuse: an identity that was never established is not permission to mutate. The object is left alone and reported as residual state. A root that is simply ABSENT stays quiet, because there is no object to confuse and nothing of a previous run to put back; the created-directory rollback owns that case. Without that distinction every fresh setup would report a compensation failure, and a test pins it. The test helper that builds created-directory records had the same discarded flag, which is why the existing rollback tests passed against the fail-open path. It now carries the identity through the way production does. This does not close the check-then-mutate window itself: the identity is still read on a handle that closes before the pathname operation. That half is separate. --- .../sandbox/runtime_lease_ownership_test.go | 4 +- internal/sandbox/windows_setup.go | 44 +++++++-- ...indows_setup_rollback_completeness_test.go | 24 ++++- ...ws_setup_unidentified_compensation_test.go | 95 +++++++++++++++++++ 4 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 internal/sandbox/windows_setup_unidentified_compensation_test.go diff --git a/internal/sandbox/runtime_lease_ownership_test.go b/internal/sandbox/runtime_lease_ownership_test.go index bc4d958ba..259dd3e7a 100644 --- a/internal/sandbox/runtime_lease_ownership_test.go +++ b/internal/sandbox/runtime_lease_ownership_test.go @@ -86,8 +86,8 @@ func TestAHeldLeaseDoesNotProtectASiblingRoot(t *testing.T) { func createdRuntimeDirsForTest(paths ...string) []windowsCreatedRuntimeDir { records := make([]windowsCreatedRuntimeDir, 0, len(paths)) for _, path := range paths { - identity, _ := runtimeDirIdentity(path) - records = append(records, windowsCreatedRuntimeDir{path: path, identity: identity}) + identity, identified := runtimeDirIdentity(path) + records = append(records, windowsCreatedRuntimeDir{path: path, identity: identity, identified: identified}) } return records } diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index afb6ab5f8..76c92919e 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -618,6 +618,9 @@ type windowsRuntimeRootRollback struct { type windowsCreatedRuntimeDir struct { path string identity string + // identified separates "no identity" from "identity not established", for the + // same reason as the stamp snapshot above. + identified bool } // windowsSandboxStampSnapshot records the runtime stamp as it was before setup @@ -637,6 +640,12 @@ type windowsSandboxStampSnapshot struct { // directory is the object whose replacement this has to detect. root string rootIdentity string + // rootIdentified records whether the identity above was ESTABLISHED, as + // opposed to being empty because the capture failed. The two were + // indistinguishable, and restore treated the empty case as permission to + // mutate the pathname: exactly the case where compensation cannot prove what + // it is undoing. + rootIdentified bool } func snapshotWindowsSandboxRuntimeStamp(root string) windowsSandboxStampSnapshot { @@ -645,13 +654,13 @@ func snapshotWindowsSandboxRuntimeStamp(root string) windowsSandboxStampSnapshot return windowsSandboxStampSnapshot{} } path := windowsSandboxRuntimeStampPath(root) - rootIdentity, _ := runtimeDirIdentity(root) + rootIdentity, rootIdentified := runtimeDirIdentity(root) prior, err := os.ReadFile(path) if err != nil { // Absent, or unreadable and therefore not something to put back. - return windowsSandboxStampSnapshot{path: path, root: root, rootIdentity: rootIdentity} + return windowsSandboxStampSnapshot{path: path, root: root, rootIdentity: rootIdentity, rootIdentified: rootIdentified} } - return windowsSandboxStampSnapshot{path: path, prior: prior, existed: true, root: root, rootIdentity: rootIdentity} + return windowsSandboxStampSnapshot{path: path, prior: prior, existed: true, root: root, rootIdentity: rootIdentity, rootIdentified: rootIdentified} } func (snapshot windowsSandboxStampSnapshot) restore() error { @@ -663,7 +672,21 @@ func (snapshot windowsSandboxStampSnapshot) restore() error { // mutate a directory this run never touched, while the original keeps the // grant and the stamp. Leave the substitute alone and say what was left // behind. - if snapshot.rootIdentity != "" { + if !snapshot.rootIdentified { + // AN IDENTITY THAT WAS NEVER ESTABLISHED IS NOT PERMISSION TO MUTATE. This + // used to fall through to the pathname operations below, so a root that + // could not be opened when setup began was compensated by writing to, or + // removing from, whatever answered to the name afterwards. + // + // A root that is simply absent is different: there is no object to confuse + // and nothing of a previous run to put back, and the created-directory + // rollback owns that case. + if _, err := os.Lstat(snapshot.root); err == nil { + return fmt.Errorf("sandbox runtime root %s could not be identified when setup began, so the stamp at %s cannot be shown to belong to this run; leaving it untouched", snapshot.root, snapshot.path) + } + return nil + } + { current, ok := runtimeDirIdentity(snapshot.root) if !ok { return fmt.Errorf("identify the sandbox runtime root %s for stamp compensation", snapshot.root) @@ -710,7 +733,14 @@ func (rollback windowsRuntimeRootRollback) run() error { // run can prove it created. continue } - if entry.identity != "" && current != entry.identity { + if !entry.identified { + // Created, but never identified. Removing whatever is here now would be + // a pathname-authoritative delete of an object this run cannot prove it + // made. + errs = append(errs, fmt.Errorf("sandbox runtime root %s was created by this run but could not be identified, so it cannot be removed safely; leaving it in place", entry.path)) + continue + } + if current != entry.identity { errs = append(errs, fmt.Errorf("sandbox runtime root %s is no longer the directory this run created; "+ "leaving the replacement in place", entry.path)) continue @@ -847,8 +877,8 @@ func createRuntimeDirRecording(root string) ([]windowsCreatedRuntimeDir, error) } // Identified immediately after creating it, so compensation can prove it is // still the same object rather than trusting the name. - identity, _ := runtimeDirIdentity(missing[index]) - created = append(created, windowsCreatedRuntimeDir{path: missing[index], identity: identity}) + identity, identified := runtimeDirIdentity(missing[index]) + created = append(created, windowsCreatedRuntimeDir{path: missing[index], identity: identity, identified: identified}) } // Re-checked after creation. If an ancestor was swapped for a junction while // we were creating, the leaf we just made is in the wrong tree, and granting diff --git a/internal/sandbox/windows_setup_rollback_completeness_test.go b/internal/sandbox/windows_setup_rollback_completeness_test.go index 457407496..841781adf 100644 --- a/internal/sandbox/windows_setup_rollback_completeness_test.go +++ b/internal/sandbox/windows_setup_rollback_completeness_test.go @@ -110,7 +110,11 @@ func TestRollbackContinuesAfterACompensationFails(t *testing.T) { } rollback := windowsRuntimeRootRollback{ created: createdRuntimeDirsForTest(filepath.Join(parent, "zero"), root), - stamp: windowsSandboxStampSnapshot{path: filepath.Join(broken, "stamp"), prior: []byte("x"), existed: true}, + // Carries a REAL identified root, because compensation now refuses to + // mutate a pathname whose root identity was never established, and a + // snapshot without one is a shape production never builds. The failure + // under test is the unwritable stamp path, not a missing identity. + stamp: stampSnapshotForTest(t, root, filepath.Join(broken, "stamp")), } err := rollback.run() @@ -122,6 +126,24 @@ func TestRollbackContinuesAfterACompensationFails(t *testing.T) { } } +// stampSnapshotForTest builds the snapshot production would build for root, +// then points it at a different stamp path so a test can choose the failure. +func stampSnapshotForTest(t *testing.T, root string, stampPath string) windowsSandboxStampSnapshot { + t.Helper() + identity, identified := runtimeDirIdentity(root) + if !identified { + t.Fatalf("identify the runtime root %s for the snapshot", root) + } + return windowsSandboxStampSnapshot{ + path: stampPath, + prior: []byte("x"), + existed: true, + root: root, + rootIdentity: identity, + rootIdentified: identified, + } +} + // A FAILING ACL ROLLBACK MUST NOT STRAND THE RUNTIME ROLLBACK. // // The two undos used to compose by calling each other, and the outer one diff --git a/internal/sandbox/windows_setup_unidentified_compensation_test.go b/internal/sandbox/windows_setup_unidentified_compensation_test.go new file mode 100644 index 000000000..683c1dddc --- /dev/null +++ b/internal/sandbox/windows_setup_unidentified_compensation_test.go @@ -0,0 +1,95 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// AN IDENTITY THAT WAS NEVER ESTABLISHED IS NOT PERMISSION TO MUTATE. +// +// Both capture sites discarded runtimeDirIdentity's success flag, so a root that +// could not be opened when setup began was indistinguishable from one with no +// identity, and both mutation sites read the empty string as "skip the check". +// Compensation then wrote to, or removed from, whatever answered to the pathname +// afterwards. That is elevated compensation reaching an object this run cannot +// prove it touched. +func TestStampCompensationRefusesAnUnidentifiedRoot(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime", "v1", "abcd") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + stamp := windowsSandboxRuntimeStampPath(root) + const foreign = "belongs to whoever put it here" + if err := os.WriteFile(stamp, []byte(foreign), 0o600); err != nil { + t.Fatalf("seed the stamp: %v", err) + } + + // The shape the old capture produced when runtimeDirIdentity failed. + snapshot := windowsSandboxStampSnapshot{ + path: stamp, + prior: []byte("this run's stamp"), + existed: true, + root: root, + rootIdentified: false, + } + + err := snapshot.restore() + if err == nil { + t.Fatal("compensation proceeded with an identity it never established") + } + if !strings.Contains(err.Error(), "could not be identified") { + t.Errorf("the refusal does not say why: %v", err) + } + + // The object is untouched, which is the half that matters. + after, readErr := os.ReadFile(stamp) + if readErr != nil { + t.Fatalf("read the stamp back: %v", readErr) + } + if string(after) != foreign { + t.Errorf("compensation rewrote a stamp it could not prove was this run's: %q", string(after)) + } +} + +// The same rule for the directory removal, where the mutation is a delete. +func TestDirectoryCompensationRefusesAnUnidentifiedDirectory(t *testing.T) { + parent := t.TempDir() + created := filepath.Join(parent, "zero") + if err := os.MkdirAll(created, 0o700); err != nil { + t.Fatalf("create the directory: %v", err) + } + + rollback := windowsRuntimeRootRollback{ + created: []windowsCreatedRuntimeDir{{path: created, identified: false}}, + } + err := rollback.run() + if err == nil { + t.Fatal("an unidentified directory was removed by pathname") + } + if !strings.Contains(err.Error(), "could not be identified") { + t.Errorf("the refusal does not say why: %v", err) + } + if _, statErr := os.Stat(created); statErr != nil { + t.Errorf("the directory was removed despite the unproven identity: %v", statErr) + } +} + +// And a root that is simply ABSENT is not the same case: there is no object to +// confuse, nothing of a previous run to put back, and the created-directory +// rollback owns the cleanup. Refusing here would make every fresh setup report a +// compensation failure. +func TestStampCompensationStaysQuietWhenTheRootIsAbsent(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime", "v1", "abcd") + snapshot := windowsSandboxStampSnapshot{ + path: windowsSandboxRuntimeStampPath(root), + root: root, + rootIdentified: false, + } + if err := snapshot.restore(); err != nil { + t.Fatalf("an absent root was treated as a compensation failure: %v", err) + } +} From 231cc74122586cb9c239bd70e39c79e839126121 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 13:05:21 +0530 Subject: [PATCH 32/38] fix(sandbox): mutate compensation targets through the verified handle The identity check and the mutation used two different resolutions of the same name: runtimeDirIdentity opened a handle, read the volume and file ID, closed it, and then os.Remove or os.WriteFile resolved the pathname again. A rename followed by a replacement in that interval makes the comparison true about one object while the write or the delete lands on another. This is elevated compensation, so a redirected pathname gives the mutation reach the replacer does not have directly. Open once, verify the identity on that handle, and perform the mutation through it: the stamp relative to the directory handle, the created directory by FileDispositionInfo on its own handle. No ancestor is re-resolved and there is no interval to land in. A seam between the check and the mutation drives the replacement in tests; with the old pathname resolution they fail, naming the substitute. Two things fell out of doing it properly. The stamp restore now deletes and recreates through the ordinary writer rather than overwriting. The stamp carries a protected DACL that withholds write, so an in-place overwrite is denied under the token that wrote it, and only the writer puts that DACL back on the replacement. The reader ACE gains DELETE alongside read. Withholding write from the SANDBOX is the real boundary and the capability SID has no ACE here at all. Withholding it from the root owner is not one: they own the parent, so delete-then-create forges a stamp exactly as well as an overwrite. What read-only actually cost was rollback's ability to remove a stamp this run wrote. A directory removal that reports success is verified rather than assumed, for the reason recorded on the promote rename in #751. --- .../sandbox/runtime_compensation_other.go | 57 ++++++ .../runtime_compensation_swap_windows_test.go | 100 +++++++++++ .../sandbox/runtime_compensation_windows.go | 169 ++++++++++++++++++ .../sandbox/windows_runtime_tail_windows.go | 18 +- internal/sandbox/windows_setup.go | 38 +--- 5 files changed, 344 insertions(+), 38 deletions(-) create mode 100644 internal/sandbox/runtime_compensation_other.go create mode 100644 internal/sandbox/runtime_compensation_swap_windows_test.go create mode 100644 internal/sandbox/runtime_compensation_windows.go diff --git a/internal/sandbox/runtime_compensation_other.go b/internal/sandbox/runtime_compensation_other.go new file mode 100644 index 000000000..fd23d3d91 --- /dev/null +++ b/internal/sandbox/runtime_compensation_other.go @@ -0,0 +1,57 @@ +//go:build !windows + +package sandbox + +import ( + "fmt" + "os" +) + +// runtimeCompensationSwapSeam exists so the shared compensation code compiles +// everywhere. Only the Windows build closes a check-then-mutate window, because +// only there does setup run elevated against a tree an unelevated process can +// rename. +var runtimeCompensationSwapSeam func() + +func compensateRuntimeStampBound(root string, identity string, prior []byte, existed bool) error { + current, ok := runtimeDirIdentity(root) + if !ok { + return fmt.Errorf("identify the sandbox runtime root %s for stamp compensation", root) + } + if current != identity { + return fmt.Errorf("sandbox runtime root %s is no longer the directory this setup stamped; "+ + "leaving the replacement untouched, and the original still carries this run's stamp", root) + } + if runtimeCompensationSwapSeam != nil { + runtimeCompensationSwapSeam() + } + path := windowsSandboxRuntimeStampPath(root) + if !existed { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) + } + return nil + } + if err := os.WriteFile(path, prior, 0o600); err != nil { + return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err) + } + return nil +} + +func removeCreatedRuntimeDirBound(path string, identity string) error { + current, ok := runtimeDirIdentity(path) + if !ok { + return nil + } + if current != identity { + return fmt.Errorf("sandbox runtime root %s is no longer the directory this run created; "+ + "leaving the replacement in place", path) + } + if runtimeCompensationSwapSeam != nil { + runtimeCompensationSwapSeam() + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err) + } + return nil +} diff --git a/internal/sandbox/runtime_compensation_swap_windows_test.go b/internal/sandbox/runtime_compensation_swap_windows_test.go new file mode 100644 index 000000000..a5f95e7ed --- /dev/null +++ b/internal/sandbox/runtime_compensation_swap_windows_test.go @@ -0,0 +1,100 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// swapDuringCompensation installs a seam that runs once, between the identity +// check and the mutation, and replaces root with a fresh directory carrying a +// file of its own. +func swapDuringCompensation(t *testing.T, root string, aside string) *string { + t.Helper() + fired := false + previous := runtimeCompensationSwapSeam + t.Cleanup(func() { runtimeCompensationSwapSeam = previous }) + runtimeCompensationSwapSeam = func() { + if fired { + return + } + fired = true + if err := os.Rename(root, aside); err != nil { + t.Logf("could not rename the original aside: %v", err) + return + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the substitute: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "substitute.txt"), []byte("belongs to whoever put it here"), 0o600); err != nil { + t.Fatalf("seed the substitute: %v", err) + } + } + return &aside +} + +// A REPLACEMENT AFTER THE CHECK MUST NOT REACH THE SUBSTITUTE. +// +// Compensation used to read the identity through a handle, close it, and then +// resolve the pathname again for the write or the delete. A rename plus a +// replacement in that interval made the comparison true about one object while +// the mutation landed on another, under elevation. Holding the handle across +// both means the mutation follows the object that was verified, and whatever now +// answers to the name is untouched. +func TestStampCompensationDoesNotReachASubstitute(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "zero", "runtime", "v1", "abcd") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime root: %v", err) + } + if err := writeWindowsSandboxRuntimeStamp(root, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + identity, ok := runtimeDirIdentity(root) + if !ok { + t.Fatal("identify the runtime root") + } + aside := filepath.Join(parent, "renamed-aside") + swapDuringCompensation(t, root, aside) + + // Removal of a stamp this run wrote, i.e. the fresh-setup rollback. + _ = compensateRuntimeStampBound(root, identity, nil, false) + + // Whatever the outcome, the substitute is not this run's business. + substitute := filepath.Join(root, "substitute.txt") + if _, err := os.Stat(substitute); err != nil { + t.Errorf("compensation removed a file from the substitute directory: %v", err) + } + if _, err := os.Stat(windowsSandboxRuntimeStampPath(root)); err == nil { + t.Error("compensation created a stamp inside the substitute directory") + } + // And the original, which is the object that was verified, is the one that + // lost the stamp this run wrote. + if _, err := os.Stat(windowsSandboxRuntimeStampPath(aside)); err == nil { + t.Error("the stamp this run wrote is still on the original object, so compensation followed the name instead") + } +} + +// The same for the delete, where following the name would remove a directory +// this run never created. +func TestDirectoryCompensationDoesNotRemoveASubstitute(t *testing.T) { + parent := t.TempDir() + created := filepath.Join(parent, "created") + if err := os.MkdirAll(created, 0o700); err != nil { + t.Fatalf("create the directory: %v", err) + } + identity, ok := runtimeDirIdentity(created) + if !ok { + t.Fatal("identify the created directory") + } + aside := filepath.Join(parent, "renamed-aside") + swapDuringCompensation(t, created, aside) + + _ = removeCreatedRuntimeDirBound(created, identity) + + if _, err := os.Stat(filepath.Join(created, "substitute.txt")); err != nil { + t.Errorf("compensation removed the substitute directory's contents: %v", err) + } +} diff --git a/internal/sandbox/runtime_compensation_windows.go b/internal/sandbox/runtime_compensation_windows.go new file mode 100644 index 000000000..56c0dca75 --- /dev/null +++ b/internal/sandbox/runtime_compensation_windows.go @@ -0,0 +1,169 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// THE HANDLE IS THE OBJECT. A pathname is not. +// +// Compensation used to read the directory identity through a handle, close it, +// and then resolve the pathname again for the mutation. A rename followed by a +// replacement between those two steps makes the comparison true about one +// object while the write or delete lands on another, and this runs elevated, so +// a redirected pathname gives the mutation reach the replacer does not have +// directly. +// +// Opening once and keeping the handle open across BOTH the identity check and +// the mutation removes the interval. The child operations are relative to that +// handle, so no ancestor is re-resolved either. + +// runtimeCompensationSwapSeam runs between the identity check and the mutation. +// Nil in production; a test installs one to replace the directory in exactly the +// window this design closes. +var runtimeCompensationSwapSeam func() + +type fileDispositionInfo struct { + DeleteFile byte +} + +func handleRuntimeIdentity(handle windows.Handle) (string, error) { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return "", err + } + return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), nil +} + +// openVerifiedRuntimeDirectory opens path without following a link at the final +// component and returns it only if it is still the object identity names. +func openVerifiedRuntimeDirectory(path string, identity string, access uint32, what string) (windows.Handle, error) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + handle, err := windows.CreateFile( + utf16Path, + access|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 { + return 0, err + } + current, err := handleRuntimeIdentity(handle) + if err != nil { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("identify the sandbox runtime directory %s for compensation: %w", path, err) + } + if current != identity { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("sandbox runtime root %s is no longer the directory this run %s; "+ + "leaving the replacement in place, and the original still carries this run's changes", path, what) + } + return handle, nil +} + +// markForDeletion queues the object the handle names for removal. It never +// resolves a pathname, so it can only reach what this process already holds. +func markForDeletion(handle windows.Handle) error { + info := fileDispositionInfo{DeleteFile: 1} + return windows.SetFileInformationByHandle( + handle, + windows.FileDispositionInfo, + (*byte)(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ) +} + +// compensateRuntimeStampBound restores or removes the stamp through a handle on +// the directory whose identity still matches. +// +// Both branches DELETE first. The stamp carries a protected DACL that withholds +// write from the root owner, so an in-place overwrite is denied under the very +// token that wrote it; and recreating it through the ordinary writer is what +// puts that DACL back, which a raw write would not. +func compensateRuntimeStampBound(root string, identity string, prior []byte, existed bool) error { + directory, err := openVerifiedRuntimeDirectory(root, identity, + windows.FILE_TRAVERSE|windowsFileAddFile|windows.READ_CONTROL|windows.SYNCHRONIZE, "stamped") + if err != nil { + return err + } + defer windows.CloseHandle(directory) + + if runtimeCompensationSwapSeam != nil { + runtimeCompensationSwapSeam() + } + + if err := deleteRuntimeStampChild(directory); err != nil { + return err + } + if !existed { + return nil + } + // Recreated through the ordinary writer so it is protected again, and so the + // reader ACE is resolved the same way a fresh setup resolves it. + if err := writeWindowsRuntimeStampToDirectoryHandle(directory, string(prior)); err != nil { + return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err) + } + return nil +} + +// deleteRuntimeStampChild removes the stamp relative to an already verified +// directory handle. A stamp that is not there is the desired end state; anything +// else is reported rather than swallowed. +func deleteRuntimeStampChild(directory windows.Handle) error { + stamp, err := openWindowsChildNoFollow(directory, windowsSandboxRuntimeStampName, + windows.DELETE|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE) + if err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || errors.Is(err, windows.ERROR_PATH_NOT_FOUND) || errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) + } + if err := markForDeletion(stamp); err != nil { + _ = windows.CloseHandle(stamp) + return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) + } + // The entry goes when the last handle closes. + _ = windows.CloseHandle(stamp) + return nil +} + +// removeCreatedRuntimeDirBound removes a directory this run created, through a +// handle on the object identity names. +func removeCreatedRuntimeDirBound(path string, identity string) error { + handle, err := openVerifiedRuntimeDirectory(path, identity, windows.DELETE, "created") + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if runtimeCompensationSwapSeam != nil { + runtimeCompensationSwapSeam() + } + if err := markForDeletion(handle); err != nil { + _ = windows.CloseHandle(handle) + return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err) + } + // The entry goes when the last handle closes, so releasing ours is what makes + // the removal observable. + _ = windows.CloseHandle(handle) + // REPORTED SUCCESS IS NOT PROOF. A handle-bound operation accepting the call + // has been seen not to take effect (PR #751, the promote rename), so the + // outcome is checked rather than assumed. + if _, err := os.Lstat(path); err == nil { + return fmt.Errorf("remove sandbox runtime root %s created by this run: it is still present after the deletion was accepted", path) + } + return nil +} diff --git a/internal/sandbox/windows_runtime_tail_windows.go b/internal/sandbox/windows_runtime_tail_windows.go index ff38a5406..9536f8681 100644 --- a/internal/sandbox/windows_runtime_tail_windows.go +++ b/internal/sandbox/windows_runtime_tail_windows.go @@ -262,12 +262,20 @@ func protectWindowsRuntimeStamp(handle windows.Handle, reader *windows.SID) erro // runtime root the stamp lives in binds it to the install rather than to the // elevation. // - // Read only, because nothing outside setup and repair should be able to - // rewrite an attestation about the tree. The write below still succeeds: the - // handle was opened GENERIC_WRITE before this DACL was applied, and Windows - // checks access at open time. + // Read and DELETE, not write. Withholding write from the SANDBOX is the real + // boundary and the capability SID has no ACE here at all. Withholding it from + // the root owner is not: they own the parent directory, so delete-then-create + // forges a stamp exactly as well as an overwrite would. What read-only would + // actually cost is rollback, which has to remove a stamp this run wrote under + // the same token that wrote it. + // + // So: no FILE_WRITE_DATA, no WRITE_DAC, no WRITE_OWNER, which keeps an + // accidental in-place rewrite off the table, and DELETE so compensation can + // undo its own work. The write below still succeeds either way: the handle was + // opened GENERIC_WRITE before this DACL was applied, and Windows checks access + // at open time. entries := []windows.EXPLICIT_ACCESS{{ - AccessPermissions: windows.FILE_GENERIC_READ, + AccessPermissions: windows.FILE_GENERIC_READ | windows.DELETE, AccessMode: windows.SET_ACCESS, Inheritance: windows.NO_INHERITANCE, Trustee: windows.TRUSTEE{ diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 76c92919e..6960af2d1 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -686,26 +686,9 @@ func (snapshot windowsSandboxStampSnapshot) restore() error { } return nil } - { - current, ok := runtimeDirIdentity(snapshot.root) - if !ok { - return fmt.Errorf("identify the sandbox runtime root %s for stamp compensation", snapshot.root) - } - if current != snapshot.rootIdentity { - return fmt.Errorf("sandbox runtime root %s is no longer the directory this setup stamped; "+ - "leaving the replacement untouched, and the original still carries this run's stamp", snapshot.root) - } - } - if !snapshot.existed { - if err := os.Remove(snapshot.path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err) - } - return nil - } - if err := os.WriteFile(snapshot.path, snapshot.prior, 0o600); err != nil { - return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err) - } - return nil + // BOUND TO THE OBJECT, not to the name. The identity check and the mutation + // now share one handle, so a rename and replacement cannot land between them. + return compensateRuntimeStampBound(snapshot.root, snapshot.rootIdentity, snapshot.prior, snapshot.existed) } // run removes what was created, innermost first. @@ -727,12 +710,6 @@ func (rollback windowsRuntimeRootRollback) run() error { // Same rule as the stamp: prove this is the directory we made before // removing it. A substitute at the same name belongs to whoever put it // there. - current, ok := runtimeDirIdentity(entry.path) - if !ok { - // Gone already, or unreadable: either way there is nothing here this - // run can prove it created. - continue - } if !entry.identified { // Created, but never identified. Removing whatever is here now would be // a pathname-authoritative delete of an object this run cannot prove it @@ -740,13 +717,8 @@ func (rollback windowsRuntimeRootRollback) run() error { errs = append(errs, fmt.Errorf("sandbox runtime root %s was created by this run but could not be identified, so it cannot be removed safely; leaving it in place", entry.path)) continue } - if current != entry.identity { - errs = append(errs, fmt.Errorf("sandbox runtime root %s is no longer the directory this run created; "+ - "leaving the replacement in place", entry.path)) - continue - } - if err := os.Remove(entry.path); err != nil && !os.IsNotExist(err) { - errs = append(errs, fmt.Errorf("remove sandbox runtime root %s created by this run: %w", entry.path, err)) + if err := removeCreatedRuntimeDirBound(entry.path, entry.identity); err != nil { + errs = append(errs, err) } } return errors.Join(errs...) From 6769940026139e11249b4f26e9f5adac6ed441c3 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 13:47:13 +0530 Subject: [PATCH 33/38] test(sandbox): fail the rollback fixture on identity, not on a stale path TestRollbackContinuesAfterACompensationFails injected its failure with a snapshot pointing at an unwritable stamp path. That stopped meaning anything once compensation began deriving the stamp from the verified root handle rather than the recorded path: the restore wrote a stamp INTO the directory the rollback then tries to remove, so the removal failed for a reason this test is not about. It passed on an unelevated box and failed on all three CI runners, because whether that recreate succeeds depends on the token the tests run under. Inject a root that is no longer the directory this run stamped, which is a shape production produces and which writes nothing on any platform. --- ...indows_setup_rollback_completeness_test.go | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/internal/sandbox/windows_setup_rollback_completeness_test.go b/internal/sandbox/windows_setup_rollback_completeness_test.go index 841781adf..be3a7ad9a 100644 --- a/internal/sandbox/windows_setup_rollback_completeness_test.go +++ b/internal/sandbox/windows_setup_rollback_completeness_test.go @@ -103,18 +103,26 @@ func TestRollbackContinuesAfterACompensationFails(t *testing.T) { t.Fatalf("create the runtime root: %v", err) } - // A stamp snapshot naming a path that cannot be written: its parent is a file. - broken := filepath.Join(parent, "not-a-directory") - if err := os.WriteFile(broken, []byte("x"), 0o600); err != nil { - t.Fatalf("seed the blocker: %v", err) + // The failure is a root that is no longer the directory this run stamped, + // which is a shape production actually produces. + // + // It used to be a snapshot pointing at an unwritable stamp path. That stopped + // meaning anything once compensation began deriving the stamp from the verified + // root handle rather than the recorded path: the restore then wrote a stamp INTO + // the directory the rollback goes on to remove, so the removal failed for a + // reason this test is not about. It passed on an unelevated box and failed on + // every CI runner, because whether that recreate succeeds depends on the token. + stamp := windowsSandboxStampSnapshot{ + path: windowsSandboxRuntimeStampPath(root), + prior: []byte("x"), + existed: true, + root: root, + rootIdentity: "0:0:0", + rootIdentified: true, } rollback := windowsRuntimeRootRollback{ created: createdRuntimeDirsForTest(filepath.Join(parent, "zero"), root), - // Carries a REAL identified root, because compensation now refuses to - // mutate a pathname whose root identity was never established, and a - // snapshot without one is a shape production never builds. The failure - // under test is the unwritable stamp path, not a missing identity. - stamp: stampSnapshotForTest(t, root, filepath.Join(broken, "stamp")), + stamp: stamp, } err := rollback.run() @@ -126,24 +134,6 @@ func TestRollbackContinuesAfterACompensationFails(t *testing.T) { } } -// stampSnapshotForTest builds the snapshot production would build for root, -// then points it at a different stamp path so a test can choose the failure. -func stampSnapshotForTest(t *testing.T, root string, stampPath string) windowsSandboxStampSnapshot { - t.Helper() - identity, identified := runtimeDirIdentity(root) - if !identified { - t.Fatalf("identify the runtime root %s for the snapshot", root) - } - return windowsSandboxStampSnapshot{ - path: stampPath, - prior: []byte("x"), - existed: true, - root: root, - rootIdentity: identity, - rootIdentified: identified, - } -} - // A FAILING ACL ROLLBACK MUST NOT STRAND THE RUNTIME ROLLBACK. // // The two undos used to compose by calling each other, and the outer one From b34024f252e448184dc3ce54f6fbf0a28aca9807 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Fri, 28 Aug 2026 13:57:41 +0530 Subject: [PATCH 34/38] fix(sandbox): keep repair write when the reader is a repair identity The resolved stamp reader can BE one of the repair identities. A runtime root created by an elevated process is commonly owned by BUILTIN\Administrators rather than by the invoking user, which is what CI runners do. Naming the same SID twice, once read-only as the reader and once GENERIC_ALL as repair, let the narrower entry win: Administrators came back with mask 0x00130089 and could no longer rewrite the stamp. Setup would succeed and leave an attestation nothing could replace. Skip the reader entry when a repair identity already covers it, and pin it with a test that resolves the reader to Administrators directly, so the case is exercised on an unelevated box too. My own regression caught this, but only on CI, because here the directory is owned by the ordinary user. That is also why the existing assertion had to change: no-write is right for an ordinary owner and wrong for one that is itself repair. --- .../sandbox/windows_runtime_tail_windows.go | 28 +++++--- .../windows_stamp_reader_windows_test.go | 66 +++++++++++++++++-- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/internal/sandbox/windows_runtime_tail_windows.go b/internal/sandbox/windows_runtime_tail_windows.go index 9536f8681..bfefe8093 100644 --- a/internal/sandbox/windows_runtime_tail_windows.go +++ b/internal/sandbox/windows_runtime_tail_windows.go @@ -274,16 +274,24 @@ func protectWindowsRuntimeStamp(handle windows.Handle, reader *windows.SID) erro // undo its own work. The write below still succeeds either way: the handle was // opened GENERIC_WRITE before this DACL was applied, and Windows checks access // at open time. - entries := []windows.EXPLICIT_ACCESS{{ - AccessPermissions: windows.FILE_GENERIC_READ | windows.DELETE, - AccessMode: windows.SET_ACCESS, - Inheritance: windows.NO_INHERITANCE, - Trustee: windows.TRUSTEE{ - TrusteeForm: windows.TRUSTEE_IS_SID, - TrusteeType: windows.TRUSTEE_IS_USER, - TrusteeValue: windows.TrusteeValueFromSID(reader), - }, - }} + // The reader can BE one of the repair identities. A runtime root created by + // an elevated process is commonly owned by BUILTINAdministrators rather than + // by the invoking user, which is what CI runners do. Naming the same SID + // twice let the narrower read-only entry win and left repair unable to rewrite + // the stamp, so skip the reader entry when the broader grant already covers it. + entries := make([]windows.EXPLICIT_ACCESS, 0, 3) + if !reader.Equals(system) && !reader.Equals(administrators) { + entries = append(entries, windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.FILE_GENERIC_READ | windows.DELETE, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(reader), + }, + }) + } for _, sid := range []*windows.SID{system, administrators} { entries = append(entries, windows.EXPLICIT_ACCESS{ AccessPermissions: windows.GENERIC_ALL, diff --git a/internal/sandbox/windows_stamp_reader_windows_test.go b/internal/sandbox/windows_stamp_reader_windows_test.go index e12e8eb48..e92f8e567 100644 --- a/internal/sandbox/windows_stamp_reader_windows_test.go +++ b/internal/sandbox/windows_stamp_reader_windows_test.go @@ -89,11 +89,17 @@ func TestStampGrantsTheRuntimeRootOwnerReadOnly(t *testing.T) { if mask&readBits == 0 { t.Errorf("the runtime root owner cannot read the stamp (mask 0x%08x)", mask) } - // Not write. This is the half that fails against the old GENERIC_ALL grant: - // an ordinary user able to rewrite the attestation about their own sandbox - // setup defeats the point of protecting it. + // Not write, UNLESS the owner is itself a repair identity. A runtime root + // created by an elevated process is commonly owned by BUILTINAdministrators + // rather than by the invoking user, which is what CI runners do, and repair + // has to keep write there. Asserting no-write unconditionally failed on every + // runner while passing on an unelevated box. const writeBits = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | windows.WRITE_DAC | windows.WRITE_OWNER - if mask&writeBits != 0 { + if isRepairIdentity(t, owner) { + if mask&windows.FILE_WRITE_DATA == 0 { + t.Errorf("the runtime root is owned by a repair identity that cannot rewrite the stamp (mask 0x%08x)", mask) + } + } else if mask&writeBits != 0 { t.Errorf("the runtime root owner can rewrite the stamp (mask 0x%08x, write bits 0x%08x)", mask, mask&writeBits) } @@ -141,3 +147,55 @@ func TestProtectRefusesWithoutAResolvedReader(t *testing.T) { t.Fatalf("refused for the wrong reason, so this does not pin the guard: %v", err) } } + +func isRepairIdentity(t *testing.T, sid *windows.SID) bool { + t.Helper() + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{windows.WinLocalSystemSid, windows.WinBuiltinAdministratorsSid} { + known, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + t.Fatalf("resolve well-known SID: %v", err) + } + if sid.Equals(known) { + return true + } + } + return false +} + +// A READER THAT IS ALSO A REPAIR IDENTITY MUST NOT LOSE WRITE. +// +// The reader entry and the repair entries can name the same SID, because an +// elevated create commonly leaves BUILTINAdministrators as the owner. Naming it +// twice let the narrower read-only entry win, and repair could no longer rewrite +// the stamp: setup succeeded and left an attestation nothing could replace. My +// own regression caught this on CI and not here, since an unelevated box owns +// the directory as the ordinary user. +func TestReaderThatIsARepairIdentityKeepsWrite(t *testing.T) { + administrators, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + t.Fatalf("resolve the Administrators SID: %v", err) + } + stamp := filepath.Join(t.TempDir(), "stamp.json") + if err := os.WriteFile(stamp, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile(windows.StringToUTF16Ptr(stamp), + windows.READ_CONTROL|windows.WRITE_DAC, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("open the stamp: %v", err) + } + if err := protectWindowsRuntimeStamp(handle, administrators); err != nil { + windows.CloseHandle(handle) + t.Fatalf("protect the stamp: %v", err) + } + windows.CloseHandle(handle) + + mask, present := stampACEMask(t, stamp, administrators) + if !present { + t.Fatal("Administrators has no ACE at all") + } + if mask&windows.FILE_WRITE_DATA == 0 { + t.Errorf("Administrators lost write when it was also the resolved reader (mask 0x%08x)", mask) + } +} From dca6b7ab5ecf5f12224ef9fc31e7e3106fe94d77 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 29 Aug 2026 13:50:11 +0530 Subject: [PATCH 35/38] fix(sandbox): treat only proven absence as a verified removal The post-deletion probe distinguished only err == nil from err != nil, so every Lstat failure took the success path. A second process holding a share-delete handle leaves the entry delete-pending, and an access denial or sharing violation from the probe then reported complete compensation for a directory that is still there. A holder able to clear the disposition can make the "removed" object visible again, after setup has already said the rollback finished. Ordinary inspection failures were hidden the same way, and those carry no evidence about the postcondition either. Three outcomes rather than two: not-found proves absence and returns nil, a successful stat means it is still present, and anything else is reported as residue whose removal could not be verified. The probe goes through a seam so a test can produce that third outcome, which no real filesystem yields on demand. Reverting to the two-way check fails the unverifiable case naming it as complete compensation. --- ...untime_compensation_verify_windows_test.go | 78 +++++++++++++++++++ .../sandbox/runtime_compensation_windows.go | 21 ++++- 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 internal/sandbox/runtime_compensation_verify_windows_test.go diff --git a/internal/sandbox/runtime_compensation_verify_windows_test.go b/internal/sandbox/runtime_compensation_verify_windows_test.go new file mode 100644 index 000000000..3f5d547c2 --- /dev/null +++ b/internal/sandbox/runtime_compensation_verify_windows_test.go @@ -0,0 +1,78 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// ONLY NOT-FOUND IS EVIDENCE OF REMOVAL. +// +// The verification probe used to read any Lstat error as absence, so a sharing +// violation, an access denial, or an entry left delete-pending by another +// process that holds a share-delete handle all reported complete compensation +// for a directory that is still there. A holder able to clear the disposition +// could then make the "removed" object visible again, and setup would already +// have said the rollback finished. +// +// The three outcomes are distinguished with a probe seam, because a real +// filesystem will not produce the third on demand. +func TestDeletionVerificationDistinguishesAllThreeOutcomes(t *testing.T) { + previous := runtimeCompensationStat + t.Cleanup(func() { runtimeCompensationStat = previous }) + + newDir := func() (string, string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "created") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + identity, ok := runtimeDirIdentity(dir) + if !ok { + t.Fatal("identify the created directory") + } + return dir, identity + } + + t.Run("absence is proven and reported as success", func(t *testing.T) { + dir, identity := newDir() + runtimeCompensationStat = func(string) (fs.FileInfo, error) { + return nil, &os.PathError{Op: "lstat", Path: dir, Err: os.ErrNotExist} + } + if err := removeCreatedRuntimeDirBound(dir, identity); err != nil { + t.Errorf("a proven-absent directory was reported as a failure: %v", err) + } + }) + + t.Run("still present is reported", func(t *testing.T) { + dir, identity := newDir() + runtimeCompensationStat = func(path string) (fs.FileInfo, error) { return os.Stat(".") } + err := removeCreatedRuntimeDirBound(dir, identity) + if err == nil { + t.Fatal("a directory still present after deletion was reported as removed") + } + if !strings.Contains(err.Error(), "still present") { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("an unverifiable probe is residue, not success", func(t *testing.T) { + dir, identity := newDir() + // What a share-delete holder produces: neither absence nor presence. + runtimeCompensationStat = func(path string) (fs.FileInfo, error) { + return nil, &os.PathError{Op: "lstat", Path: path, Err: errors.New("Access is denied.")} + } + err := removeCreatedRuntimeDirBound(dir, identity) + if err == nil { + t.Fatal("an unverifiable removal was reported as complete compensation") + } + if !strings.Contains(err.Error(), "could not be verified") { + t.Errorf("the error does not say the removal is unproven: %v", err) + } + }) +} diff --git a/internal/sandbox/runtime_compensation_windows.go b/internal/sandbox/runtime_compensation_windows.go index 56c0dca75..673c4313d 100644 --- a/internal/sandbox/runtime_compensation_windows.go +++ b/internal/sandbox/runtime_compensation_windows.go @@ -29,6 +29,11 @@ import ( // window this design closes. var runtimeCompensationSwapSeam func() +// runtimeCompensationStat is the post-deletion existence probe. A var so a test +// can produce the third outcome, an inspection that neither proves absence nor +// presence, which no real filesystem produces on demand. +var runtimeCompensationStat = os.Lstat + type fileDispositionInfo struct { DeleteFile byte } @@ -162,8 +167,20 @@ func removeCreatedRuntimeDirBound(path string, identity string) error { // REPORTED SUCCESS IS NOT PROOF. A handle-bound operation accepting the call // has been seen not to take effect (PR #751, the promote rename), so the // outcome is checked rather than assumed. - if _, err := os.Lstat(path); err == nil { + // + // THREE OUTCOMES, NOT TWO. This used to read any Lstat error as absence, so a + // sharing violation, an access denial, or a delete-pending entry held open by + // another process all reported complete compensation for a directory that is + // still there. A holder that clears the disposition can then make the + // "removed" object visible again. Only not-found is evidence of removal; + // everything else is at best unproven and has to be reported as residue. + _, statErr := runtimeCompensationStat(path) + switch { + case errors.Is(statErr, os.ErrNotExist): + return nil + case statErr == nil: return fmt.Errorf("remove sandbox runtime root %s created by this run: it is still present after the deletion was accepted", path) + default: + return fmt.Errorf("remove sandbox runtime root %s created by this run: its removal could not be verified, so it may still be present: %w", path, statErr) } - return nil } From 1242e94de4fcf39de96bc96b50929bd33cf6774b Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 29 Aug 2026 14:03:38 +0530 Subject: [PATCH 36/38] fix(sandbox): carry the stamp reader across the elevation boundary Resolving the reader from the runtime leaf's owner does not name the token that consumes the stamp, because the elevated helper CREATES that leaf when it is absent. The owner is then commonly BUILTIN\Administrators, and the repair-identity collision fix correctly folds the reader into the Administrators grant rather than emitting a duplicate ACE. Both steps are right on their own and wrong together. Production crosses a token boundary that the test did not: a later UAC-filtered administrator carries Administrators deny-only, and a standard user given alternate administrator credentials is not in the group at all. Neither can satisfy an allow ACE, so the protected stamp holds no enabled grant for the token that has to validate it, ValidateWindowsSandboxSetupMarker fails immediately after setup reported success, every restricted command stops before launch, and doctor calls the setup unusable. Resolve the consumer in the operator's shell instead, before elevation, and carry it in as --consumer-sid. That is the same rule the runtime root already follows two lines away: selected where the answer is knowable, not derived where it is not. The owner fallback stays for the paths that are not setup, notably rollback recreating a stamp it just removed, where the leaf already exists and belongs to whoever owns the install. A test pins that the carried identity outranks the leaf owner, since that precedence is the entire difference between the two designs. --- internal/sandbox/setup_consumer_sid_other.go | 9 +++ .../sandbox/setup_consumer_sid_windows.go | 30 ++++++++ .../windows_consumer_reader_windows_test.go | 76 +++++++++++++++++++ .../sandbox/windows_runtime_tail_windows.go | 48 ++++++++++++ internal/sandbox/windows_setup.go | 28 +++++++ internal/sandbox/windows_setup_windows.go | 14 ++++ 6 files changed, 205 insertions(+) create mode 100644 internal/sandbox/setup_consumer_sid_other.go create mode 100644 internal/sandbox/setup_consumer_sid_windows.go create mode 100644 internal/sandbox/windows_consumer_reader_windows_test.go diff --git a/internal/sandbox/setup_consumer_sid_other.go b/internal/sandbox/setup_consumer_sid_other.go new file mode 100644 index 000000000..fe976ae43 --- /dev/null +++ b/internal/sandbox/setup_consumer_sid_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package sandbox + +// currentProcessSID has no meaning off Windows. Windows sandbox setup args are +// only ever built on Windows; this exists so the shared builder compiles. +func currentProcessSID() (string, error) { + return "", nil +} diff --git a/internal/sandbox/setup_consumer_sid_windows.go b/internal/sandbox/setup_consumer_sid_windows.go new file mode 100644 index 000000000..c251b193e --- /dev/null +++ b/internal/sandbox/setup_consumer_sid_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// currentProcessSID is the SID of the token running this process. +// +// Called from BuildWindowsSandboxSetupArgs, which runs in the OPERATOR'S shell +// before elevation, so it answers "who will read the stamp afterwards" rather +// than "who is provisioning it". Those are different principals whenever setup +// elevates, and the difference is the whole point: the elevated helper creates +// the runtime leaf when it is absent, so anything inferred from that leaf +// describes the installer and not the consumer. +func currentProcessSID() (string, error) { + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() + if err != nil { + return "", fmt.Errorf("resolve the calling user SID for sandbox setup: %w", err) + } + sid := user.User.Sid + if sid == nil { + return "", fmt.Errorf("resolve the calling user SID for sandbox setup: the token carried none") + } + return sid.String(), nil +} diff --git a/internal/sandbox/windows_consumer_reader_windows_test.go b/internal/sandbox/windows_consumer_reader_windows_test.go new file mode 100644 index 000000000..1559510a6 --- /dev/null +++ b/internal/sandbox/windows_consumer_reader_windows_test.go @@ -0,0 +1,76 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// THE READER MUST BE THE TOKEN THAT VALIDATES, NOT THE ONE THAT INSTALLS. +// +// Deriving the reader from the runtime leaf's owner looked right and is wrong +// across the elevation boundary: the elevated helper CREATES that leaf when it +// is absent, so the owner is commonly BUILTIN\Administrators. A later +// UAC-filtered administrator carries that group deny-only, and a standard user +// given alternate administrator credentials is not in it at all, so the +// protected stamp ends up with no enabled allow ACE for the token that has to +// read it. Setup reports success and every restricted command then stops before +// launch. +// +// The consumer is therefore resolved in the operator's shell and carried in. +// This test pins that the carried identity wins over the leaf owner, which is +// the whole difference between the two designs. +func TestCarriedConsumerSIDOutranksTheLeafOwner(t *testing.T) { + base := t.TempDir() + root := filepath.Join(append([]string{base}, append(append([]string{}, windowsSandboxRuntimeOwnedNames...), "abcdef0123456789")...)...) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatalf("create the runtime tree: %v", err) + } + + // A consumer that is deliberately NOT this process and NOT a repair identity, + // so it cannot be confused with the leaf owner or folded into the + // SYSTEM/Administrators grants. + consumer, err := windows.StringToSid("S-1-5-21-1111111111-2222222222-3333333333-1001") + if err != nil { + t.Fatalf("build the stand-in consumer SID: %v", err) + } + restore := setWindowsSetupConsumerSID(consumer) + t.Cleanup(restore) + + if err := writeWindowsRuntimeStampThroughHandle(root, "planhash"); err != nil { + t.Fatalf("write the stamp: %v", err) + } + stamp := windowsSandboxRuntimeStampPath(root) + + mask, present := stampACEMask(t, stamp, consumer) + if !present { + t.Fatal("the carried consumer has no ACE; the stamp still names whoever owns the leaf") + } + if mask&windows.FILE_READ_DATA == 0 { + t.Errorf("the carried consumer cannot read the stamp (mask 0x%08x)", mask) + } + // It is not the owner of the leaf, so this also proves the owner fallback did + // not silently win. + owner := ownerOfDirectory(t, root) + if owner.Equals(consumer) { + t.Skip("the leaf owner happens to equal the stand-in consumer; this cannot distinguish the two") + } + if _, ownerPresent := stampACEMask(t, stamp, owner); ownerPresent && !isRepairIdentity(t, owner) { + t.Error("the leaf owner was granted alongside the carried consumer") + } + + // Repair must still work, and the capability SID must still be absent. + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{windows.WinLocalSystemSid, windows.WinBuiltinAdministratorsSid} { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + t.Fatalf("resolve well-known SID: %v", err) + } + if m, ok := stampACEMask(t, stamp, sid); !ok || m&windows.FILE_WRITE_DATA == 0 { + t.Errorf("repair identity %s lost write (present=%v mask 0x%08x)", sid, ok, m) + } + } +} diff --git a/internal/sandbox/windows_runtime_tail_windows.go b/internal/sandbox/windows_runtime_tail_windows.go index bfefe8093..685af6156 100644 --- a/internal/sandbox/windows_runtime_tail_windows.go +++ b/internal/sandbox/windows_runtime_tail_windows.go @@ -5,6 +5,7 @@ package sandbox import ( "fmt" "os" + "sync" "unsafe" "golang.org/x/sys/windows" @@ -201,7 +202,54 @@ func writeWindowsRuntimeStampToDirectoryHandle(directory windows.Handle, planHas // and may run as a different administrator account than the one that later runs // the command or zero doctor; the runtime root lives under the ordinary user // profile and its owner is stable across that boundary. +// windowsSetupConsumerSID is the ordinary reader carried across the elevation +// boundary by `zero sandbox setup`, resolved in the operator's shell. +// +// Guarded because tests set it; production writes it once, before setup touches +// anything, and clears it on the way out. +var ( + windowsSetupConsumerMu sync.Mutex + windowsSetupConsumerSID *windows.SID +) + +func setWindowsSetupConsumerSID(sid *windows.SID) func() { + windowsSetupConsumerMu.Lock() + previous := windowsSetupConsumerSID + windowsSetupConsumerSID = sid + windowsSetupConsumerMu.Unlock() + return func() { + windowsSetupConsumerMu.Lock() + windowsSetupConsumerSID = previous + windowsSetupConsumerMu.Unlock() + } +} + +func carriedWindowsSetupConsumerSID() *windows.SID { + windowsSetupConsumerMu.Lock() + defer windowsSetupConsumerMu.Unlock() + return windowsSetupConsumerSID +} + +// windowsRuntimeStampReader resolves the identity that must READ the stamp once +// setup has returned. +// +// The carried SID wins. It is the token that will actually run the commands, +// resolved before elevation, and it is the only source that survives the token +// boundary: the elevated helper CREATES the runtime leaf when it is absent, so +// deriving the reader from that leaf yields BUILTINAdministrators. A later +// UAC-filtered administrator carries that group deny-only and a standard user +// given alternate admin credentials is not in it at all, so the protected stamp +// would end up with no enabled allow ACE for the token that has to validate it, +// and every restricted command would stop before launch on a setup that had +// just reported success. +// +// The owner fallback stays for the paths that are not setup, notably rollback +// recreating a stamp it just removed, where the leaf already exists and belongs +// to whoever owns the install. func windowsRuntimeStampReader(directory windows.Handle) (*windows.SID, error) { + if carried := carriedWindowsSetupConsumerSID(); carried != nil { + return carried, nil + } descriptor, err := windows.GetSecurityInfo(directory, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) if err != nil { return nil, fmt.Errorf("read the sandbox runtime root owner: %w", err) diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 6960af2d1..993a521f2 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -41,6 +41,12 @@ type WindowsSandboxSetupConfig struct { CommandCWD string WorkspaceRoots []string PermissionProfile PermissionProfile + // ConsumerSID is the ordinary token that will READ the setup stamp after + // elevation returns. Resolved in the operator's shell and carried across the + // elevation boundary, never inferred here: the elevated helper creates the + // runtime leaf when it is absent, so that leaf's owner describes the + // installer rather than the consumer. + ConsumerSID string } type WindowsSandboxSetupMarker struct { @@ -134,11 +140,26 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str if err != nil { return nil, fmt.Errorf("marshal windows sandbox setup permission profile: %w", err) } + // RESOLVED HERE, in the operator's shell, for the same reason the runtime + // root is selected here: the elevated helper cannot observe who will run the + // commands afterwards. It creates the runtime leaf when it is missing, so a + // reader derived from that leaf is BUILTINAdministrators, and a later + // UAC-filtered administrator carries that group deny-only while a standard + // user given alternate admin credentials is not in it at all. Either way the + // protected stamp ends up with no enabled allow ACE for the token that has to + // validate it, and every restricted command stops before launch. + consumerSID, sidErr := currentProcessSID() + if sidErr != nil { + return nil, sidErr + } args := []string{ "--sandbox-home", sandboxHome, "--command-cwd", commandCWD, "--permission-profile", string(profileJSON), } + if consumerSID != "" { + args = append(args, "--consumer-sid", consumerSID) + } for _, root := range workspaceRoots { args = append(args, "--workspace-root", root) } @@ -174,6 +195,13 @@ func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, err config.WorkspaceRoots = append(config.WorkspaceRoots, root) } index = next + case "--consumer-sid": + value, next, err := nextWindowsSandboxFlagValue(args, index) + if err != nil { + return WindowsSandboxSetupConfig{}, err + } + config.ConsumerSID = strings.TrimSpace(value) + index = next case "--permission-profile": value, next, err := nextWindowsSandboxFlagValue(args, index) if err != nil { diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index d2fa257e6..4c5221b6f 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -18,6 +18,20 @@ 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 } + // CARRY THE CONSUMER ACROSS THE ELEVATION BOUNDARY, before anything is + // provisioned. The unelevated caller resolved who will read the stamp and + // passed it in; this process cannot observe that, because it is the installer + // and it creates the runtime leaf the old code derived the reader from. + if trimmed := strings.TrimSpace(config.ConsumerSID); trimmed != "" { + consumer, sidErr := windows.StringToSid(trimmed) + if sidErr != nil { + fmt.Fprintln(stderr, WindowsSandboxSetupName+": the calling user SID could not be parsed: "+sidErr.Error()) + return 1 + } + restore := setWindowsSetupConsumerSID(consumer) + defer restore() + } + // HOLD THE SELECTED ROOT FOR THE WHOLE TRANSACTION. // // The unelevated caller took a lease only to learn which root wins and released From 1710bde75f8cc2cd5825848666109b4d2a9e36fb Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 29 Aug 2026 14:13:24 +0530 Subject: [PATCH 37/38] fix(sandbox): assemble rollback records from one object, not one name Compensation verifies and mutates through a single handle now, but the records it trusts were still built from separate pathname opens. snapshotWindowsSandboxRuntimeStamp took the identity through a handle, closed it, and then let os.ReadFile resolve the name again. A rename and substitution in that interval pairs one directory's identity with another's stamp bytes, and a rollback that correctly proves it holds the first writes the second's contents into it, corrupting an attestation that predates this run. Reading the stamp as a child of the identified handle removes the second resolution. The creation ledger had the same split: os.Mkdir created the directory and runtimeDirIdentity reopened the name, so the ledger could record a substituted object this run never made. Rollback would then prove it held that object and delete it, while the real one kept this run's ACL and stamp under a name nothing was tracking. The identity now comes from the creation itself. The lease stops cleanup selecting the root; it does not stop the parent's owner renaming it, which is what makes both windows reachable. The race needs an elevated installer against an unelevated renamer and is not reproducible here, so the tests pin the contract instead: both halves of a snapshot come back together and agree with the object at the path, and a created directory's identity describes what was created. --- .../sandbox/runtime_bound_records_test.go | 80 +++++++++++++++++++ internal/sandbox/runtime_create_other.go | 15 ++++ internal/sandbox/runtime_create_windows.go | 53 ++++++++++++ internal/sandbox/runtime_snapshot_other.go | 17 ++++ internal/sandbox/runtime_snapshot_windows.go | 63 +++++++++++++++ internal/sandbox/windows_setup.go | 27 ++++--- 6 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 internal/sandbox/runtime_bound_records_test.go create mode 100644 internal/sandbox/runtime_create_other.go create mode 100644 internal/sandbox/runtime_create_windows.go create mode 100644 internal/sandbox/runtime_snapshot_other.go create mode 100644 internal/sandbox/runtime_snapshot_windows.go diff --git a/internal/sandbox/runtime_bound_records_test.go b/internal/sandbox/runtime_bound_records_test.go new file mode 100644 index 000000000..ffb5a1597 --- /dev/null +++ b/internal/sandbox/runtime_bound_records_test.go @@ -0,0 +1,80 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// THE TWO HALVES OF A ROLLBACK RECORD MUST DESCRIBE ONE OBJECT. +// +// The snapshot used to read the identity through a handle, close it, and then +// re-resolve the pathname to read the stamp. A rename between those pairs one +// directory's identity with another's bytes, and a rollback that correctly +// proves it holds the first then writes the second's contents into it. +// +// The window itself needs an elevated installer racing an unelevated renamer and +// is not reproducible here, so this pins the contract the binding provides: both +// facts come back together, and they agree with the object actually at the path. +func TestStampSnapshotPairsIdentityWithItsOwnBytes(t *testing.T) { + root := filepath.Join(t.TempDir(), "root") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + const contents = "the stamp that belongs to this directory" + if err := os.WriteFile(windowsSandboxRuntimeStampPath(root), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + identity, identified, prior, existed := snapshotRuntimeStampBound(root) + if !identified { + t.Fatal("the snapshot established no identity for a directory that exists") + } + if !existed || string(prior) != contents { + t.Fatalf("prior stamp = %q existed=%v, want %q", string(prior), existed, contents) + } + if direct, ok := runtimeDirIdentity(root); !ok || direct != identity { + t.Errorf("snapshot identity %q does not describe the directory at the path (%q)", identity, direct) + } +} + +// An absent stamp still establishes the identity, because that came from the +// directory handle and not from the stamp read. +func TestStampSnapshotIdentifiesARootWithNoStamp(t *testing.T) { + root := filepath.Join(t.TempDir(), "root") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + identity, identified, _, existed := snapshotRuntimeStampBound(root) + if !identified || identity == "" { + t.Error("a root with no stamp established no identity") + } + if existed { + t.Error("reported a prior stamp that does not exist") + } +} + +// A created directory's identity must come from the creation, so the ledger +// cannot record an object this run did not make. +func TestCreatedDirectoryIdentityDescribesWhatWasCreated(t *testing.T) { + path := filepath.Join(t.TempDir(), "created") + + identity, identified, err := createRuntimeDirIdentified(path) + if err != nil { + t.Fatalf("create: %v", err) + } + if !identified || identity == "" { + t.Fatal("creation established no identity") + } + if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() { + t.Fatalf("the directory was not created: %v", statErr) + } + if direct, ok := runtimeDirIdentity(path); !ok || direct != identity { + t.Errorf("creation identity %q does not describe the directory now at the path (%q)", identity, direct) + } + + // Creating over something that exists is the caller's already-handled signal. + if _, _, again := createRuntimeDirIdentified(path); !os.IsExist(again) { + t.Errorf("creating over an existing directory returned %v, want an IsExist error", again) + } +} diff --git a/internal/sandbox/runtime_create_other.go b/internal/sandbox/runtime_create_other.go new file mode 100644 index 000000000..27ea2a7f2 --- /dev/null +++ b/internal/sandbox/runtime_create_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package sandbox + +import "os" + +// createRuntimeDirIdentified keeps the pathname form off Windows, where the +// elevated-installer-versus-unelevated-renamer split this closes does not apply. +func createRuntimeDirIdentified(path string) (string, bool, error) { + if err := os.Mkdir(path, 0o700); err != nil { + return "", false, err + } + identity, ok := runtimeDirIdentity(path) + return identity, ok, nil +} diff --git a/internal/sandbox/runtime_create_windows.go b/internal/sandbox/runtime_create_windows.go new file mode 100644 index 000000000..82306c281 --- /dev/null +++ b/internal/sandbox/runtime_create_windows.go @@ -0,0 +1,53 @@ +//go:build windows + +package sandbox + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// createRuntimeDirIdentified creates path and returns the identity of the +// directory it created, taken from the creation handle. +// +// os.Mkdir followed by runtimeDirIdentity(path) is two resolutions of one name: +// Mkdir creates A, and the reopen can land on a B substituted in between, so the +// rollback ledger records B's identity for a directory this run never made. +// Compensation then proves it holds B and deletes it, while A keeps this run's +// ACL and stamp under a name nothing is tracking. +// +// FILE_CREATE fails if anything already exists at the name, which is the same +// os.IsExist signal the caller already handles, and the returned handle IS the +// object created, so its identity cannot describe anything else. +func createRuntimeDirIdentified(path string) (string, bool, error) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", false, 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.CREATE_NEW, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_FLAG_POSIX_SEMANTICS, + 0, + ) + if err != nil { + // CreateFile cannot make a directory; fall back to Mkdir plus an + // immediate no-follow open, which is still two steps but keeps the + // window to the creation itself rather than to a later reopen. + if mkErr := os.Mkdir(path, 0o700); mkErr != nil { + return "", false, mkErr + } + identity, ok := runtimeDirIdentity(path) + return identity, ok, nil + } + defer windows.CloseHandle(handle) + identity, idErr := handleRuntimeIdentity(handle) + if idErr != nil { + return "", false, nil + } + return identity, true, nil +} diff --git a/internal/sandbox/runtime_snapshot_other.go b/internal/sandbox/runtime_snapshot_other.go new file mode 100644 index 000000000..d5655a887 --- /dev/null +++ b/internal/sandbox/runtime_snapshot_other.go @@ -0,0 +1,17 @@ +//go:build !windows + +package sandbox + +import "os" + +// snapshotRuntimeStampBound keeps the pathname form off Windows. The split it +// closes there is specific to an elevated installer racing an unelevated +// renamer; the same eager identity capture still applies. +func snapshotRuntimeStampBound(root string) (identity string, identified bool, prior []byte, existed bool) { + identity, identified = runtimeDirIdentity(root) + data, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) + if err != nil { + return identity, identified, nil, false + } + return identity, identified, data, true +} diff --git a/internal/sandbox/runtime_snapshot_windows.go b/internal/sandbox/runtime_snapshot_windows.go new file mode 100644 index 000000000..4cc6001fb --- /dev/null +++ b/internal/sandbox/runtime_snapshot_windows.go @@ -0,0 +1,63 @@ +//go:build windows + +package sandbox + +import ( + "io" + "os" + + "golang.org/x/sys/windows" +) + +// snapshotRuntimeStampBound reads the runtime root's identity and its existing +// stamp through ONE handle. +// +// The two used to be taken separately: runtimeDirIdentity opened the root, read +// its volume and file ID, and closed the handle, and then os.ReadFile resolved +// the pathname again. A rename and substitution in that interval pairs A's +// identity with B's stamp bytes, and a rollback that correctly proves it holds A +// then writes B's bytes into it, corrupting an attestation that predates this +// run. The lease stops cleanup selecting the root; it does not stop the parent's +// owner renaming it. +// +// Reading the child relative to the identified handle removes the second +// resolution, so both facts describe the same object by construction. +func snapshotRuntimeStampBound(root string) (identity string, identified bool, prior []byte, existed bool) { + utf16Root, err := windows.UTF16PtrFromString(root) + if err != nil { + return "", false, nil, false + } + directory, err := windows.CreateFile( + utf16Root, + windows.FILE_TRAVERSE|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|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return "", false, nil, false + } + defer windows.CloseHandle(directory) + + identity, idErr := handleRuntimeIdentity(directory) + if idErr != nil { + return "", false, nil, false + } + + stamp, err := openWindowsChildNoFollow(directory, windowsSandboxRuntimeStampName, + windows.GENERIC_READ|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE) + if err != nil { + // Absent, or unreadable and therefore not something to put back. The + // identity still stands: it came from the handle above, not from this. + return identity, true, nil, false + } + file := os.NewFile(uintptr(stamp), windowsSandboxRuntimeStampName) + defer file.Close() + data, readErr := io.ReadAll(file) + if readErr != nil { + return identity, true, nil, false + } + return identity, true, data, true +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 993a521f2..0fe316741 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -682,13 +682,19 @@ func snapshotWindowsSandboxRuntimeStamp(root string) windowsSandboxStampSnapshot return windowsSandboxStampSnapshot{} } path := windowsSandboxRuntimeStampPath(root) - rootIdentity, rootIdentified := runtimeDirIdentity(root) - prior, err := os.ReadFile(path) - if err != nil { - // Absent, or unreadable and therefore not something to put back. - return windowsSandboxStampSnapshot{path: path, root: root, rootIdentity: rootIdentity, rootIdentified: rootIdentified} + // ONE HANDLE FOR BOTH FACTS. Taking the identity and then re-resolving the + // pathname to read the stamp let a rename in between pair one directory's + // identity with another's bytes, so a rollback could verify the right object + // and then write the wrong contents into it. + rootIdentity, rootIdentified, prior, existed := snapshotRuntimeStampBound(root) + return windowsSandboxStampSnapshot{ + path: path, + prior: prior, + existed: existed, + root: root, + rootIdentity: rootIdentity, + rootIdentified: rootIdentified, } - return windowsSandboxStampSnapshot{path: path, prior: prior, existed: true, root: root, rootIdentity: rootIdentity, rootIdentified: rootIdentified} } func (snapshot windowsSandboxStampSnapshot) restore() error { @@ -868,16 +874,17 @@ func createRuntimeDirRecording(root string) ([]windowsCreatedRuntimeDir, error) } var created []windowsCreatedRuntimeDir for index := len(missing) - 1; index >= 0; index-- { - if err := os.Mkdir(missing[index], 0o700); err != nil { + // IDENTITY FROM THE CREATION, not from a reopen of the name. Creating and + // then re-resolving is two chances to name a different object, and the + // ledger only means anything if it describes the directory this run made. + identity, identified, err := createRuntimeDirIdentified(missing[index]) + if err != nil { if os.IsExist(err) { // Raced with something else creating it; not ours to remove. continue } return created, fmt.Errorf("create sandbox runtime root %s: %w", missing[index], err) } - // Identified immediately after creating it, so compensation can prove it is - // still the same object rather than trusting the name. - identity, identified := runtimeDirIdentity(missing[index]) created = append(created, windowsCreatedRuntimeDir{path: missing[index], identity: identity, identified: identified}) } // Re-checked after creation. If an ancestor was swapped for a junction while From 008182cbe407418dde2769420da113c338f4fdeb Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Mon, 31 Aug 2026 14:46:43 +0530 Subject: [PATCH 38/38] fix(sandbox): prove the runtime create and stamp records before mutating Two rollback records were still assembled from facts that were never established, so compensation was correct about the wrong thing. The created-directory record took its identity from a pathname resolved after creation. Win32 CreateFile cannot create a directory whatever disposition it is given, so the CREATE_NEW attempt fell through to os.Mkdir plus a separate reopen on EVERY real creation, and the documented creation-handle contract never once held. The runtime parent belongs to the ordinary user, who can rename the new directory away and put an ordinary one at the predictable name in between; the ledger then recorded the substitute, and a failed setup could delete it while the directory this run actually created kept the ACL and stamp under a name nothing was tracking. NtCreateFile with FILE_CREATE and FILE_DIRECTORY_FILE does create a directory and returns the handle, so identity is read from the object created and there is no second resolution to race. A collision still surfaces as a *PathError carrying os.ErrExist, because the caller asks os.IsExist, which does not unwrap a %w chain: wrapping alone would have turned a benign lost race into a hard setup failure. An unidentifiable create now errors instead of manufacturing an ownership record. The stamp record collapsed every failure into proven absence. An encoding failure, a root that would not open, an unreadable identity, a denied child open and a short read all arrived as existed=false, exactly like a real not-found. The writer uses FILE_OVERWRITE_IF and can replace an existing stamp where the read was denied, and compensation for "did not exist" deletes the current stamp and returns with nothing to restore, so a setup attempt that REPORTED FAILURE destroyed the attestation of the previous successful setup. The snapshot now returns three states. Only ERROR_FILE_NOT_FOUND and ERROR_PATH_NOT_FOUND produce absent; a complete read produces present; anything else is unknown and returns an error. Setup refuses on unknown before the ACL and stamp are applied, and restore refuses to compensate an unproven prior state, so the two halves of the guard sit on both sides of the mutation. The zero value is unknown deliberately. Tests cover the three states separately, an unreadable prior stamp left byte-for-byte intact with the writer never reached, and a create/reopen barrier that counts pathname resolutions. One honest limit on that barrier: it counts resolutions through runtimeIdentityAfterCreate, which the non-Windows path uses and which is documented as the pathname reopen. It would not catch a future hand-rolled one. --- .../sandbox/runtime_bound_records_test.go | 12 +- .../runtime_compensation_identity_test.go | 9 +- internal/sandbox/runtime_create.go | 15 ++ internal/sandbox/runtime_create_other.go | 2 +- internal/sandbox/runtime_create_windows.go | 116 +++++++--- .../runtime_record_states_windows_test.go | 208 ++++++++++++++++++ internal/sandbox/runtime_snapshot_other.go | 26 ++- internal/sandbox/runtime_snapshot_windows.go | 53 ++++- internal/sandbox/windows_setup.go | 61 ++++- ...indows_setup_rollback_completeness_test.go | 12 +- ...ws_setup_unidentified_compensation_test.go | 2 +- internal/sandbox/windows_setup_windows.go | 13 +- 12 files changed, 466 insertions(+), 63 deletions(-) create mode 100644 internal/sandbox/runtime_create.go create mode 100644 internal/sandbox/runtime_record_states_windows_test.go diff --git a/internal/sandbox/runtime_bound_records_test.go b/internal/sandbox/runtime_bound_records_test.go index ffb5a1597..de5c63e4e 100644 --- a/internal/sandbox/runtime_bound_records_test.go +++ b/internal/sandbox/runtime_bound_records_test.go @@ -26,7 +26,11 @@ func TestStampSnapshotPairsIdentityWithItsOwnBytes(t *testing.T) { t.Fatal(err) } - identity, identified, prior, existed := snapshotRuntimeStampBound(root) + identity, identified, prior, state, err := snapshotRuntimeStampBound(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + existed := state == runtimeStampPresent if !identified { t.Fatal("the snapshot established no identity for a directory that exists") } @@ -45,7 +49,11 @@ func TestStampSnapshotIdentifiesARootWithNoStamp(t *testing.T) { if err := os.MkdirAll(root, 0o700); err != nil { t.Fatal(err) } - identity, identified, _, existed := snapshotRuntimeStampBound(root) + identity, identified, _, state, err := snapshotRuntimeStampBound(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + existed := state == runtimeStampPresent if !identified || identity == "" { t.Error("a root with no stamp established no identity") } diff --git a/internal/sandbox/runtime_compensation_identity_test.go b/internal/sandbox/runtime_compensation_identity_test.go index 5a42b3ef3..1bd96b58b 100644 --- a/internal/sandbox/runtime_compensation_identity_test.go +++ b/internal/sandbox/runtime_compensation_identity_test.go @@ -27,8 +27,11 @@ func TestStampCompensationRefusesAReplacementDirectory(t *testing.T) { t.Fatal(err) } - snapshot := snapshotWindowsSandboxRuntimeStamp(root) - if !snapshot.existed { + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if snapshot.priorState != runtimeStampPresent { t.Fatal("SETUP INVALID: the snapshot did not record the pre-existing stamp") } @@ -45,7 +48,7 @@ func TestStampCompensationRefusesAReplacementDirectory(t *testing.T) { t.Fatal(err) } - err := snapshot.restore() + err = snapshot.restore() if err == nil { t.Fatal("compensation mutated a directory it never touched and reported success") } diff --git a/internal/sandbox/runtime_create.go b/internal/sandbox/runtime_create.go new file mode 100644 index 000000000..c7e2336ba --- /dev/null +++ b/internal/sandbox/runtime_create.go @@ -0,0 +1,15 @@ +package sandbox + +// runtimeIdentityAfterCreate resolves a just-created runtime directory's +// identity BY PATHNAME, which is a second resolution of the same name and +// therefore a window: the directory created can be renamed away and an ordinary +// one substituted before this runs, and the ledger then records the substitute +// for a directory the run never made. +// +// It is the honest implementation off Windows, where the elevated-installer +// versus unelevated-renamer split this closes does not apply. On Windows the +// creation returns the handle and identity is read from that, so nothing there +// may call this: a Windows test asserts the count is zero, which is what makes +// "the creation establishes the identity" a checked property rather than a +// comment. +var runtimeIdentityAfterCreate = runtimeDirIdentity diff --git a/internal/sandbox/runtime_create_other.go b/internal/sandbox/runtime_create_other.go index 27ea2a7f2..59c5a08bc 100644 --- a/internal/sandbox/runtime_create_other.go +++ b/internal/sandbox/runtime_create_other.go @@ -10,6 +10,6 @@ func createRuntimeDirIdentified(path string) (string, bool, error) { if err := os.Mkdir(path, 0o700); err != nil { return "", false, err } - identity, ok := runtimeDirIdentity(path) + identity, ok := runtimeIdentityAfterCreate(path) return identity, ok, nil } diff --git a/internal/sandbox/runtime_create_windows.go b/internal/sandbox/runtime_create_windows.go index 82306c281..0c3e7c69b 100644 --- a/internal/sandbox/runtime_create_windows.go +++ b/internal/sandbox/runtime_create_windows.go @@ -3,51 +3,111 @@ package sandbox import ( + "errors" + "fmt" "os" + "path/filepath" + "unsafe" "golang.org/x/sys/windows" ) // createRuntimeDirIdentified creates path and returns the identity of the -// directory it created, taken from the creation handle. +// directory it created, read from the handle the creation itself returned. // -// os.Mkdir followed by runtimeDirIdentity(path) is two resolutions of one name: -// Mkdir creates A, and the reopen can land on a B substituted in between, so the -// rollback ledger records B's identity for a directory this run never made. -// Compensation then proves it holds B and deletes it, while A keeps this run's -// ACL and stamp under a name nothing is tracking. +// THE CREATION MUST BE THE THING THAT ESTABLISHES IDENTITY. os.Mkdir followed by +// runtimeDirIdentity(path) is two resolutions of one name: Mkdir creates A, and +// the reopen can land on a B substituted in between, so the rollback ledger +// records B's identity for a directory this run never made. Compensation then +// correctly proves it holds B and deletes it, while A keeps this run's ACL and +// stamp under a name nothing is tracking. The runtime parent belongs to the +// ordinary user, so that substitution needs no privilege. // -// FILE_CREATE fails if anything already exists at the name, which is the same -// os.IsExist signal the caller already handles, and the returned handle IS the -// object created, so its identity cannot describe anything else. +// Win32 CreateFile cannot create a directory at all, whatever disposition it is +// given, so an earlier attempt to do this with CREATE_NEW fell through to the +// Mkdir-plus-reopen path on EVERY real creation and the documented handle +// contract never once held. NtCreateFile with FILE_CREATE and +// FILE_DIRECTORY_FILE does create one, and returns the handle to it. +// +// If the atomic create cannot be completed, this returns an error rather than +// manufacturing an ownership record from a reopen: an unidentified create must +// stop setup before privileged state is applied, not enter the ledger as though +// it were proven. func createRuntimeDirIdentified(path string) (string, bool, error) { - utf16Path, err := windows.UTF16PtrFromString(path) + clean := filepath.Clean(path) + parentPath, leaf := filepath.Split(clean) + leaf = filepath.Clean(leaf) + parentPath = filepath.Clean(parentPath) + if leaf == "" || leaf == "." || parentPath == clean { + return "", false, fmt.Errorf("sandbox runtime path %s has no component to create", path) + } + + // The parent is either a directory that already existed when the missing + // components were computed, or one this same loop created a moment ago. + parent, err := openWindowsDirectoryByName(parentPath) if err != nil { - return "", false, err + return "", false, fmt.Errorf("open sandbox runtime parent %s: %w", parentPath, 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.CREATE_NEW, - windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_ATTRIBUTE_DIRECTORY|windows.FILE_FLAG_POSIX_SEMANTICS, - 0, - ) + defer windows.CloseHandle(parent) + + handle, err := createWindowsChildDirectory(parent, leaf) if err != nil { - // CreateFile cannot make a directory; fall back to Mkdir plus an - // immediate no-follow open, which is still two steps but keeps the - // window to the creation itself rather than to a later reopen. - if mkErr := os.Mkdir(path, 0o700); mkErr != nil { - return "", false, mkErr + // A collision is the same signal os.Mkdir gives for a component another + // process won the race to create, and the caller already treats that as + // "not ours" (windows_setup.go). Returned as a *PathError so BOTH + // os.IsExist, which the caller uses and which does not unwrap %w, and + // errors.Is recognize it; wrapping with %w alone would have turned a + // benign race into a hard setup failure. + if errors.Is(err, windows.STATUS_OBJECT_NAME_COLLISION) { + return "", false, &os.PathError{Op: "mkdir", Path: clean, Err: os.ErrExist} } - identity, ok := runtimeDirIdentity(path) - return identity, ok, nil + return "", false, fmt.Errorf("create sandbox runtime directory %s: %w", clean, err) } defer windows.CloseHandle(handle) + identity, idErr := handleRuntimeIdentity(handle) if idErr != nil { - return "", false, nil + return "", false, fmt.Errorf("identify the sandbox runtime directory created at %s: %w", clean, idErr) } return identity, true, nil } + +// createWindowsChildDirectory creates exactly one directory component beneath +// parent and returns the handle to the object it created. +// +// Relative to a handle, so no ancestor is re-resolved and there is no interval +// for a swap to land in. FILE_CREATE fails rather than opening anything that is +// already there, so the returned handle cannot describe a pre-existing object, +// and FILE_OPEN_REPARSE_POINT keeps the failure honest if one is. +func createWindowsChildDirectory(parent windows.Handle, name string) (windows.Handle, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, fmt.Errorf("encode sandbox runtime 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 iosb windows.IO_STATUS_BLOCK + err = windows.NtCreateFile( + &handle, + windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE, + &attributes, + &iosb, + nil, + windows.FILE_ATTRIBUTE_DIRECTORY, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_CREATE, + windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return 0, err + } + return handle, nil +} diff --git a/internal/sandbox/runtime_record_states_windows_test.go b/internal/sandbox/runtime_record_states_windows_test.go new file mode 100644 index 000000000..7836fa9e2 --- /dev/null +++ b/internal/sandbox/runtime_record_states_windows_test.go @@ -0,0 +1,208 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// THE CREATION ITSELF HAS TO ESTABLISH THE IDENTITY. +// +// Win32 CreateFile cannot create a directory whatever disposition it is given, +// so the CREATE_NEW attempt fell through to os.Mkdir plus a separate reopen on +// EVERY real creation and the documented creation-handle contract never once +// held. The runtime parent belongs to the ordinary user, who can rename the new +// directory A away and drop an ordinary directory B at the predictable name in +// between, and the ledger then records B for a directory this run never made. +// +// Driving the interleaving is not the point, and a barrier there would only +// prove the window was still measurable. The property is that no reopen exists +// to be raced: whatever the name resolves to afterwards, the recorded identity +// is the object the create returned. +func TestCreatedRuntimeDirIdentityComesFromTheCreation(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "created") + + // THE DETERMINISTIC BARRIER. The old shape resolved the pathname a second + // time after os.Mkdir, and that reopen is the whole window. Counting the + // resolutions turns "the creation establishes the identity" into something + // checked: on Windows the create returns the handle, so this must be zero. + resolutions := 0 + restore := runtimeIdentityAfterCreate + runtimeIdentityAfterCreate = func(p string) (string, bool) { + resolutions++ + return restore(p) + } + t.Cleanup(func() { runtimeIdentityAfterCreate = restore }) + + identity, identified, err := createRuntimeDirIdentified(path) + if err != nil || !identified { + t.Fatalf("create: identity=%q identified=%v err=%v", identity, identified, err) + } + if resolutions != 0 { + t.Errorf("the creation resolved the pathname again %d time(s); identity must come from the creation handle", resolutions) + } + info, statErr := os.Stat(path) + if statErr != nil || !info.IsDir() { + t.Fatalf("no directory was created: err=%v", statErr) + } + + // Substitute the whole directory the way the parent's owner could, then ask + // what the name says now. The record must still describe what was created. + aside := filepath.Join(root, "moved-aside") + if err := os.Rename(path, aside); err != nil { + t.Skipf("cannot rename the runtime directory on this filesystem: %v", err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + + substitute, ok := runtimeDirIdentity(path) + if !ok { + t.Fatal("the substitute could not be identified, so the comparison proves nothing") + } + if identity == substitute { + t.Fatal("SETUP INVALID: the substitute has the same identity as the created directory") + } + moved, ok := runtimeDirIdentity(aside) + if !ok { + t.Fatal("the created directory could not be identified after the rename") + } + if identity != moved { + t.Errorf("the recorded identity %q describes neither the created directory (%q) nor anything this run owns", identity, moved) + } +} + +// A component another process created first must stay "not ours", and it must +// keep saying so through os.IsExist, which is what the caller asks and which +// does not unwrap a %w chain. +func TestCreatedRuntimeDirRefusesAnExistingName(t *testing.T) { + path := filepath.Join(t.TempDir(), "created") + if _, _, err := createRuntimeDirIdentified(path); err != nil { + t.Fatalf("first create: %v", err) + } + identity, identified, err := createRuntimeDirIdentified(path) + if !os.IsExist(err) { + t.Errorf("creating over an existing directory returned %v, want an IsExist error", err) + } + if identified || identity != "" { + t.Errorf("a refused create still produced an ownership record: identity=%q identified=%v", identity, identified) + } +} + +// THE THREE STATES ARE DIFFERENT FACTS. +// +// "Read it and there was nothing" and "could not read it" both used to arrive as +// existed=false. The stamp writer uses FILE_OVERWRITE_IF and can replace an +// existing stamp even where the read was denied, so that lie let a setup which +// then FAILED delete an attestation it had no record of, leaving the previous +// run's marker pointing at a runtime root it can no longer prove. +func TestRuntimeStampSnapshotSeparatesAbsentPresentAndUnknown(t *testing.T) { + t.Run("absent", func(t *testing.T) { + root := t.TempDir() + _, _, prior, state, err := snapshotRuntimeStampBound(root) + if err != nil { + t.Fatalf("a readable root with no stamp is not an error: %v", err) + } + if state != runtimeStampAbsent { + t.Errorf("state = %v, want absent", state) + } + if prior != nil { + t.Errorf("absent produced prior bytes %q", prior) + } + }) + + t.Run("present", func(t *testing.T) { + root := t.TempDir() + want := []byte("prior-attestation") + if err := os.WriteFile(filepath.Join(root, windowsSandboxRuntimeStampName), want, 0o600); err != nil { + t.Fatal(err) + } + _, _, prior, state, err := snapshotRuntimeStampBound(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if state != runtimeStampPresent { + t.Errorf("state = %v, want present", state) + } + if string(prior) != string(want) { + t.Errorf("prior = %q, want %q", prior, want) + } + }) + + // A stamp NAME that cannot be read as a file. The child open is refused for a + // reason that is emphatically not "not found", which is the whole distinction. + t.Run("unknown", func(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName), 0o700); err != nil { + t.Fatal(err) + } + _, _, prior, state, err := snapshotRuntimeStampBound(root) + if err == nil { + t.Fatal("an unreadable stamp was reported as a successful snapshot") + } + if state != runtimeStampUnknown { + t.Errorf("state = %v, want unknown", state) + } + if prior != nil { + t.Errorf("unknown produced prior bytes %q", prior) + } + }) +} + +// AND UNKNOWN MUST NOT AUTHORIZE COMPENSATION. +// +// The forward mutation is refused before it begins, so this asserts the second +// half: a record that somehow reached compensation with an unproven prior state +// leaves the current stamp alone and says so, rather than deleting it and +// returning with nothing to put back. +func TestUnknownPriorStampIsNeverCompensated(t *testing.T) { + root := t.TempDir() + stampPath := filepath.Join(root, windowsSandboxRuntimeStampName) + current := []byte("the-attestation-of-the-previous-successful-setup") + if err := os.WriteFile(stampPath, current, 0o600); err != nil { + t.Fatal(err) + } + identity, identified := runtimeDirIdentity(root) + if !identified { + t.Fatal("SETUP INVALID: the runtime root could not be identified") + } + + snapshot := windowsSandboxStampSnapshot{ + path: stampPath, + priorState: runtimeStampUnknown, + root: root, + rootIdentity: identity, + rootIdentified: true, + } + err := snapshot.restore() + if err == nil { + t.Fatal("compensation acted on a prior state it never established, and reported success") + } + + after, readErr := os.ReadFile(stampPath) + if readErr != nil { + t.Fatalf("the existing stamp was destroyed by a rollback that had nothing to restore: %v", readErr) + } + if string(after) != string(current) { + t.Errorf("the existing stamp was rewritten: got %q, want %q", after, current) + } +} + +// And setup refuses BEFORE the ACL and stamp are applied, which is the half that +// keeps the writer from running at all. +func TestSetupRefusesAnUnreadablePriorStamp(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName), 0o700); err != nil { + t.Fatal(err) + } + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err == nil { + t.Fatal("setup accepted a snapshot that could not read the prior stamp") + } + if snapshot.priorState != runtimeStampUnknown { + t.Errorf("the refused snapshot carried state %v, want unknown", snapshot.priorState) + } +} diff --git a/internal/sandbox/runtime_snapshot_other.go b/internal/sandbox/runtime_snapshot_other.go index d5655a887..33c0c3c41 100644 --- a/internal/sandbox/runtime_snapshot_other.go +++ b/internal/sandbox/runtime_snapshot_other.go @@ -2,16 +2,30 @@ package sandbox -import "os" +import ( + "errors" + "fmt" + "io/fs" + "os" +) // snapshotRuntimeStampBound keeps the pathname form off Windows. The split it // closes there is specific to an elevated installer racing an unelevated // renamer; the same eager identity capture still applies. -func snapshotRuntimeStampBound(root string) (identity string, identified bool, prior []byte, existed bool) { +// +// The three-state result is NOT Windows-specific, though: "read it and there was +// nothing" and "could not read it" are different facts on every platform, and +// only the first may authorize a compensating delete. A permission or I/O error +// here stops setup rather than being recorded as proven absence. +func snapshotRuntimeStampBound(root string) (identity string, identified bool, prior []byte, state runtimeStampState, err error) { identity, identified = runtimeDirIdentity(root) - data, err := os.ReadFile(windowsSandboxRuntimeStampPath(root)) - if err != nil { - return identity, identified, nil, false + path := windowsSandboxRuntimeStampPath(root) + data, readErr := os.ReadFile(path) + if readErr != nil { + if errors.Is(readErr, fs.ErrNotExist) { + return identity, identified, nil, runtimeStampAbsent, nil + } + return identity, identified, nil, runtimeStampUnknown, fmt.Errorf("read the sandbox runtime stamp at %s: %w", path, readErr) } - return identity, identified, data, true + return identity, identified, data, runtimeStampPresent, nil } diff --git a/internal/sandbox/runtime_snapshot_windows.go b/internal/sandbox/runtime_snapshot_windows.go index 4cc6001fb..1985e9975 100644 --- a/internal/sandbox/runtime_snapshot_windows.go +++ b/internal/sandbox/runtime_snapshot_windows.go @@ -3,6 +3,8 @@ package sandbox import ( + "errors" + "fmt" "io" "os" @@ -22,10 +24,19 @@ import ( // // Reading the child relative to the identified handle removes the second // resolution, so both facts describe the same object by construction. -func snapshotRuntimeStampBound(root string) (identity string, identified bool, prior []byte, existed bool) { +// +// EVERY FAILURE IS AN ERROR, NOT AN ABSENCE. This used to collapse an encoding +// failure, a directory that would not open, an identity that could not be read, +// a denied child open and a short read all into the same "there was no stamp" +// answer that a genuine ERROR_FILE_NOT_FOUND produces. The stamp writer uses +// FILE_OVERWRITE_IF and can replace an existing stamp even where the read was +// denied, so that lie let a FAILED setup delete an attestation it had no record +// of, leaving the previous run's marker pointing at an unusable runtime root. +// Only a positive not-found produces runtimeStampAbsent. +func snapshotRuntimeStampBound(root string) (identity string, identified bool, prior []byte, state runtimeStampState, err error) { utf16Root, err := windows.UTF16PtrFromString(root) if err != nil { - return "", false, nil, false + return "", false, nil, runtimeStampUnknown, fmt.Errorf("encode sandbox runtime root %s: %w", root, err) } directory, err := windows.CreateFile( utf16Root, @@ -37,27 +48,51 @@ func snapshotRuntimeStampBound(root string) (identity string, identified bool, p 0, ) if err != nil { - return "", false, nil, false + // A root that is simply not there yet is the ordinary first-run case: the + // created-directory rollback owns it and there is no prior stamp to lose. + if isWindowsNotFound(err) { + return "", false, nil, runtimeStampAbsent, nil + } + return "", false, nil, runtimeStampUnknown, fmt.Errorf("open sandbox runtime root %s: %w", root, err) } defer windows.CloseHandle(directory) identity, idErr := handleRuntimeIdentity(directory) if idErr != nil { - return "", false, nil, false + return "", false, nil, runtimeStampUnknown, fmt.Errorf("identify sandbox runtime root %s: %w", root, idErr) } stamp, err := openWindowsChildNoFollow(directory, windowsSandboxRuntimeStampName, windows.GENERIC_READ|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE) if err != nil { - // Absent, or unreadable and therefore not something to put back. The - // identity still stands: it came from the handle above, not from this. - return identity, true, nil, false + if isWindowsNotFound(err) { + // Proven absent. The identity still stands: it came from the handle + // above, not from this. + return identity, true, nil, runtimeStampAbsent, nil + } + return identity, true, nil, runtimeStampUnknown, fmt.Errorf("open the sandbox runtime stamp in %s: %w", root, err) } file := os.NewFile(uintptr(stamp), windowsSandboxRuntimeStampName) defer file.Close() data, readErr := io.ReadAll(file) if readErr != nil { - return identity, true, nil, false + return identity, true, nil, runtimeStampUnknown, fmt.Errorf("read the sandbox runtime stamp in %s: %w", root, readErr) + } + return identity, true, data, runtimeStampPresent, nil +} + +// isWindowsNotFound reports the two statuses that mean the object genuinely is +// not there, as opposed to the many that mean it could not be looked at. +// +// openWindowsChildNoFollow wraps its NTSTATUS, and CreateFile returns the Win32 +// errno, so both spellings are checked rather than assuming one layer. +func isWindowsNotFound(err error) bool { + if err == nil { + return false } - return identity, true, data, true + return errors.Is(err, os.ErrNotExist) || + errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) || + errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) || + errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND) } diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 0fe316741..1a090c91d 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -659,10 +659,35 @@ type windowsCreatedRuntimeDir struct { // stamp, which reads as "the runtime directory was removed since setup ran" -- // a healthy machine reporting itself broken because an unrelated later setup // failed. +// runtimeStampState is what the snapshot could actually establish about the +// stamp that was there BEFORE this run. +// +// Two booleans could not say it. "Read it, and there was nothing" and "could not +// read it" both arrived as existed=false, so compensation could not tell an undo +// from destruction: it deleted the current stamp and returned immediately with +// nothing to put back, and a setup attempt that REPORTED FAILURE had destroyed +// the attestation belonging to the previous successful setup. +type runtimeStampState int + +const ( + // runtimeStampUnknown is the zero value on purpose: a snapshot nobody filled + // in must never read as proven absence. + runtimeStampUnknown runtimeStampState = iota + // runtimeStampAbsent is a POSITIVE observation of nothing there, and only + // ERROR_FILE_NOT_FOUND or ERROR_PATH_NOT_FOUND produces it. + runtimeStampAbsent + // runtimeStampPresent means the prior bytes were read completely. + runtimeStampPresent +) + type windowsSandboxStampSnapshot struct { - path string - prior []byte - existed bool + path string + prior []byte + // priorState is what was actually observed. See runtimeStampState: an + // encoding, directory-open, identity, child-open or read failure stays + // UNKNOWN and must stop the forward mutation rather than authorizing a + // delete-without-restore later. + priorState runtimeStampState // root and rootIdentity identify the DIRECTORY the stamp lives in, captured // when the snapshot was taken. The stamp file itself may not exist yet, so the // directory is the object whose replacement this has to detect. @@ -676,25 +701,33 @@ type windowsSandboxStampSnapshot struct { rootIdentified bool } -func snapshotWindowsSandboxRuntimeStamp(root string) windowsSandboxStampSnapshot { +// snapshotWindowsSandboxRuntimeStamp captures what compensation will need, and +// FAILS rather than guessing. An error here must stop setup before the ACL and +// stamp are applied: the writer can replace an existing stamp even when the +// earlier read was denied, and compensation would then delete it with nothing +// recorded to restore. +func snapshotWindowsSandboxRuntimeStamp(root string) (windowsSandboxStampSnapshot, error) { root = strings.TrimSpace(root) if root == "" { - return windowsSandboxStampSnapshot{} + return windowsSandboxStampSnapshot{}, nil } path := windowsSandboxRuntimeStampPath(root) // ONE HANDLE FOR BOTH FACTS. Taking the identity and then re-resolving the // pathname to read the stamp let a rename in between pair one directory's // identity with another's bytes, so a rollback could verify the right object // and then write the wrong contents into it. - rootIdentity, rootIdentified, prior, existed := snapshotRuntimeStampBound(root) + rootIdentity, rootIdentified, prior, state, err := snapshotRuntimeStampBound(root) + if err != nil { + return windowsSandboxStampSnapshot{}, fmt.Errorf("record the sandbox runtime stamp at %s before changing it: %w", path, err) + } return windowsSandboxStampSnapshot{ path: path, prior: prior, - existed: existed, + priorState: state, root: root, rootIdentity: rootIdentity, rootIdentified: rootIdentified, - } + }, nil } func (snapshot windowsSandboxStampSnapshot) restore() error { @@ -720,9 +753,19 @@ func (snapshot windowsSandboxStampSnapshot) restore() error { } return nil } + // AN UNPROVEN PRIOR STATE IS NOT PERMISSION TO DELETE. Absence has to have + // been observed, not inferred from a generic error: compensation for + // "existed=false" removes the current stamp and returns with nothing to put + // back, so reaching here on an unreadable snapshot would destroy the + // attestation of the previous successful setup on behalf of a run that + // failed. Setup refuses before the apply for exactly this reason, and this is + // the second half of that guard for any path that assembled a record anyway. + if snapshot.priorState == runtimeStampUnknown { + return fmt.Errorf("the stamp at %s could not be read when setup began, so removing or restoring it now cannot be shown to be an undo; leaving it untouched", snapshot.path) + } // BOUND TO THE OBJECT, not to the name. The identity check and the mutation // now share one handle, so a rename and replacement cannot land between them. - return compensateRuntimeStampBound(snapshot.root, snapshot.rootIdentity, snapshot.prior, snapshot.existed) + return compensateRuntimeStampBound(snapshot.root, snapshot.rootIdentity, snapshot.prior, snapshot.priorState == runtimeStampPresent) } // run removes what was created, innermost first. diff --git a/internal/sandbox/windows_setup_rollback_completeness_test.go b/internal/sandbox/windows_setup_rollback_completeness_test.go index be3a7ad9a..2705411d8 100644 --- a/internal/sandbox/windows_setup_rollback_completeness_test.go +++ b/internal/sandbox/windows_setup_rollback_completeness_test.go @@ -24,7 +24,10 @@ func TestRollbackRemovesTheStampItWroteAndThenTheRoot(t *testing.T) { } // Snapshot BEFORE the stamp exists, which is the fresh-setup case. - snapshot := snapshotWindowsSandboxRuntimeStamp(root) + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } if err := writeWindowsSandboxRuntimeStamp(root, "planhash"); err != nil { t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) } @@ -58,7 +61,10 @@ func TestRollbackRestoresAPreviousSetupsStamp(t *testing.T) { t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) } - snapshot := snapshotWindowsSandboxRuntimeStamp(root) + snapshot, err := snapshotWindowsSandboxRuntimeStamp(root) + if err != nil { + t.Fatalf("snapshot: %v", err) + } if err := writeWindowsSandboxRuntimeStamp(root, "this-run"); err != nil { t.Fatalf("writeWindowsSandboxRuntimeStamp: %v", err) } @@ -115,7 +121,7 @@ func TestRollbackContinuesAfterACompensationFails(t *testing.T) { stamp := windowsSandboxStampSnapshot{ path: windowsSandboxRuntimeStampPath(root), prior: []byte("x"), - existed: true, + priorState: runtimeStampPresent, root: root, rootIdentity: "0:0:0", rootIdentified: true, diff --git a/internal/sandbox/windows_setup_unidentified_compensation_test.go b/internal/sandbox/windows_setup_unidentified_compensation_test.go index 683c1dddc..947d0aa89 100644 --- a/internal/sandbox/windows_setup_unidentified_compensation_test.go +++ b/internal/sandbox/windows_setup_unidentified_compensation_test.go @@ -31,7 +31,7 @@ func TestStampCompensationRefusesAnUnidentifiedRoot(t *testing.T) { snapshot := windowsSandboxStampSnapshot{ path: stamp, prior: []byte("this run's stamp"), - existed: true, + priorState: runtimeStampPresent, root: root, rootIdentified: false, } diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 4c5221b6f..9bef1cbd7 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -101,7 +101,18 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) // stamp. Taking it afterwards would record this run's own stamp as the // state to restore, so a failed setup would put its own artifact back // rather than what it found. - runtimeRollback.stamp = snapshotWindowsSandboxRuntimeStamp(root) + // + // AND REFUSED IF IT CANNOT BE ESTABLISHED. The stamp writer uses + // FILE_OVERWRITE_IF, so it can replace an existing stamp even where this + // read was denied. Continuing on an unknown prior state would let a setup + // that then fails delete an attestation it never recorded, leaving the + // previous run's marker pointing at a runtime root it can no longer + // prove. No privileged state is applied until this is known. + snapshot, snapshotErr := snapshotWindowsSandboxRuntimeStamp(root) + if snapshotErr != nil { + return failed(snapshotErr) + } + runtimeRollback.stamp = snapshot } rollback, err := applyWindowsACLPlanWithStamp(plan, stamp) if err != nil {