diff --git a/internal/cli/sandbox.go b/internal/cli/sandbox.go index 56e09988a..7647f4d1a 100644 --- a/internal/cli/sandbox.go +++ b/internal/cli/sandbox.go @@ -20,7 +20,7 @@ type sandboxCommandOptions struct { func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if len(args) == 0 { - return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy` or `zero sandbox grants list`.") + return writeExecUsageError(stderr, "sandbox subcommand required. Use `zero sandbox policy`, `zero sandbox exec`, or `zero sandbox grants list`.") } switch args[0] { case "-h", "--help", "help": @@ -34,6 +34,8 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) return runSandboxSetup(args[1:], stdout, stderr, deps) case "check": return runSandboxCheck(args[1:], stdout, stderr, deps) + case "exec": + return runSandboxExec(args[1:], stdout, stderr, deps) case "grants": return runSandboxGrants(args[1:], stdout, stderr, deps) default: @@ -163,10 +165,17 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app if !setupHelper.Available() { return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider) } + // Resolved here, in the shell the user typed `zero sandbox setup` into, and + // carried in the args. The helper may be launched elevated, and an elevated + // process does not inherit this shell's environment. Stated explicitly rather + // than left nil (which resolves the same way) because this is the call site + // the opt-in is about. + principalOptIn := zeroSandbox.WindowsSandboxPrincipalOptIn(nil) setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{ CommandCWD: workspaceRoot, WorkspaceRoots: []string{workspaceRoot}, PermissionProfile: profile, + PrincipalOptIn: &principalOptIn, }) if err != nil { return writeAppError(stderr, err.Error(), exitCrash) @@ -632,6 +641,7 @@ Commands: policy Inspect active sandbox policy and platform backend setup Run native platform sandbox setup check Evaluate the sandbox decision for a hypothetical tool action + exec Run one command through the real sandbox grants Manage persistent sandbox grants `) diff --git a/internal/cli/sandbox_exec.go b/internal/cli/sandbox_exec.go new file mode 100644 index 000000000..460216552 --- /dev/null +++ b/internal/cli/sandbox_exec.go @@ -0,0 +1,165 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/Gitlawb/zero/internal/config" + zeroSandbox "github.com/Gitlawb/zero/internal/sandbox" +) + +// runSandboxExec runs ONE command through the real sandbox and exits with its +// status. +// +// This exists because until now the sandbox could only be exercised through a +// full agent turn with a model in the loop. `zero sandbox policy` reports what +// the posture would be and `zero sandbox check` evaluates a hypothetical +// decision, but nothing actually ran a command and let you look at what +// happened on disk afterwards. The practical result is that enforcement is +// covered almost entirely by tests asserting the shape of an ACL plan, and +// almost not at all by tests asserting a write was refused. +// +// A plan can be perfectly correct and never reach the filesystem. That is not +// hypothetical here: the .git rename guard was emitted correctly by the planner +// and silently skipped by the applier, and four tests covering the plan all +// passed while the ACE was absent from disk. Something that runs the real +// binary and then stats the file is the only thing that catches that class. +// +// Deliberately NOT a debug curiosity: it takes the same path a shell tool +// takes, through SandboxManager.BuildCommandPlan, so what it proves is what +// users get. It prints the resolved backend and enforcement level to stderr +// before running, so a harness can assert the sandbox was actually engaged +// rather than quietly downgraded. +func runSandboxExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + command, err := parseSandboxExecArgs(args) + if err != nil { + if errors.Is(err, errSandboxExecHelp) { + if writeErr := writeSandboxExecHelp(stdout); writeErr != nil { + return exitCrash + } + return exitSuccess + } + return writeExecUsageError(stderr, err.Error()) + } + + workspaceRoot, err := resolveWorkspaceRoot("", deps) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{}) + if err != nil { + return writeAppError(stderr, err.Error(), exitProvider) + } + policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox) + + scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots) + if err != nil { + return writeAppError(stderr, fmt.Sprintf("resolve sandbox write roots: %v", err), exitCrash) + } + + manager := zeroSandbox.NewSandboxManager(zeroSandbox.SandboxManagerOptions{ + Backend: deps.selectSandboxBackend(zeroSandbox.BackendOptions{}), + }) + plan, err := manager.BuildCommandPlan(zeroSandbox.SandboxManagerRequest{ + WorkspaceRoot: workspaceRoot, + Command: zeroSandbox.CommandSpec{ + Name: command[0], + Args: command[1:], + Dir: workspaceRoot, + Env: os.Environ(), + }, + Policy: policy, + Scope: scope, + // Ask for validation rather than a best-effort plan: a harness asserting + // that a write was refused needs to know the sandbox was really there. + ValidateExecution: true, + }) + if err != nil { + return writeAppError(stderr, fmt.Sprintf("build sandbox command plan: %v", err), exitCrash) + } + + // Printed before the command runs and on stderr, so it survives a command + // that writes to stdout and stays greppable by a test harness. A downgrade + // is reported loudly for the same reason: a smoke test that passes because + // the sandbox quietly stood down is worse than no smoke test. + fmt.Fprintf(stderr, "sandbox: backend=%s enforcement=%s wrapped=%t workspace=%s\n", + plan.Backend.Name, plan.EnforcementLevel, plan.Wrapped, plan.WorkspaceRoot) + if strings.TrimSpace(plan.DowngradeReason) != "" { + fmt.Fprintf(stderr, "sandbox: DOWNGRADED: %s\n", plan.DowngradeReason) + } + + return runSandboxPlannedCommand(plan, stdout, stderr) +} + +func runSandboxPlannedCommand(plan zeroSandbox.CommandPlan, stdout io.Writer, stderr io.Writer) int { + process := exec.Command(plan.Name, plan.Args...) + process.Dir = plan.Dir + if process.Dir == "" { + process.Dir = plan.WorkspaceRoot + } + if len(plan.Env) > 0 { + process.Env = plan.Env + } + process.Stdin = os.Stdin + process.Stdout = stdout + process.Stderr = stderr + + if err := process.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // The command's own status, not ours. A harness asserting "the write + // was refused" needs the refusal's exit code, not a wrapper's. + return exitErr.ExitCode() + } + fmt.Fprintf(stderr, "sandbox exec: %v\n", err) + return exitCrash + } + return exitSuccess +} + +var errSandboxExecHelp = errors.New("help requested") + +// parseSandboxExecArgs takes everything after `--` as the command, so the +// command's own flags are never mistaken for ours. +func parseSandboxExecArgs(args []string) ([]string, error) { + for index, arg := range args { + switch arg { + case "-h", "--help", "help": + return nil, errSandboxExecHelp + case "--": + command := args[index+1:] + if len(command) == 0 { + return nil, errors.New("usage: zero sandbox exec -- [args...]") + } + return command, nil + } + } + if len(args) == 0 { + return nil, errors.New("usage: zero sandbox exec -- [args...]") + } + // Tolerated without the separator for interactive use, but the separator is + // what the help shows, because anything with a leading dash needs it. + return args, nil +} + +func writeSandboxExecHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero sandbox exec -- [args...] + +Runs one command through the real sandbox and exits with its status. + +Everything after the -- separator is the command, so its own flags are not +parsed as Zero's. The resolved backend and enforcement level are written to +stderr before the command runs, and a downgrade is reported there explicitly. + +Examples: + zero sandbox exec -- cmd /c echo hello + zero sandbox exec -- powershell -Command "Set-Content out.txt x" + +`) + return err +} diff --git a/internal/doctor/hardening.go b/internal/doctor/hardening.go index baf21e04c..90ffb7d23 100644 --- a/internal/doctor/hardening.go +++ b/internal/doctor/hardening.go @@ -104,6 +104,10 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo CommandCWD: workspaceRoot, WorkspaceRoots: []string{workspaceRoot}, PermissionProfile: profile, + // Same opt-in a command would resolve, so doctor reports the principal + // mismatch as out-of-date setup instead of passing a check the next command + // will fail. + PrincipalOptIn: sandbox.WindowsSandboxPrincipalOptIn(nil), } if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil { result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{ diff --git a/internal/sandbox/analyzer.go b/internal/sandbox/analyzer.go index 7973fa951..5ca146b33 100644 --- a/internal/sandbox/analyzer.go +++ b/internal/sandbox/analyzer.go @@ -15,6 +15,15 @@ type AnalysisResult struct { Interactive bool Destructive bool Network bool + // LocalServer is set when a command BINDS a local port rather than reaching + // out: `python -m http.server`, `vite`, `next dev` and friends. + // + // Kept distinct from Network instead of folded into it. Listening and + // fetching are different acts with different consequences, and treating a + // dev server as egress made ordinary local work prompt for network approval + // it never needed. The information is preserved rather than dropped, so a + // caller that does care about inbound can still see it. + LocalServer bool // TooComplex is set when the script cannot be parsed (obfuscated or invalid), // so a caller can treat it as higher-risk instead of trusting a clean result. TooComplex bool @@ -183,6 +192,9 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de if commandUsesNetwork(prog, rest) { result.Network = true } + if commandRunsLocalServer(prog, rest) { + result.LocalServer = true + } if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) || @@ -198,9 +210,9 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool { return true } words := literalWordTexts(args) - if localServerPrograms[prog] { - return true - } + // localServerPrograms deliberately does NOT land here. Binding a port is not + // egress, and counting it as such is what made `python -m http.server` ask + // for network approval to serve files out of the workspace. switch prog { case "python", "python2", "python3", "py": return pythonModuleUsesNetwork(words) @@ -253,11 +265,11 @@ func packageManagerUsesNetwork(words []string, aliases map[string]string) bool { "update", "upgrade", "search", "view", "info", "show", "dist-tag", "deprecate", "owner", "org", "team", "token", "profile", "access": return true - case "start", "serve", "dev", "preview": - return true - case "run": - second := secondSubcommand(words) - return second == "start" || second == "serve" || second == "dev" || second == "preview" + // start / serve / dev / preview are handled by packageManagerRunsLocalServer + // instead. They start a dev server, which binds rather than fetches, and + // `npm run dev` is the single most common command an agent is asked to run + // while building something. Classifying it as egress made every one of them + // stop for a network approval that protected nothing. case "exec": // Package-manager exec commands may resolve and download a missing // package before launching it. An explicit offline flag keeps this path @@ -303,11 +315,61 @@ func pythonModuleUsesNetwork(words []string) bool { if words[index] != "-m" || index+1 >= len(words) { continue } - module := words[index+1] - if module == "http.server" { + // http.server is handled by pythonModuleRunsLocalServer instead: it + // listens, it does not fetch. pip install genuinely reaches out. + if words[index+1] == "pip" && firstSubcommand(words[index+2:], nil) == "install" { return true } - if module == "pip" && firstSubcommand(words[index+2:], nil) == "install" { + } + return false +} + +// commandRunsLocalServer reports a command that binds a local port. +// +// Separate from commandUsesNetwork on purpose. A dev server is the single most +// common thing an agent is asked to start while building something, and making +// it indistinguishable from `curl` meant every one of them stopped for a +// network approval that protected nobody. +// +// Honest about the edges: some of these do touch the network incidentally, and +// `npm run dev` may install first. What is claimed here is narrow, that BINDING +// is not EGRESS, not that dev tooling is inert. Anything that actually fetches +// still matches commandUsesNetwork through its own program or subcommand. +func commandRunsLocalServer(prog string, args []*syntax.Word) bool { + if localServerPrograms[prog] { + return true + } + words := literalWordTexts(args) + switch prog { + case "python", "python2", "python3", "py": + return pythonModuleRunsLocalServer(words) + case "npm", "pnpm", "yarn", "bun": + return packageManagerRunsLocalServer(words) + } + return false +} + +// packageManagerRunsLocalServer covers `npm run dev` and its siblings across the +// package managers, both as a direct subcommand and behind `run`. +func packageManagerRunsLocalServer(words []string) bool { + switch firstSubcommand(words, nil) { + case "start", "serve", "dev", "preview": + return true + case "run": + switch secondSubcommand(words) { + case "start", "serve", "dev", "preview": + return true + } + } + return false +} + +func pythonModuleRunsLocalServer(words []string) bool { + for index := 0; index < len(words); index++ { + if words[index] != "-m" || index+1 >= len(words) { + continue + } + if words[index+1] == "http.server" { return true } } diff --git a/internal/sandbox/analyzer_test.go b/internal/sandbox/analyzer_test.go index 9f44eca25..3ec36df61 100644 --- a/internal/sandbox/analyzer_test.go +++ b/internal/sandbox/analyzer_test.go @@ -9,6 +9,7 @@ func TestAnalyzeCommand(t *testing.T) { interactive bool destructive bool network bool + localServer bool tooComplex bool }{ {name: "editor", script: "vim foo.txt", interactive: true}, @@ -67,7 +68,7 @@ func TestAnalyzeCommand(t *testing.T) { {name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true}, {name: "Windows npm cmd", script: "npm.cmd install", network: true}, {name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true}, - {name: "python http server", script: "python3 -m http.server 8000", network: true}, + {name: "python http server", script: "python3 -m http.server 8000", localServer: true}, {name: "python pip install", script: "python3 -m pip install requests", network: true}, {name: "npm install", script: "npm install", network: true}, {name: "npm ci", script: "npm ci", network: true}, @@ -76,12 +77,12 @@ func TestAnalyzeCommand(t *testing.T) { {name: "npm metadata search", script: "npm search typescript", network: true}, {name: "npm offline install", script: "npm install --offline", network: false}, {name: "npm version is offline", script: "npm --version", network: false}, - {name: "npm start", script: "npm start", network: true}, - {name: "npm run dev", script: "npm run dev", network: true}, + {name: "npm start", script: "npm start", localServer: true}, + {name: "npm run dev", script: "npm run dev", localServer: true}, {name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true}, - {name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true}, - {name: "direct vite", script: "vite --host 127.0.0.1", network: true}, - {name: "next dev", script: "next dev", network: true}, + {name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", localServer: true}, + {name: "direct vite", script: "vite --host 127.0.0.1", localServer: true}, + {name: "next dev", script: "next dev", localServer: true}, {name: "git clone", script: "git clone https://example.com/repo.git", network: true}, {name: "git fetch", script: "git fetch origin", network: true}, {name: "git status is offline", script: "git status", network: false}, @@ -90,15 +91,26 @@ func TestAnalyzeCommand(t *testing.T) { {name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false}, {name: "process listing is not special-cased", script: "ps aux", network: false}, + // Binding a port is not reaching out. These four are the shape that made + // ordinary local work stop for a network approval it never needed, and the + // fetching siblings beside them are what must keep asking. + {name: "pnpm dev binds", script: "pnpm dev", localServer: true}, + {name: "yarn serve binds", script: "yarn serve", localServer: true}, + {name: "bun run preview binds", script: "bun run preview", localServer: true}, + {name: "pnpm install still fetches", script: "pnpm install", network: true}, + {name: "yarn add still fetches", script: "yarn add left-pad", network: true}, + {name: "npm publish still fetches", script: "npm publish", network: true}, + {name: "python pip install still fetches", script: "python3 -m pip install requests", network: true}, + {name: "unparseable", script: `'unterminated quote`, tooComplex: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := AnalyzeCommand(tc.script) if got.Interactive != tc.interactive || got.Destructive != tc.destructive || - got.Network != tc.network || got.TooComplex != tc.tooComplex { - t.Fatalf("AnalyzeCommand(%q) = %#v, want interactive=%v destructive=%v network=%v tooComplex=%v", - tc.script, got, tc.interactive, tc.destructive, tc.network, tc.tooComplex) + got.Network != tc.network || got.LocalServer != tc.localServer || got.TooComplex != tc.tooComplex { + t.Fatalf("AnalyzeCommand(%q) = %#v, want interactive=%v destructive=%v network=%v localServer=%v tooComplex=%v", + tc.script, got, tc.interactive, tc.destructive, tc.network, tc.localServer, tc.tooComplex) } }) } diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 47947316c..21258ac7e 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -58,15 +58,81 @@ var protectedMetadataNames = []string{".git", ".zero", ".agents"} // gitMetadataWriteCarveouts below. var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"} +// sandboxRenameProtectedMetadataName is the metadata directory that cannot be +// fully write-protected but must still not be REPLACEABLE. +// +// It is deliberately not in the list above. That list denies write, and git has +// to write index, objects and refs. But the carveouts guarding it are attached +// to .git/config and .git/hooks as objects, so a principal that renames .git and +// recreates it gets fresh paths inheriting the workspace allow with no denies, +// which restores credential.helper and core.hooksPath. The Windows ACL plan +// therefore denies DELETE on this directory alone, uninherited. +const sandboxRenameProtectedMetadataName = ".git" + // gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied // under the OS-level sandbox even though the rest of .git is writable to git // subprocesses. Nonexistent paths are harmless no-ops in every backend's // enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry). func gitMetadataWriteCarveouts(root string) []string { - return []string{ - filepath.Join(root, ".git", "hooks"), - filepath.Join(root, ".git", "config"), + specs := gitMetadataWriteCarveoutSpecs(root) + out := make([]string, 0, len(specs)) + for _, spec := range specs { + out = append(out, spec.Path) + } + return out +} + +// gitMetadataCarveout is a write-denied .git path together with the shape git +// expects it to have. The shape matters to exactly one backend: the Windows ACL +// plan creates a missing carveout so the deny ACE is in place before git first +// runs, and creating .git/config as a directory makes `git init` fail outright. +// Every other backend only ever names the path, so it can ignore IsFile. +type gitMetadataCarveout struct { + Path string + IsFile bool +} + +// gitMetadataWriteCarveoutSpecs is the single source of truth for the carveout +// set. gitMetadataWriteCarveouts derives its list from this so a path can never +// be added in one place and have its shape forgotten in the other. +func gitMetadataWriteCarveoutSpecs(root string) []gitMetadataCarveout { + return []gitMetadataCarveout{ + {Path: filepath.Join(root, ".git", "hooks")}, + {Path: filepath.Join(root, ".git", "config"), IsFile: true}, + } +} + +// gitMetadataCarveoutSuffixBase is a sentinel root used only to recover the +// trailing segments of the carveout specs. It is never touched on disk. +const gitMetadataCarveoutSuffixBase = string(filepath.Separator) + "zero-carveout-base" + +// gitMetadataCarveoutIsFile reports whether path names a carveout git expects +// to be a file. +// +// It matches on the trailing segments rather than on a whole reconstructed +// path. The subpaths reaching the ACL plan are already normalized — resolved +// through EvalSymlinks where that succeeds — while a rebuilt spec path cannot +// be, because .git/config does not exist yet at setup and resolution falls back +// to a plain Clean. On a host where two spellings of the same path differ (an +// 8.3 short name, different casing) a whole-path equality check silently misses +// and the carveout is created as a directory again, which is the original bug +// reintroduced quietly. The suffix cannot drift from the spec list because it +// is derived from it. +func gitMetadataCarveoutIsFile(path string) bool { + candidate := strings.ToLower(filepath.Clean(strings.TrimSpace(path))) + if candidate == "" { + return false + } + for _, spec := range gitMetadataWriteCarveoutSpecs(gitMetadataCarveoutSuffixBase) { + if !spec.IsFile { + continue + } + suffix := strings.ToLower(strings.TrimPrefix(spec.Path, gitMetadataCarveoutSuffixBase)) + if suffix != "" && strings.HasSuffix(candidate, suffix) { + return true + } } + return false } func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile { diff --git a/internal/sandbox/risk.go b/internal/sandbox/risk.go index a0e784f67..e24afe78f 100644 --- a/internal/sandbox/risk.go +++ b/internal/sandbox/risk.go @@ -33,7 +33,13 @@ var ( // unparseableNetworkPattern is used only after the shell parser fails. At // that point the command is already marked too complex, so this intentionally // favors catching obvious network programs over proving exact shell syntax. - unparseableNetworkPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx|http-server|vite|next|nuxt|astro)\b|\b(npm|pnpm|yarn|bun|pip|pip2|pip3)\s+(install|add|publish|login|start|serve|dev|preview|run\s+(start|serve|dev|preview)|exec|x|dlx)\b|\bgo\s+get\b|\bgit\s+clone\b|\bpython(2|3)?\s+-m\s+(http\.server|pip\s+install)\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`) + // The unparseable fallback must agree with the analyzer, or an obfuscated + // command gets flagged for network when the same command written plainly + // does not. The local-server programs and `python -m http.server` are + // therefore absent here too: they bind a port, they do not fetch. The + // subcommands that genuinely reach out (install, add, publish, login) stay, + // including for the same package managers whose dev and serve do not. + unparseableNetworkPattern = regexp.MustCompile(`(?i)\b(curl|wget|fetch|aria2c|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|npx)\b|\b(npm|pnpm|yarn|bun|pip|pip2|pip3)\s+(install|add|publish|login|exec|x|dlx)\b|\bgo\s+get\b|\bgit\s+clone\b|\bpython(2|3)?\s+-m\s+pip\s+install\b|\bgh\s+(api|repo\s+clone|release\s+download)\b`) // destructiveExtraPatterns hold high-severity patterns that the legacy // destructiveCommandPattern does not already cover. Folded in from the // blueprint safe_bash.go without duplicating existing matches. diff --git a/internal/sandbox/runner_windows_integration_test.go b/internal/sandbox/runner_windows_integration_test.go index d0f715744..a54d1227d 100644 --- a/internal/sandbox/runner_windows_integration_test.go +++ b/internal/sandbox/runner_windows_integration_test.go @@ -351,6 +351,15 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) { t.Helper() + // options.PrincipalOptIn is deliberately left nil by both call sites, which + // makes BuildWindowsSandboxSetupArgs resolve the opt-in from this process's + // environment — the same value the command half resolves, since the smoke + // WindowsSandboxCommandArgsOptions carries no explicit entry either. Do not + // "fix" this by setting it to false: anyone running this suite with + // ZERO_WINDOWS_SANDBOX_IDENTITY=1 (the only way to exercise the principal + // backend) would then serialize `--sandbox-principal 0`, disagree with the + // command half, and fail every command at marker validation instead of + // testing the sandbox. args, err := BuildWindowsSandboxSetupArgs(options) if err != nil { t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 4a5fdfc9a..061bde8ea 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -33,8 +33,40 @@ 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. It creates +// nothing, which sandboxRuntimeRootFor cannot promise: its fallback calls +// os.MkdirTemp. +// +// Callers that only need to NAME the tree — teardown, working out which paths a +// principal could hold an ACE on — have to use this. Going through +// sandboxRuntimeRootFor there would create a fresh temp directory on the way +// out, and a useless one at that, since the fallback root is random per process +// and would never match the one the commands actually used. +func deterministicSandboxRuntimeRoot(workspaceRoot string, cacheRoot string) (string, bool) { + digest := sha256.Sum256([]byte(workspaceRoot)) + root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8])) + return root, !pathWithinRoot(workspaceRoot, root) +} + func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { - workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) + workspaceRoot = canonicalSandboxWorkspaceRoot(workspaceRoot) if workspaceRoot == "" || workspaceRoot == "." { return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root") } @@ -42,17 +74,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 { @@ -226,3 +261,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/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index b707f67f2..7dce23780 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -329,3 +329,79 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) { } cleanupLease.release() } + +// sandboxRuntimeRootFor compares the workspace root against the derived runtime +// root to decide whether to fall back to a private temp tree. Both sides +// therefore have to be the same spelling of the same path. +// +// Canonicalizing only the workspace root broke this on CI: the workspace +// resolved (/var to /private/var on macOS, an 8.3 short name to its long form +// on Windows) while the cache root kept its original spelling, so the +// containment check compared two different strings, the fallback never fired, +// and the runtime tree was placed inside the workspace it exists to stay out of. +// +// A symlink is the portable way to produce a spelling that only resolution +// reconciles — Clean cannot see through one. Windows refuses to create symlinks +// without privilege, so this skips there; the platforms that CI caught the bug +// on are the ones that run it. +func TestPrepareSandboxRuntimeNormalizesTheCacheRootBeforeComparingIt(t *testing.T) { + workspace := t.TempDir() + link := filepath.Join(t.TempDir(), "workspace-link") + if err := os.Symlink(workspace, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + // The cache root reaches us spelled through the symlink; the workspace does + // not. Resolved, it is plainly inside the workspace and the fallback must + // fire. Unresolved, the two strings share no prefix and it does not. + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return filepath.Join(link, ".cache"), nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + runtimeState, release, err := prepareSandboxRuntime(workspace) + if err != nil { + t.Fatalf("prepareSandboxRuntime: %v", err) + } + if release != nil { + defer release() + } + // Resolve before comparing. Spelled through the link the runtime root shares + // no textual prefix with the workspace, so an unresolved comparison would + // call it "outside" while it sits physically inside — the test would pass + // against the very bug it exists for. + resolved := runtimeState.Root + if actual, err := filepath.EvalSymlinks(runtimeState.Root); err == nil { + resolved = actual + } + if pathWithinRoot(workspace, resolved) { + t.Fatalf("runtime root %q resolves to %q, inside workspace %q; the containment check did not see through the cache root's spelling", runtimeState.Root, resolved, workspace) + } +} + +// A path whose final segments do not exist yet must still normalize the same +// way as one that does. This is the shape macOS CI hit: t.TempDir() sits under +// /var, a symlink to /private/var, and the cache root it derives has not been +// created when it is first normalized. Resolving only the workspace left the +// two sides of the containment check spelled differently. +func TestCanonicalSandboxWorkspaceRootResolvesThroughAMissingLeaf(t *testing.T) { + real := t.TempDir() + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + + existing := canonicalSandboxWorkspaceRoot(link) + if existing != canonicalSandboxWorkspaceRoot(real) { + t.Fatalf("an existing symlinked dir did not resolve: %q vs %q", existing, canonicalSandboxWorkspaceRoot(real)) + } + + // The leaf, and its parent, do not exist. + missing := filepath.Join(link, ".cache", "zero") + got := canonicalSandboxWorkspaceRoot(missing) + want := filepath.Join(existing, ".cache", "zero") + if got != want { + t.Errorf("missing leaf normalized to %q, want %q — the ancestor was not resolved", got, want) + } + if !pathWithinRoot(existing, got) { + t.Errorf("%q should be inside %q once both are canonical", got, existing) + } +} diff --git a/internal/sandbox/windows_acl.go b/internal/sandbox/windows_acl.go index 55d37d347..170001da5 100644 --- a/internal/sandbox/windows_acl.go +++ b/internal/sandbox/windows_acl.go @@ -2,16 +2,77 @@ package sandbox import ( "errors" + "fmt" "path/filepath" "strings" ) +// isWindowsVolumeRoot reports whether a cleaned path is the top of a volume, +// with nothing above it: `C:\`, a bare separator, or a UNC share root. +// +// Detected structurally rather than by pattern matching drive letters, because +// filepath.Dir of a root is that same root and of anything else is strictly +// shorter. That holds for drive-qualified paths, for the separator alone, and +// for UNC roots, on either build host. +func isWindowsVolumeRoot(path string) bool { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "" || cleaned == "." { + return false + } + return filepath.Dir(cleaned) == cleaned +} + +// validateWindowsACLComponent rejects anything that is not a single path +// component. +// +// This is load-bearing in two places, which is why it lives in the portable file +// rather than beside either of them. +// +// At apply time NtCreateFile happily resolves a RELATIVE name containing +// separators, and it resolves it the ordinary way, so an intermediate junction +// inside that name is followed and the object lands outside the pinned parent. +// A name with a separator reopens exactly the hole the parent handle exists to +// close. +// +// At plan time the same shape escapes the write root: a name is joined onto the +// root to place a deny ACE, so ".." or a separator puts that ACE on a directory +// outside the workspace entirely. +// +// The separators are checked explicitly rather than via filepath.Base, because +// these are Windows paths whatever the build host is, and on Linux +// filepath.Base leaves a backslash-joined name untouched and would wave it +// through. A colon is rejected too: it names an alternate data stream or a +// drive, neither of which is a child. +func validateWindowsACLComponent(name string) error { + switch { + case name == "": + return errors.New("windows ACL path component is empty") + case name == "." || name == "..": + return fmt.Errorf("windows ACL path component %q is a relative reference, not a child", name) + case strings.ContainsAny(name, `\/`): + return fmt.Errorf("windows ACL path component %q contains a separator, so it would resolve through intermediate directories instead of staying a child", name) + case strings.Contains(name, ":"): + return fmt.Errorf("windows ACL path component %q contains a colon, which names a stream or a drive rather than a child", name) + } + return nil +} + type WindowsACLAction string const ( WindowsACLAllowWrite WindowsACLAction = "allow-write" WindowsACLDenyRead WindowsACLAction = "deny-read" WindowsACLDenyWrite WindowsACLAction = "deny-write" + // WindowsACLDenyDelete denies removing or renaming the object it names, + // WITHOUT denying writes to it or inside it, and without inheriting. + // + // It exists for .git. The write-denied carveouts live on .git/config and + // .git/hooks as objects, so replacing the .git directory discards them: the + // recreated config and hooks inherit the workspace allow with no deny, which + // restores credential.helper and core.hooksPath. .git cannot simply join + // sandboxFullyProtectedMetadataNames, because DenyWrite's mask includes + // FILE_GENERIC_WRITE and git must write index, objects and refs. + WindowsACLDenyDelete WindowsACLAction = "deny-delete" ) type WindowsACLEntry struct { @@ -19,6 +80,11 @@ type WindowsACLEntry struct { Path string `json:"path"` Capability string `json:"capability"` Materialize bool `json:"materialize,omitempty"` + // MaterializeFile makes Materialize create an empty FILE instead of a + // directory. Only meaningful with Materialize. .git/config is the case that + // forces the distinction: created as a directory it does not merely carry + // the wrong ACL, it makes `git init` fail outright. + MaterializeFile bool `json:"materializeFile,omitempty"` } type WindowsACLPlan struct { diff --git a/internal/sandbox/windows_acl_apply_windows.go b/internal/sandbox/windows_acl_apply_windows.go index c666aa9ef..ef02ea0b4 100644 --- a/internal/sandbox/windows_acl_apply_windows.go +++ b/internal/sandbox/windows_acl_apply_windows.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "sort" "strings" @@ -15,28 +16,102 @@ import ( const windowsFileDeleteChild windows.ACCESS_MASK = 0x00000040 type windowsACLPathGroup struct { - Path string - Entries []WindowsACLEntry - Materialize bool + Path string + Entries []WindowsACLEntry + Materialize bool + MaterializeFile bool +} + +// windowsACLChainStep is one directory component beneath the anchor, and +// whether THIS run created it. The flag comes from the kernel rather than from +// "it was missing when we looked", because something else can win the gap +// between the probe and the create, and removing a directory the sandbox did not +// make is how a rollback deletes a user's data. +type windowsACLChainStep struct { + Name string + Made bool +} + +// windowsACLMaterialization is exactly what materialization created, recorded in +// the shape rollback needs to undo it without resolving a single pathname below +// the anchor. +// +// The anchor is the deepest directory that already existed, and it is the ONLY +// pathname rollback re-resolves. Everything under it is a list of single +// components walked one handle at a time, because a name containing a separator +// is resolved the ordinary way by the kernel and would follow an intermediate +// junction straight out of the approved tree. +type windowsACLMaterialization struct { + AnchorPath string + AnchorID windowsFileIdentity + // Chain is every component between the anchor and the target, shallow to + // deep. All of them are needed to descend at rollback time; only the ones + // with Made set are removed. + Chain []windowsACLChainStep + // File is the leaf file component created inside the deepest Chain entry, + // for the .git/config carveout. Empty when the target is a directory. + File string + FileMade bool +} + +func (materialization windowsACLMaterialization) createdAnything() bool { + if materialization.FileMade { + return true + } + for _, step := range materialization.Chain { + if step.Made { + return true + } + } + return false } type windowsACLSnapshot struct { - Path string - Descriptor *windows.SECURITY_DESCRIPTOR - Materialized bool + Path string + Descriptor *windows.SECURITY_DESCRIPTOR + Created windowsACLMaterialization } func applyWindowsACLPlan(plan WindowsACLPlan) (func() error, error) { groups := groupWindowsACLPlanByPath(plan) snapshots := make([]windowsACLSnapshot, 0, len(groups)) + abort := func(err error) (func() error, error) { + if rollbackErr := rollbackWindowsACLSnapshots(snapshots); rollbackErr != nil { + return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr) + } + return nil, err + } + + var deferred []windowsACLPathGroup for _, group := range groups { snapshot, applied, err := applyWindowsACLPathGroup(group) if err != nil { - rollbackErr := rollbackWindowsACLSnapshots(snapshots) - if rollbackErr != nil { - return nil, fmt.Errorf("%w; rollback failed: %v", err, rollbackErr) - } - return nil, err + return abort(err) + } + if applied { + snapshots = append(snapshots, snapshot) + continue + } + deferred = append(deferred, group) + } + + // A group that applied to nothing was skipped because its target did not + // exist and the group does not materialize one. A LATER group can still + // create it, so the skip has to be retried rather than treated as final. + // + // The .git rename guard is exactly this shape and was silently absent + // because of it. .git must NOT be materialized, since an empty one breaks + // `git init`, so its deny-delete group carries no Materialize. But .git does + // get created, as the parent chain of the .git\config carveout, and that + // group sorts AFTER it. So on every workspace that did not already have a + // .git, the guard was skipped, the directory appeared moments later, and + // nothing went back for it: the principal could then rename .git aside and + // shed the config and hooks carveouts, which is the escape the guard exists + // to stop. Groups that are genuinely absent simply skip again here. + for _, group := range deferred { + snapshot, applied, err := applyWindowsACLPathGroup(group) + if err != nil { + return abort(err) } if applied { snapshots = append(snapshots, snapshot) @@ -61,6 +136,7 @@ func groupWindowsACLPlanByPath(plan WindowsACLPlan) []windowsACLPathGroup { } group.Entries = append(group.Entries, entry) group.Materialize = group.Materialize || entry.Materialize + group.MaterializeFile = group.MaterializeFile || entry.MaterializeFile } out := make([]windowsACLPathGroup, 0, len(byPath)) for _, group := range byPath { @@ -84,8 +160,40 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // elevated setup a lower-privileged local user could swap the target for a // symlink/junction between operations and redirect the ACL change onto a // system object it never validated (issue #728, a TOCTOU privilege boundary). - materialized := false - handle, isDir, err := openWindowsACLTarget(path) + // Reject a malformed group BEFORE touching the filesystem. This validation + // used to run after materialization, so a bad SID or an unknown action + // created a directory chain and only then failed, leaving the error path to + // unwind work that never needed doing. Nothing here depends on isDir: that + // argument only selects the inheritance flag, while the errors come from the + // action lookup and the SID parse. + if _, err := windowsExplicitAccessEntries(group.Entries, false); err != nil { + return windowsACLSnapshot{}, false, err + } + + var created windowsACLMaterialization + var handle windows.Handle + // Every exit from here goes through one closure, because there are now two + // things to undo rather than one: the open handle, and whatever + // materialization created. Both are captured by reference and both start + // zero, so calling this before either is set is safe and does nothing. + // + // The unwind is handle-relative. It must never fall back to a pathname + // delete: the failure being cleaned up here can BE the path swap, and + // os.RemoveAll on a swapped ancestor is precisely the recursive elevated + // delete outside the workspace that this cleanup is supposed to prevent. + fail := func(err error) (windowsACLSnapshot, bool, error) { + if handle != 0 { + _ = windows.CloseHandle(handle) + } + if unwindErr := rollbackWindowsACLMaterialization(created); unwindErr != nil { + return windowsACLSnapshot{}, false, fmt.Errorf("%w; cleanup failed: %v", err, unwindErr) + } + return windowsACLSnapshot{}, false, err + } + + var isDir bool + var err error + handle, isDir, err = openWindowsACLTarget(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { return windowsACLSnapshot{}, false, err @@ -96,25 +204,19 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo } return windowsACLSnapshot{}, false, nil } - if err := os.MkdirAll(path, 0o700); err != nil { - return windowsACLSnapshot{}, false, fmt.Errorf("materialize windows ACL target %s: %w", path, err) + // created is assigned even on failure: materialization reports what it + // managed to make before it stopped, and fail() unwinds exactly that. + created, err = materializeWindowsACLTarget(path, group.MaterializeFile) + if err != nil { + return fail(fmt.Errorf("materialize windows ACL target %s: %w", path, err)) } - materialized = true handle, isDir, err = openWindowsACLTarget(path) if err != nil { - _ = os.RemoveAll(path) - return windowsACLSnapshot{}, false, fmt.Errorf("open materialized windows ACL target %s: %w", path, err) + // This is the branch that fires when the post-create verify catches a + // swap, so it is the single most important cleanup in the file. + return fail(fmt.Errorf("open materialized windows ACL target %s: %w", path, err)) } } - // From here the handle is open; every early return must close it first (and - // remove a freshly materialized target) so a failure leaks neither. - fail := func(err error) (windowsACLSnapshot, bool, error) { - _ = windows.CloseHandle(handle) - if materialized { - _ = os.RemoveAll(path) - } - return windowsACLSnapshot{}, false, err - } descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) if err != nil { return fail(fmt.Errorf("read windows ACL for %s: %w", path, err)) @@ -139,7 +241,7 @@ func applyWindowsACLPathGroup(group windowsACLPathGroup) (windowsACLSnapshot, bo // closed now — rollback re-opens no-follow rather than holding a handle for // the whole sandbox lifetime, since one caller discards the rollback closure. _ = windows.CloseHandle(handle) - return windowsACLSnapshot{Path: path, Descriptor: descriptor, Materialized: materialized}, true, nil + return windowsACLSnapshot{Path: path, Descriptor: descriptor, Created: created}, true, nil } // openWindowsACLTarget opens path for reading and rewriting its DACL without @@ -178,6 +280,12 @@ func openWindowsACLTarget(path string) (windows.Handle, bool, error) { _ = windows.CloseHandle(handle) return 0, false, fmt.Errorf("refusing to apply ACL to reparse-point target %s: possible path swap during elevated setup", path) } + // Ancestors are resolved by CreateFile even with FILE_FLAG_OPEN_REPARSE_POINT, + // so the check above is not enough on its own. + if err := verifyWindowsACLTargetNotRedirected(handle, path); err != nil { + _ = windows.CloseHandle(handle) + return 0, false, err + } isDir := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 return handle, isDir, nil } @@ -206,10 +314,19 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind if err != nil { return nil, err } + entryInheritance := inheritance + // DenyDelete governs the object it names and nothing beneath it. Inherited + // onto .git's children it would deny DELETE on every file inside, so git + // could not remove a lock file, a ref, or anything else it rewrites, and + // the guard would read as a broken repository rather than as a blocked + // rename. + if entry.Action == WindowsACLDenyDelete { + entryInheritance = windows.NO_INHERITANCE + } out = append(out, windows.EXPLICIT_ACCESS{ AccessPermissions: permissions, AccessMode: accessMode, - Inheritance: inheritance, + Inheritance: entryInheritance, Trustee: windows.TRUSTEE{ TrusteeForm: windows.TRUSTEE_IS_SID, TrusteeType: windows.TRUSTEE_IS_GROUP, @@ -223,11 +340,59 @@ func windowsExplicitAccessEntries(entries []WindowsACLEntry, isDir bool) ([]wind func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACCESS_MASK, error) { switch action { case WindowsACLAllowWrite: - return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE, nil + // DELETE is part of the grant, not an extra. FILE_GENERIC_WRITE covers + // creating and modifying but not removing or renaming, and a rename needs + // delete access on the source. Under the old same-user token that gap was + // invisible, because the caller already held inherited rights on its own + // tree; a sandbox principal is a separate account with no such + // inheritance, so without DELETE it can write a file it can never delete. + // Ordinary editing and most git operations rewrite files by replacing + // them, so the omission fails normal work rather than an edge case. + // + // FILE_DELETE_CHILD is deliberately NOT granted, for the same reason + // WRITE_DAC and WRITE_OWNER are not. On a parent it authorises deleting a + // child whatever the child's own DACL says, so granting it on a write root + // hands back the write-denied carve-outs underneath it: a principal could + // delete .git/config and recreate it, and the replacement inherits this + // grant with no deny of its own — restoring exactly the credential.helper + // and core.hooksPath control the carve-out exists to prevent. + // + // It was granted here originally to keep the mask symmetric with + // WindowsACLDenyWrite, which does treat FILE_DELETE_CHILD as part of + // write. Symmetry is the wrong goal: denying a capability is not a reason + // to grant it. DELETE alone covers removing and renaming files the + // principal owns inside its roots, which is what the grant is for. + return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.FILE_GENERIC_EXECUTE | windows.DELETE, nil + case WindowsACLAllowRead: + // Read and traverse without write. A sandbox principal is a separate + // account with no inherent access to the caller's tree, so a read-only + // root has to be granted rather than assumed. Deliberately omits + // FILE_GENERIC_WRITE, DELETE and WRITE_DAC. + return windows.GRANT_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil + case windowsACLRevoke: + // REVOKE_ACCESS drops every ACE naming the trustee regardless of the mask, + // so the mask is ignored here. Used to retire a principal without having + // to remember which access each path was granted. + return windows.REVOKE_ACCESS, 0, nil case WindowsACLDenyRead: return windows.DENY_ACCESS, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE, nil case WindowsACLDenyWrite: return windows.DENY_ACCESS, windows.FILE_GENERIC_WRITE | windows.DELETE | windowsFileDeleteChild | windows.WRITE_DAC | windows.WRITE_OWNER, nil + case WindowsACLDenyDelete: + // Deny removing or RENAMING the object itself, nothing more. Renaming a + // directory needs DELETE on that directory, so denying DELETE is what + // stops .git being moved aside and recreated without its carveouts. + // + // WRITE_DAC and WRITE_OWNER come along because a guard the principal can + // rewrite, or take ownership of and then rewrite, is not a guard. + // + // FILE_GENERIC_WRITE is deliberately absent: git writes index, objects and + // refs constantly, and denying it would break every commit rather than the + // rename. FILE_DELETE_CHILD is absent for the same reason one level down, + // since git deletes its own lock files and refs. Neither is needed here: + // this ACE does not inherit (see windowsExplicitAccessEntries), so it + // governs the .git directory object alone. + return windows.DENY_ACCESS, windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER, nil default: return 0, 0, fmt.Errorf("unsupported windows ACL action %q", action) } @@ -235,11 +400,18 @@ func windowsACLAccess(action WindowsACLAction) (windows.ACCESS_MODE, windows.ACC func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { var errs []error + // Reverse order is load-bearing twice over. Groups are sorted ascending by + // path key and an ancestor key is always a proper prefix of its descendants, + // so walking backwards unwinds descendant before ancestor: a materialized + // directory is therefore empty by the time its own removal is attempted. + // Restoring ACLs in the same order is independently right, because + // SetSecurityInfo propagates inheritable ACEs down, so the ancestor must go + // last. TestRollbackUnwindsDescendantsBeforeAncestors pins it. for index := len(snapshots) - 1; index >= 0; index-- { snapshot := snapshots[index] - if snapshot.Materialized { - if err := os.RemoveAll(snapshot.Path); err != nil { - errs = append(errs, fmt.Errorf("remove materialized windows ACL target %s: %w", snapshot.Path, err)) + if snapshot.Created.createdAnything() { + if err := rollbackWindowsACLMaterialization(snapshot.Created); err != nil { + errs = append(errs, err) } continue } @@ -265,3 +437,217 @@ func rollbackWindowsACLSnapshots(snapshots []windowsACLSnapshot) error { } return errors.Join(errs...) } + +// materializeWindowsACLTarget creates a missing ACL target with the shape the +// owning tool expects. A directory target is created whole; a file target gets +// its parent chain created and then an empty file, because creating it as a +// directory would break the tool that owns it rather than just mis-ACL it. +// The returned record is meaningful even when the error is non-nil: a chain that +// got three levels deep and then failed still has three levels to unwind. +func materializeWindowsACLTarget(path string, asFile bool) (windowsACLMaterialization, error) { + directory := path + leaf := "" + if asFile { + directory = filepath.Dir(path) + leaf = filepath.Base(path) + } + created, parent, err := makeWindowsACLDirChainNoFollow(directory) + if err != nil { + return created, err + } + defer func() { _ = windows.CloseHandle(parent) }() + if !asFile { + return created, nil + } + // A racing creator winning is still fine: the target exists, which is all + // materialization needed. createWindowsACLChildFile reports that as + // created=false, so rollback will not delete a file the sandbox did not make. + created.File = leaf + madeFile, err := createWindowsACLChildFile(parent, leaf) + created.FileMade = madeFile + return created, err +} + +// rollbackWindowsACLMaterialization removes exactly what materialization +// created, deepest first, without resolving any pathname below the anchor. +// +// This is the other half of the pathname problem. The old cleanup called +// os.RemoveAll on the target pathname, which re-resolves every ancestor at the +// moment it runs, so an ancestor swapped to a junction after the object was +// created sent a recursive elevated delete into an unrelated tree. It also only +// ever removed the final component, leaving every intermediate directory the +// chain had created behind. +// +// The anchor is the one pathname that has to be resolved again, and it is +// checked by file identity rather than by name: replacing a directory with +// another REAL directory of the same name needs no reparse point at all and +// would otherwise pass every no-follow check there is. +// +// Residue is preferable to over-deletion throughout. When something cannot be +// removed safely this reports it and leaves it, and never falls back to a +// pathname delete. +func rollbackWindowsACLMaterialization(materialization windowsACLMaterialization) error { + if !materialization.createdAnything() { + return nil + } + anchor, err := reopenWindowsACLDirectoryAsIdentity(materialization.AnchorPath, materialization.AnchorID) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // The anchor is gone, so everything created beneath it is gone too. + // Nothing to undo, and no way to undo it if there were. + return nil + } + return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err) + } + + // One handle per level: handles[i] is the parent of Chain[i], which is what + // deleting Chain[i] relative to a pinned parent requires. + handles := []windows.Handle{anchor} + defer func() { + for _, handle := range handles { + if handle != 0 { + _ = windows.CloseHandle(handle) + } + } + }() + + // Descend only as far as is actually required. Removing a directory needs its + // PARENT's handle, not its own, so the deepest component is opened only when + // a file leaf lives inside it. This is not just economy: the deepest + // component is usually the ACL target itself, so it may already carry the + // deny-read ACE this rollback is undoing, and opening it would be refused by + // the very ACL being unwound. + needed := len(materialization.Chain) + if !materialization.FileMade && needed > 0 { + needed-- + } + depth := 0 + for ; depth < needed; depth++ { + child, err := openWindowsACLChildDirectory(handles[depth], materialization.Chain[depth].Name) + if err != nil { + // Already removed by something else. Stop descending; whatever is + // below it is gone with it. + if isWindowsNotExist(err) { + break + } + return fmt.Errorf("unwind windows ACL materialization under %s: %w", materialization.AnchorPath, err) + } + handles = append(handles, child) + } + + var errs []error + // The file leaf lives inside the deepest chain directory, so it goes first + // and only if the descent actually reached that far. + if materialization.FileMade && depth == needed { + if err := deleteWindowsACLChildFile(handles[len(handles)-1], materialization.File); err != nil { + errs = append(errs, fmt.Errorf("remove materialized windows ACL file %s: %w", materialization.File, err)) + } + } + // Chain[i] is removed through handles[i], so the deepest one that can be + // removed is bounded by how far the descent actually got. + deepest := len(handles) - 1 + if last := len(materialization.Chain) - 1; deepest > last { + deepest = last + } + for index := deepest; index >= 0; index-- { + // Close the child's own handle, if the descent opened one, before asking + // its parent to remove it. A directory with a live handle open still + // counts as present, so the parent's delete would come back as non-empty. + if index+1 < len(handles) && handles[index+1] != 0 { + _ = windows.CloseHandle(handles[index+1]) + handles[index+1] = 0 + } + if !materialization.Chain[index].Made { + continue + } + if err := deleteWindowsACLChildDirectory(handles[index], materialization.Chain[index].Name); err != nil { + errs = append(errs, fmt.Errorf("remove materialized windows ACL directory %s: %w", materialization.Chain[index].Name, err)) + } + } + return errors.Join(errs...) +} + +// windowsACLMaterializeSwapHook is a test seam and nothing else. It fires inside +// makeWindowsACLDirChainNoFollow at the exact instant the race used to be +// exploitable: the anchor is verified and pinned, and nothing has been created +// yet. A race reproducible only by luck is not a regression test, so the instant +// is made addressable rather than hoped for. Always nil in production. +var windowsACLMaterializeSwapHook func(anchor string) + +// makeWindowsACLDirChainNoFollow is an os.MkdirAll that never resolves a +// pathname below its anchor. +// +// It walks UP to the deepest ancestor that already exists and opens it +// no-follow. Because GetFinalPathNameByHandle answers for the whole resolved +// path, that single check clears every ancestor above it too. Then it walks back +// DOWN, creating one component at a time relative to the HANDLE of the level +// above, so the tree it descends is pinned to objects rather than named by +// strings. +// +// That is the difference that matters. This used to verify a component by +// pathname, close the handle, and then hand the same string to os.Mkdir: two +// independent kernel resolutions with a gap between them. A workspace owner who +// swapped the verified ancestor for a junction inside that gap got the component +// created outside the approved tree, as Administrator, and verifying again +// afterwards cannot un-create it. Junctions need no privilege, so this was +// reachable by exactly the unprivileged user the sandbox exists to contain. +// +// It deliberately does NOT re-verify each created component by pathname. A child +// created relative to a pinned parent is in the right place by construction, so +// comparing pathnames afterwards would add nothing and would reject correct +// creates whenever the tree was legitimately renamed mid-setup. +// +// Returns what it created, plus an open handle to the deepest directory which +// the caller must close. +func makeWindowsACLDirChainNoFollow(dir string) (windowsACLMaterialization, windows.Handle, error) { + cleaned := filepath.Clean(strings.TrimSpace(dir)) + if cleaned == "" || cleaned == "." { + return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target: empty directory path %q", dir) + } + + // Walk up to the deepest ancestor that exists, collecting the component + // NAMES that are missing. Names, not paths: everything below the anchor is + // addressed relative to a handle from here on. + var missing []string + current := cleaned + var anchor windows.Handle + var anchorID windowsFileIdentity + for { + handle, identity, err := openWindowsACLDirectoryNoFollowWithIdentity(current) + if err == nil { + anchor, anchorID = handle, identity + break + } + if !errors.Is(err, os.ErrNotExist) { + return windowsACLMaterialization{}, 0, err + } + parent := filepath.Dir(current) + if parent == current { + return windowsACLMaterialization{}, 0, fmt.Errorf("materialize windows ACL target %s: no existing ancestor to anchor on", dir) + } + missing = append(missing, filepath.Base(current)) + current = parent + } + + created := windowsACLMaterialization{AnchorPath: current, AnchorID: anchorID} + + if hook := windowsACLMaterializeSwapHook; hook != nil { + hook(current) + } + + // Walk back down, one component per handle. The anchor handle is released as + // soon as its child is open, so at most two levels are held at once. + parent := anchor + for index := len(missing) - 1; index >= 0; index-- { + name := missing[index] + child, madeNow, err := createWindowsACLChildDirectory(parent, name) + if err != nil { + _ = windows.CloseHandle(parent) + return created, 0, err + } + created.Chain = append(created.Chain, windowsACLChainStep{Name: name, Made: madeNow}) + _ = windows.CloseHandle(parent) + parent = child + } + return created, parent, nil +} diff --git a/internal/sandbox/windows_acl_apply_windows_test.go b/internal/sandbox/windows_acl_apply_windows_test.go index f0b7675d0..2df9cca0a 100644 --- a/internal/sandbox/windows_acl_apply_windows_test.go +++ b/internal/sandbox/windows_acl_apply_windows_test.go @@ -37,8 +37,15 @@ func TestApplyWindowsACLPathGroupHandleBasedRoundTrip(t *testing.T) { if !applied { t.Fatal("applied = false, want true for an existing directory target") } - if snapshot.Path != dir || snapshot.Materialized { - t.Fatalf("snapshot = %#v, want Path=%q Materialized=false", snapshot, dir) + if snapshot.Path != dir { + t.Fatalf("snapshot.Path = %q, want %q", snapshot.Path, dir) + } + // The target already existed, so nothing was created and rollback must have + // nothing to remove. Asserting the chain rather than a bool matters: a + // rewiring that recorded the walked components instead of only the created + // ones would make rollback delete a directory the sandbox never made. + if snapshot.Created.createdAnything() { + t.Fatalf("snapshot recorded %#v as created for a target that already existed", snapshot.Created) } if snapshot.Descriptor == nil { t.Fatal("snapshot has no captured descriptor to roll back to") @@ -67,8 +74,16 @@ func TestApplyWindowsACLPathGroupMaterializes(t *testing.T) { if err != nil { t.Fatalf("applyWindowsACLPathGroup: %v", err) } - if !applied || !snapshot.Materialized { - t.Fatalf("applied=%v materialized=%v, want both true", applied, snapshot.Materialized) + if !applied { + t.Fatal("applied = false, want true for a materialized target") + } + // Exactly one component was missing, so exactly one must be recorded as + // created, and it must be the leaf's own name rather than a path. + if len(snapshot.Created.Chain) != 1 || snapshot.Created.Chain[0] != (windowsACLChainStep{Name: "created", Made: true}) { + t.Fatalf("created chain = %#v, want one step {created true}", snapshot.Created.Chain) + } + if snapshot.Created.AnchorPath != filepath.Dir(target) { + t.Fatalf("anchor = %q, want the existing parent %q", snapshot.Created.AnchorPath, filepath.Dir(target)) } if _, err := os.Stat(target); err != nil { t.Fatalf("materialized target not created: %v", err) diff --git a/internal/sandbox/windows_acl_junction_ancestor_windows_test.go b/internal/sandbox/windows_acl_junction_ancestor_windows_test.go new file mode 100644 index 000000000..24817ee94 --- /dev/null +++ b/internal/sandbox/windows_acl_junction_ancestor_windows_test.go @@ -0,0 +1,94 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// makeJunction points link at target, skipping the test when the environment +// refuses to create one. A junction needs no privilege, which is exactly why +// this attack is reachable by an ordinary workspace owner. +func makeJunction(t *testing.T, link, target string) { + t.Helper() + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil { + t.Skipf("cannot create a junction here: %v: %s", err, out) + } + // Assert it actually redirects. A junction that silently did nothing would + // green this test while proving nothing. + probe := filepath.Join(link, "redirect-probe") + if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil { + t.Fatalf("write through junction: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "redirect-probe")); err != nil { + t.Fatalf("junction does not redirect, so this test would prove nothing: %v", err) + } + if err := os.Remove(probe); err != nil { + t.Fatalf("clean probe: %v", err) + } +} + +// Elevated setup must not create anything through a reparse-point ancestor. +// +// FILE_FLAG_OPEN_REPARSE_POINT only stops the FINAL component being followed. +// materializeWindowsACLTarget used os.MkdirAll/os.OpenFile on the pathname, both +// of which resolve ancestors, so a workspace owner who turned .git into a +// junction before setup ran got objects created at a location of their choosing +// — as Administrator — and the no-follow check only rejected it afterwards, far +// too late to un-create them. +func TestMaterializeRefusesAncestorJunctionBeforeCreating(t *testing.T) { + for name, asFile := range map[string]bool{"file target": true, "directory target": false} { + t.Run(name, func(t *testing.T) { + external := t.TempDir() + workspace := t.TempDir() + gitDir := filepath.Join(workspace, ".git") + makeJunction(t, gitDir, external) + + target := filepath.Join(gitDir, "hooks", "config") + _, err := materializeWindowsACLTarget(target, asFile) + if err == nil { + t.Fatalf("materialized %s through a junction ancestor instead of refusing", target) + } + if !strings.Contains(err.Error(), "reparse") { + t.Fatalf("refused for the wrong reason: %v", err) + } + // Nothing may survive on the other side of the junction. The old code + // left every intermediate directory MkdirAll had created. + leaked, lerr := os.ReadDir(external) + if lerr != nil { + t.Fatalf("read external dir: %v", lerr) + } + if len(leaked) != 0 { + names := make([]string, 0, len(leaked)) + for _, entry := range leaked { + names = append(names, entry.Name()) + } + t.Fatalf("created %v outside the workspace through the junction", names) + } + }) + } +} + +// The ordinary path still works: no reparse point anywhere, target gets made. +func TestMaterializeStillCreatesOrdinaryTargets(t *testing.T) { + root := t.TempDir() + for name, asFile := range map[string]bool{"file target": true, "directory target": false} { + t.Run(name, func(t *testing.T) { + target := filepath.Join(root, name, "nested", "deeper", "target") + if _, err := materializeWindowsACLTarget(target, asFile); err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("target was not created: %v", err) + } + if info.IsDir() == asFile { + t.Fatalf("target isDir=%v, wanted file=%v", info.IsDir(), asFile) + } + }) + } +} diff --git a/internal/sandbox/windows_acl_materialize_swap_windows_test.go b/internal/sandbox/windows_acl_materialize_swap_windows_test.go new file mode 100644 index 000000000..8776b3427 --- /dev/null +++ b/internal/sandbox/windows_acl_materialize_swap_windows_test.go @@ -0,0 +1,453 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// swapAncestorAside moves approved out of the way and leaves a junction wearing +// its name, pointing at elsewhere. This is the whole attack in three lines, and +// it needs no privilege: junctions are creatable by any user, which is exactly +// why an unprivileged workspace owner can aim elevated setup wherever they like. +// +// Returns the path the real directory now lives at. +func swapAncestorAside(t *testing.T, approved, elsewhere string) string { + t.Helper() + moved := approved + "-moved" + if err := os.Rename(approved, moved); err != nil { + t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err) + } + makeJunction(t, approved, elsewhere) + return moved +} + +// requireNothingEscaped fails when anything at all was created on the far side +// of the junction. +func requireNothingEscaped(t *testing.T, elsewhere string) { + t.Helper() + leaked, err := os.ReadDir(elsewhere) + if err != nil { + t.Fatalf("read the decoy directory: %v", err) + } + if len(leaked) == 0 { + return + } + names := make([]string, 0, len(leaked)) + for _, entry := range leaked { + names = append(names, entry.Name()) + } + t.Fatalf("ESCAPED: created %v outside the approved tree, as Administrator", names) +} + +// THE MATERIALIZATION RACE, ON THE PRODUCTION CALL PATH. +// +// The existing junction test plants its junction before the walk even starts, so +// the very first check sees it and refuses. That proves the easy half. The half +// the reviewer filed is the swap that happens AFTER a component has been +// verified and BEFORE it is used, and no test reached it: a race nobody can +// trigger on demand is not a regression test, so makeWindowsACLDirChainNoFollow +// carries a seam that fires at exactly that instant. +// +// The control arm matters as much as the fixed one. It performs the identical +// swap and then does what this code used to do, creating by pathname, and +// asserts that the object DOES escape. Without it, the fixed arm passing proves +// only that some code ran, not that the hole it closes was ever open. +func TestMaterializeSurvivesAnAncestorSwappedMidWalk(t *testing.T) { + t.Run("control: creating by pathname escapes", func(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + elsewhere := filepath.Join(root, "OUTSIDE") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + // Verified, exactly as the walk verifies its anchor. + if err := verifyWindowsACLPathComponentNotRedirected(approved); err != nil { + t.Fatalf("anchor did not verify before the swap: %v", err) + } + moved := swapAncestorAside(t, approved, elsewhere) + + // The old create: a pathname, re-resolved by the kernel right now. + if err := os.MkdirAll(filepath.Join(approved, "a", "b"), 0o700); err != nil { + t.Fatalf("pathname create: %v", err) + } + if _, err := os.Stat(filepath.Join(elsewhere, "a", "b")); err != nil { + t.Fatalf("the control arm did not reproduce the escape, so the fixed arm below proves nothing: %v", err) + } + if _, err := os.Stat(filepath.Join(moved, "a", "b")); err == nil { + t.Error("the control arm created inside the verified directory, which is not the behaviour being contrasted") + } + }) + + t.Run("fixed: creating through the pinned handle stays put", func(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + elsewhere := filepath.Join(root, "OUTSIDE") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + + var moved string + swapped := false + windowsACLMaterializeSwapHook = func(anchor string) { + if swapped { + return + } + swapped = true + if anchor != approved { + t.Errorf("anchored on %q, want the deepest existing ancestor %q", anchor, approved) + } + moved = swapAncestorAside(t, approved, elsewhere) + } + t.Cleanup(func() { windowsACLMaterializeSwapHook = nil }) + + created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + if !swapped { + t.Fatal("the seam never fired, so the swap never happened and this test proved nothing") + } + requireNothingEscaped(t, elsewhere) + if _, err := os.Stat(filepath.Join(moved, "a", "b")); err != nil { + t.Errorf("the target did not land in the directory that was verified: %v", err) + } + // And the record must describe what to unwind, in components rather than + // paths, shallowest first. + if len(created.Chain) != 2 || created.Chain[0].Name != "a" || created.Chain[1].Name != "b" { + t.Fatalf("created chain = %#v, want [a b] shallow to deep", created.Chain) + } + for _, step := range created.Chain { + if !step.Made { + t.Errorf("component %q was not recorded as created, so rollback would leave it behind", step.Name) + } + } + }) +} + +// The FILE target has the same race, and it is the one that matters most: +// .git/config is materialized as a file on every stock setup, and it is the file +// whose credential.helper is worth stealing. +func TestMaterializeFileSurvivesAnAncestorSwappedMidWalk(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + elsewhere := filepath.Join(root, "OUTSIDE") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + + var moved string + swapped := false + windowsACLMaterializeSwapHook = func(string) { + if swapped { + return + } + swapped = true + moved = swapAncestorAside(t, approved, elsewhere) + } + t.Cleanup(func() { windowsACLMaterializeSwapHook = nil }) + + created, err := materializeWindowsACLTarget(filepath.Join(approved, ".git", "config"), true) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + if !swapped { + t.Fatal("the seam never fired, so this test proved nothing") + } + requireNothingEscaped(t, elsewhere) + + landed := filepath.Join(moved, ".git", "config") + info, err := os.Stat(landed) + if err != nil { + t.Fatalf("the file did not land in the directory that was verified: %v", err) + } + if info.IsDir() { + t.Error("materialized .git/config as a directory, which breaks git init") + } + if !created.FileMade || created.File != "config" { + t.Errorf("file record = %q made=%v, want config/true", created.File, created.FileMade) + } +} + +// THE ROLLBACK RACE. The ancestor is swapped AFTER the target was created, which +// is the window the teardown path lives in: minutes or hours, not microseconds. +// +// The bystander is the point. If the unwind resolves by pathname it walks into +// the decoy and deletes what it finds there, recursively and elevated. Its +// survival is the only thing that proves the unwind did not. +func TestRollbackDoesNotFollowAnAncestorSwappedAfterCreation(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + elsewhere := filepath.Join(root, "OUTSIDE") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + + created, err := materializeWindowsACLTarget(filepath.Join(approved, "a", "b"), false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + + // A bystander tree under the decoy, shaped exactly like what we created, so a + // pathname unwind would find something to destroy at every level. + bystander := filepath.Join(elsewhere, "a", "b") + if err := os.MkdirAll(bystander, 0o700); err != nil { + t.Fatalf("seed bystander: %v", err) + } + witness := filepath.Join(bystander, "irreplaceable.txt") + if err := os.WriteFile(witness, []byte("not yours to delete"), 0o600); err != nil { + t.Fatalf("seed witness: %v", err) + } + + moved := swapAncestorAside(t, approved, elsewhere) + + // The anchor pathname now names the decoy, and the decoy is a junction, so + // the unwind must refuse rather than proceed. Either way it must not delete. + err = rollbackWindowsACLMaterialization(created) + + if _, statErr := os.Stat(witness); statErr != nil { + t.Fatalf("DESTRUCTIVE: rollback followed the junction and deleted a tree outside the approved directory: %v", statErr) + } + if _, statErr := os.Stat(bystander); statErr != nil { + t.Fatalf("DESTRUCTIVE: rollback removed the bystander directory outside the approved directory: %v", statErr) + } + if err == nil { + t.Error("rollback reported success while unwinding through a swapped ancestor; it must say it could not") + } + // Residue inside the real tree is the accepted price: leaving it is strictly + // better than a recursive delete through a path someone else controls. + if _, statErr := os.Stat(filepath.Join(moved, "a", "b")); statErr != nil { + t.Logf("note: the real tree was also unwound (%v); leaving it would be acceptable too", statErr) + } +} + +// A real directory wearing the anchor's name is a swap with NO reparse point +// anywhere, so every no-follow check in this package passes it. Only the file +// identity notices. +func TestRollbackRefusesAnAnchorReplacedByARealDirectory(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "ws") + if err := os.Mkdir(approved, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + created, err := materializeWindowsACLTarget(filepath.Join(approved, "a"), false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + + if err := os.Rename(approved, approved+"-moved"); err != nil { + t.Skipf("cannot rename here: %v", err) + } + // An ordinary directory. Nothing is a link; nothing is a reparse point. + if err := os.Mkdir(approved, 0o700); err != nil { + t.Fatalf("plant the replacement: %v", err) + } + decoy := filepath.Join(approved, "a") + if err := os.Mkdir(decoy, 0o700); err != nil { + t.Fatalf("plant the decoy child: %v", err) + } + + err = rollbackWindowsACLMaterialization(created) + if err == nil { + t.Error("rollback accepted a different directory wearing the anchor's name") + } else if !strings.Contains(err.Error(), "no longer the directory") { + t.Errorf("refused for the wrong reason: %v", err) + } + if _, statErr := os.Stat(decoy); statErr != nil { + t.Errorf("rollback deleted a directory it never created: %v", statErr) + } +} + +// Rollback removes ONLY what this run created. A pre-existing ancestor is walked +// through and left alone. +func TestRollbackLeavesDirectoriesItDidNotCreate(t *testing.T) { + root := t.TempDir() + existing := filepath.Join(root, "ws", "already-here") + if err := os.MkdirAll(existing, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + target := filepath.Join(existing, "made", "deeper") + + created, err := materializeWindowsACLTarget(target, false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + if created.AnchorPath != existing { + t.Fatalf("anchor = %q, want the deepest pre-existing directory %q", created.AnchorPath, existing) + } + if err := rollbackWindowsACLMaterialization(created); err != nil { + t.Fatalf("rollbackWindowsACLMaterialization: %v", err) + } + if _, err := os.Stat(filepath.Join(existing, "made")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("created directory survived rollback: stat err = %v", err) + } + if _, err := os.Stat(existing); err != nil { + t.Errorf("rollback removed a directory that already existed: %v", err) + } +} + +// The whole apply path, through the closure callers actually hold, rather than +// through rollbackWindowsACLSnapshots directly. Every other rollback test in +// this package calls the unwind by hand, which cannot catch applyWindowsACLPlan +// failing to carry the created record into the snapshots it hands over. +func TestApplyWindowsACLPlanClosureRemovesWhatItMaterialized(t *testing.T) { + root := t.TempDir() + directoryTarget := filepath.Join(root, "ws", "hooks") + fileTarget := filepath.Join(root, "ws", "config") + + plan := WindowsACLPlan{Entries: []WindowsACLEntry{ + {Action: WindowsACLDenyWrite, Path: directoryTarget, Capability: "S-1-1-0", Materialize: true}, + {Action: WindowsACLDenyWrite, Path: fileTarget, Capability: "S-1-1-0", Materialize: true, MaterializeFile: true}, + }} + + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + for _, path := range []string{directoryTarget, fileTarget} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("%s was not materialized: %v", path, err) + } + } + if err := rollback(); err != nil { + t.Fatalf("rollback closure: %v", err) + } + for _, path := range []string{directoryTarget, fileTarget} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%s survived the rollback closure: stat err = %v", path, err) + } + } + // The shared prefix both targets needed must go too, and it is created by + // whichever group runs first rather than being owned by both. + if _, err := os.Stat(filepath.Join(root, "ws")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("the shared parent survived: stat err = %v", err) + } +} + +// A rollback that cannot remove something must SAY so. This is the regression +// guard for the trap a naive handle-relative port walks straight into: +// FILE_DELETE_ON_CLOSE reports success on a non-empty directory and leaves it +// there, which turns a loud failure into a silent lie. The directory being +// populated is not adversarial; .git/hooks fills up the moment git runs. +func TestRollbackReportsWhatItCouldNotRemove(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "ws", "hooks") + created, err := materializeWindowsACLTarget(target, false) + if err != nil { + t.Fatalf("materializeWindowsACLTarget: %v", err) + } + if err := os.WriteFile(filepath.Join(target, "pre-commit"), []byte("#!/bin/sh\n"), 0o600); err != nil { + t.Fatalf("populate: %v", err) + } + + err = rollbackWindowsACLMaterialization(created) + if err == nil { + t.Fatal("rollback reported success on a directory it could not empty, so callers cannot tell teardown failed") + } + if !strings.Contains(strings.ToLower(err.Error()), "hooks") { + t.Errorf("the error does not name what was left behind: %v", err) + } + // Left in place deliberately. Removing it would mean recursing, and recursion + // through a path the workspace owner controls is the thing being avoided. + if _, statErr := os.Stat(target); statErr != nil { + t.Errorf("rollback recursed into a populated directory instead of reporting it: %v", statErr) + } +} + +// The primitives take a single component and the walk relies on that. A joined +// name is resolved the ordinary way by the kernel, so an intermediate junction +// inside it is followed and the object lands outside the anchor: the pinned +// parent buys nothing if the name itself walks. +func TestChildOperationsRefuseNamesThatAreNotSingleComponents(t *testing.T) { + root := t.TempDir() + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + for _, name := range []string{`sub\child`, "sub/child", "..", ".", "", `C:\absolute`, "stream:name"} { + t.Run("create dir "+name, func(t *testing.T) { + handle, _, err := createWindowsACLChildDirectory(parent, name) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatalf("accepted %q, which the kernel would resolve through intermediate directories", name) + } + }) + t.Run("delete dir "+name, func(t *testing.T) { + if err := deleteWindowsACLChildDirectory(parent, name); err == nil { + t.Fatalf("accepted %q for deletion", name) + } + }) + t.Run("create file "+name, func(t *testing.T) { + if _, err := createWindowsACLChildFile(parent, name); err == nil { + t.Fatalf("accepted %q for file creation", name) + } + }) + } +} + +// A junction sitting where a chain component should be must be refused when it +// is OPENED, not merely when it is created. FILE_OPEN_REPARSE_POINT hands back a +// handle to the junction itself, and using that as the next parent puts every +// deeper create on the far side of it. +func TestChildOperationsRefuseAnExistingJunction(t *testing.T) { + root := t.TempDir() + elsewhere := t.TempDir() + makeJunction(t, filepath.Join(root, "hop"), elsewhere) + + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + handle, _, err := createWindowsACLChildDirectory(parent, "hop") + if err == nil { + _ = windows.CloseHandle(handle) + t.Error("createWindowsACLChildDirectory returned a junction as the next parent in the walk") + } + opened, err := openWindowsACLChildDirectory(parent, "hop") + if err == nil { + _ = windows.CloseHandle(opened) + t.Error("openWindowsACLChildDirectory returned a junction to descend through") + } +} + +// Rollback descends; it must never create. If a component was removed by +// something else in the meantime, re-making it and then deleting it would remove +// a directory the sandbox never made. +func TestRollbackDescentNeverCreatesAMissingComponent(t *testing.T) { + root := t.TempDir() + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + handle, err := openWindowsACLChildDirectory(parent, "never-existed") + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatal("the descent open created a directory that did not exist") + } + if !isWindowsNotExist(err) { + t.Errorf("a missing component reported %v, which rollback cannot distinguish from a real failure", err) + } + if _, statErr := os.Stat(filepath.Join(root, "never-existed")); statErr == nil { + t.Error("a directory appeared on disk from an open that should never create") + } +} diff --git a/internal/sandbox/windows_acl_relative_windows.go b/internal/sandbox/windows_acl_relative_windows.go new file mode 100644 index 000000000..1fc4d3231 --- /dev/null +++ b/internal/sandbox/windows_acl_relative_windows.go @@ -0,0 +1,470 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Handle-relative directory operations. +// +// WHY THESE EXIST. Every pathname-based call re-resolves the whole path inside +// the kernel at the moment it runs. So verifying a component and then creating +// through it are two separate resolutions of the same string, and a workspace +// owner can swap an ancestor for a junction in the gap between them: setup +// verifies, the attacker swaps, setup creates, and the object lands outside the +// approved tree. Checking again afterwards is too late, because the thing has +// already been created somewhere it should not be. +// +// A HANDLE pins the object rather than the name. Once a directory is open, that +// handle keeps referring to the same directory however the path is later +// rearranged, so creating a child relative to it cannot be redirected. os.Mkdir, +// os.OpenFile and os.RemoveAll are pathname-based by construction with no +// relative form on Windows, which is why this drops to NtCreateFile with +// OBJECT_ATTRIBUTES.RootDirectory. + +// IO_STATUS_BLOCK.Information values for a create/open, named because "2 means +// it was created" is not something a reader should have to look up. +const ( + windowsFileOpened uintptr = 1 + windowsFileCreated uintptr = 2 +) + +// windowsACLDirectoryShare is the share mode every open here uses. A sandbox +// tree is live, so refusing to share would fail on any directory something else +// happens to have open: a denial of service on ourselves rather than a security +// property. +const windowsACLDirectoryShare = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE + +// windowsFileIdentity is the kernel's own answer to "is this the same object", +// independent of what it is currently called. +// +// It exists because rollback cannot hold the anchor handle open for the whole +// sandbox lifetime (see rollbackWindowsACLSnapshots), so it has to re-open the +// anchor by pathname, and a pathname can be made to name a different object. +// Crucially that substitution needs NO reparse point: rename the real directory +// aside and create an ordinary directory wearing its name, and every no-follow +// check still passes because nothing anywhere is a link. Comparing the volume +// and file index catches it, because those identify the object the kernel +// actually opened. +type windowsFileIdentity struct { + Volume uint32 + IndexHigh uint32 + IndexLow uint32 +} + +func (identity windowsFileIdentity) empty() bool { + return identity == windowsFileIdentity{} +} + +// windowsIdentityOfHandle reads the identity of an already-open object. +func windowsIdentityOfHandle(handle windows.Handle) (windowsFileIdentity, error) { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return windowsFileIdentity{}, fmt.Errorf("read windows file identity: %w", err) + } + return windowsFileIdentity{ + Volume: info.VolumeSerialNumber, + IndexHigh: info.FileIndexHigh, + IndexLow: info.FileIndexLow, + }, nil +} + +// openWindowsACLDirectoryNoFollow opens an existing directory by pathname, +// refusing to traverse or land on a reparse point. +// +// This is the ANCHOR for a handle-relative walk: the one pathname resolution +// that has to happen, with everything below it relative to the handle it +// returns. FILE_FLAG_OPEN_REPARSE_POINT stops the final component being +// followed, and verifyWindowsACLTargetNotRedirected then confirms no ancestor +// redirected either, because GetFinalPathNameByHandle answers for the whole +// resolved path. +func openWindowsACLDirectoryNoFollow(path string) (windows.Handle, error) { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, fmt.Errorf("encode windows ACL directory %s: %w", path, err) + } + handle, err := windows.CreateFile( + utf16Path, + windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windowsACLDirectoryShare, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + // Errno.Is maps the not-found codes to os.ErrNotExist, so a caller + // walking up to find the deepest existing ancestor keeps working. + return 0, fmt.Errorf("open windows ACL directory %s: %w", path, err) + } + if err := verifyWindowsACLHandleIsCleanDirectory(handle, path); err != nil { + _ = windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +// openWindowsACLDirectoryNoFollowWithIdentity is the anchor open plus the +// identity a later rollback needs in order to prove it re-opened the same +// object. +func openWindowsACLDirectoryNoFollowWithIdentity(path string) (windows.Handle, windowsFileIdentity, error) { + handle, err := openWindowsACLDirectoryNoFollow(path) + if err != nil { + return 0, windowsFileIdentity{}, err + } + identity, err := windowsIdentityOfHandle(handle) + if err != nil { + _ = windows.CloseHandle(handle) + return 0, windowsFileIdentity{}, fmt.Errorf("identify windows ACL directory %s: %w", path, err) + } + return handle, identity, nil +} + +// reopenWindowsACLDirectoryAsIdentity re-opens an anchor by pathname and refuses +// it unless the kernel says it is the object that was opened before. +// +// Used only by rollback. See windowsFileIdentity for why the pathname alone is +// not enough, and rollbackWindowsACLSnapshots for why a handle cannot simply be +// held instead. +func reopenWindowsACLDirectoryAsIdentity(path string, want windowsFileIdentity) (windows.Handle, error) { + handle, err := openWindowsACLDirectoryNoFollow(path) + if err != nil { + return 0, err + } + if want.empty() { + return handle, nil + } + got, err := windowsIdentityOfHandle(handle) + if err != nil { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("identify windows ACL directory %s: %w", path, err) + } + if got != want { + _ = windows.CloseHandle(handle) + return 0, fmt.Errorf("refusing to unwind under %s: it is no longer the directory setup created into, so something replaced it since (possible path swap during elevated setup)", path) + } + return handle, nil +} + +// createWindowsACLChildDirectory creates one directory directly beneath parent, +// or opens it when it already exists, and reports which happened. +// +// name must be a single component; see validateWindowsACLComponent for why that +// is enforced rather than assumed. The kernel resolves it relative to the parent +// HANDLE, so nothing above it is consulted and nothing above it can be swapped +// underneath us. +// +// created is true only when this call made the directory, which the rollback +// needs: removing one that already existed would delete a user's data over a +// failure that had nothing to do with it. +func createWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, created bool, err error) { + if err := validateWindowsACLComponent(name); err != nil { + return 0, false, err + } + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, false, fmt.Errorf("encode windows ACL directory component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var status windows.IO_STATUS_BLOCK + if err := windows.NtCreateFile( + &handle, + windows.FILE_LIST_DIRECTORY|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE|windows.DELETE, + &attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_DIRECTORY, + windowsACLDirectoryShare, + windows.FILE_OPEN_IF, + windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT, + 0, + 0, + ); err != nil { + return 0, false, fmt.Errorf("create windows ACL directory component %s: %w", name, err) + } + // FILE_OPEN_IF means an EXISTING child is opened rather than created, and + // FILE_OPEN_REPARSE_POINT means a junction is opened AS the junction. Without + // this check that junction becomes the parent of the next level and every + // create beneath it lands wherever it points, which is the mid-walk swap this + // whole file exists to stop. + if err := rejectWindowsACLReparseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return 0, false, err + } + return handle, status.Information == windowsFileCreated, nil +} + +// openWindowsACLChildDirectory opens an EXISTING directory beneath parent and +// never creates one. +// +// Rollback walks back down the chain it created, and it must not conjure a +// component that has since been removed: FILE_OPEN_IF would recreate it, and +// then the unwind would delete a directory setup never made. FILE_OPEN is the +// whole difference from createWindowsACLChildDirectory. +func openWindowsACLChildDirectory(parent windows.Handle, name string) (handle windows.Handle, err error) { + if err := validateWindowsACLComponent(name); err != nil { + return 0, err + } + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, fmt.Errorf("encode windows ACL directory component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + // Ask for the least that still allows passing through and checking the + // reparse attribute. This runs during rollback, on directories whose ACEs + // have already been applied, so every extra right is another way for the + // unwind to be refused by the very ACL it is unwinding. Notably absent: + // SYNCHRONIZE, and with it FILE_SYNCHRONOUS_IO_NONALERT, since nothing is + // read or written through this handle. + var status windows.IO_STATUS_BLOCK + if err := windows.NtCreateFile( + &handle, + windows.FILE_TRAVERSE|windows.FILE_READ_ATTRIBUTES, + &attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_DIRECTORY, + windowsACLDirectoryShare, + windows.FILE_OPEN, + windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ); err != nil { + return 0, fmt.Errorf("open windows ACL directory component %s: %w", name, err) + } + if err := rejectWindowsACLReparseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +// createWindowsACLChildFile creates an empty file directly beneath parent, or +// opens it when it already exists, and reports which happened. +// +// The counterpart to createWindowsACLChildDirectory for the one materialized +// target that must be a FILE: .git/config, where creating a directory instead +// would break `git init` outright rather than merely mis-ACL it. +// +// FILE_OPEN_IF rather than FILE_CREATE deliberately. The pathname version this +// replaces used O_CREATE|O_EXCL and then tolerated os.ErrExist, so a racing +// creator winning was fine. FILE_CREATE's collision status is +// STATUS_OBJECT_NAME_COLLISION, which errors.Is(err, os.ErrExist) does NOT +// match, so porting it literally would have turned that tolerated race into a +// hard failure. FILE_OPEN_IF keeps the old behaviour and reports the truth in +// created, which rollback needs so it never deletes a file it did not make. +func createWindowsACLChildFile(parent windows.Handle, name string) (created bool, err error) { + if err := validateWindowsACLComponent(name); err != nil { + return false, err + } + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return false, fmt.Errorf("encode windows ACL file component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + var handle windows.Handle + var status windows.IO_STATUS_BLOCK + if err := windows.NtCreateFile( + &handle, + windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + &attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windowsACLDirectoryShare, + windows.FILE_OPEN_IF, + windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT, + 0, + 0, + ); err != nil { + return false, fmt.Errorf("create windows ACL file component %s: %w", name, err) + } + if err := rejectWindowsACLReparseHandle(handle, name); err != nil { + _ = windows.CloseHandle(handle) + return false, err + } + createdNow := status.Information == windowsFileCreated + if err := windows.CloseHandle(handle); err != nil { + return createdNow, fmt.Errorf("close windows ACL file component %s: %w", name, err) + } + return createdNow, nil +} + +// deleteWindowsACLChildDirectory removes one directory directly beneath parent. +// +// The counterpart to the create above, and the reason rollback cannot use +// os.RemoveAll: that takes a pathname, so an ancestor swapped to a junction +// AFTER the object was created sends the recursive delete somewhere else and +// takes unrelated trees with it. Resolving relative to the parent handle makes +// that impossible. +// +// A missing child is not an error: rollback runs on failure paths where the +// object may never have been created. +func deleteWindowsACLChildDirectory(parent windows.Handle, name string) error { + return deleteWindowsACLChild(parent, name, true) +} + +// deleteWindowsACLChildFile removes one file directly beneath parent, for the +// materialized .git/config carveout. Rollback picks between this and the +// directory form from the shape recorded at materialization time rather than by +// stat-ing the pathname, because a stat is another pathname resolution and this +// whole file exists to avoid those. +func deleteWindowsACLChildFile(parent windows.Handle, name string) error { + return deleteWindowsACLChild(parent, name, false) +} + +// deleteWindowsACLChild opens one child relative to parent and deletes it by +// SETTING ITS DISPOSITION, not with FILE_DELETE_ON_CLOSE. +// +// That distinction is the whole point, and it was measured rather than assumed. +// FILE_DELETE_ON_CLOSE defers the removal to cleanup, where a non-empty +// directory makes it fail with nothing to report it to: NtCreateFile returns +// success, CloseHandle returns success, and the directory is still there. A +// rollback built on it would report success while leaving materialized state on +// disk, which is strictly worse than the os.RemoveAll it replaces, because +// os.RemoveAll at least removed it. +// +// NtSetInformationFile answers synchronously and to the caller, so a non-empty +// directory comes back as STATUS_DIRECTORY_NOT_EMPTY. Leaving residue is +// acceptable, since the alternative is a recursive delete through a pathname an +// attacker may control; lying about having removed it is not. +// +// FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE also make the open refuse an +// object of the wrong shape rather than deleting it. +func deleteWindowsACLChild(parent windows.Handle, name string, directory bool) error { + if err := validateWindowsACLComponent(name); err != nil { + return err + } + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return fmt.Errorf("encode windows ACL component %s: %w", name, err) + } + attributes := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + attributes.Length = uint32(unsafe.Sizeof(attributes)) + + shapeOption := uint32(windows.FILE_NON_DIRECTORY_FILE) + shapeAttribute := uint32(windows.FILE_ATTRIBUTE_NORMAL) + if directory { + shapeOption = windows.FILE_DIRECTORY_FILE + shapeAttribute = windows.FILE_ATTRIBUTE_DIRECTORY + } + + // DELETE alone. Asking for SYNCHRONIZE as well would make this fail on any + // object already carrying a deny-read ACE, because FILE_GENERIC_READ and + // FILE_GENERIC_EXECUTE both include SYNCHRONIZE, and rollback exists + // precisely to undo objects that have just been ACL'd. Nothing is read or + // written through this handle, so synchronous IO is not needed either. + var handle windows.Handle + var status windows.IO_STATUS_BLOCK + if err := windows.NtCreateFile( + &handle, + windows.DELETE, + &attributes, + &status, + nil, + shapeAttribute, + windowsACLDirectoryShare, + windows.FILE_OPEN, + shapeOption|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ); err != nil { + if isWindowsNotExist(err) { + return nil + } + return fmt.Errorf("open windows ACL component %s for delete: %w", name, err) + } + defer func() { _ = windows.CloseHandle(handle) }() + + // One BOOLEAN: FILE_DISPOSITION_INFORMATION.DeleteFile = TRUE. + disposition := byte(1) + var setStatus windows.IO_STATUS_BLOCK + if err := windows.NtSetInformationFile( + handle, + &setStatus, + &disposition, + uint32(unsafe.Sizeof(disposition)), + windows.FileDispositionInformation, + ); err != nil { + return fmt.Errorf("delete windows ACL component %s: %w", name, err) + } + return nil +} + +// rejectWindowsACLReparseHandle refuses a handle that landed on a reparse point. +// +// Deliberately NOT verifyWindowsACLTargetNotRedirected: that one compares the +// handle's resolved path against an expected pathname, which is exactly the +// pathname dependency the handle-relative walk removes. A child opened relative +// to a pinned parent is in the right place by construction even when the +// pathname no longer leads there, so comparing paths would reject correct, safe +// creates whenever the tree was legitimately renamed. The attribute is the only +// thing worth checking here. +func rejectWindowsACLReparseHandle(handle windows.Handle, name string) error { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("inspect windows ACL component %s: %w", name, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("refusing to work through reparse-point component %s: possible path swap during elevated setup", name) + } + return nil +} + +// verifyWindowsACLHandleIsCleanDirectory rejects a handle that landed on a +// reparse point, or on an object other than the path asked for. +func verifyWindowsACLHandleIsCleanDirectory(handle windows.Handle, path string) error { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("inspect windows ACL directory %s: %w", path, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path) + } + return verifyWindowsACLTargetNotRedirected(handle, path) +} + +// isWindowsNotExist reports a missing-object error from either side of the API. +// NtCreateFile returns NTSTATUS values, which do not map to os.ErrNotExist the +// way the Win32 error codes do. +func isWindowsNotExist(err error) bool { + if err == nil { + return false + } + if os.IsNotExist(err) { + return true + } + var status windows.NTStatus + if errors.As(err, &status) { + return status == windows.STATUS_OBJECT_NAME_NOT_FOUND || status == windows.STATUS_OBJECT_PATH_NOT_FOUND + } + return false +} diff --git a/internal/sandbox/windows_acl_relative_windows_test.go b/internal/sandbox/windows_acl_relative_windows_test.go new file mode 100644 index 000000000..4f52a83d5 --- /dev/null +++ b/internal/sandbox/windows_acl_relative_windows_test.go @@ -0,0 +1,230 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// THE SWAP THAT PATHNAMES CANNOT SURVIVE. +// +// This is the race behind the materialization P1. A pathname-based create +// resolves the whole path again at the moment it runs, so an ancestor replaced +// between the verification and the create sends the new directory somewhere else +// entirely. Verifying afterwards is too late: the object already exists in the +// wrong place. +// +// A handle pins the OBJECT, not the name. This test performs the swap for real, +// in the window that used to be exploitable, and asserts the child still lands +// in the directory that was verified. +// +// Worth noting for anyone extending this: the fix is what makes the race +// testable at all. Against the old code the swap had to be threaded into the +// middle of a function; here it is three ordinary lines between an open and a +// create, because the handle is held across them. +func TestChildCreationFollowsTheHandleNotThePath(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "approved") + elsewhere := filepath.Join(root, "elsewhere") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + + // Verified once, exactly as setup does before it materializes anything. + parent, err := openWindowsACLDirectoryNoFollow(approved) + if err != nil { + t.Fatalf("open approved directory: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + // THE SWAP, in the window that used to be exploitable: move the verified + // directory aside and leave a junction to somewhere else wearing its name. + moved := filepath.Join(root, "approved-moved") + if err := os.Rename(approved, moved); err != nil { + t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err) + } + makeJunction(t, approved, elsewhere) + + // The create resolves against the handle, so it must ignore the junction now + // sitting at the original pathname. + child, created, err := createWindowsACLChildDirectory(parent, "materialized") + if err != nil { + t.Fatalf("create child relative to the pinned handle: %v", err) + } + defer func() { _ = windows.CloseHandle(child) }() + if !created { + t.Error("created = false for a directory that did not exist") + } + + if _, err := os.Stat(filepath.Join(moved, "materialized")); err != nil { + t.Errorf("the child did not land in the verified directory: %v", err) + } + if _, err := os.Stat(filepath.Join(elsewhere, "materialized")); err == nil { + t.Fatal("ESCAPED: the child was created through the junction, outside the approved tree") + } +} + +// Materialization runs on trees that may already be half-built, so creating an +// existing directory has to be a no-op rather than a failure. created must still +// report the truth, because rollback deletes only what this call made. +func TestCreatingAnExistingChildOpensItInstead(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "already"), 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + handle, created, err := createWindowsACLChildDirectory(parent, "already") + if err != nil { + t.Fatalf("open existing child: %v", err) + } + _ = windows.CloseHandle(handle) + if created { + t.Error("created = true for a directory that already existed; rollback would delete a user's data") + } +} + +// The rollback counterpart. os.RemoveAll on a pathname whose ancestor has since +// become a junction recurses outside the workspace and deletes unrelated trees, +// which is the second P1. Resolving relative to the parent handle cannot. +func TestDeleteFollowsTheHandleNotThePath(t *testing.T) { + root := t.TempDir() + approved := filepath.Join(root, "approved") + elsewhere := filepath.Join(root, "elsewhere") + for _, dir := range []string{approved, elsewhere} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("seed %s: %v", dir, err) + } + } + // A bystander under the decoy: if the delete ever resolves by pathname it is + // reachable, and its survival is what proves the delete did not. + if err := os.Mkdir(filepath.Join(elsewhere, "victim"), 0o700); err != nil { + t.Fatalf("seed victim: %v", err) + } + if err := os.Mkdir(filepath.Join(approved, "victim"), 0o700); err != nil { + t.Fatalf("seed target: %v", err) + } + + parent, err := openWindowsACLDirectoryNoFollow(approved) + if err != nil { + t.Fatalf("open approved directory: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + moved := filepath.Join(root, "approved-moved") + if err := os.Rename(approved, moved); err != nil { + t.Skipf("cannot rename a directory with an open handle on this filesystem: %v", err) + } + makeJunction(t, approved, elsewhere) + + if err := deleteWindowsACLChildDirectory(parent, "victim"); err != nil { + t.Fatalf("delete relative to the pinned handle: %v", err) + } + if _, err := os.Stat(filepath.Join(elsewhere, "victim")); err != nil { + t.Fatal("DESTRUCTIVE: rollback followed the junction and deleted a directory outside the approved tree") + } + if _, err := os.Stat(filepath.Join(moved, "victim")); err == nil { + t.Error("the directory inside the approved tree was not removed") + } +} + +// A delete that cannot happen must SAY so. +// +// This is the gap that let a silent bug ship in the first version of this file. +// It deleted with FILE_DELETE_ON_CLOSE, which defers the removal to cleanup, +// where a non-empty directory makes it fail with nowhere to report it: the open +// returned success, the close returned success, and the directory was still +// there. The only test covering deletion used an EMPTY directory, so it passed +// throughout. Rollback built on that would have reported success while leaving +// materialized state on disk, which is worse than the os.RemoveAll it replaced, +// because os.RemoveAll actually removed it. +func TestDeletingANonEmptyDirectoryIsReported(t *testing.T) { + root := t.TempDir() + populated := filepath.Join(root, "populated") + if err := os.Mkdir(populated, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.WriteFile(filepath.Join(populated, "occupant"), []byte("x"), 0o600); err != nil { + t.Fatalf("seed occupant: %v", err) + } + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + err = deleteWindowsACLChildDirectory(parent, "populated") + if _, statErr := os.Stat(populated); statErr != nil { + t.Fatalf("the directory was removed with its contents, which this delete must never do: %v", statErr) + } + if err == nil { + t.Fatal("reported success while leaving the directory in place") + } +} + +// The directory form must refuse a file rather than delete it, and the file form +// must handle the one materialized target that is a file. +func TestDeleteDistinguishesFilesFromDirectories(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "config"), []byte("x"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + if err := deleteWindowsACLChildDirectory(parent, "config"); err == nil { + t.Error("the directory delete accepted a file") + } + if err := deleteWindowsACLChildFile(parent, "config"); err != nil { + t.Fatalf("deleteWindowsACLChildFile: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "config")); err == nil { + t.Error("the file survived its delete") + } +} + +// Rollback runs on failure paths where the object may never have been created, +// so a missing child is success rather than an error to report. +func TestDeletingAMissingChildIsNotAnError(t *testing.T) { + root := t.TempDir() + parent, err := openWindowsACLDirectoryNoFollow(root) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer func() { _ = windows.CloseHandle(parent) }() + + if err := deleteWindowsACLChildDirectory(parent, "never-existed"); err != nil { + t.Fatalf("deleting a missing child reported an error: %v", err) + } +} + +// The anchor open is the one pathname resolution in the walk, so it has to +// refuse a junction itself rather than leaving it to a later check. +func TestOpeningAJunctionAnchorIsRefused(t *testing.T) { + root := t.TempDir() + real := filepath.Join(root, "real") + if err := os.Mkdir(real, 0o700); err != nil { + t.Fatalf("seed: %v", err) + } + link := filepath.Join(root, "link") + makeJunction(t, link, real) + + handle, err := openWindowsACLDirectoryNoFollow(link) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatal("opened a junction as the materialization anchor; every create beneath it would land outside the approved tree") + } +} diff --git a/internal/sandbox/windows_acl_reparse_windows.go b/internal/sandbox/windows_acl_reparse_windows.go new file mode 100644 index 000000000..7465e677d --- /dev/null +++ b/internal/sandbox/windows_acl_reparse_windows.go @@ -0,0 +1,106 @@ +//go:build windows + +package sandbox + +import ( + "fmt" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +// GetFinalPathNameByHandle flags. x/sys/windows does not export these. +const ( + windowsFileNameNormalized uint32 = 0x0 + windowsVolumeNameDOS uint32 = 0x0 +) + +// verifyWindowsACLTargetNotRedirected fails when an opened handle resolved +// somewhere other than the requested path, which means a component along the way +// is a reparse point. +// +// openWindowsACLTarget's FILE_FLAG_OPEN_REPARSE_POINT check covers the FINAL +// component only; CreateFile still resolves ANCESTORS. A user who controls the +// workspace can turn an ancestor — .git, say — into a junction before elevated +// setup runs, and setup would then apply its DACL change to an object outside +// the approved tree while the final-component check still passed. Junctions need +// no privilege to create, unlike symlinks, so this is reachable by exactly the +// unprivileged user the sandbox exists to contain. +// +// GetFinalPathNameByHandle answers where the handle actually landed, covering +// every component in one call rather than walking the path and re-checking each +// component (which would also race between the checks). +// +// The comparison is against the path's own resolved form rather than the raw +// string, because a legitimate target can be spelled with different casing or an +// 8.3 short name and still be the same object. Only a genuine redirect makes the +// two disagree. +func verifyWindowsACLTargetNotRedirected(handle windows.Handle, path string) error { + buffer := make([]uint16, windows.MAX_LONG_PATH) + n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), windowsFileNameNormalized|windowsVolumeNameDOS) + if err != nil { + return fmt.Errorf("resolve windows ACL target %s: %w", path, err) + } + if int(n) < len(buffer) { + buffer = buffer[:n] + } + actual := trimWindowsExtendedPathPrefix(windows.UTF16ToString(buffer)) + expected := trimWindowsExtendedPathPrefix(canonicalSandboxWorkspaceRoot(path)) + if !strings.EqualFold(filepath.Clean(actual), filepath.Clean(expected)) { + return fmt.Errorf("refusing to apply ACL to %s: it resolves to %s, so a parent directory is a reparse point (possible path swap during elevated setup)", path, actual) + } + return nil +} + +// trimWindowsExtendedPathPrefix strips the \?\ form GetFinalPathNameByHandle +// returns so it can be compared with an ordinary path. +func trimWindowsExtendedPathPrefix(path string) string { + // Built from filepath.Separator rather than written as literals so the + // backslashes cannot be miscounted by whatever writes this file. + sep := string(filepath.Separator) + devicePrefix := sep + sep + "?" + sep + uncPrefix := devicePrefix + "UNC" + sep + if strings.HasPrefix(path, uncPrefix) { + return sep + sep + strings.TrimPrefix(path, uncPrefix) + } + return strings.TrimPrefix(path, devicePrefix) +} + +// verifyWindowsACLPathComponentNotRedirected opens one path component no-follow +// and refuses it if it is a reparse point or resolves anywhere other than its own +// pathname. Because GetFinalPathNameByHandle answers for the WHOLE resolved path, +// verifying a single existing component also clears every ancestor above it. +// +// A missing component surfaces as os.ErrNotExist so the caller can walk further +// up. Only FILE_READ_ATTRIBUTES is requested: this inspects, it never writes, and +// asking for more would fail on ancestors the setup process has no rights on. +func verifyWindowsACLPathComponentNotRedirected(path string) error { + utf16Path, err := windows.UTF16PtrFromString(path) + if err != nil { + return fmt.Errorf("encode windows ACL path component %s: %w", path, err) + } + handle, err := windows.CreateFile( + utf16Path, + windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + // syscall.Errno.Is maps ERROR_FILE_NOT_FOUND/ERROR_PATH_NOT_FOUND to + // os.ErrNotExist, so the caller's errors.Is check keeps working. + return fmt.Errorf("open windows ACL path component %s: %w", path, err) + } + defer func() { _ = windows.CloseHandle(handle) }() + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return fmt.Errorf("inspect windows ACL path component %s: %w", path, err) + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("refusing to materialize under reparse-point path component %s: possible path swap during elevated setup", path) + } + return verifyWindowsACLTargetNotRedirected(handle, path) +} diff --git a/internal/sandbox/windows_acl_reparse_windows_test.go b/internal/sandbox/windows_acl_reparse_windows_test.go new file mode 100644 index 000000000..c529f461b --- /dev/null +++ b/internal/sandbox/windows_acl_reparse_windows_test.go @@ -0,0 +1,79 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// An unprivileged user who controls the workspace can turn an ancestor of a +// configured ACL target into a junction before elevated setup runs. CreateFile +// resolves ancestors even with FILE_FLAG_OPEN_REPARSE_POINT, so the +// final-component check passes and setup would rewrite the DACL of an object +// outside the approved tree. +// +// Junctions, unlike symlinks, need no privilege — which is what makes this +// reachable by exactly the user the sandbox is containing. +func TestOpenWindowsACLTargetRefusesAJunctionAncestor(t *testing.T) { + base := t.TempDir() + workspace := filepath.Join(base, "workspace") + outside := filepath.Join(base, "OUTSIDE") + for _, dir := range []string{workspace, outside} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + // The object the attacker wants setup to touch. + victim := filepath.Join(outside, "hooks") + if err := os.MkdirAll(victim, 0o700); err != nil { + t.Fatalf("mkdir victim: %v", err) + } + + // .git is the ancestor, and it is a junction to OUTSIDE. + gitDir := filepath.Join(workspace, ".git") + if out, err := exec.Command("cmd", "/c", "mklink", "/J", gitDir, outside).CombinedOutput(); err != nil { + t.Skipf("cannot create a junction here: %v %s", err, out) + } + + // This is the path setup would be configured with. + target := filepath.Join(gitDir, "hooks") + if _, err := os.Stat(target); err != nil { + t.Fatalf("precondition: the junction should make %s reachable: %v", target, err) + } + + handle, _, err := openWindowsACLTarget(target) + if err == nil { + _ = windows.CloseHandle(handle) + t.Fatal("opened an ACL target through a junction ancestor; setup would have rewritten a DACL outside the workspace") + } + if !strings.Contains(err.Error(), "reparse point") { + t.Errorf("error = %v, want it to name the reparse point", err) + } + t.Logf("refused as expected: %v", err) +} + +// The guard must not reject ordinary targets, including ones spelled +// non-canonically — a differently-cased path is the same object, not a redirect. +func TestOpenWindowsACLTargetAcceptsAnOrdinaryTarget(t *testing.T) { + dir := t.TempDir() + nested := filepath.Join(dir, "Nested") + if err := os.MkdirAll(nested, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + for _, spelling := range []string{nested, strings.ToLower(nested)} { + handle, isDir, err := openWindowsACLTarget(spelling) + if err != nil { + t.Fatalf("openWindowsACLTarget(%q): %v", spelling, err) + } + if !isDir { + t.Errorf("%q reported as not a directory", spelling) + } + _ = windows.CloseHandle(handle) + } +} diff --git a/internal/sandbox/windows_command_runner_windows.go b/internal/sandbox/windows_command_runner_windows.go index 0b9e8f64e..378239f02 100644 --- a/internal/sandbox/windows_command_runner_windows.go +++ b/internal/sandbox/windows_command_runner_windows.go @@ -3,8 +3,11 @@ package sandbox import ( + "errors" "fmt" "io" + "os" + "strings" ) func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int { @@ -75,6 +78,52 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ // reads under that flag (#612). Profiles with DenyRead keep the fully // restricted token, trading spawn capability for read-deny enforcement. writeRestricted := len(config.PermissionProfile.FileSystem.DenyRead) == 0 + + // A provisioned sandbox principal replaces the restricted token entirely: it + // is a separate account, so reads outside its granted roots are denied by the + // filesystem rather than left open the way a same-user restricted token has + // to leave them (#662). Absent, unprovisioned or opted-out, ok is false and + // the restricted-token backend below runs exactly as before. + principalToken, ok, err := windowsSandboxPrincipalToken(config) + if err != nil { + // The one path here that does not fall back, because a provisioned but + // unusable principal means the sandbox is broken rather than absent. Say + // how to get out of it, since the whole backend is opt-in. + fmt.Fprintf(stderr, "%s: sandbox principal is provisioned but unusable: %v. Re-run `zero sandbox setup` from an elevated terminal, or unset %s to fall back to the restricted-token sandbox.\n", + WindowsSandboxCommandRunnerName, err, windowsSandboxIdentityEnv) + return 1 + } + if ok { + defer principalToken.Close() + // The principal gets its own identity AND the write jail, not one or the + // other. Its ACEs confine reads; without the restricted token it would + // still hold every write its ambient memberships grant, so a profile + // permitting writes only to the workspace could still write anywhere + // BATCH or BUILTIN\Users may — C:\Users\Public\Documents, for one. + // + // The principal's own SID joins the capability SIDs because the ACL plan + // grants the workspace to that SID; leaving it out jails the principal + // out of the tree it is supposed to own. + principalUser, err := principalToken.GetTokenUser() + if err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": read sandbox principal SID: "+err.Error()) + return 1 + } + jailSIDs := append(append([]string{}, tokenSIDs...), principalUser.User.Sid.String()) + jailedToken, err := restrictWindowsTokenForCapabilitySIDs(principalToken, jailSIDs, writeRestricted) + if err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } + defer jailedToken.Close() + exitCode, err := runWindowsCommandAsUser(jailedToken, config) + if err != nil { + fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) + return 1 + } + return exitCode + } + token, err := createWindowsRestrictedTokenForCapabilitySIDs(tokenSIDs, writeRestricted) if err != nil { fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error()) @@ -112,8 +161,56 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error { return nil } if _, err := applyWindowsACLPlan(plan); err != nil { + // Refusing to run is right: without these ACEs the write jail does not + // exist, so continuing would run the command believing it is sandboxed + // when it is not. What was wrong was the diagnosis. Every failure got the + // same "the workspace may be on a filesystem you do not own" guess, and + // the suggested remedy was elevated setup, which does not help at all + // when the real problem is one root in the plan that nobody can ACL. + // + // Being precise matters because this failure repeats: the success marker + // is only recorded on success, so the same plan fails identically on + // every later command until the offending root leaves it. A reader who + // cannot tell which root is at fault has no way out of that. + if denied := windowsACLPlanDeniedPath(err); denied != "" { + return fmt.Errorf("apply unelevated workspace ACLs: %w; %s cannot have its permissions changed by this user, "+ + "so the sandbox cannot enforce a write boundary there and will not run the command. "+ + "That path is one of this workspace's sandbox roots, usually a system directory that arrived via TEMP or TMP. "+ + "Check those, or re-run with `--sandbox forbid` to skip OS sandboxing. "+ + "Running `zero sandbox setup` elevated will NOT fix this", err, denied) + } return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+ "run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err) } return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied) } + +// windowsACLPlanDeniedPath pulls the target path out of an apply failure that +// was an access denial, and returns "" for anything else. +// +// applyWindowsACLPathGroup already wraps the path into its error, so this reads +// the message rather than threading a typed error through four layers for one +// diagnostic. The string it matches is produced in the same package by +// openWindowsACLTarget, and a test pins the pairing so the two cannot drift +// apart silently. +func windowsACLPlanDeniedPath(err error) string { + if err == nil || !errors.Is(err, os.ErrPermission) { + return "" + } + const marker = "open windows ACL target " + message := err.Error() + start := strings.Index(message, marker) + if start < 0 { + return "" + } + // Colon-SPACE, not colon. The wrapper is "...target %s: %w", and on Windows + // the path itself starts with a drive colon, so splitting on the first colon + // returns "C". A drive colon is always followed by a separator, never a + // space, which makes ": " the only unambiguous boundary here. + rest := message[start+len(marker):] + end := strings.Index(rest, ": ") + if end <= 0 { + return "" + } + return strings.TrimSpace(rest[:end]) +} diff --git a/internal/sandbox/windows_git_carveout_windows_test.go b/internal/sandbox/windows_git_carveout_windows_test.go new file mode 100644 index 000000000..2f444f06c --- /dev/null +++ b/internal/sandbox/windows_git_carveout_windows_test.go @@ -0,0 +1,66 @@ +//go:build windows + +package sandbox + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// The git control-plane carveouts are two different shapes: .git/hooks is a +// directory, .git/config is a FILE. Materialization has to respect that. +// +// On a fresh workspace neither exists yet, which is precisely the case +// Materialize was added for — so this is the common path, not a corner. Creating +// .git/config as a directory does not just mis-ACL it: it makes the workspace +// permanently unusable, because git refuses to initialise over a directory +// where its config file belongs. +func TestPrincipalACLPlanMaterializesGitConfigAsFile(t *testing.T) { + workspace := t.TempDir() + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + // Guests, deliberately: the deny-write ACE must land on the sandbox + // principal, not on whoever runs the test. With Everyone (S-1-1-0) the + // ACE denies the test process too and `git init` fails with "Permission + // denied" for a reason that has nothing to do with the shape bug. + PrincipalSID: "S-1-5-32-546", + WriteRoots: []WritableRoot{{ + Root: workspace, + ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + t.Cleanup(func() { + if rollback != nil { + _ = rollback() + } + }) + + configPath := filepath.Join(workspace, ".git", "config") + if info, err := os.Stat(configPath); err == nil && info.IsDir() { + t.Errorf(".git/config was materialized as a directory; git requires a file") + } + hooksPath := filepath.Join(workspace, ".git", "hooks") + if info, err := os.Stat(hooksPath); err == nil && !info.IsDir() { + t.Errorf(".git/hooks was materialized as a file; git requires a directory") + } + + // The failure users would actually hit. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH; shape assertions above still ran") + } + cmd := exec.Command("git", "init") + cmd.Dir = workspace + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("git init failed on a workspace after sandbox setup: %v\n%s", err, out) + } +} diff --git a/internal/sandbox/windows_git_rename_guard_apply_windows_test.go b/internal/sandbox/windows_git_rename_guard_apply_windows_test.go new file mode 100644 index 000000000..3dcfd6202 --- /dev/null +++ b/internal/sandbox/windows_git_rename_guard_apply_windows_test.go @@ -0,0 +1,145 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// windowsPathDeniesDelete reports whether the object's real DACL carries a deny +// ACE covering DELETE for the given SID. +// +// The plan-shape tests assert what the planner emits. This reads what actually +// landed on disk, which is the gap that let the guard go missing: the plan was +// right the whole time and the apply silently dropped it. +func windowsPathDeniesDelete(t *testing.T, path, sid string) bool { + t.Helper() + handle, _, err := openWindowsACLTarget(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer func() { _ = windows.CloseHandle(handle) }() + + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read DACL for %s: %v", path, err) + } + // SDDL rather than walking the ACE buffer by hand: this x/sys does not export + // an ACE enumerator, and unsafe pointer arithmetic in a test that exists to + // catch a security regression is its own hazard. + // + // An ACE renders as (type;flags;rights;guid;inherit_guid;sid), so the deny + // entries are the ones whose type field is D. Rights come back as SDDL + // abbreviations when they fit and as a hex mask when they do not, so both are + // accepted; SD is the abbreviation for DELETE. + for _, ace := range strings.Split(descriptor.String(), "(") { + fields := strings.Split(strings.TrimSuffix(strings.TrimSpace(ace), ")"), ";") + if len(fields) != 6 || fields[0] != "D" || !strings.EqualFold(fields[5], sid) { + continue + } + rights := fields[2] + if strings.Contains(rights, "SD") { + return true + } + if mask, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(rights), "0x"), 16, 32); err == nil { + if uint32(mask)&uint32(windows.DELETE) != 0 { + return true + } + } + } + return false +} + +// THE GUARD MUST REACH DISK ON A WORKSPACE THAT HAD NO .git. +// +// This is the case the whole plan is built around and it was the one case where +// the guard was absent. .git deliberately carries no Materialize, because an +// empty .git breaks `git init`. Groups are applied in ascending path order, so +// the .git group ran while .git did not yet exist, was skipped as a +// non-materializing missing target, and nothing revisited it once the +// .git\config carveout created .git as its parent chain moments later. +// +// Four tests already covered the deny-delete mask and the plan emitting it. +// None of them applied the plan and looked at the object, which is exactly why +// a green suite said nothing. +func TestGitRenameGuardReachesDiskOnAWorkspaceWithoutGit(t *testing.T) { + const principal = testPrincipalSID // Unaliased, so it renders literally in SDDL. + workspace := t.TempDir() + gitDir := filepath.Join(workspace, ".git") + if _, err := os.Stat(gitDir); err == nil { + t.Fatal("the workspace already has .git, so this proves nothing") + } + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principal, + WriteRoots: []WritableRoot{{ + Root: workspace, + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + t.Cleanup(func() { + if rollback != nil { + _ = rollback() + } + }) + + // The carveouts create .git on the way to .git\config. + if _, err := os.Stat(gitDir); err != nil { + t.Fatalf(".git was never created by the carveout materialization: %v", err) + } + if !windowsPathDeniesDelete(t, gitDir, principal) { + t.Fatal("no deny-delete ACE on .git after applying the plan: the principal can rename it aside and recreate it without the config and hooks carveouts") + } +} + +// The same guard on a workspace that already had .git must keep working. This +// case was already correct, and it is kept so a fix aimed at the fresh +// workspace cannot quietly trade one for the other. +func TestGitRenameGuardStillReachesDiskWhenGitAlreadyExists(t *testing.T) { + const principal = testPrincipalSID + workspace := t.TempDir() + gitDir := filepath.Join(workspace, ".git") + if err := os.Mkdir(gitDir, 0o700); err != nil { + t.Fatalf("seed .git: %v", err) + } + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principal, + WriteRoots: []WritableRoot{{ + Root: workspace, + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + ReadOnlySubpaths: gitMetadataWriteCarveouts(workspace), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + rollback, err := applyWindowsACLPlan(plan) + if err != nil { + t.Fatalf("applyWindowsACLPlan: %v", err) + } + t.Cleanup(func() { + if rollback != nil { + _ = rollback() + } + }) + + if !windowsPathDeniesDelete(t, gitDir, principal) { + t.Fatal("no deny-delete ACE on a .git that already existed") + } +} diff --git a/internal/sandbox/windows_git_rename_guard_test.go b/internal/sandbox/windows_git_rename_guard_test.go new file mode 100644 index 000000000..4d82f243b --- /dev/null +++ b/internal/sandbox/windows_git_rename_guard_test.go @@ -0,0 +1,98 @@ +package sandbox + +import ( + "path/filepath" + "testing" +) + +// A sandbox principal must not be able to REPLACE .git. +// +// The write-denied carveouts are attached to .git/config and .git/hooks as +// objects. Rename .git aside, recreate it, and those objects are gone: the fresh +// config and hooks inherit the workspace allow with no deny of their own, which +// hands back credential.helper and core.hooksPath, and with them arbitrary code +// execution on the next git command. +// +// .git cannot join sandboxFullyProtectedMetadataNames to fix this, because that +// list emits DenyWrite, whose mask includes FILE_GENERIC_WRITE. Git has to write +// index, objects and refs. So the directory needs DELETE denied on itself while +// staying writable underneath. +func TestThePrincipalCannotRenameTheGitDirectory(t *testing.T) { + root := filepath.FromSlash("/ws/repo") + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-1-2-3-1001", + WriteRoots: []WritableRoot{{ + Root: root, + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + ReadOnlySubpaths: gitMetadataWriteCarveouts(root), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + // The plan normalizes every write root before it names an ACE, so the + // expected path has to be normalized too or this compares two spellings of + // the same directory. Hardcoding a drive letter instead would pass on + // Windows and fail everywhere else, since the builder is portable code. + gitDir := filepath.Join(normalizeProfilePath(root), ".git") + var denyDelete *WindowsACLEntry + for index := range plan.Entries { + if plan.Entries[index].Action == WindowsACLDenyDelete && plan.Entries[index].Path == gitDir { + denyDelete = &plan.Entries[index] + break + } + } + if denyDelete == nil { + t.Fatalf("no deny-delete entry for %s, so the principal can rename .git and recreate it without the carveouts:\n%#v", gitDir, plan.Entries) + } + if denyDelete.Capability != "S-1-5-21-1-2-3-1001" { + t.Errorf("deny-delete names %q, want the principal SID", denyDelete.Capability) + } +} + +// The guard must not become a write ban. Git writes constantly inside .git, so a +// deny that reached the children would break every commit rather than just the +// rename. It also must not be materialized into existence: .git is git's to +// create, and an empty .git directory made by setup breaks `git init`. +func TestTheGitRenameGuardDoesNotBlockGitsOwnWrites(t *testing.T) { + root := filepath.FromSlash("/ws/repo") + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-1-2-3-1001", + WriteRoots: []WritableRoot{{ + Root: root, + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + ReadOnlySubpaths: gitMetadataWriteCarveouts(root), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + gitDir := filepath.Join(normalizeProfilePath(root), ".git") + for _, entry := range plan.Entries { + if entry.Path != gitDir { + continue + } + if entry.Action == WindowsACLDenyWrite { + t.Errorf("deny-write on %s would stop git writing index/objects/refs", gitDir) + } + if entry.Action == WindowsACLDenyDelete && entry.Materialize { + t.Errorf("the rename guard materializes %s; git must create it, an empty .git breaks git init", gitDir) + } + } + + // The existing carveouts must survive unchanged. + for _, want := range []string{filepath.Join(gitDir, "config"), filepath.Join(gitDir, "hooks")} { + found := false + for _, entry := range plan.Entries { + if entry.Action == WindowsACLDenyWrite && entry.Path == want { + found = true + break + } + } + if !found { + t.Errorf("the write-deny carveout for %s disappeared", want) + } + } +} diff --git a/internal/sandbox/windows_git_rename_guard_windows_test.go b/internal/sandbox/windows_git_rename_guard_windows_test.go new file mode 100644 index 000000000..0b4a9f841 --- /dev/null +++ b/internal/sandbox/windows_git_rename_guard_windows_test.go @@ -0,0 +1,81 @@ +//go:build windows + +package sandbox + +import ( + "testing" + + "golang.org/x/sys/windows" +) + +// The mask is the whole point of the action, so it is asserted bit by bit. +// +// Renaming a directory needs DELETE on the directory itself, so denying DELETE +// is what stops .git being replaced. Everything else in the mask is there to +// stop the principal removing the guard: WRITE_DAC would let it rewrite the +// DACL, WRITE_OWNER would let it take ownership and then rewrite the DACL. +// +// What must NOT be in it matters just as much. FILE_GENERIC_WRITE would stop git +// writing index, objects and refs. FILE_DELETE_CHILD would stop git deleting its +// own lock files and refs. Either one turns a rename guard into a broken repo. +func TestDenyDeleteMaskStopsRenameWithoutStoppingGit(t *testing.T) { + mode, mask, err := windowsACLAccess(WindowsACLDenyDelete) + if err != nil { + t.Fatalf("windowsACLAccess(deny-delete): %v", err) + } + if mode != windows.DENY_ACCESS { + t.Fatalf("access mode = %v, want DENY_ACCESS", mode) + } + + for _, required := range []struct { + name string + bit windows.ACCESS_MASK + }{ + {"DELETE", windows.DELETE}, + {"WRITE_DAC", windows.WRITE_DAC}, + {"WRITE_OWNER", windows.WRITE_OWNER}, + } { + if mask&required.bit == 0 { + t.Errorf("mask %#x is missing %s, so the guard can be removed or bypassed", mask, required.name) + } + } + for _, forbidden := range []struct { + name string + bit windows.ACCESS_MASK + breaks string + }{ + {"FILE_GENERIC_WRITE", windows.FILE_GENERIC_WRITE, "git writing index/objects/refs"}, + {"FILE_DELETE_CHILD", windowsFileDeleteChild, "git deleting its own lock files and refs"}, + } { + if mask&forbidden.bit != 0 { + t.Errorf("mask %#x includes %s, which breaks %s", mask, forbidden.name, forbidden.breaks) + } + } +} + +// Inheritance is the other half. An inherited deny would reach every file inside +// .git and stop git deleting anything at all, so this ACE has to apply to the +// directory object alone while the other actions keep inheriting as before. +func TestDenyDeleteDoesNotInheritWhileOtherActionsStillDo(t *testing.T) { + entries := []WindowsACLEntry{ + {Action: WindowsACLDenyDelete, Path: `C:\work\repo\.git`, Capability: "S-1-5-32-9999"}, + {Action: WindowsACLDenyWrite, Path: `C:\work\repo\.zero`, Capability: "S-1-5-32-9999"}, + {Action: WindowsACLAllowWrite, Path: `C:\work\repo`, Capability: "S-1-5-32-9999"}, + } + + access, err := windowsExplicitAccessEntries(entries, true) + if err != nil { + t.Fatalf("windowsExplicitAccessEntries: %v", err) + } + if len(access) != len(entries) { + t.Fatalf("got %d access entries, want %d", len(access), len(entries)) + } + if access[0].Inheritance != windows.NO_INHERITANCE { + t.Errorf("deny-delete inheritance = %#x, want NO_INHERITANCE; an inherited deny would stop git deleting inside .git", access[0].Inheritance) + } + for index, entry := range entries[1:] { + if got := access[index+1].Inheritance; got != windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT { + t.Errorf("%s inheritance = %#x, want the directory default to be unchanged", entry.Action, got) + } + } +} diff --git a/internal/sandbox/windows_identity_acl.go b/internal/sandbox/windows_identity_acl.go new file mode 100644 index 000000000..7547f1caf --- /dev/null +++ b/internal/sandbox/windows_identity_acl.go @@ -0,0 +1,231 @@ +package sandbox + +// ACLs for a sandbox principal. +// +// The capability-SID model this sits beside starts from "the caller can already +// read everything" and narrows writes, because the sandboxed child runs as the +// caller. A principal inverts that: a separate local account has no access to +// the caller's profile at all, so the interesting direction is what to GRANT. +// +// That inversion is the point. Credential stores under the user's profile are +// unreachable because the principal is a different account, not because a deny +// rule enumerated them, which is what makes this able to close #662 and #675 on +// Windows where a deny-read ACE against the caller's own SID never could. Deny +// rules stay useful only for objects that are readable by everyone. +// +// Grants are explicit and narrow: the workspace and any extra write roots get +// read+write, declared read-only roots get read, and the protected metadata +// carve-outs the profile already defines stay denied so .git internals and +// .zero/.agents cannot be rewritten from inside the sandbox. + +import ( + "errors" + "fmt" + "path/filepath" +) + +// WindowsACLAllowRead grants read and execute without write. It exists for the +// principal model, where a read root must be granted rather than assumed. +const WindowsACLAllowRead WindowsACLAction = "allow-read" + +// windowsPrincipalACLInput is everything needed to describe a principal's +// access. It is deliberately a plain struct rather than the full command config +// so the plan can be built and tested without a live sandbox. +type windowsPrincipalACLInput struct { + // PrincipalSID is the string SID of the sandbox account every ACE names. + PrincipalSID string + // WriteRoots receive read+write+execute. The workspace lives here. + WriteRoots []WritableRoot + // ReadRoots receive read+execute only. + ReadRoots []string + // DenyRead covers objects a principal could otherwise reach because they are + // world-readable; per-user secrets need no entry. + DenyRead []string + // DenyWrite carries the policy's own deny-write paths. The capability plan + // has always emitted these; the principal plan denied write only on + // protected metadata and read-only subpaths inside write roots, so a policy + // deny sitting anywhere else was simply not enforced once the runner used a + // principal token, and a shell child could write where the restricted-token + // backend would have blocked it. + DenyWrite []string +} + +// buildWindowsPrincipalACLPlan turns a principal's access into ACL entries. +// +// Ordering matters at apply time: deny entries are emitted before allows so a +// carve-out inside a granted root survives, which mirrors how Windows evaluates +// an explicit DACL (deny ACEs first). +func buildWindowsPrincipalACLPlan(input windowsPrincipalACLInput) (WindowsACLPlan, error) { + if input.PrincipalSID == "" { + return WindowsACLPlan{}, errors.New("windows principal ACL plan requires a principal SID") + } + if len(input.WriteRoots) == 0 && len(input.ReadRoots) == 0 { + return WindowsACLPlan{}, errors.New("windows principal ACL plan requires at least one root") + } + + entries := make([]WindowsACLEntry, 0, len(input.WriteRoots)*2+len(input.ReadRoots)+len(input.DenyRead)) + + // Deny first. A deny ACE inside a write root (protected metadata, git + // internals) has to win over the grant that follows it. + // Materialized, matching the capability plan. applyWindowsACLPlan skips a + // target that does not exist, so without this a deny-read path created after + // setup ran never got an ACE at all and the principal could read it. The + // deny has to be in place before the object is. + for _, path := range normalizeProfilePaths(input.DenyRead) { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyRead, + Path: path, + Capability: input.PrincipalSID, + Materialize: true, + }) + } + for _, path := range normalizeProfilePaths(input.DenyWrite) { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyWrite, + Path: path, + Capability: input.PrincipalSID, + Materialize: true, + }) + } + for _, root := range input.WriteRoots { + // Normalized the same way as read and deny paths: a write root may arrive + // with "~" or as a relative path, and an ACE has to name the same absolute, + // symlink-resolved object the deny entries do or the two disagree. + cleaned := normalizeProfilePath(root.Root) + if cleaned == "" { + return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: unusable write root %q", root.Root) + } + // Materialized, like the metadata and policy denies below and above. + // + // These are the git control-plane carveouts (.git/config, .git/hooks). On a + // workspace where git has not run yet they do not exist at setup time, and + // applyWindowsACLPlan skips a target that is absent, so the ACEs were never + // written. Once git created those paths the principal still held inherited + // write access to the workspace and could install a hook or rewrite + // credential.helper. The capability plan gets away without this because its + // child runs as the caller; a separate principal account does not. + // Which carveouts are files rather than directories comes from the same + // spec list the profile built ReadOnlySubpaths from, so a new carveout + // cannot be added without its shape coming along. + for _, subpath := range normalizeProfilePaths(root.ReadOnlySubpaths) { + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyWrite, + Path: subpath, + Capability: input.PrincipalSID, + Materialize: true, + MaterializeFile: gitMetadataCarveoutIsFile(subpath), + }) + } + for _, name := range root.ProtectedMetadataNames { + // These are documented as NAMES, and the join below is what makes that + // documentation load-bearing rather than descriptive: ".." or a + // separator would place this deny ACE, and the directory it + // materializes, outside the write root entirely. Today every caller + // passes a package constant, so this is unreachable; it is here so that + // stays true when a future caller sources these from config. + if err := validateWindowsACLComponent(name); err != nil { + return WindowsACLPlan{}, fmt.Errorf("windows principal ACL plan: protected metadata name: %w", err) + } + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyWrite, + Path: filepath.Join(cleaned, name), + Capability: input.PrincipalSID, + Materialize: true, + }) + } + // .git gets DELETE denied on the directory itself, because the carveouts + // that protect it are attached to .git/config and .git/hooks as OBJECTS. + // Rename .git aside and recreate it and those objects are gone, so the + // fresh config and hooks inherit the workspace allow with no deny of their + // own, handing back credential.helper and core.hooksPath. + // + // Not DenyWrite (git writes index, objects and refs), not materialized + // (git creates .git, and an empty one breaks git init), and not inherited, + // so everything underneath stays writable. + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLDenyDelete, + Path: filepath.Join(cleaned, sandboxRenameProtectedMetadataName), + Capability: input.PrincipalSID, + }) + } + + // Then the grants the principal cannot work without. + for _, root := range input.WriteRoots { + cleaned := normalizeProfilePath(root.Root) + if cleaned == "" { + continue + } + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLAllowWrite, + Path: cleaned, + Capability: input.PrincipalSID, + }) + } + for _, path := range normalizeProfilePaths(input.ReadRoots) { + // NEVER grant at a volume root. + // + // permissionProfileReadRoots seeds its list with profileRootPath(), which + // is the separator alone, because the workspace-write posture is + // read-all/write-jail. That is harmless for the capability backend, whose + // child runs as the CALLER and therefore reads what the caller could read + // anyway. It is not harmless here: a principal is a separate local + // account, so this loop turns that synthetic entry into a real, + // persistent, inheritable allow-read ACE for that account at the root of + // the current drive, reaching every directory that does not block + // inheritance. + // + // Dropping it does not take away the reads the principal needs to run + // commands. NetUserAdd with USER_PRIV_USER puts the account in the + // built-in Users group (see usrPrivUser in windows_identity_windows.go), + // and the machine's own ACLs already grant Users read on the system and + // program directories. What the grant added on top was read access to the + // places Users are deliberately kept out of, which is the opposite of what + // a sandbox is for. + // + // Note this inherits rather than asserts: nothing here checks that those + // default ACLs are actually in place, so a hardened image that strips + // Users read would need an explicit bounded read set instead. + if isWindowsVolumeRoot(path) { + continue + } + entries = append(entries, WindowsACLEntry{ + Action: WindowsACLAllowRead, + Path: path, + Capability: input.PrincipalSID, + }) + } + + return WindowsACLPlan{Entries: dedupeWindowsACLEntries(entries)}, nil +} + +// windowsPrincipalRevokePlan returns the entries whose ACEs should be removed +// when a principal is retired. Revocation is by TRUSTEE rather than by path: +// every ACE naming this principal is dropped, so cleanup does not depend on +// remembering which paths were granted, and a grant added by an older version +// is still removed. +// +// This is the removal path the capability-SID model never had, where a synthetic +// SID left ACEs behind on the user's tree with nothing to match them against. +func windowsPrincipalRevokePlan(principalSID string, paths []string) (WindowsACLPlan, error) { + if principalSID == "" { + return WindowsACLPlan{}, errors.New("windows principal revoke plan requires a principal SID") + } + cleaned := normalizeProfilePaths(paths) + entries := make([]WindowsACLEntry, 0, len(cleaned)) + for _, path := range cleaned { + entries = append(entries, WindowsACLEntry{ + Action: windowsACLRevoke, + Path: path, + Capability: principalSID, + }) + } + return WindowsACLPlan{Entries: entries}, nil +} + +// windowsACLRevoke removes every ACE naming the trustee on a path, whatever +// access it granted or denied. +const windowsACLRevoke WindowsACLAction = "revoke" + +// windowsACLPlanPaths lives in windows_identity_runtime_windows.go, beside its +// only callers. It was here, in the portable file, which made it dead code on +// every non-Windows build and failed the static analysis gate. diff --git a/internal/sandbox/windows_identity_acl_test.go b/internal/sandbox/windows_identity_acl_test.go new file mode 100644 index 000000000..79b5ff85b --- /dev/null +++ b/internal/sandbox/windows_identity_acl_test.go @@ -0,0 +1,275 @@ +package sandbox + +import ( + "path/filepath" + "strings" + "testing" +) + +const testPrincipalSID = "S-1-5-21-1111111111-2222222222-3333333333-1005" + +func testPrincipalInput() windowsPrincipalACLInput { + return windowsPrincipalACLInput{ + PrincipalSID: testPrincipalSID, + WriteRoots: []WritableRoot{{ + Root: filepath.FromSlash("/ws/project"), + ReadOnlySubpaths: []string{filepath.FromSlash("/ws/project/.git/config")}, + ProtectedMetadataNames: []string{".zero", ".agents"}, + }}, + ReadRoots: []string{filepath.FromSlash("/usr/lib")}, + DenyRead: []string{filepath.FromSlash("/shared/secrets")}, + } +} + +// Windows evaluates an explicit DACL deny-before-allow, so a carve-out inside a +// granted root only survives if its deny ACE is written first. If the grant on +// the workspace landed before the deny on .zero, the protected metadata would be +// writable from inside the sandbox. +func TestPrincipalACLPlanEmitsDeniesBeforeAllows(t *testing.T) { + plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput()) + if err != nil { + t.Fatalf("build: %v", err) + } + lastDeny, firstAllow := -1, -1 + for index, entry := range plan.Entries { + switch entry.Action { + case WindowsACLDenyRead, WindowsACLDenyWrite: + lastDeny = index + case WindowsACLAllowWrite, WindowsACLAllowRead: + if firstAllow == -1 { + firstAllow = index + } + } + } + if firstAllow == -1 || lastDeny == -1 { + t.Fatalf("plan is missing a deny or an allow: %+v", plan.Entries) + } + if lastDeny > firstAllow { + t.Fatalf("deny at %d comes after allow at %d; carve-outs would be overridden", lastDeny, firstAllow) + } +} + +// Every ACE must name the sandbox principal. An entry with any other trustee +// would change access for a real user. +func TestPrincipalACLPlanNamesOnlyThePrincipal(t *testing.T) { + plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput()) + if err != nil { + t.Fatalf("build: %v", err) + } + for _, entry := range plan.Entries { + if entry.Capability != testPrincipalSID { + t.Fatalf("entry %+v names %q, want the principal SID", entry, entry.Capability) + } + } +} + +// A principal is a separate account with no inherent access, so a write root +// must be granted read+write and a read root granted read. Without the grant the +// sandbox cannot open its own workspace. +func TestPrincipalACLPlanGrantsRoots(t *testing.T) { + plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput()) + if err != nil { + t.Fatalf("build: %v", err) + } + var grantedWrite, grantedRead bool + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowWrite && entry.Path == normalizeProfilePath(filepath.FromSlash("/ws/project")) { + grantedWrite = true + } + if entry.Action == WindowsACLAllowRead && entry.Path == normalizeProfilePath(filepath.FromSlash("/usr/lib")) { + grantedRead = true + } + } + if !grantedWrite { + t.Fatal("write root was not granted; the sandbox could not write its workspace") + } + if !grantedRead { + t.Fatal("read root was not granted; the sandbox could not read it") + } +} + +// Protected metadata is denied write and marked Materialize so the ACE is +// created even when the directory does not exist yet, closing the window where +// a sandboxed command creates .zero before the deny lands. +func TestPrincipalACLPlanProtectsMetadata(t *testing.T) { + plan, err := buildWindowsPrincipalACLPlan(testPrincipalInput()) + if err != nil { + t.Fatalf("build: %v", err) + } + found := map[string]WindowsACLEntry{} + for _, entry := range plan.Entries { + found[entry.Path] = entry + } + for _, name := range []string{".zero", ".agents"} { + path := filepath.Join(normalizeProfilePath(filepath.FromSlash("/ws/project")), name) + entry, ok := found[path] + if !ok { + t.Fatalf("no entry protecting %s", path) + } + if entry.Action != WindowsACLDenyWrite { + t.Fatalf("%s has action %q, want deny-write", path, entry.Action) + } + if !entry.Materialize { + t.Fatalf("%s must be materialized so the deny exists before the directory does", path) + } + } +} + +// A missing principal SID must be a hard error: an empty trustee would either +// fail at apply time or, worse, be interpreted as some other account. +func TestPrincipalACLPlanRequiresSID(t *testing.T) { + input := testPrincipalInput() + input.PrincipalSID = "" + if _, err := buildWindowsPrincipalACLPlan(input); err == nil { + t.Fatal("an empty principal SID must be rejected") + } +} + +// A plan with no roots at all is a caller mistake rather than a valid empty +// grant, since the resulting sandbox could not run anything. +func TestPrincipalACLPlanRequiresRoots(t *testing.T) { + input := windowsPrincipalACLInput{PrincipalSID: testPrincipalSID} + if _, err := buildWindowsPrincipalACLPlan(input); err == nil { + t.Fatal("a plan with no roots must be rejected") + } +} + +// Revocation is keyed to the trustee, so retiring a principal removes every ACE +// naming it without having to remember what was granted. This is the cleanup +// path the capability-SID model lacks. +func TestPrincipalRevokePlanTargetsTrustee(t *testing.T) { + paths := []string{filepath.FromSlash("/ws/project"), filepath.FromSlash("/usr/lib")} + plan, err := windowsPrincipalRevokePlan(testPrincipalSID, paths) + if err != nil { + t.Fatalf("revoke plan: %v", err) + } + if len(plan.Entries) != len(paths) { + t.Fatalf("got %d entries, want %d", len(plan.Entries), len(paths)) + } + for _, entry := range plan.Entries { + if entry.Action != windowsACLRevoke { + t.Fatalf("entry %+v is not a revoke", entry) + } + if entry.Capability != testPrincipalSID { + t.Fatalf("revoke names %q, want the principal", entry.Capability) + } + } +} + +func TestPrincipalRevokePlanRequiresSID(t *testing.T) { + if _, err := windowsPrincipalRevokePlan("", []string{"/ws"}); err == nil { + t.Fatal("revoking without a principal SID must be rejected") + } +} + +// The action strings end up in a serialized plan consumed by the elevated +// helper, so they must stay stable and distinct from the existing actions. +func TestPrincipalACLActionsAreDistinct(t *testing.T) { + actions := []WindowsACLAction{ + WindowsACLAllowWrite, WindowsACLAllowRead, + WindowsACLDenyRead, WindowsACLDenyWrite, windowsACLRevoke, + } + seen := map[WindowsACLAction]bool{} + for _, action := range actions { + if strings.TrimSpace(string(action)) == "" { + t.Fatal("an action string is empty") + } + if seen[action] { + t.Fatalf("duplicate action %q", action) + } + seen[action] = true + } +} + +// ProtectedMetadataNames is joined onto the write root to place a deny ACE and to +// materialize the directory it names. A value that is not a single component +// therefore puts both OUTSIDE the workspace: ".." walks up out of it, and a +// separator reaches through whatever sits in between. Every caller passes a +// package constant today, so this is the guard that keeps it true if one ever +// sources these from config. +func TestPrincipalACLPlanRefusesProtectedNamesThatEscapeTheWriteRoot(t *testing.T) { + root := filepath.FromSlash("/ws/project") + for _, name := range []string{"..", ".", "", `..\..\Windows\System32`, "nested/child", `nested\child`, "C:", "stream:name"} { + t.Run(name, func(t *testing.T) { + input := testPrincipalInput() + input.WriteRoots = []WritableRoot{{ + Root: root, + ProtectedMetadataNames: []string{name}, + }} + plan, err := buildWindowsPrincipalACLPlan(input) + if err == nil { + t.Fatalf("accepted protected metadata name %q, which would place a deny ACE outside %s:\n%#v", name, root, plan.Entries) + } + if len(plan.Entries) != 0 { + t.Errorf("returned %d entries alongside the error, so a caller ignoring err would still apply them", len(plan.Entries)) + } + }) + } +} + +// A PRINCIPAL MUST NEVER BE GRANTED READ AT A VOLUME ROOT. +// +// permissionProfileReadRoots seeds its list with the bare separator, because +// the workspace-write posture is read-all with a write jail. That costs nothing +// for the capability backend, whose child runs as the caller and could read +// those paths anyway. For a principal it is a real, persistent, inheritable +// allow-read ACE for a separate local account at the root of the drive. +// +// Built from the production profile rather than a synthetic fixture, because +// the whole point is that the shipped configuration produced it. +func TestPrincipalACLPlanNeverGrantsReadAtAVolumeRoot(t *testing.T) { + workspace := filepath.FromSlash("/ws/project") + profile := DefaultPermissionProfile(workspace) + + // If the profile ever stops carrying a volume root, this test proves nothing + // and should be retired rather than left passing vacuously. + seeded := false + for _, root := range profile.FileSystem.ReadRoots { + if isWindowsVolumeRoot(normalizeProfilePath(root)) { + seeded = true + break + } + } + if !seeded { + t.Skipf("the production profile no longer contains a volume read root: %v", profile.FileSystem.ReadRoots) + } + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: testPrincipalSID, + ReadRoots: profile.FileSystem.ReadRoots, + WriteRoots: profile.FileSystem.WriteRoots, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + for _, entry := range plan.Entries { + if entry.Action == WindowsACLAllowRead && isWindowsVolumeRoot(entry.Path) { + t.Errorf("plan grants the principal read at the volume root %q, which inherits into every directory on the drive", entry.Path) + } + } + // And the workspace itself must still be reachable, or this traded a real + // grant for a broken sandbox. + wantWorkspace := normalizeProfilePath(workspace) + reachable := false + for _, entry := range plan.Entries { + if entry.Path == wantWorkspace && (entry.Action == WindowsACLAllowRead || entry.Action == WindowsACLAllowWrite) { + reachable = true + break + } + } + if !reachable { + t.Errorf("no grant for the workspace root %q, so the principal could not read its own workspace", wantWorkspace) + } +} + +// The ordinary names must keep working, or the guard above is just a break. +func TestPrincipalACLPlanStillAcceptsTheRealProtectedNames(t *testing.T) { + input := testPrincipalInput() + input.WriteRoots = []WritableRoot{{ + Root: filepath.FromSlash("/ws/project"), + ProtectedMetadataNames: sandboxFullyProtectedMetadataNames, + }} + if _, err := buildWindowsPrincipalACLPlan(input); err != nil { + t.Fatalf("the shipped protected names were rejected: %v", err) + } +} diff --git a/internal/sandbox/windows_identity_dpapi_windows.go b/internal/sandbox/windows_identity_dpapi_windows.go new file mode 100644 index 000000000..d9a0bbf1d --- /dev/null +++ b/internal/sandbox/windows_identity_dpapi_windows.go @@ -0,0 +1,76 @@ +//go:build windows + +package sandbox + +// DPAPI wrapping for the stored sandbox principal password. +// +// The file ACL is the primary control and remains the thing that keeps the +// sandbox principal itself from reading its own credential. This adds the layer +// the ACL cannot: an ACL is only meaningful while the filesystem is being asked +// to enforce it, so a backup, a mounted disk image, or a copy taken by anyone +// who can bypass the DACL yields the password in the clear. CryptProtectData +// binds the ciphertext to the invoking user's logon secret, so an offline copy +// is inert without that user's credentials. +// +// The principal name is passed as optional entropy, which makes a blob usable +// only for the account it was minted for; moving one secret file over another +// then fails to decrypt instead of silently authenticating the wrong principal. + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +// protectWindowsSecret encrypts a password to the current user. +// +// CRYPTPROTECT_UI_FORBIDDEN matters here: this runs inside a CLI and, on the +// setup path, potentially without an interactive desktop, so DPAPI must fail +// rather than try to prompt. +func protectWindowsSecret(plaintext string, entropy string) ([]byte, error) { + if plaintext == "" { + return nil, errors.New("windows sandbox secret: refusing to protect an empty password") + } + in := windows.DataBlob{ + Size: uint32(len(plaintext)), + Data: &[]byte(plaintext)[0], + } + entropyBytes := []byte(entropy) + var entropyBlob *windows.DataBlob + if len(entropyBytes) > 0 { + entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]} + } + var out windows.DataBlob + if err := windows.CryptProtectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil { + return nil, fmt.Errorf("protect sandbox secret: %w", err) + } + // DPAPI allocates the output with LocalAlloc; copy it out and hand it back. + defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data))) + return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil +} + +// unprotectWindowsSecret reverses protectWindowsSecret. It fails for any user +// other than the one that wrote the blob, and for a blob minted with a different +// principal name as entropy. +func unprotectWindowsSecret(ciphertext []byte, entropy string) (string, error) { + if len(ciphertext) == 0 { + return "", errWindowsSandboxIdentityUnavailable + } + in := windows.DataBlob{ + Size: uint32(len(ciphertext)), + Data: &ciphertext[0], + } + entropyBytes := []byte(entropy) + var entropyBlob *windows.DataBlob + if len(entropyBytes) > 0 { + entropyBlob = &windows.DataBlob{Size: uint32(len(entropyBytes)), Data: &entropyBytes[0]} + } + var out windows.DataBlob + if err := windows.CryptUnprotectData(&in, nil, entropyBlob, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out); err != nil { + return "", fmt.Errorf("unprotect sandbox secret: %w", err) + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data))) + return string(unsafe.Slice(out.Data, out.Size)), nil +} diff --git a/internal/sandbox/windows_identity_logon_windows.go b/internal/sandbox/windows_identity_logon_windows.go new file mode 100644 index 000000000..45ebdaa5f --- /dev/null +++ b/internal/sandbox/windows_identity_logon_windows.go @@ -0,0 +1,270 @@ +//go:build windows + +package sandbox + +// Minting a token for a sandbox principal. +// +// A provisioned account is inert until something can log on as it. Windows +// gates that behind account rights held in the local security policy, so setup +// grants the principal exactly one: the right to be logged on as a batch job, +// which is what a non-interactive service-style logon needs. It is deliberately +// NOT granted interactive, network or remote-interactive logon, and those three +// are explicitly DENIED, so the account cannot be used to sign in at the +// console, over SMB, or through RDP even if its password leaked. The password +// exists only so LogonUser can mint a token; nobody is meant to type it. +// +// Rights are granted at setup (elevated) because LsaAddAccountRights requires +// administrator privileges. The per-command path only calls LogonUser, which +// needs no special privilege once the batch right is in place. + +import ( + "errors" + "fmt" + "runtime" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // Logon type/provider for a non-interactive token. Batch is the closest + // match for "run this command as a service-like principal": it produces a + // full token without a desktop or network-credential footprint. + logon32LogonBatch = 4 + logon32ProviderDefault = 0 + + // LSA policy access rights needed to add account rights. + policyCreateAccount = 0x00000010 + policyLookupNames = 0x00000800 + + // Account rights. The sandbox principal gets the batch right and is denied + // every interactive path. + seBatchLogonRight = "SeBatchLogonRight" + seDenyInteractiveLogonRight = "SeDenyInteractiveLogonRight" + seDenyNetworkLogonRight = "SeDenyNetworkLogonRight" + seDenyRemoteInteractiveRight = "SeDenyRemoteInteractiveLogonRight" + seDenyServiceLogonRightName = "SeDenyServiceLogonRight" + windowsIdentityLogonRightsNote = "granted by `zero sandbox setup`" +) + +var ( + procLogonUserW = windows.NewLazySystemDLL("advapi32.dll").NewProc("LogonUserW") + procLsaOpenPolicy = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaOpenPolicy") + procLsaClose = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaClose") + procLsaAddAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaAddAccountRights") + // Retiring a principal has to drop its rights as well as its account, or the + // LSA policy database keeps an entry keyed to a SID that no longer resolves. + procLsaRemoveAccountRights = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaRemoveAccountRights") + procLsaNtStatusToWinErr = windows.NewLazySystemDLL("advapi32.dll").NewProc("LsaNtStatusToWinError") +) + +// lsaUnicodeString mirrors LSA_UNICODE_STRING. Length and MaximumLength are +// BYTE counts, not rune counts, which is the usual source of bugs here. +type lsaUnicodeString struct { + Length uint16 + MaximumLength uint16 + Buffer *uint16 +} + +// lsaObjectAttributes mirrors LSA_OBJECT_ATTRIBUTES. Every field except Length +// is unused for LsaOpenPolicy, but the struct must still be the right size. +type lsaObjectAttributes struct { + Length uint32 + RootDirectory windows.Handle + ObjectName *lsaUnicodeString + Attributes uint32 + SecurityDescriptor unsafe.Pointer + SecurityQualityOfService unsafe.Pointer +} + +// newLSAString builds an LSA_UNICODE_STRING over a UTF-16 buffer the caller +// keeps alive. The returned value borrows that buffer, so the buffer must +// outlive every use of the string. +func newLSAString(buffer []uint16) lsaUnicodeString { + if len(buffer) == 0 { + return lsaUnicodeString{} + } + // The buffer is NUL-terminated; the LSA length counts bytes WITHOUT the + // terminator, while MaximumLength counts bytes WITH it. + runes := len(buffer) - 1 + return lsaUnicodeString{ + Length: uint16(runes * 2), + MaximumLength: uint16(len(buffer) * 2), + Buffer: &buffer[0], + } +} + +// lsaStatusError converts an NTSTATUS from an Lsa* call into a Go error, going +// through LsaNtStatusToWinError so the message is the familiar Win32 one rather +// than a raw NTSTATUS. +func lsaStatusError(call string, status uintptr) error { + if status == 0 { + return nil + } + winErr, _, _ := procLsaNtStatusToWinErr.Call(status) + return fmt.Errorf("%s: %w", call, windows.Errno(winErr)) +} + +// grantWindowsSandboxLogonRights gives the principal the batch logon right and +// denies every interactive logon path. Idempotent: LsaAddAccountRights silently +// succeeds when the account already holds a right, so setup can re-run. +// +// Requires an elevated caller. +func grantWindowsSandboxLogonRights(sid *windows.SID) error { + if sid == nil { + return fmt.Errorf("grant sandbox logon rights: nil SID") + } + var attributes lsaObjectAttributes + attributes.Length = uint32(unsafe.Sizeof(attributes)) + var policy windows.Handle + status, _, _ := procLsaOpenPolicy.Call( + 0, // local system + uintptr(unsafe.Pointer(&attributes)), + uintptr(policyCreateAccount|policyLookupNames), + uintptr(unsafe.Pointer(&policy)), + ) + // LsaOpenPolicy borrows the attributes struct by address, so it has to stay + // reachable until the call has returned. + runtime.KeepAlive(attributes) + if err := lsaStatusError("LsaOpenPolicy", status); err != nil { + return err + } + defer procLsaClose.Call(uintptr(policy)) + + rights := []string{ + seBatchLogonRight, + seDenyInteractiveLogonRight, + seDenyNetworkLogonRight, + seDenyRemoteInteractiveRight, + seDenyServiceLogonRightName, + } + // Each right is added on its own call so one unsupported name on an odd SKU + // cannot silently drop the others. + for _, right := range rights { + buffer, err := windows.UTF16FromString(right) + if err != nil { + return err + } + entry := newLSAString(buffer) + status, _, _ := procLsaAddAccountRights.Call( + uintptr(policy), + uintptr(unsafe.Pointer(sid)), + uintptr(unsafe.Pointer(&entry)), + 1, + ) + // Both the descriptor and the buffer it points at are borrowed by the + // call. Kept alive before the error check, not after, so the failure path + // does not return with them already collectable. + runtime.KeepAlive(entry) + runtimeKeepAliveUint16(buffer) + if err := lsaStatusError("LsaAddAccountRights("+right+")", status); err != nil { + return err + } + } + return nil +} + +// revokeWindowsSandboxLogonRights drops every account right held by a principal +// and removes its entry from the LSA policy database. +// +// This is the logon-rights counterpart to revoking ACEs by trustee, and it has +// the same reason to exist: deleting the account on its own leaves the rights +// behind, keyed to a SID that no longer resolves, which is the orphaned residue +// this model is supposed to avoid. It must therefore run BEFORE the account is +// deleted, while the SID is still resolvable. +// +// Removing all rights rather than naming them is deliberate. The principal is +// being retired, so anything keyed to it should go, including rights a previous +// version of setup granted and this one no longer knows about. +// +// Requires an elevated caller. A principal that holds no rights is not an error: +// LsaRemoveAccountRights reports ERROR_FILE_NOT_FOUND for an account with no LSA +// entry, which is the state teardown is trying to reach anyway. +func revokeWindowsSandboxLogonRights(sid *windows.SID) error { + if sid == nil { + return fmt.Errorf("revoke sandbox logon rights: nil SID") + } + var attributes lsaObjectAttributes + attributes.Length = uint32(unsafe.Sizeof(attributes)) + var policy windows.Handle + status, _, _ := procLsaOpenPolicy.Call( + 0, // local system + uintptr(unsafe.Pointer(&attributes)), + uintptr(policyCreateAccount|policyLookupNames), + uintptr(unsafe.Pointer(&policy)), + ) + runtime.KeepAlive(attributes) + if err := lsaStatusError("LsaOpenPolicy", status); err != nil { + return err + } + defer procLsaClose.Call(uintptr(policy)) + + status, _, _ = procLsaRemoveAccountRights.Call( + uintptr(policy), + uintptr(unsafe.Pointer(sid)), + 1, // AllRights: drop everything and delete the LSA account object + 0, // UserRights ignored when AllRights is set + 0, // CountOfRights likewise + ) + runtime.KeepAlive(sid) + if err := lsaStatusError("LsaRemoveAccountRights", status); err != nil { + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + return nil + } + return err + } + return nil +} + +// logonWindowsSandboxPrincipal mints a primary token for the sandbox account. +// The caller owns the returned token and must Close it. +// +// This needs no elevation: the batch logon right granted at setup is what makes +// it work, which is why the per-command path can run unelevated once setup has +// been done once. +func logonWindowsSandboxPrincipal(username string, password string) (windows.Token, error) { + user, err := windows.UTF16PtrFromString(username) + if err != nil { + return 0, err + } + // "." is the local machine, so the lookup never leaves this host even if the + // machine is domain-joined and a same-named domain account exists. + domain, err := windows.UTF16PtrFromString(".") + if err != nil { + return 0, err + } + secret, err := windows.UTF16PtrFromString(password) + if err != nil { + return 0, err + } + var token windows.Token + result, _, callErr := procLogonUserW.Call( + uintptr(unsafe.Pointer(user)), + uintptr(unsafe.Pointer(domain)), + uintptr(unsafe.Pointer(secret)), + logon32LogonBatch, + logon32ProviderDefault, + uintptr(unsafe.Pointer(&token)), + ) + // The three strings are borrowed for the duration of the call. + runtime.KeepAlive(user) + runtime.KeepAlive(domain) + runtime.KeepAlive(secret) + if result == 0 { + if callErr != nil && callErr != windows.ERROR_SUCCESS { + return 0, fmt.Errorf("LogonUser(%s): %w", username, callErr) + } + return 0, fmt.Errorf("LogonUser(%s) failed", username) + } + return token, nil +} + +// runtimeKeepAliveUint16 keeps a UTF-16 buffer reachable across a syscall that +// borrows it. Declared rather than inlined so the intent is explicit at each +// call site; the compiler must not free the slice while LSA holds the pointer. +func runtimeKeepAliveUint16(buffer []uint16) { + if len(buffer) == 0 { + return + } + _ = buffer[0] +} diff --git a/internal/sandbox/windows_identity_logon_windows_test.go b/internal/sandbox/windows_identity_logon_windows_test.go new file mode 100644 index 000000000..503d12922 --- /dev/null +++ b/internal/sandbox/windows_identity_logon_windows_test.go @@ -0,0 +1,41 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +// revokeWindowsSandboxLogonRights treats "this account holds no rights" as +// success, because that is the state teardown is trying to reach anyway. That +// relies on the NTSTATUS for it surviving the trip through LsaNtStatusToWinError +// as an error errors.Is can still match, which is exactly the kind of Windows +// errno assumption that quietly turns out to be false. Assert it rather than +// trust it. +// +// Needs no privilege: LsaNtStatusToWinError is a pure status translation, so +// this runs everywhere rather than joining the gated set. +func TestLsaStatusErrorMapsObjectNameNotFound(t *testing.T) { + // STATUS_OBJECT_NAME_NOT_FOUND, what LsaRemoveAccountRights reports for an + // account that has no LSA entry. + const statusObjectNameNotFound = 0xC0000034 + + err := lsaStatusError("LsaRemoveAccountRights", statusObjectNameNotFound) + if err == nil { + t.Fatal("a nonzero NTSTATUS produced no error") + } + if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { + t.Fatalf("error = %v, want one errors.Is matches against ERROR_FILE_NOT_FOUND; "+ + "without that, revoking a principal that simply holds no rights fails teardown", err) + } + + // The tolerance must be specific. If any failure matched it, revoke would + // swallow a real one and teardown would report success having done nothing. + const statusAccessDenied = 0xC0000022 + if other := lsaStatusError("LsaRemoveAccountRights", statusAccessDenied); errors.Is(other, windows.ERROR_FILE_NOT_FOUND) { + t.Fatalf("access denied matched the not-found tolerance: %v", other) + } +} diff --git a/internal/sandbox/windows_identity_policy_windows_test.go b/internal/sandbox/windows_identity_policy_windows_test.go new file mode 100644 index 000000000..f1137cebd --- /dev/null +++ b/internal/sandbox/windows_identity_policy_windows_test.go @@ -0,0 +1,391 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// Policy deny-write has to reach the principal plan. +// +// The capability plan has always emitted these. The principal plan denied write +// only on protected metadata and read-only subpaths inside write roots, so once +// the runner used a principal token a policy deny sitting anywhere else was not +// enforced at the OS layer at all, and a shell child could write where the +// restricted-token backend would have stopped it. +func TestPrincipalACLPlanCarriesPolicyDenyWrite(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspace") + denied := filepath.Join(root, "protected", "keep-out") + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-0-0-0-1001", + WriteRoots: []WritableRoot{{Root: root}}, + DenyWrite: []string{denied}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + + entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, denied) + if !ok { + t.Fatalf("no deny-write ACE for the policy path; plan = %+v", plan.Entries) + } + // Materialized for the same reason the capability plan does it: the applier + // skips targets that do not exist, so a deny on a path created after setup + // would never be written. + if !entry.Materialize { + t.Error("policy deny-write ACE is not materialized, so it is skipped when the path does not exist yet") + } +} + +// Deny-read has to be materialized too, which it was not. +func TestPrincipalACLPlanMaterializesDenyRead(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspace") + secret := filepath.Join(t.TempDir(), "elsewhere", "creds") + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-0-0-0-1001", + WriteRoots: []WritableRoot{{Root: root}}, + DenyRead: []string{secret}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyRead, secret) + if !ok { + t.Fatalf("no deny-read ACE emitted; plan = %+v", plan.Entries) + } + if !entry.Materialize { + t.Fatal("deny-read ACE is not materialized, so a path created after setup never gets one") + } +} + +// Deny entries must still precede the grants they carve out of, which is what +// makes them win under Windows DACL evaluation. Adding deny-write to the plan is +// only safe if it did not disturb that ordering. +func TestPrincipalACLPlanKeepsDeniesBeforeGrants(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspace") + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-0-0-0-1001", + WriteRoots: []WritableRoot{{Root: root}}, + DenyWrite: []string{filepath.Join(root, "nope")}, + DenyRead: []string{filepath.Join(root, "secret")}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + firstGrant := -1 + for i, entry := range plan.Entries { + switch entry.Action { + case WindowsACLAllowWrite, WindowsACLAllowRead: + if firstGrant == -1 { + firstGrant = i + } + case WindowsACLDenyRead, WindowsACLDenyWrite: + if firstGrant != -1 { + t.Fatalf("deny entry at %d follows a grant at %d; the grant would win", i, firstGrant) + } + } + } +} + +func findPrincipalACLEntry(plan WindowsACLPlan, action WindowsACLAction, path string) (WindowsACLEntry, bool) { + want := normalizeProfilePath(path) + for _, entry := range plan.Entries { + if entry.Action == action && entry.Path == want { + return entry, true + } + } + return WindowsACLEntry{}, false +} + +// An adopted account must not have its password rotated during provisioning. +// +// Rotating there left every later step running against an account whose password +// had already been replaced with nothing on disk holding it. Any failure in +// between stranded a working principal: the rollback correctly declined to +// delete an account it had not created, so what remained was a live account +// authenticated by a password no longer stored anywhere, and the command path +// read the absent secret as "not provisioned" and quietly fell back to the +// weaker backend. +func TestProvisionWindowsSandboxIdentityDefersPasswordRotation(t *testing.T) { + stubWindowsProvisioning(t, true, nil, nil) + + rotated := false + previous := resetWindowsSandboxUserPasswordFn + t.Cleanup(func() { resetWindowsSandboxUserPasswordFn = previous }) + resetWindowsSandboxUserPasswordFn = func(string, string) error { + rotated = true + return nil + } + + if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey"); err != nil { + t.Fatalf("provisioning an adopted account: %v", err) + } + if rotated { + t.Fatal("provisioning rotated the password; the window this closes lasts until the secret is committed") + } +} + +// The ownership comment carries the full workspace key, so two workspaces whose +// digests collide in the 11 characters the account name can hold are refused +// rather than silently sharing one account, one secret and one ACL identity. +func TestWindowsSandboxUserCommentDistinguishesWorkspaces(t *testing.T) { + first := windowsSandboxUserCommentFor("aaaaaaaaaaaabbbbbbbb") + second := windowsSandboxUserCommentFor("aaaaaaaaaaaacccccccc") + if first == second { + t.Fatal("two workspaces produced the same ownership comment, so a name collision would be adopted") + } + if !strings.HasPrefix(first, windowsSandboxUserComment) { + t.Fatalf("comment %q lost the marker prefix that identifies it as ours", first) + } + // The names DO collide, which is the whole reason the comment has to carry + // the key. If this stops being true the test is no longer covering anything. + if windowsSandboxUserName("aaaaaaaaaaaabbbbbbbb") != windowsSandboxUserName("aaaaaaaaaaaacccccccc") { + t.Skip("account names no longer collide for these keys; revisit what this test is for") + } +} + +// Rollback must not strip an adopted principal's logon rights. +// +// revokeWindowsSandboxLogonRights passes AllRights, which drops every right the +// account holds and deletes its LSA object. On an account this run created that +// is a rollback; on one it adopted it destroys the SeBatchLogonRight and +// deny-logon rights an earlier setup established, which is the working +// principal this path exists to preserve. +func TestSetupRollbackRevokesRightsOnlyForCreatedPrincipals(t *testing.T) { + for name, testCase := range map[string]struct { + existed bool + wantRevoked bool + }{ + "adopted principal": {existed: true, wantRevoked: false}, + "created principal": {existed: false, wantRevoked: true}, + } { + t.Run(name, func(t *testing.T) { + stubWindowsProvisioning(t, testCase.existed, nil, nil) + + revoked := false + prevGrant, prevRevoke := grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn + t.Cleanup(func() { + grantWindowsSandboxLogonRightsFn, revokeWindowsSandboxLogonRightsFn = prevGrant, prevRevoke + }) + // Fail the grant so the undo path runs with rights already attempted, + // which is the state that used to revoke unconditionally. + grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { + return errors.New("LSA grant refused by policy") + } + revokeWindowsSandboxLogonRightsFn = func(*windows.SID) error { + revoked = true + return nil + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + WorkspaceRoots: []string{`C:\ws`}, + } + if _, _, err := provisionWindowsSandboxPrincipalForSetup(config); err == nil { + t.Fatal("provisioning reported success despite an injected grant failure") + } + if revoked != testCase.wantRevoked { + if testCase.wantRevoked { + t.Fatal("rights were not revoked for an account this run created, leaving LSA entries keyed to a SID about to be deleted") + } + t.Fatal("rights were revoked for an adopted account; AllRights drops its pre-existing rights and deletes the LSA object") + } + }) + } +} + +// A secret the current user cannot read is unavailability, not breakage. +// +// The secret's DACL names whoever ran setup. An operator who elevated with a +// separate administrative account, via runas or an over-the-shoulder UAC +// prompt, leaves a secret their ordinary account cannot open. Treating that as a +// hard error made every sandboxed command fail on a machine that was merely set +// up by a different admin; it belongs in the same fail-soft path as a missing +// secret, so the warning fires and the restricted token takes over. +func TestReadWindowsSandboxSecretTreatsPermissionDeniedAsUnavailable(t *testing.T) { + previous := readWindowsSandboxSecretFile + t.Cleanup(func() { readWindowsSandboxSecretFile = previous }) + readWindowsSandboxSecretFile = func(string) ([]byte, error) { + return nil, &os.PathError{Op: "open", Path: "secret", Err: windows.ERROR_ACCESS_DENIED} + } + + if _, err := readWindowsSandboxSecret(`C:\anything.secret`); err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("permission-denied read returned %v, want errWindowsSandboxIdentityUnavailable so the command falls back", err) + } +} + +// A workspace must not bind to another workspace's principal. +// +// The account name carries only 11 characters of the workspace digest, so two +// workspaces can derive the same name. Provisioning refuses that case by +// checking the full key in the account comment, but the command path resolved +// the name straight to a SID. The workspace that lost the race would have failed +// setup and then quietly run as the other one's principal, using its secret and +// its ACL identity. +func TestLookupWindowsSandboxIdentityRejectsForeignWorkspace(t *testing.T) { + previous := windowsSandboxUserIsManagedFn + t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous }) + + prevSID := resolveWindowsSandboxSIDFn + t.Cleanup(func() { resolveWindowsSandboxSIDFn = prevSID }) + // The account resolves; whether it BELONGS to this workspace is the question. + resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) { + return windows.CreateWellKnownSid(windows.WinLocalSystemSid) + } + + var askedKey string + windowsSandboxUserIsManagedFn = func(_ string, workspaceKey string) (bool, error) { + askedKey = workspaceKey + return false, nil + } + _, err := lookupWindowsSandboxIdentity("workspacekey") + if err == nil { + t.Fatal("lookup accepted an account belonging to another workspace") + } + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Fatal("a foreign account must not read as unprovisioned; that would silently fall back instead of reporting the conflict") + } + if askedKey != "" && askedKey != "workspacekey" { + t.Fatalf("ownership was checked against %q, want the caller's workspace key", askedKey) + } +} + +// An account that does not exist must stay the unavailable sentinel rather than +// becoming a collision error, since that is the ordinary not-set-up state. +func TestLookupWindowsSandboxIdentityAbsentAccountIsUnavailable(t *testing.T) { + previous := windowsSandboxUserIsManagedFn + t.Cleanup(func() { windowsSandboxUserIsManagedFn = previous }) + windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { + t.Fatal("ownership must not be consulted for an account that does not resolve") + return false, nil + } + if _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey"); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Fatalf("absent account returned %v, want errWindowsSandboxIdentityUnavailable", err) + } +} + +// The git control-plane carveouts must be materialized. +// +// gitMetadataWriteCarveouts supplies .git/config and .git/hooks as +// ReadOnlySubpaths of the workspace. On a workspace where git has not run yet +// they do not exist when setup applies the plan, and applyWindowsACLPlan skips +// an absent target, so the deny ACEs were never written. Once git created those +// paths the principal still held inherited write access and could install a +// hook or rewrite credential.helper. +func TestPrincipalACLPlanMaterializesReadOnlySubpaths(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspace") + carveout := filepath.Join(root, ".git", "config") + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-21-0-0-0-1001", + WriteRoots: []WritableRoot{{Root: root, ReadOnlySubpaths: []string{carveout}}}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + entry, ok := findPrincipalACLEntry(plan, WindowsACLDenyWrite, carveout) + if !ok { + t.Fatalf("no deny-write ACE for the git carveout; plan = %+v", plan.Entries) + } + if !entry.Materialize { + t.Fatal("git carveout deny-write is not materialized, so it is skipped on a workspace where .git does not exist yet") + } +} + +// Setup must grant the principal the same runtime root that commands write to. +// +// permissionProfileWithRuntime appends this root to WriteRoots on every command +// and redirects HOME, GOCACHE and npm_config_cache into it, but it lives under +// the user cache rather than the workspace, so the profile setup sees never +// contains it. A principal is a separate account with no rights there, so +// without a grant every npm install or go build fails on a cache write. +// +// The assertion that matters is that the two derivations agree. If they drift, +// setup writes the ACE on one directory while commands use another, and the +// symptom is a bare ACCESS_DENIED with nothing pointing at the sandbox. +func TestSetupGrantsTheRuntimeRootCommandsActuallyUse(t *testing.T) { + workspace := filepath.Join(t.TempDir(), "ws") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + + granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{workspace}, + }) + if err != nil { + t.Fatalf("setupWindowsSandboxRuntimeRoot: %v", err) + } + if granted == "" { + t.Fatal("no runtime root resolved for a configured workspace") + } + if info, err := os.Stat(granted); err != nil || !info.IsDir() { + t.Fatalf("runtime root %q was not created; applyWindowsACLPlan skips absent targets so the grant would no-op (stat err %v)", granted, err) + } + + // What a command would actually use. + runtimeState, release, err := prepareSandboxRuntime(workspace) + if err != nil { + t.Fatalf("prepareSandboxRuntime: %v", err) + } + if release != nil { + defer release() + } + if filepath.Clean(runtimeState.Root) != filepath.Clean(granted) { + t.Fatalf("setup granted %q but commands write to %q", granted, runtimeState.Root) + } +} + +// No workspace root means nothing to grant, which is not an error. +func TestSetupRuntimeRootWithoutWorkspaceIsNotAnError(t *testing.T) { + granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{}) + if err != nil { + t.Fatalf("no workspace root should not error: %v", err) + } + if granted != "" { + t.Fatalf("granted %q with no workspace configured", granted) + } +} + +// An account in a privileged group must not be adopted, even when name and +// ownership comment say it is ours. +// +// Adoption resets the password and hands the account to the sandbox. If that +// account is also in Administrators, the sandbox gains the rights the sandbox +// exists to withhold: rewriting the ACLs confining it, reading the secret locked +// to the invoking user, and stopping Zero. +func TestProvisionWindowsSandboxIdentityRefusesPrivilegedAccount(t *testing.T) { + stubWindowsProvisioning(t, true, nil, nil) + + previous := windowsSandboxUserIsPrivilegedFn + t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous }) + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return true, nil } + + _, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + if !errors.Is(err, errWindowsSandboxPrivilegedAccount) { + t.Fatalf("provisioning adopted a privileged account, err = %v", err) + } + if created { + t.Fatal("created must stay false for an account this run refused to adopt") + } +} + +// The ordinary adopted account is unaffected. +func TestProvisionWindowsSandboxIdentityAdoptsUnprivilegedAccount(t *testing.T) { + stubWindowsProvisioning(t, true, nil, nil) + + previous := windowsSandboxUserIsPrivilegedFn + t.Cleanup(func() { windowsSandboxUserIsPrivilegedFn = previous }) + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil } + + if _, _, _, err := provisionWindowsSandboxIdentity("workspacekey"); err != nil { + t.Fatalf("an unprivileged managed account must still be adopted: %v", err) + } +} diff --git a/internal/sandbox/windows_identity_privilege_recheck_windows_test.go b/internal/sandbox/windows_identity_privilege_recheck_windows_test.go new file mode 100644 index 000000000..9544b867a --- /dev/null +++ b/internal/sandbox/windows_identity_privilege_recheck_windows_test.go @@ -0,0 +1,92 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +// Group membership is not frozen at setup. An account provisioned clean can be +// added to Administrators afterwards — by an operator, or by an attacker who +// already has that access and wants the sandbox to hand it back. Provisioning's +// refusal cannot see that; only the path that mints the token can. +func TestLookupPrincipalForCommandRefusesAnAccountThatBecamePrivileged(t *testing.T) { + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatalf("CreateWellKnownSid: %v", err) + } + restoreLookupSeams(t, sid) + + privilegedCalls := 0 + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { + privilegedCalls++ + return true, nil + } + + _, err = lookupWindowsSandboxPrincipalForCommand("workspace-key") + if !errors.Is(err, errWindowsSandboxPrivilegedAccount) { + t.Fatalf("err = %v, want errWindowsSandboxPrivilegedAccount", err) + } + if privilegedCalls != 1 { + t.Errorf("privileged check ran %d times, want exactly 1", privilegedCalls) + } + // It must be a hard refusal, not the unavailable sentinel — that one is the + // quiet "not provisioned" fallback and would silently drop the sandbox back + // to the restricted token instead of telling the operator. + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Error("privileged refusal must not read as the not-provisioned fallback") + } +} + +func TestLookupPrincipalForCommandAcceptsAnUnprivilegedAccount(t *testing.T) { + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatalf("CreateWellKnownSid: %v", err) + } + restoreLookupSeams(t, sid) + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { return false, nil } + + identity, err := lookupWindowsSandboxPrincipalForCommand("workspace-key") + if err != nil { + t.Fatalf("lookupWindowsSandboxPrincipalForCommand: %v", err) + } + if identity.Username == "" { + t.Error("expected the resolved principal") + } +} + +// Teardown must stay able to clean up an account that has become privileged. +// If the refusal lived inside lookupWindowsSandboxIdentity, the account this +// guard exists to catch would become undeletable by Zero. +func TestLookupIdentityItselfDoesNotConsultPrivilege(t *testing.T) { + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatalf("CreateWellKnownSid: %v", err) + } + restoreLookupSeams(t, sid) + windowsSandboxUserIsPrivilegedFn = func(string) (bool, error) { + t.Error("teardown's lookup must not be gated on privilege") + return true, nil + } + + if _, err := lookupWindowsSandboxIdentity("workspace-key"); err != nil { + t.Fatalf("lookupWindowsSandboxIdentity: %v", err) + } +} + +func restoreLookupSeams(t *testing.T, sid *windows.SID) { + t.Helper() + prevResolve := resolveWindowsSandboxSIDFn + prevManaged := windowsSandboxUserIsManagedFn + prevPrivileged := windowsSandboxUserIsPrivilegedFn + t.Cleanup(func() { + resolveWindowsSandboxSIDFn = prevResolve + windowsSandboxUserIsManagedFn = prevManaged + windowsSandboxUserIsPrivilegedFn = prevPrivileged + }) + resolveWindowsSandboxSIDFn = func(string) (*windows.SID, error) { return sid, nil } + windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil } +} diff --git a/internal/sandbox/windows_identity_rollback_windows_test.go b/internal/sandbox/windows_identity_rollback_windows_test.go new file mode 100644 index 000000000..e8593b816 --- /dev/null +++ b/internal/sandbox/windows_identity_rollback_windows_test.go @@ -0,0 +1,168 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +// stubWindowsProvisioning replaces the four provisioning syscalls so the +// function can run on an ordinary machine. Every one of them needs an elevated +// caller and a real local account, so without this the test would stop at the +// first call and never reach the behaviour it is named for. +func stubWindowsProvisioning(t *testing.T, existed bool, groupErr error, sidErr error) { + t.Helper() + prevGroup, prevUser := ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn + prevAdd, prevSID := addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn + prevManaged := windowsSandboxUserIsManagedFn + t.Cleanup(func() { + ensureWindowsSandboxGroupFn, ensureWindowsSandboxUserFn = prevGroup, prevUser + addWindowsSandboxUserToGroupFn, resolveWindowsSandboxSIDFn = prevAdd, prevSID + windowsSandboxUserIsManagedFn = prevManaged + }) + + ensureWindowsSandboxGroupFn = func() error { return nil } + // Adopted accounts are ours in these tests; the ownership check is a real + // syscall and would otherwise refuse before the code under test runs. + windowsSandboxUserIsManagedFn = func(string, string) (bool, error) { return true, nil } + ensureWindowsSandboxUserFn = func(string, string, string) (bool, error) { return existed, nil } + addWindowsSandboxUserToGroupFn = func(string) error { return groupErr } + resolveWindowsSandboxSIDFn = func(username string) (*windows.SID, error) { + if sidErr != nil { + return nil, sidErr + } + return windows.CreateWellKnownSid(windows.WinLocalSystemSid) + } +} + +// A failure after the account has been created must still hand back the name. +// +// The caller's rollback deletes by identity.Username, so returning a zero +// identity alongside created=true asked it to delete "" and quietly left the +// account this run had just made. Group attachment is the case that matters +// most: it is the enforcement boundary and it can fail under local policy. +func TestProvisionWindowsSandboxIdentityReturnsNameForRollback(t *testing.T) { + groupFailure := errors.New("group attachment refused by policy") + sidFailure := errors.New("sid lookup failed") + + for name, testCase := range map[string]struct { + groupErr error + sidErr error + }{ + "group attachment fails": {groupErr: groupFailure}, + "sid resolution fails": {sidErr: sidFailure}, + } { + t.Run(name, func(t *testing.T) { + stubWindowsProvisioning(t, false, testCase.groupErr, testCase.sidErr) + + identity, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + if err == nil { + t.Fatal("provisioning reported success despite an injected failure") + } + if !created { + t.Fatal("created = false, so the rollback would skip an account this run made") + } + // The whole point: without a name there is nothing to delete. + if identity.Username == "" { + t.Fatal("identity carries no username, so the rollback deletes \"\" and strands the account") + } + if want := windowsSandboxUserName("workspacekey"); identity.Username != want { + t.Fatalf("username = %q, want %q", identity.Username, want) + } + }) + } +} + +// An account that already existed must not be deleted because a later step +// failed. created=false is what stops the rollback turning a partial failure +// into the loss of a working principal from an earlier setup. +func TestProvisionWindowsSandboxIdentityDoesNotClaimPreexistingAccount(t *testing.T) { + stubWindowsProvisioning(t, true, errors.New("group attachment refused"), nil) + + _, _, created, err := provisionWindowsSandboxIdentity("workspacekey") + if err == nil { + t.Fatal("provisioning reported success despite an injected failure") + } + // created is the whole assertion. It is what stops the rollback deleting an + // account it did not make, and it must stay false however provisioning + // fails. The identity is deliberately not checked: an adopted account exits + // early at the ownership check, which is a real syscall and not stubbed + // here, so asserting on the name would be testing the stub rather than the + // contract. + if created { + t.Fatal("created = true for an account this run did not create; rollback would delete a working principal") + } +} + +// The write grant has to include delete. +// +// FILE_GENERIC_WRITE covers creating and modifying but not removing or +// renaming, and a rename needs delete on the source. The old same-user token hid +// this because the caller already held inherited rights on its own tree; a +// principal is a separate account with none, so without these it can write files +// it can never remove, which fails ordinary editing and most git operations +// rather than an edge case. +func TestWindowsACLAllowWriteGrantsDelete(t *testing.T) { + mode, mask, err := windowsACLAccess(WindowsACLAllowWrite) + if err != nil { + t.Fatalf("windowsACLAccess: %v", err) + } + if mode != windows.GRANT_ACCESS { + t.Fatalf("mode = %v, want GRANT_ACCESS", mode) + } + // Atomic bits only. FILE_GENERIC_READ and FILE_GENERIC_WRITE both carry + // READ_CONTROL and SYNCHRONIZE, so testing a composite constant with & is + // satisfied by any grant at all and proves nothing. + for label, bit := range map[string]windows.ACCESS_MASK{ + "DELETE": windows.DELETE, + "FILE_WRITE_DATA": windows.FILE_WRITE_DATA, + } { + if mask&bit == 0 { + t.Errorf("write grant is missing %s", label) + } + } + // Granting these would let the principal rewrite the very restrictions + // placed on it. They are in the deny mask for that reason and must not + // appear here. + // + // FILE_DELETE_CHILD belongs in this set and was originally in the one + // above, on the reasoning that the grant should mirror the deny mask. On a + // parent it authorises deleting a child whatever the child's own DACL says, + // so on a write root it hands back the write-denied carve-outs underneath: + // delete .git/config, recreate it, and the replacement inherits the grant + // with no deny of its own. Mirroring the deny mask is the wrong instinct — + // denying a capability is not a reason to grant it. + for label, bit := range map[string]windows.ACCESS_MASK{ + "WRITE_DAC": windows.WRITE_DAC, + "WRITE_OWNER": windows.WRITE_OWNER, + "FILE_DELETE_CHILD": windowsFileDeleteChild, + } { + if mask&bit != 0 { + t.Errorf("write grant unexpectedly includes %s", label) + } + } +} + +// The read grant must stay read-only. Widening the write mask above is only +// safe if this one did not move with it. +func TestWindowsACLAllowReadGrantsNoDelete(t *testing.T) { + _, mask, err := windowsACLAccess(WindowsACLAllowRead) + if err != nil { + t.Fatalf("windowsACLAccess: %v", err) + } + // Atomic bits, for the same reason as above: the read and write composites + // overlap on the standard rights, so a composite check here would report a + // failure that is not real. + for label, bit := range map[string]windows.ACCESS_MASK{ + "DELETE": windows.DELETE, + "FILE_DELETE_CHILD": windowsFileDeleteChild, + "FILE_WRITE_DATA": windows.FILE_WRITE_DATA, + } { + if mask&bit != 0 { + t.Errorf("read grant unexpectedly includes %s", label) + } + } +} diff --git a/internal/sandbox/windows_identity_runtime_windows.go b/internal/sandbox/windows_identity_runtime_windows.go new file mode 100644 index 000000000..b1942a87c --- /dev/null +++ b/internal/sandbox/windows_identity_runtime_windows.go @@ -0,0 +1,776 @@ +//go:build windows + +package sandbox + +// Using a sandbox principal at command time. +// +// This is the seam between the identity model and the existing runner. It is +// deliberately fail-soft: when no principal is provisioned, when the secret is +// missing, or when the opt-in is off, it reports "not available" and the caller +// keeps using today's restricted-token backend. Only an outright failure to log +// on with a principal that IS provisioned surfaces as an error, because that +// means setup ran but the identity is broken, and silently downgrading the +// sandbox in that case would be the wrong kind of quiet. + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + "sync" + + "golang.org/x/sys/windows" +) + +// The opt-in itself (windowsSandboxIdentityEnv and windowsSandboxIdentityEnabled) +// lives in windows_setup.go: it is part of the setup protocol, which the elevated +// half and the command half both have to read the same way, so it cannot be +// Windows-only. + +// windowsSandboxWorkspaceKey derives the per-workspace key a principal is named +// after. It hashes the workspace root the same way the sandbox runtime keys its +// own state, so the account name leaks no path and one workspace always maps to +// one principal. +func windowsSandboxWorkspaceKey(workspaceRoots []string) string { + root := "" + for _, candidate := range workspaceRoots { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + root = normalizeProfilePath(trimmed) + break + } + } + if root == "" { + root = "default" + } + digest := sha256.Sum256([]byte(strings.ToLower(root))) + return hex.EncodeToString(digest[:]) +} + +// windowsSandboxPrincipalEligible reports whether the principal backend may be +// used for this command at all, before any account or secret is consulted. +// +// Kept separate from the lookup so the decision is observable on its own: on a +// machine with nothing provisioned the lookup declines anyway, which would let a +// missing guard here pass unnoticed. +func windowsSandboxPrincipalEligible(config WindowsSandboxCommandConfig) bool { + if !windowsSandboxIdentityEnabled(config.Env) { + return false + } + // Network denial is enforced by WFP filters keyed to the offline-marker SID, + // which the restricted token carries and a principal token cannot: LogonUser + // mints a token for the account, not for a synthetic capability SID. Using a + // principal here would leave those filters matching nothing and silently drop + // network enforcement, which is a worse trade than the read confinement it + // buys. Fall back to the restricted token, which still enforces the network, + // until the filters are also keyed to the principal's own SID. + return config.PermissionProfile.Network.Mode != NetworkDeny +} + +// windowsSandboxPrincipalToken returns a token for this workspace's sandbox +// principal. +// +// ok is false, with a nil error, whenever the principal backend simply is not in +// play: the opt-in is off, setup has not provisioned an account, or no secret is +// stored. The caller falls back to the restricted token in those cases. An error +// means the identity exists but could not be used, which is worth surfacing +// rather than downgrading around. +func windowsSandboxPrincipalToken(config WindowsSandboxCommandConfig) (windows.Token, bool, error) { + if !windowsSandboxPrincipalEligible(config) { + // Deliberately silent, and this is a change of mind worth recording. + // + // Announcing it looks right: deny is the DEFAULT network mode, so an + // operator who opted in never gets a principal for ordinary commands, and + // that is worth knowing. But the warning cannot be delivered here. This + // runner is re-exec'd per command as `zero __windows-command-runner`, so the + // sync.Once below is once per COMMAND, not once per session — the notice + // would land on the stderr of essentially every sandboxed tool call. Noise + // that repeats gets filtered by the reader rather than acted on, which is + // the exact failure the helper's own comment warns about. + // + // It is also not actionable per command: windowsSandboxPrincipalEligible + // prefers network enforcement over read confinement on purpose, so there is + // nothing to do differently. A standing configuration fact belongs on a + // surface read once — `zero doctor`, which carries the opt-in now. + return 0, false, nil + } + key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + identity, err := lookupWindowsSandboxPrincipalForCommand(key) + if err != nil { + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + // Not provisioned. On the restricted-token tier the marker check has + // already refused a command whose opt-in disagrees with setup, so reaching + // here means the unelevated tier, which validates no marker at all and + // cannot provision an account (that needs Administrator). Falling back is + // right — refusing would break every machine-wide opt-in that relies on + // the unelevated tier — but it must not be silent. + warnWindowsSandboxPrincipalNotUsed("no sandbox principal is provisioned for this workspace; `zero sandbox setup` from an elevated (Administrator) terminal provisions one") + return 0, false, nil + } + // The name resolves to something that is not a usable principal, most + // likely squatted by a local group or alias. That is a conflict an + // operator has to see, not a reason to pretend setup never ran. + return 0, false, err + } + secretPath, err := windowsSandboxSecretPath(config.SandboxHome, identity.Username) + if err != nil { + return 0, false, err + } + password, err := readWindowsSandboxSecret(secretPath) + if err != nil { + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + // The account exists but its password does not. Setup was interrupted + // or the secret was removed; fall back rather than fail the command. + // + // Falling back is right, staying quiet about it was not. The opt-in is + // set and an account IS provisioned, so the operator asked for + // principal isolation and is silently getting the weaker same-user + // restricted token instead. That is the one fail-soft case worth + // announcing: the others mean the backend was never set up, while this + // one means it was and has broken since. + warnWindowsSandboxPrincipalUnavailable(identity.Username) + return 0, false, nil + } + return 0, false, err + } + token, err := logonWindowsSandboxPrincipal(identity.Username, password) + if err != nil { + // Provisioned but unusable. Surface it: a wrong password or a revoked + // batch-logon right is a broken sandbox, not an absent one. + return 0, false, err + } + return token, true, nil +} + +// warnWindowsSandboxPrincipalUnavailable tells the operator once per process +// that the backend they opted into is not the one running. +// +// Once, because this sits on the command path: a warning per command would be +// noise on every tool call for the whole session, and noise that repeats gets +// filtered out by the reader rather than acted on. Indirected through a var so a +// test can observe it without capturing stderr. +var warnWindowsSandboxPrincipalUnavailable = func(username string) { + windowsSandboxPrincipalWarnOnce.Do(func() { + fmt.Fprintf(os.Stderr, + "[zero] %s is set and sandbox principal %q is provisioned, but its stored password is missing or unreadable. "+ + "Falling back to the restricted-token sandbox, which does not confine reads. "+ + "Re-run `zero sandbox setup` from an elevated terminal to restore it.\n", + windowsSandboxIdentityEnv, username) + }) +} + +var windowsSandboxPrincipalWarnOnce sync.Once + +// warnWindowsSandboxPrincipalNotUsed covers the other ways an opted-in command +// ends up on the restricted token: the principal is ineligible for this +// command's policy, or none is provisioned on a tier that validates no marker. +// Neither is an error — both are correct fallbacks — but both leave the operator +// believing an account boundary is isolating them when it is not, which is the +// one thing this backend must never do quietly. +// +// Once per process and behind its own sync.Once, so it neither silences nor is +// silenced by the provisioned-but-secretless warning above. +var warnWindowsSandboxPrincipalNotUsed = func(reason string) { + windowsSandboxPrincipalNotUsedWarnOnce.Do(func() { + fmt.Fprintf(os.Stderr, + "[zero] %s is set, but this command is not running as a sandbox principal: %s. "+ + "Falling back to the restricted-token sandbox, which does not confine reads.\n", + windowsSandboxIdentityEnv, reason) + }) +} + +var windowsSandboxPrincipalNotUsedWarnOnce sync.Once + +// provisionWindowsSandboxPrincipalForSetup does the elevated half: create the +// account, grant it the batch logon right, and store its password locked to the +// invoking user. Called from `zero sandbox setup`. +// +// The password is written BEFORE the caller applies any ACL plan, so a setup +// that fails partway leaves a principal that can at least be logged on and +// therefore cleaned up, rather than an account nothing holds the secret for. +func provisionWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) (windowsSandboxIdentity, bool, error) { + key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + identity, password, created, err := provisionWindowsSandboxIdentityFn(key) + + // Undo whatever this run actually did, in reverse, on any failure after the + // account exists. Without it a failure between creating the account and + // storing its secret left the account behind with no caller able to remove + // it: the rollback the setup path installs is only built once this function + // has returned successfully. + // + // Scoped to what THIS run created on purpose. An account that already existed + // and belongs to Zero is a working principal from an earlier setup, and + // deleting it because a later run failed would turn a partial failure into a + // total one. + rightsAttempted := false + rotated := false + // Resolved from the account name rather than the identity, so it is known + // before anything can fail. + secretPath, secretPathErr := windowsSandboxSecretPath(config.SandboxHome, windowsSandboxUserName(key)) + undo := func() error { + // Only when this run invalidated it. The secret is removed if this run + // created the account, or if it rotated an existing account's password, + // because in both cases what is on disk cannot authenticate and absent + // beats stale: the command path treats a missing secret as "not + // provisioned" and falls back, while a stale one fails the logon and + // reports a broken sandbox. + // + // Removing it unconditionally, as this used to, destroyed a WORKING + // secret whenever setup failed before rotation on a machine that was + // already provisioned. The account kept its old password, the only copy + // of it was deleted, and the sandbox silently degraded. + // + // This one failure is reported rather than swallowed. The others below + // leave residue; this one leaves a CREDENTIAL for a password that no + // longer works, which is the stale-secret state the invariant above + // exists to prevent. Staying quiet would claim the invariant was restored + // when it was not, and the operator would instead meet a + // provisioned-but-unusable principal on the next command. + var cleanupErr error + if secretPath != "" && (created || rotated) { + if err := removeWindowsSandboxSecretFn(secretPath); err != nil { + cleanupErr = fmt.Errorf( + "sandbox secret %s is stale and could not be removed, so the next command will "+ + "fail to log the principal on rather than falling back; delete it and re-run "+ + "`zero sandbox setup`: %w", secretPath, err) + } + } + // Only for an account this run created, and attempted rather than + // completed. + // + // Attempted, because grantWindowsSandboxLogonRights adds rights one at a + // time and returns on the first failure, so a partial grant is possible + // and gating on success left those entries behind, keyed to a SID that + // deleting the account then made unresolvable. + // + // Created, because revokeWindowsSandboxLogonRights passes AllRights, which + // drops every right the account holds and deletes its LSA object outright. + // On an adopted principal that is not a rollback, it is destruction: a + // transient failure anywhere below would strip the SeBatchLogonRight and + // deny-logon rights a previous setup established, leaving exactly the + // broken-but-present principal this whole function exists to avoid. The + // rights this run granted are the ones the account is supposed to have, so + // leaving them in place on an adopted account is the safe direction. + if identity.SID != nil && rightsAttempted && created { + _ = revokeWindowsSandboxLogonRightsFn(identity.SID) + } + if created { + _ = removeWindowsSandboxIdentity(identity.Username) + } + return cleanupErr + } + + if err != nil { + // provisionWindowsSandboxIdentity can fail after creating the account, so + // this path needs the same cleanup even though nothing below ran. + return windowsSandboxIdentity{}, false, errors.Join(err, undo()) + } + rightsAttempted = true + if err := grantWindowsSandboxLogonRightsFn(identity.SID); err != nil { + return windowsSandboxIdentity{}, false, errors.Join(err, undo()) + } + if secretPathErr != nil { + return windowsSandboxIdentity{}, false, errors.Join(secretPathErr, undo()) + } + // Rotation happens HERE, immediately before the secret is committed, rather + // than inside provisioning where it used to. + // + // A new account already has this password from NetUserAdd, so only an + // adopted one needs setting. Doing it at the top of provisioning meant every + // step in between ran with the account's password already replaced and no + // copy of it stored, so any failure there stranded a working principal. The + // two operations are now adjacent, which is the smallest window this can + // have without a way to restore the previous password, which Windows does + // not offer. + if !created { + if err := resetWindowsSandboxUserPasswordFn(identity.Username, password); err != nil { + return windowsSandboxIdentity{}, false, errors.Join(err, undo()) + } + rotated = true + } + if err := writeWindowsSandboxSecretFn(secretPath, password); err != nil { + return windowsSandboxIdentity{}, false, errors.Join(err, undo()) + } + return identity, created, nil +} + +// setupWindowsSandboxPrincipal provisions this workspace's principal and grants +// it the filesystem access its permission profile describes. It returns a +// rollback that undoes everything it created, so a later setup step failing does +// not leave a half-provisioned account behind. +// +// Rollback order is the inverse of creation and matters: ACEs are revoked BEFORE +// the account is deleted, because removing the account first would leave ACEs +// naming a SID that no longer resolves, which is the orphaned-entry residue this +// model exists to avoid. +func setupWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) (func() error, error) { + username := windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + // Retire a principal whose grants were never recorded, BEFORE provisioning + // adopts it. + // + // This is the one case where the prior grant set is not empty but unknowable: + // an account from an earlier setup exists, and nothing on Windows can + // enumerate the paths whose DACL names its SID. Carrying on would revoke only + // what the new plan happens to name and leave the rest — the fail-open this + // record exists to close, reached on the single path where it cannot be ruled + // out. + // + // Retiring is a real fix rather than a gesture because Windows never reuses a + // deleted local account's RID: whatever ACEs survive name a principal that no + // longer exists and grant access to nobody, and the SID minted below is one + // no DACL on this machine can already carry. It also needs no new operator + // action, which matters — there is no `zero sandbox teardown` to send anyone + // to, so refusing here would strand the workspace instead of fixing it. + if _, recorded := readWindowsPrincipalACLLedger(config.SandboxHome, username); !recorded { + if err := retireUnrecordedWindowsSandboxPrincipal(config); err != nil { + return nil, err + } + } + identity, created, err := provisionWindowsSandboxPrincipalForSetup(config) + if err != nil { + return nil, err + } + // Scoped to what this run created, the same contract provisioning already + // applies to its own rollback. + // + // Unconditional removal here meant a transient ACL failure during a re-run of + // elevated setup deleted a principal that was working before the run started, + // taking its secret and logon rights with it. Provisioning was careful not to + // do that and then this undid the care one level up. A pre-existing principal + // is left alone: its ACEs are still reverted, since this run applied them, + // but the account itself is not this run's to destroy. + removePrincipal := func() error { + if !created { + return nil + } + return removeWindowsSandboxPrincipalForSetup(config) + } + + filesystem := config.PermissionProfile.FileSystem + writeRoots := filesystem.WriteRoots + // The runtime tree has to be granted here, at setup, because nothing grants it + // later. + // + // permissionProfileWithRuntime appends the per-workspace runtime root to + // WriteRoots on every COMMAND, and redirects HOME, GOCACHE, npm_config_cache + // and friends into it. That root lives under the user cache, not the + // workspace, so the profile setup sees never contains it. On the + // restricted-token path that costs nothing, since the child still runs as the + // caller and already has rights there. A principal is a separate local account + // with none, so without this every npm install, go build or pip install fails + // on a cache write with a bare ACCESS_DENIED and nothing pointing at the + // sandbox as the cause. + if runtimeRoot, err := setupWindowsSandboxRuntimeRoot(config); err != nil { + _ = removePrincipal() + return nil, err + } else if runtimeRoot != "" { + writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: runtimeRoot}) + } + revertACL, err := applyWindowsPrincipalACLs(config.SandboxHome, username, identity.SID.String(), filesystem, writeRoots) + if err != nil { + _ = removePrincipal() + return nil, err + } + return func() error { + aclErr := revertACL() + // Remove the principal even when the ACL revert failed, so a broken + // rollback does not also strand an account; report the ACL error since it + // is the one that leaves state behind. + removeErr := removePrincipal() + if aclErr != nil { + return aclErr + } + return removeErr + }, nil +} + +// retireUnrecordedWindowsSandboxPrincipal removes this workspace's principal +// when one exists, and does nothing when one does not. +// +// The absent case is the ordinary one and is not a problem: with no account +// there is nothing that could be holding an ACE, so a missing record is simply +// a machine where setup has not run yet. +func retireUnrecordedWindowsSandboxPrincipal(config WindowsSandboxCommandConfig) error { + _, err := lookupWindowsSandboxIdentityFn(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + if err != nil { + if errors.Is(err, errWindowsSandboxIdentityUnavailable) { + return nil + } + return err + } + return removeWindowsSandboxPrincipalForSetupFn(config) +} + +// removeWindowsSandboxPrincipalForSetup retires a workspace's principal in the +// order that leaves nothing behind: secret, then ACEs, then LSA logon rights, +// then the account itself. Everything keyed to the SID has to go while the SID +// still resolves. +func removeWindowsSandboxPrincipalForSetup(config WindowsSandboxCommandConfig) error { + key := windowsSandboxWorkspaceKey(config.WorkspaceRoots) + username := windowsSandboxUserName(key) + secretPath, err := windowsSandboxSecretPath(config.SandboxHome, username) + if err != nil { + return err + } + if err := removeWindowsSandboxSecret(secretPath); err != nil { + return err + } + // Set when ACE revocation could not complete. Teardown continues regardless, + // but the ledger is kept and the error surfaced, so the residue stays + // findable instead of being silently orphaned. + var revokeErr error + // Drop the LSA account rights before the account itself. Deleting the account + // first would leave its rights behind keyed to a SID that no longer resolves, + // which is the same orphaned residue the trustee-keyed ACE revocation exists + // to avoid. A principal that was never provisioned has no SID to resolve and + // nothing to revoke, so that case is not an error. + if identity, err := lookupWindowsSandboxIdentity(windowsSandboxWorkspaceKey(config.WorkspaceRoots)); err == nil { + // ACEs first, for the same reason: once the account is gone its SID stops + // resolving and every ACE naming it becomes an orphaned raw-SID entry on + // the user's own tree, which is precisely the residue the capability-SID + // model left behind and this one exists to avoid. Revocation is by + // trustee, so it clears grants written by older versions too. + // + // Failing to revoke is not fatal. A path the user has since deleted or + // renamed cannot be cleaned, and refusing to remove the account over it + // would strand the principal and its logon rights permanently — a worse + // outcome than a leftover ACE on a path that may not exist any more. + // + // The rollback is discarded here on purpose, unlike at setup: this is + // teardown, the account is about to be deleted, and putting its ACEs back + // is the opposite of what the caller asked for. + // + // The ERROR is not discarded, though it used to be. Teardown carried on + // and reported success, which meant a failed revocation left ACEs naming + // this SID on the user's tree while the ledger recording which paths they + // sat on was deleted moments later: residue nothing could find again. + // Remembered below rather than returned here, so removing the account + // still happens and the principal is not stranded. + paths, pathsErr := windowsPrincipalRevocationPaths(config, identity.SID.String()) + if pathsErr != nil { + revokeErr = fmt.Errorf("resolve the paths holding ACEs for sandbox principal %s: %w", username, pathsErr) + } else if _, err := revokeWindowsPrincipalACEs(identity.SID.String(), paths); err != nil { + revokeErr = fmt.Errorf("revoke ACEs for sandbox principal %s: %w", username, err) + } + if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { + return err + } + } else if !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + return err + } + if err := removeWindowsSandboxIdentity(username); err != nil { + return err + } + // Last, and only once the account is actually gone, so a failure anywhere + // above leaves the record describing a principal that still exists. + // + // It describes grants for a SID that no longer resolves, and leaving it would + // have the next setup revoke those paths on behalf of a freshly minted SID + // that never held them. That is a harmless no-op rather than a hole — the + // deleted account's RID is never reused, but a record that outlives its + // principal is a lie the next reader has no way to detect. + // + // Unless revocation failed. Then the ledger is the ONLY surviving record of + // which paths still carry ACEs for this SID, and deleting it turns a + // reportable leftover into permanent unfindable residue. Keeping a record + // that outlives its principal is the lesser problem, and the error says so. + if revokeErr != nil { + return fmt.Errorf("%w; the principal ACL ledger has been kept so the remaining ACEs can still be found", revokeErr) + } + return removeWindowsPrincipalACLLedger(config.SandboxHome, username) +} + +// setupWindowsSandboxRuntimeRoot resolves this workspace's runtime root and +// makes sure it exists, so the principal ACL plan can name it. +// +// It is created here rather than left to the first command because +// applyWindowsACLPlan skips a target that does not exist: granting write on a +// directory that setup never made would silently no-op, and the failure would +// only show up later as a denied cache write. Creating it under the elevated +// setup process is safe, since it lives under the invoking user's own cache +// directory and prepareSandboxRuntime would create it on the same path anyway. +// +// An empty return means there is no runtime root to grant (no workspace root +// configured), which is not an error: the caller simply grants nothing extra. +func windowsSandboxRuntimeRootPath(config WindowsSandboxCommandConfig) (string, error) { + workspaceRoot := "" + for _, candidate := range config.WorkspaceRoots { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + workspaceRoot = canonicalSandboxWorkspaceRoot(trimmed) + break + } + } + if workspaceRoot == "" { + return "", nil + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return "", fmt.Errorf("resolve user cache directory for sandbox runtime: %w", err) + } + // Same canonicalization as the workspace root above: sandboxRuntimeRootFor + // compares them, so they have to be the same spelling of the same path. + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) + if cacheRoot == "" || cacheRoot == "." { + return "", errors.New("user cache directory is unavailable for sandbox runtime") + } + return sandboxRuntimeRootFor(workspaceRoot, cacheRoot) +} + +// windowsSandboxDeterministicRuntimeRootPath names the cache-derived runtime +// tree without creating anything, and returns "" when that tree is unusable +// because it would land inside the workspace. +// +// Teardown needs this rather than windowsSandboxRuntimeRootPath: that one ends +// in sandboxRuntimeRootFor, whose fallback calls os.MkdirTemp, so merely asking +// for the name would make a directory on the way out. +func windowsSandboxDeterministicRuntimeRootPath(config WindowsSandboxCommandConfig) (string, error) { + workspaceRoot := "" + for _, candidate := range config.WorkspaceRoots { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + workspaceRoot = canonicalSandboxWorkspaceRoot(trimmed) + break + } + } + if workspaceRoot == "" { + return "", nil + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + return "", fmt.Errorf("resolve user cache directory for sandbox runtime: %w", err) + } + cacheRoot = canonicalSandboxWorkspaceRoot(cacheRoot) + if cacheRoot == "" || cacheRoot == "." { + return "", errors.New("user cache directory is unavailable for sandbox runtime") + } + root, ok := deterministicSandboxRuntimeRoot(workspaceRoot, cacheRoot) + if !ok { + return "", nil + } + return root, nil +} + +// setupWindowsSandboxRuntimeRoot resolves the runtime root AND creates it. +// Teardown wants the name without the side effect, so the derivation lives in +// windowsSandboxRuntimeRootPath above and this only adds the mkdir. +func setupWindowsSandboxRuntimeRoot(config WindowsSandboxCommandConfig) (string, error) { + root, err := windowsSandboxRuntimeRootPath(config) + if err != nil || root == "" { + return "", err + } + if err := os.MkdirAll(root, 0o700); err != nil { + return "", fmt.Errorf("create sandbox runtime root: %w", err) + } + return root, nil +} + +// revokeWindowsPrincipalACEs drops every ACE naming principalSID on paths and +// returns a rollback that puts them back. +// +// A path that does not exist is skipped rather than failing: revocation is +// cleanup, and there is nothing to clean on a path that was never created. +func revokeWindowsPrincipalACEs(principalSID string, paths []string) (func() error, error) { + if len(paths) == 0 { + return func() error { return nil }, nil + } + plan, err := windowsPrincipalRevokePlan(principalSID, paths) + if err != nil { + return nil, err + } + return applyWindowsACLPlanFn(plan) +} + +// applyWindowsPrincipalACLs writes the principal's ACEs for one policy: it +// revokes whatever this trustee already had on the paths the new plan touches +// AND on the paths an earlier setup recorded, then applies the plan. +// +// The order is the whole point. applyWindowsACLPlan MERGES into the existing +// DACL, so without the revocation first a re-run after narrowing a write root +// or shortening a deny list leaves the previous, wider ACEs beside the new ones +// and the principal keeps access the current policy no longer grants — the +// sandbox silently widens as a result of tightening it. Setup does get the +// chance to notice: marker validation refuses commands with "permission roots +// or deny lists changed" until setup runs again. +// +// The recorded paths are what makes that revocation complete. Revoking only the +// new plan's paths could never reach a root that had LEFT the policy, which is +// precisely the root whose ACE has to go: absent from the new plan, it was +// skipped, so the re-setup that was supposed to resolve the widening preserved +// it instead. +// +// Revocation is by TRUSTEE, so it drops every ACE naming this principal on +// these paths whatever an older version of Zero granted, and a recorded path +// that no longer exists or never held an ACE costs nothing. +func applyWindowsPrincipalACLs(sandboxHome string, username string, principalSID string, filesystem FileSystemPolicy, writeRoots []WritableRoot) (func() error, error) { + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principalSID, + WriteRoots: writeRoots, + ReadRoots: filesystem.ReadRoots, + DenyRead: filesystem.DenyRead, + DenyWrite: filesystem.DenyWrite, + }) + if err != nil { + return nil, err + } + granted := windowsACLPlanPaths(plan) + // An unreadable record arrives here as an empty prior set, which taken alone + // would be the fail-open. It cannot be reached: setupWindowsSandboxPrincipal + // retires any principal whose record is missing BEFORE provisioning, so by + // this point either the record is trustworthy or principalSID is one that no + // DACL on this machine has ever been able to name. + recorded, _ := readWindowsPrincipalACLLedger(sandboxHome, username) + stale := unionWindowsPrincipalACLPaths(recorded, granted) + + // Recorded BEFORE a single DACL changes, and as the union rather than the new + // set. A crash between the grant below and the narrowing write would otherwise + // leave a record that omits paths this run granted, and the next policy change + // would strand them in exactly the way this fix exists to prevent. A superset + // is the safe direction to be wrong in: revoking a path that holds no ACE for + // this trustee is a no-op. + if err := writeWindowsPrincipalACLLedger(sandboxHome, username, stale); err != nil { + return nil, err + } + + // The revocation's own rollback matters, and discarding it was a real bug. + // + // It was discarded on the reasoning that the only failure path from here + // removes the principal outright, so restoring stale ACEs for an account + // about to be deleted would be pointless. That holds for a principal this + // run CREATED. It is false for one this run ADOPTED: setup keeps a + // pre-existing account on failure rather than destroying someone else's + // working principal, so discarding the snapshot left that account alive with + // its previous ACEs stripped and the new ones rolled back — logged on and + // unable to reach its own workspace. + // + // Restoring the pre-revocation DACL first, then the grant, unwinds in the + // reverse order they were applied. + restoreRevoked, err := revokeWindowsPrincipalACEs(principalSID, stale) + if err != nil { + return nil, err + } + revertGrant, err := applyWindowsACLPlanFn(plan) + if err != nil { + if restoreRevoked != nil { + _ = restoreRevoked() + } + return nil, err + } + // Narrow the record to what is granted now, so it tracks the policy instead + // of accumulating every root the workspace has ever had. + // + // A failure here fails the setup rather than being shrugged off. The same + // file was written successfully moments ago, so failing now means the sandbox + // home has stopped being writable, and reporting a provisioned sandbox on a + // sandbox home that cannot hold its own state is the kind of quiet this + // backend must not have. Unwinding leaves the union recorded, which is the + // safe direction. + if err := writeWindowsPrincipalACLLedger(sandboxHome, username, granted); err != nil { + if revertErr := revertGrant(); revertErr != nil { + err = errors.Join(err, revertErr) + } + if restoreRevoked != nil { + if restoreErr := restoreRevoked(); restoreErr != nil { + err = errors.Join(err, restoreErr) + } + } + return nil, err + } + return func() error { + grantErr := revertGrant() + // Restore the pre-revocation ACEs even when reverting the grant failed: + // leaving the principal with neither set is the state this exists to + // avoid. Report the grant error, since that is the one leaving residue. + var restoreErr error + if restoreRevoked != nil { + restoreErr = restoreRevoked() + } + if grantErr != nil { + return grantErr + } + return restoreErr + }, nil +} + +// windowsPrincipalTeardownPaths names every path this principal could hold an +// ACE on: the policy's roots plus the per-workspace runtime tree. +// +// The runtime root is derived through deterministicSandboxRuntimeRoot rather +// than the resolver setup uses, because teardown must create nothing on its way +// out and that resolver's fallback calls os.MkdirTemp. When the deterministic +// root is unusable there is simply no runtime tree to revoke: the fallback root +// commands used was random and per-process, so nothing here could name it +// anyway. +func windowsPrincipalTeardownPaths(config WindowsSandboxCommandConfig, principalSID string) ([]string, error) { + filesystem := config.PermissionProfile.FileSystem + writeRoots := filesystem.WriteRoots + runtimeRoot, err := windowsSandboxDeterministicRuntimeRootPath(config) + if err != nil { + return nil, err + } + if runtimeRoot != "" { + writeRoots = append(append([]WritableRoot{}, writeRoots...), WritableRoot{Root: runtimeRoot}) + } + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principalSID, + WriteRoots: writeRoots, + ReadRoots: filesystem.ReadRoots, + DenyRead: filesystem.DenyRead, + DenyWrite: filesystem.DenyWrite, + }) + if err != nil { + return nil, err + } + return windowsACLPlanPaths(plan), nil +} + +// windowsPrincipalRevocationPaths is what teardown actually has to revoke: the +// paths the CURRENT policy describes, plus every path an earlier setup recorded. +// +// Teardown used the current policy alone, which reproduced the setup-side bug on +// the way out. A root the user removed from their policy is missing from today's +// plan, so retiring the principal revoked every ACE except the one that was +// widening the sandbox — and then deleted the account, leaving that ACE naming a +// SID nothing could resolve to clean it up later. +func windowsPrincipalRevocationPaths(config WindowsSandboxCommandConfig, principalSID string) ([]string, error) { + current, err := windowsPrincipalTeardownPaths(config, principalSID) + if err != nil { + return nil, err + } + recorded, _ := readWindowsPrincipalACLLedger( + config.SandboxHome, windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots))) + return unionWindowsPrincipalACLPaths(recorded, current), nil +} + +// Seams for the two elevated calls the provisioning rollback depends on, so the +// stale-secret recovery path is reachable in tests without an elevated machine. +var ( + provisionWindowsSandboxIdentityFn = provisionWindowsSandboxIdentity + removeWindowsSandboxSecretFn = removeWindowsSandboxSecret + writeWindowsSandboxSecretFn = writeWindowsSandboxSecret +) + +// Seams for the two elevated calls the unrecorded-principal retirement depends +// on, so the decision to retire is observable in a test without a provisioned +// machine — on which the lookup declines for its own reasons and would report +// success whether or not the guard existed. +var ( + lookupWindowsSandboxIdentityFn = lookupWindowsSandboxIdentity + removeWindowsSandboxPrincipalForSetupFn = removeWindowsSandboxPrincipalForSetup +) + +// windowsACLPlanPaths returns each distinct path a plan touches, in plan order. +// +// Windows-tagged deliberately: every caller is, so defining it in the portable +// file made it unused on Linux and macOS builds and failed static analysis. +func windowsACLPlanPaths(plan WindowsACLPlan) []string { + seen := make(map[string]struct{}, len(plan.Entries)) + paths := make([]string, 0, len(plan.Entries)) + for _, entry := range plan.Entries { + if _, ok := seen[entry.Path]; ok { + continue + } + seen[entry.Path] = struct{}{} + paths = append(paths, entry.Path) + } + return paths +} diff --git a/internal/sandbox/windows_identity_runtime_windows_test.go b/internal/sandbox/windows_identity_runtime_windows_test.go new file mode 100644 index 000000000..9b7bc5ae5 --- /dev/null +++ b/internal/sandbox/windows_identity_runtime_windows_test.go @@ -0,0 +1,234 @@ +//go:build windows + +package sandbox + +import ( + "os" + "strings" + "sync" + "testing" +) + +// An opted-in command that ends up on the restricted token anyway must say so. +// Both cases below are correct fallbacks, not errors — but silence leaves the +// operator believing an account boundary is isolating them when it is not, which +// is the same failure the setup-protocol opt-in check exists to prevent, reached +// from the other side. The deny case matters most: deny is the DEFAULT network +// mode, so a fully provisioned, fully agreeing setup still never uses the +// principal for an ordinary command. +func TestWindowsSandboxPrincipalFallbackIsAnnounced(t *testing.T) { + testCases := []struct { + name string + mode NetworkMode + reason string + }{ + // The network-deny case is deliberately absent. It used to warn here, but + // this runner is re-exec'd per command, so the sync.Once guarding the notice + // is once per COMMAND — and deny is the default mode, so the warning landed + // on nearly every tool call. That fact belongs to `zero doctor` now, which is + // read once. The deny-mode BEHAVIOUR is still pinned, by + // TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied below. + {name: "no principal provisioned on this machine", mode: NetworkAllow, reason: "no sandbox principal is provisioned"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var warned []string + originalWarn := warnWindowsSandboxPrincipalNotUsed + warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) } + windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{} + t.Cleanup(func() { + warnWindowsSandboxPrincipalNotUsed = originalWarn + windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{} + }) + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: testCase.mode}, + }, + Env: map[string]string{windowsSandboxIdentityEnv: "1"}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo"}, + } + // Assert the precondition rather than assume it: this must be the quiet + // fallback path, not a token this host actually minted and not an error. + token, ok, err := windowsSandboxPrincipalToken(config) + if ok { + token.Close() + t.Fatalf("host unexpectedly provisioned a principal; this test cannot measure the fallback") + } + if err != nil { + t.Fatalf("windowsSandboxPrincipalToken error = %v, want the quiet fallback", err) + } + if len(warned) != 1 { + t.Fatalf("opted-in fallback warnings = %v, want exactly one naming %q", warned, testCase.reason) + } + if !strings.Contains(warned[0], testCase.reason) { + t.Fatalf("warning = %q, want it to name %q", warned[0], testCase.reason) + } + }) + } +} + +// The opt-out must stay silent, or the warning becomes noise every user learns +// to ignore. +func TestWindowsSandboxPrincipalFallbackIsSilentWhenOptedOut(t *testing.T) { + var warned []string + originalWarn := warnWindowsSandboxPrincipalNotUsed + warnWindowsSandboxPrincipalNotUsed = func(reason string) { warned = append(warned, reason) } + windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{} + t.Cleanup(func() { + warnWindowsSandboxPrincipalNotUsed = originalWarn + windowsSandboxPrincipalNotUsedWarnOnce = sync.Once{} + }) + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkDeny}, + }, + Env: map[string]string{windowsSandboxIdentityEnv: "0"}, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo"}, + } + if _, ok, err := windowsSandboxPrincipalToken(config); ok || err != nil { + t.Fatalf("windowsSandboxPrincipalToken ok=%v err=%v, want the quiet opted-out fallback", ok, err) + } + if len(warned) != 0 { + t.Fatalf("opted-out command warned %v, want silence", warned) + } +} + +// Setup must stay inert unless the principal backend is explicitly opted into. +// This is the property that makes the branch safe to merge while the privileged +// paths are still being validated: without the opt-in, `zero sandbox setup` +// creates no local account and the capability-SID backend is the whole of setup. +func TestWindowsSandboxIdentityGating(t *testing.T) { + for name, testCase := range map[string]struct { + env map[string]string + want bool + }{ + // An explicit map entry is authoritative; these cases never reach the + // process environment. + "empty": {env: map[string]string{windowsSandboxIdentityEnv: ""}, want: false}, + "zero": {env: map[string]string{windowsSandboxIdentityEnv: "0"}, want: false}, + "true not one": {env: map[string]string{windowsSandboxIdentityEnv: "true"}, want: false}, + "one": {env: map[string]string{windowsSandboxIdentityEnv: "1"}, want: true}, + "one with space": {env: map[string]string{windowsSandboxIdentityEnv: " 1 "}, want: true}, + } { + t.Run(name, func(t *testing.T) { + // Pin the process variable too. Every case here supplies an explicit + // map entry so none of them should consult it, and pinning proves + // that rather than assuming it: without this a developer who exports + // the opt-in would see different results from CI. + t.Setenv(windowsSandboxIdentityEnv, "1") + if got := windowsSandboxIdentityEnabled(testCase.env); got != testCase.want { + t.Fatalf("enabled = %v, want %v for %q", got, testCase.want, testCase.env[windowsSandboxIdentityEnv]) + } + }) + } +} + +// With no map entry the process environment decides. That fallback is what the +// elevated setup path actually runs on — it passes no Env — so it needs its own +// coverage rather than riding on a case that also has a map entry. +func TestWindowsSandboxIdentityGatingFallsBackToTheProcessEnvironment(t *testing.T) { + for name, testCase := range map[string]struct { + value string + set bool + want bool + }{ + "unset": {set: false, want: false}, + "empty": {value: "", set: true, want: false}, + "zero": {value: "0", set: true, want: false}, + "one": {value: "1", set: true, want: true}, + "one with space": {value: " 1 ", set: true, want: true}, + } { + t.Run(name, func(t *testing.T) { + // t.Setenv registers the restore even when the variable is then + // cleared, which is the only way to test a genuinely absent variable + // without leaking that state into the rest of the package. + t.Setenv(windowsSandboxIdentityEnv, testCase.value) + if !testCase.set { + if err := os.Unsetenv(windowsSandboxIdentityEnv); err != nil { + t.Fatalf("unset %s: %v", windowsSandboxIdentityEnv, err) + } + } + if got := windowsSandboxIdentityEnabled(nil); got != testCase.want { + t.Fatalf("enabled = %v, want %v (set=%v value=%q)", got, testCase.want, testCase.set, testCase.value) + } + }) + } +} + +// The command environment wins over the process environment, so a run can opt in +// or out without depending on how the parent shell was launched. +func TestWindowsSandboxIdentityEnvOverridesProcess(t *testing.T) { + t.Setenv(windowsSandboxIdentityEnv, "1") + if windowsSandboxIdentityEnabled(map[string]string{windowsSandboxIdentityEnv: "0"}) { + t.Fatal("command env set to 0 must override a process env of 1") + } + if !windowsSandboxIdentityEnabled(map[string]string{}) { + t.Fatal("with no command-env entry the process env should apply") + } +} + +// One workspace maps to one principal, and different workspaces must not share +// an account, or two projects would run under the same identity and could reach +// each other's granted roots. +func TestWindowsSandboxWorkspaceKeyIsStableAndDistinct(t *testing.T) { + first := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`}) + again := windowsSandboxWorkspaceKey([]string{`C:\ws\alpha`}) + other := windowsSandboxWorkspaceKey([]string{`C:\ws\beta`}) + if first != again { + t.Fatalf("key is not stable: %q vs %q", first, again) + } + if first == other { + t.Fatal("two different workspaces produced the same principal key") + } + if first == "" { + t.Fatal("empty key") + } + // An empty root list still has to yield a usable key rather than a blank one. + if windowsSandboxWorkspaceKey(nil) == "" { + t.Fatal("no workspace roots produced an empty key") + } +} + +// Network denial is enforced by WFP filters keyed to the offline-marker SID, +// which only the restricted token carries. A principal token would leave those +// filters matching nothing, so the principal backend must stand down whenever +// the network is denied rather than silently trading network enforcement for +// read confinement. +// The eligibility predicate is asserted rather than the token lookup, because on +// a machine with no principal provisioned the lookup declines for its own reasons +// and would report success here whether or not the guard existed. +func TestPrincipalBackendDefersToRestrictedTokenWhenNetworkDenied(t *testing.T) { + eligible := func(mode NetworkMode, optIn string) bool { + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + WorkspaceRoots: []string{t.TempDir()}, + Env: map[string]string{windowsSandboxIdentityEnv: optIn}, + } + config.PermissionProfile.Network.Mode = mode + return windowsSandboxPrincipalEligible(config) + } + + if eligible(NetworkDeny, "1") { + t.Fatal("principal backend eligible with the network denied; the WFP filters key on the offline-marker SID, which a logon token does not carry, so egress would be unenforced") + } + // The guard must be specific to denial, not a blanket disable that would make + // the whole backend dead code. + if !eligible(NetworkAllow, "1") { + t.Fatal("principal backend refused with the network allowed; the guard is over-broad and disables the backend entirely") + } + if eligible(NetworkAllow, "0") { + t.Fatal("principal backend eligible without the opt-in") + } +} diff --git a/internal/sandbox/windows_identity_secret_windows.go b/internal/sandbox/windows_identity_secret_windows.go new file mode 100644 index 000000000..87463940b --- /dev/null +++ b/internal/sandbox/windows_identity_secret_windows.go @@ -0,0 +1,237 @@ +//go:build windows + +package sandbox + +// Storing a sandbox principal's password. +// +// The elevated setup path provisions the account, but the per-command path runs +// UNELEVATED and needs the password to call LogonUser. So the secret has to +// cross that boundary on disk, and the only thing standing between it and the +// sandboxed child is the file's ACL. +// +// The file is locked to the invoking user: an explicit, INHERITANCE-PROTECTED +// DACL granting that user and SYSTEM, and nobody else. The sandbox principal is +// deliberately absent from it, which is the property that matters, because a +// principal that could read this file could mint its own token and the whole +// identity boundary would be decorative. Administrators are not added either; +// an admin can already take ownership, so naming them buys nothing and widens +// the visible grant. +// +// Ordering is load-bearing: the ACL is applied to an EMPTY file before the +// password is written, so the bytes never exist under the directory's inherited +// permissions even briefly. + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +// windowsSandboxSecretDirName holds per-principal secrets under the Zero config +// directory. Kept in its own directory so the whole set can be removed when the +// sandbox is torn down. +const windowsSandboxSecretDirName = "windows-sandbox" + +// windowsSandboxSecretPath returns where a principal's password lives. The +// account name is already sanitised to [a-z0-9-] by windowsSandboxUserName, so +// it cannot escape the directory. +func windowsSandboxSecretPath(configDir string, username string) (string, error) { + if strings.TrimSpace(configDir) == "" { + return "", errors.New("windows sandbox secret: empty config directory") + } + if strings.TrimSpace(username) == "" { + return "", errors.New("windows sandbox secret: empty principal name") + } + // Defence in depth against a caller passing something windowsSandboxUserName + // did not produce: refuse anything with a separator or a parent reference. + if strings.ContainsAny(username, `\/:`) || strings.Contains(username, "..") { + return "", fmt.Errorf("windows sandbox secret: unsafe principal name %q", username) + } + return filepath.Join(configDir, windowsSandboxSecretDirName, username+".secret"), nil +} + +// currentTokenUserSID returns the SID of the user this process runs as. Under +// UAC the elevated token keeps the same user SID as the desktop session, so +// setup and the later unelevated command path agree on the owner, which is what +// makes an owner-scoped ACL usable across the elevation boundary. +func currentTokenUserSID() (*windows.SID, error) { + var token windows.Token + if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { + return nil, fmt.Errorf("open process token: %w", err) + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil { + return nil, fmt.Errorf("get token user: %w", err) + } + // The SID points into a buffer owned by the Tokenuser, so copy it out before + // that buffer goes away. + copied, err := user.User.Sid.Copy() + if err != nil { + return nil, fmt.Errorf("copy token user SID: %w", err) + } + return copied, nil +} + +// lockWindowsSecretToOwner replaces a file's DACL with an explicit, +// inheritance-protected one granting only owner and SYSTEM. PROTECTED is what +// drops any ACE inherited from the config directory; without it a permissive +// parent would still grant access to whoever it names. +func lockWindowsSecretToOwner(path string, owner *windows.SID) error { + if owner == nil { + return errors.New("windows sandbox secret: nil owner SID") + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return fmt.Errorf("resolve SYSTEM SID: %w", err) + } + entries := []windows.EXPLICIT_ACCESS{ + { + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(owner), + }, + }, + { + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(system), + }, + }, + } + acl, err := windows.ACLFromEntries(entries, nil) + if err != nil { + return fmt.Errorf("build secret ACL: %w", err) + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + acl, + nil, + ); err != nil { + return fmt.Errorf("lock secret to owner: %w", err) + } + return nil +} + +// writeWindowsSandboxSecret stores a principal's password readable only by the +// invoking user. +// +// The file is created empty, locked down, and only then written, so the secret +// is never on disk under the directory's inherited ACL. An existing file is +// replaced rather than appended, since a stale password would make LogonUser +// fail in a way that looks like a sandbox bug. +func writeWindowsSandboxSecret(path string, password string) error { + owner, err := currentTokenUserSID() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create secret directory: %w", err) + } + // Truncate any previous secret first: the ACL below is applied to whatever + // inode ends up at this path, so create it before locking it. + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("create secret file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close secret file: %w", err) + } + if err := lockWindowsSecretToOwner(path, owner); err != nil { + // Do not leave an unprotected empty file behind. + _ = os.Remove(path) + return err + } + // Encrypt to the invoking user on top of the ACL, so a copy taken outside the + // filesystem's enforcement (backup, disk image) is inert. The principal name is + // the entropy, which keeps one principal's blob from authenticating another. + sealed, err := protectWindowsSecret(password, windowsSandboxSecretEntropy(path)) + if err != nil { + _ = os.Remove(path) + return err + } + if err := os.WriteFile(path, sealed, 0o600); err != nil { + _ = os.Remove(path) + return fmt.Errorf("write secret: %w", err) + } + return nil +} + +// windowsSandboxSecretEntropy derives the DPAPI entropy from the secret's own +// filename, which is the principal name. Deriving it rather than threading the +// name through keeps read and write agreeing by construction. +func windowsSandboxSecretEntropy(path string) string { + return strings.TrimSuffix(filepath.Base(path), ".secret") +} + +// Seamed so the permission-denied mapping in readWindowsSandboxSecret is +// testable. Producing a real ERROR_ACCESS_DENIED needs DACL surgery on Windows, +// since a 0000 file is still readable and reading a directory reports +// "Incorrect function", so a test built that way would exercise the platform +// rather than the mapping. +var readWindowsSandboxSecretFile = os.ReadFile + +// readWindowsSandboxSecret loads a principal's password. A missing file means +// setup has not run for this workspace, which the caller turns into a fallback +// rather than a hard failure. +func readWindowsSandboxSecret(path string) (string, error) { + data, err := readWindowsSandboxSecretFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", errWindowsSandboxIdentityUnavailable + } + // Permission denied is unavailability, not breakage. The secret's DACL + // names whoever ran setup, so an operator who elevated with a separate + // administrative account, through runas or an over-the-shoulder UAC + // prompt, ends up with a secret their ordinary account cannot open. That + // is the documented fail-soft case: fall back to the restricted token and + // let the warning say so. Treating it as a hard error instead made every + // sandboxed command fail on a machine that was merely set up by a + // different admin, which is a common way to run an elevated setup. + if os.IsPermission(err) { + return "", errWindowsSandboxIdentityUnavailable + } + return "", fmt.Errorf("read sandbox secret: %w", err) + } + if len(data) == 0 { + return "", errWindowsSandboxIdentityUnavailable + } + secret, err := unprotectWindowsSecret(data, windowsSandboxSecretEntropy(path)) + if err != nil { + // A blob written by another user, for another principal, or by an older + // build that stored the password in the clear. Report it as unavailable so + // the caller falls back to the restricted token; the next elevated setup + // rewrites the secret in the current format. + return "", errWindowsSandboxIdentityUnavailable + } + if strings.TrimSpace(secret) == "" { + return "", errWindowsSandboxIdentityUnavailable + } + return secret, nil +} + +// removeWindowsSandboxSecret deletes a stored password. Called before the +// account itself is removed so a secret never outlives the principal it +// authenticates. +func removeWindowsSandboxSecret(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove sandbox secret: %w", err) + } + return nil +} diff --git a/internal/sandbox/windows_identity_secret_windows_test.go b/internal/sandbox/windows_identity_secret_windows_test.go new file mode 100644 index 000000000..0293ec573 --- /dev/null +++ b/internal/sandbox/windows_identity_secret_windows_test.go @@ -0,0 +1,300 @@ +//go:build windows + +package sandbox + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestWindowsSandboxSecretRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "cfg", "zero-sbx-test.secret") + const password = "Zs1!EXAMPLEPASSWORDVALUE" + + if err := writeWindowsSandboxSecret(path, password); err != nil { + t.Fatalf("write: %v", err) + } + got, err := readWindowsSandboxSecret(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if got != password { + t.Fatalf("read %q, want the stored password", got) + } +} + +// THE security property: the stored password must be readable only by the user +// who owns it. If any other trustee appears in the DACL, and in particular the +// sandbox principal, that account could mint its own token and the identity +// boundary would be worthless. +func TestWindowsSandboxSecretIsLockedToOwner(t *testing.T) { + path := filepath.Join(t.TempDir(), "locked.secret") + if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil { + t.Fatalf("write: %v", err) + } + + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + t.Fatalf("read back security info: %v", err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("read DACL: %v", err) + } + if dacl == nil { + t.Fatal("secret has a nil DACL, which grants everyone access") + } + + owner, err := currentTokenUserSID() + if err != nil { + t.Fatalf("owner SID: %v", err) + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatalf("SYSTEM SID: %v", err) + } + + entries, err := windowsSecretACEList(dacl) + if err != nil { + t.Fatalf("enumerate ACEs: %v", err) + } + if len(entries) == 0 { + t.Fatal("secret DACL has no ACEs") + } + for _, sid := range entries { + if sid.Equals(owner) || sid.Equals(system) { + continue + } + t.Fatalf("secret DACL grants an unexpected trustee %s; only the owner and SYSTEM may appear", sid) + } +} + +// The DACL must be inheritance-protected, otherwise a permissive ACE on the +// config directory would still reach the secret. +func TestWindowsSandboxSecretDaclIsProtected(t *testing.T) { + path := filepath.Join(t.TempDir(), "protected.secret") + if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil { + t.Fatalf("write: %v", err) + } + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("read back security info: %v", err) + } + control, _, err := descriptor.Control() + if err != nil { + t.Fatalf("read control bits: %v", err) + } + if control&windows.SE_DACL_PROTECTED == 0 { + t.Fatal("secret DACL is not protected, so inherited ACEs still apply") + } +} + +// Rewriting must replace the previous secret rather than append to it, or +// LogonUser would be handed two concatenated passwords. +func TestWindowsSandboxSecretOverwrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "rewrite.secret") + if err := writeWindowsSandboxSecret(path, "Zs1!FIRST"); err != nil { + t.Fatalf("first write: %v", err) + } + if err := writeWindowsSandboxSecret(path, "Zs1!SECOND"); err != nil { + t.Fatalf("second write: %v", err) + } + got, err := readWindowsSandboxSecret(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if got != "Zs1!SECOND" { + t.Fatalf("read %q, want only the newest password", got) + } +} + +// A workspace whose setup has not run must report the actionable sentinel so the +// command path falls back to the restricted-token backend instead of failing. +func TestWindowsSandboxSecretMissingIsSentinel(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.secret") + if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("missing secret returned %v, want errWindowsSandboxIdentityUnavailable", err) + } +} + +// An empty file is a half-written secret, not a valid empty password. +// +// Both seeds matter and only one of them tests what the name says. A +// zero-length file is the truncated-write case, and it is the only one that +// reaches the length check. Whitespace is several bytes, so it travels on to +// DPAPI and fails to unprotect instead, which is the path +// TestWindowsSandboxSecretRejectsLegacyPlaintext already covers. Seeding only +// the whitespace, as this did, left the branch in the test's own name +// unexercised. +func TestWindowsSandboxSecretEmptyIsSentinel(t *testing.T) { + for name, seed := range map[string][]byte{ + "truncated write": {}, + "whitespace only": []byte(" \r\n"), + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "empty.secret") + if err := os.WriteFile(path, seed, 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := readWindowsSandboxSecret(path); err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("secret of %d bytes returned %v, want the unavailable sentinel", len(seed), err) + } + }) + } +} + +// The principal name lands in a filename, so anything that could escape the +// directory has to be refused even though windowsSandboxUserName already +// sanitises its output. +func TestWindowsSandboxSecretPathRejectsTraversal(t *testing.T) { + for _, name := range []string{`..\evil`, "sub/dir", `C:\abs`, "..", ""} { + if _, err := windowsSandboxSecretPath(`C:\cfg`, name); err == nil { + t.Fatalf("principal name %q was accepted", name) + } + } + if _, err := windowsSandboxSecretPath("", "zero-sbx-a"); err == nil { + t.Fatal("empty config directory was accepted") + } + path, err := windowsSandboxSecretPath(`C:\cfg`, "zero-sbx-abc") + if err != nil { + t.Fatalf("valid name rejected: %v", err) + } + if !strings.HasSuffix(path, `zero-sbx-abc.secret`) { + t.Fatalf("unexpected secret path %q", path) + } +} + +// Removal must be idempotent so teardown converges the same way provisioning +// does, and must actually delete the secret. +func TestWindowsSandboxSecretRemoveIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "gone.secret") + if err := writeWindowsSandboxSecret(path, "Zs1!SECRET"); err != nil { + t.Fatalf("write: %v", err) + } + if err := removeWindowsSandboxSecret(path); err != nil { + t.Fatalf("first remove: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("secret still present after removal (stat err %v)", err) + } + if err := removeWindowsSandboxSecret(path); err != nil { + t.Fatalf("removing an absent secret must succeed, got %v", err) + } +} + +// windowsSecretACEList returns the trustee SID of every ACE in a DACL so a test +// can assert exactly who is named. +func windowsSecretACEList(dacl *windows.ACL) ([]*windows.SID, error) { + var out []*windows.SID + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + return nil, err + } + // GetAce hands back a generic ACE_HEADER and we reinterpret it. That is + // only sound for the fixed-layout types: an object ACE carries Flags and + // two GUIDs ahead of the trustee, so SidStart would land mid-structure + // and Copy would read whatever bytes happen to be there. The caller's + // "unexpected trustee" assertion would then print a nonsense SID instead + // of naming the ACE that does not belong, which is the opposite of what + // a failing test should do. Nothing under test builds anything but + // allowed ACEs today, so this exists to keep the failure legible if that + // ever changes. + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return nil, fmt.Errorf("ACE %d has type %d, want ACCESS_ALLOWED_ACE_TYPE (%d); refusing to decode its trustee", index, ace.Header.AceType, windows.ACCESS_ALLOWED_ACE_TYPE) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + copied, err := sid.Copy() + if err != nil { + return nil, err + } + out = append(out, copied) + } + return out, nil +} + +// DPAPI round-trip through the real store, which needs no privilege and so is +// genuine coverage rather than a gated stub. +func TestWindowsSandboxSecretRoundTripsThroughDPAPI(t *testing.T) { + path, err := windowsSandboxSecretPath(t.TempDir(), "zero-sbx-roundtrip") + if err != nil { + t.Fatalf("secret path: %v", err) + } + const password = "S0me-Sandbox-P@ssw0rd-value" + if err := writeWindowsSandboxSecret(path, password); err != nil { + t.Fatalf("write secret: %v", err) + } + got, err := readWindowsSandboxSecret(path) + if err != nil { + t.Fatalf("read secret: %v", err) + } + if got != password { + t.Fatalf("round-trip returned %q, want %q", got, password) + } + // The point of the exercise: the password must not be recoverable by reading + // the file, or the encryption layer is decorative. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read raw secret file: %v", err) + } + if bytes.Contains(raw, []byte(password)) { + t.Fatal("the password appears verbatim in the stored file; it was not encrypted") + } +} + +// Entropy is the principal name, so a blob moved onto another principal's secret +// path must fail to decrypt rather than authenticate the wrong account. +func TestWindowsSandboxSecretDoesNotTransferBetweenPrincipals(t *testing.T) { + home := t.TempDir() + minePath, err := windowsSandboxSecretPath(home, "zero-sbx-mine") + if err != nil { + t.Fatalf("secret path: %v", err) + } + theirsPath, err := windowsSandboxSecretPath(home, "zero-sbx-theirs") + if err != nil { + t.Fatalf("secret path: %v", err) + } + if err := writeWindowsSandboxSecret(minePath, "a-password-for-mine"); err != nil { + t.Fatalf("write secret: %v", err) + } + blob, err := os.ReadFile(minePath) + if err != nil { + t.Fatalf("read blob: %v", err) + } + if err := os.WriteFile(theirsPath, blob, 0o600); err != nil { + t.Fatalf("plant blob: %v", err) + } + if _, err := readWindowsSandboxSecret(theirsPath); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Fatalf("a blob planted at another principal's path decrypted; got err = %v", err) + } +} + +// An older plaintext secret must degrade to a fallback rather than being handed +// to LogonUser as if it were a password. +func TestWindowsSandboxSecretRejectsLegacyPlaintext(t *testing.T) { + path, err := windowsSandboxSecretPath(t.TempDir(), "zero-sbx-legacy") + if err != nil { + t.Fatalf("secret path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte("plaintext-password"), 0o600); err != nil { + t.Fatalf("write legacy secret: %v", err) + } + if _, err := readWindowsSandboxSecret(path); !errors.Is(err, errWindowsSandboxIdentityUnavailable) { + t.Fatalf("legacy plaintext secret was accepted; got err = %v", err) + } +} diff --git a/internal/sandbox/windows_identity_windows.go b/internal/sandbox/windows_identity_windows.go new file mode 100644 index 000000000..03504b135 --- /dev/null +++ b/internal/sandbox/windows_identity_windows.go @@ -0,0 +1,717 @@ +//go:build windows + +package sandbox + +// Windows sandbox principals. +// +// Every other Windows backend here derives its token from the CALLING user via +// CreateRestrictedToken, which is why the sandbox can constrain writes but not +// reads: a deny ACE that would stop the sandboxed child reading a credential +// store names the same account Zero itself runs as, so it would lock Zero out +// too. Reads therefore stay on the caller's identity and +// credentialDenyReadPaths is a no-op on Windows (#662, #675). +// +// This file provisions a SEPARATE local account per workspace, held in one +// managed local group, so the sandbox has an identity of its own. A deny-read +// ACE naming that principal denies the sandboxed child and nothing else, and +// the same SID is what a firewall rule or a write grant can be keyed to. The +// accounts are created by the elevated `zero sandbox setup` path because +// NetUserAdd requires administrator rights; nothing here runs unelevated. +// +// Provisioning is idempotent: the "already exists" status from each API is a +// success, so setup can be re-run safely and a partially provisioned machine +// converges. + +import ( + "crypto/rand" + "encoding/base32" + "errors" + "fmt" + "runtime" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // windowsSandboxGroupName holds every sandbox principal. Grouping them means + // an ACE can name the group once instead of enumerating accounts, and it + // gives setup a single place to find what it previously created. + windowsSandboxGroupName = "ZeroSandboxUsers" + windowsSandboxGroupComment = "Zero sandbox principals (managed by zero sandbox setup)" + + // windowsSandboxUserPrefix keeps the accounts recognisable in `net user` and + // lets cleanup identify what belongs to Zero. Windows caps a local account + // name at 20 characters, which windowsSandboxUserName respects. + windowsSandboxUserPrefix = "zero-sbx-" + // The comment doubles as the ownership marker AND records which workspace + // the account belongs to. The account NAME can only carry 11 characters of + // the workspace digest because of the 20-character local-account limit, so + // two workspaces whose digests share that prefix derive the same name. The + // full key here turns that from a silent share of one account, one secret + // and one ACL identity into a refusal. + windowsSandboxUserComment = "Zero sandbox principal (managed)" + windowsSandboxUserCommentKey = windowsSandboxUserComment + " key=" + windowsSandboxUserNameMax = 20 +) + +// Win32 status codes that mean "already there". Treated as success so +// provisioning converges instead of failing on a second run. +const ( + nerrSuccess = 0 + nerrGroupExists = 2223 + nerrUserExists = 2224 + errorAliasExists = 1379 + errorMemberInAlias = 1378 + errorAccessDenied32 = 5 + nerrUserNotFound = 2221 +) + +// USER_INFO_1 privilege and flag values. +const ( + usrPrivUser = 1 + ufScript = 0x0001 + ufNormalAccount = 0x0200 + ufDontExpirePasswd = 0x10000 + windowsPasswordLength = 24 +) + +var ( + netapi32 = windows.NewLazySystemDLL("netapi32.dll") + procNetUserAdd = netapi32.NewProc("NetUserAdd") + procNetLocalGroupAdd = netapi32.NewProc("NetLocalGroupAdd") + procNetLocalGroupAddMembers = netapi32.NewProc("NetLocalGroupAddMembers") + procNetUserDel = netapi32.NewProc("NetUserDel") + procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo") + procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo") + procNetApiBufferFree = netapi32.NewProc("NetApiBufferFree") + procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups") +) + +// userInfo1 mirrors USER_INFO_1. Field order and widths must match the Win32 +// struct exactly; it is passed to NetUserAdd as a raw buffer. +type userInfo1 struct { + Name *uint16 + Password *uint16 + PasswordAge uint32 + Priv uint32 + HomeDir *uint16 + Comment *uint16 + Flags uint32 + ScriptPath *uint16 +} + +// userInfo1003 mirrors USER_INFO_1003, the password-only form NetUserSetInfo +// takes when nothing else about the account should change. +type userInfo1003 struct { + Password *uint16 +} + +// localGroupInfo1 mirrors LOCALGROUP_INFO_1. +type localGroupInfo1 struct { + Name *uint16 + Comment *uint16 +} + +// localGroupMembersInfo3 mirrors LOCALGROUP_MEMBERS_INFO_3, which identifies a +// member by name rather than SID. +type localGroupMembersInfo3 struct { + DomainAndName *uint16 +} + +// windowsSandboxIdentity is a provisioned sandbox principal: the account name +// and the SID that ACEs, tokens and firewall rules are keyed to. +type windowsSandboxIdentity struct { + Username string + SID *windows.SID +} + +// String renders the identity for logs without exposing the password, which is +// never stored on this struct. +func (identity windowsSandboxIdentity) String() string { + if identity.SID == nil { + return identity.Username + } + return identity.Username + " (" + identity.SID.String() + ")" +} + +// windowsSandboxUserName derives a stable account name for a workspace key. The +// key is hashed by the caller (see windowsSandboxWorkspaceKey) so the name reveals no +// path, and it is truncated to the 20-character local-account limit. The same +// workspace always maps to the same account, so re-running setup reuses the +// principal instead of accumulating accounts. +func windowsSandboxUserName(workspaceKey string) string { + cleaned := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + return r + case r >= 'A' && r <= 'Z': + return r + ('a' - 'A') + default: + return -1 + } + }, workspaceKey) + if cleaned == "" { + cleaned = "default" + } + name := windowsSandboxUserPrefix + cleaned + if len(name) > windowsSandboxUserNameMax { + name = name[:windowsSandboxUserNameMax] + } + return name +} + +// windowsSandboxUserCommentFor returns the ownership comment for a workspace, +// carrying the full key the account name could only hold 11 characters of. +func windowsSandboxUserCommentFor(workspaceKey string) string { + return windowsSandboxUserCommentKey + workspaceKey +} + +// newWindowsSandboxPassword returns a random password for a sandbox principal. +// The account is never signed into interactively: the password exists only so +// LogonUser can mint a token for it, so it is generated per provisioning run, +// handed straight to the caller, and never persisted by this file. Base32 of +// crypto/rand bytes keeps it alphanumeric, which satisfies complexity policies +// that reject unusual punctuation, and a fixed suffix guarantees the mixed-case +// and digit classes even if the random draw happens to omit one. +func newWindowsSandboxPassword() (string, error) { + raw := make([]byte, windowsPasswordLength) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate sandbox password: %w", err) + } + encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw) + if len(encoded) > windowsPasswordLength { + encoded = encoded[:windowsPasswordLength] + } + return "Zs1!" + encoded, nil +} + +// netAPIStatus converts a netapi32 return value into an error, treating the +// supplied status codes as success so callers can spell out which "already +// exists" results are expected. +func netAPIStatus(call string, status uintptr, okStatuses ...uintptr) error { + if status == nerrSuccess { + return nil + } + for _, ok := range okStatuses { + if status == ok { + return nil + } + } + if status == errorAccessDenied32 { + return fmt.Errorf("%s: access denied (run `zero sandbox setup` from an elevated terminal)", call) + } + return fmt.Errorf("%s: status %d", call, status) +} + +// ensureWindowsSandboxGroup creates the managed local group, or leaves it alone +// when it already exists. +func ensureWindowsSandboxGroup() error { + name, err := windows.UTF16PtrFromString(windowsSandboxGroupName) + if err != nil { + return err + } + comment, err := windows.UTF16PtrFromString(windowsSandboxGroupComment) + if err != nil { + return err + } + info := localGroupInfo1{Name: name, Comment: comment} + status, _, _ := procNetLocalGroupAdd.Call( + 0, // local machine + 1, // level: LOCALGROUP_INFO_1 + uintptr(unsafe.Pointer(&info)), + 0, + ) + // The struct holds pointers into Go memory that the syscall dereferences, so + // it has to stay reachable until the call has returned. + runtime.KeepAlive(info) + runtime.KeepAlive(name) + runtime.KeepAlive(comment) + return netAPIStatus("NetLocalGroupAdd", status, nerrGroupExists, errorAliasExists) +} + +// ensureWindowsSandboxUser creates a sandbox account with the supplied password. +// The account is a plain local user with no home directory or logon script, +// flagged so its password never expires (nobody is there to rotate it) and so it +// is a normal, enabled account LogonUser can authenticate. +// +// It reports whether the account already existed, because NetUserAdd leaves such +// an account completely untouched, password included. The caller has to reset it +// or the secret it goes on to store would not be the account's password at all. +func ensureWindowsSandboxUser(username string, password string, workspaceKey string) (bool, error) { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return false, err + } + secret, err := windows.UTF16PtrFromString(password) + if err != nil { + return false, err + } + comment, err := windows.UTF16PtrFromString(windowsSandboxUserCommentFor(workspaceKey)) + if err != nil { + return false, err + } + info := userInfo1{ + Name: name, + Password: secret, + Priv: usrPrivUser, + Comment: comment, + Flags: ufScript | ufNormalAccount | ufDontExpirePasswd, + } + status, _, _ := procNetUserAdd.Call( + 0, // local machine + 1, // level: USER_INFO_1 + uintptr(unsafe.Pointer(&info)), + 0, + ) + // The struct holds pointers into Go memory that the call dereferences, so + // everything it borrows has to outlive the call. + runtime.KeepAlive(info) + runtime.KeepAlive(name) + runtime.KeepAlive(secret) + runtime.KeepAlive(comment) + if status == nerrUserExists { + return true, nil + } + return false, netAPIStatus("NetUserAdd", status) +} + +// resetWindowsSandboxUserPassword sets the password on an account that already +// existed, so the secret the caller stores is actually the account's password. +// +// Without this, re-running setup produced a fresh random password, wrote it to +// disk, and left the account authenticating with the old one, so every later +// command failed to log on with a principal that looked correctly provisioned. +func resetWindowsSandboxUserPassword(username string, password string) error { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return err + } + secret, err := windows.UTF16PtrFromString(password) + if err != nil { + return err + } + // USER_INFO_1003 is a password-only update, so nothing else about the + // account is disturbed. + info := userInfo1003{Password: secret} + status, _, _ := procNetUserSetInfo.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 1003, // level: USER_INFO_1003 + uintptr(unsafe.Pointer(&info)), + 0, + ) + runtime.KeepAlive(info) + runtime.KeepAlive(name) + runtime.KeepAlive(secret) + return netAPIStatus("NetUserSetInfo", status) +} + +// addWindowsSandboxUserToGroup puts a principal in the managed group, ignoring +// the status that means it is already a member. +func addWindowsSandboxUserToGroup(username string) error { + group, err := windows.UTF16PtrFromString(windowsSandboxGroupName) + if err != nil { + return err + } + member, err := windows.UTF16PtrFromString(username) + if err != nil { + return err + } + entry := localGroupMembersInfo3{DomainAndName: member} + status, _, _ := procNetLocalGroupAddMembers.Call( + 0, // local machine + uintptr(unsafe.Pointer(group)), + 3, // level: LOCALGROUP_MEMBERS_INFO_3 + uintptr(unsafe.Pointer(&entry)), + 1, // one member + ) + runtime.KeepAlive(entry) + runtime.KeepAlive(group) + runtime.KeepAlive(member) + return netAPIStatus("NetLocalGroupAddMembers", status, errorMemberInAlias) +} + +// errWindowsSandboxNameCollision reports that the derived account name is taken +// by a local account Zero did not create. Setup refuses rather than adopting it. +var errWindowsSandboxPrivilegedAccount = errors.New("the local account matching Zero's derived sandbox name belongs to a privileged group (Administrators, Power Users or Backup Operators); refusing to adopt it as a sandbox principal") + +var errWindowsSandboxNameCollision = errors.New("a local account with Zero's derived sandbox name already exists and was not created by Zero") + +// windowsSandboxUserIsManaged reports whether a local account is one Zero +// created, by reading back the comment provisioning stamps on it. +// +// This is the gate on adopting an existing account. The name is derived, not +// discovered, so an account can be sitting on it for reasons that have nothing +// to do with Zero, and taking it over means resetting a stranger's password. +// +// A missing account is not managed rather than an error, so callers can use this +// as a plain question without special-casing absence. +func windowsSandboxUserIsManaged(username string, workspaceKey string) (bool, error) { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return false, err + } + var buffer *byte + status, _, _ := procNetUserGetInfo.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 1, // level: USER_INFO_1 + uintptr(unsafe.Pointer(&buffer)), + ) + runtime.KeepAlive(name) + if status == nerrUserNotFound { + return false, nil + } + if err := netAPIStatus("NetUserGetInfo", status); err != nil { + return false, err + } + if buffer == nil { + return false, nil + } + defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer))) + info := (*userInfo1)(unsafe.Pointer(buffer)) + if info.Comment == nil { + return false, nil + } + comment := windows.UTF16PtrToString(info.Comment) + // An account provisioned before the key was recorded is still ours; it + // predates this check and cannot be attributed to a workspace, so it is + // adopted and its comment rewritten on the way through. + if comment == windowsSandboxUserComment { + return true, nil + } + return comment == windowsSandboxUserCommentFor(workspaceKey), nil +} + +// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0: one group name pointer. +type localGroupUsersInfo0 struct { + Name *uint16 +} + +// windowsSandboxUserIsPrivileged reports whether an account belongs to a local +// group that would make it a poor sandbox principal. +// +// Adoption is the reason this exists. Provisioning will take over an account +// whose name and ownership comment match, and an account that is also in +// Administrators would hand the sandbox exactly the rights the sandbox is meant +// to withhold: it could rewrite the ACLs confining it, read the secret locked to +// the invoking user, and terminate Zero. The name is derived rather than +// discovered, so an account can end up matching without anyone intending it. +// +// Membership is resolved by SID rather than by name so a localised install, where +// the group is called Administrateurs or Administratoren, is still recognised. +func windowsSandboxUserIsPrivileged(username string) (bool, error) { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return false, err + } + var ( + buffer *byte + entries uint32 + total uint32 + ) + status, _, _ := procNetUserGetLocalGroups.Call( + 0, // local machine + uintptr(unsafe.Pointer(name)), + 0, // level: LOCALGROUP_USERS_INFO_0 + 0, // flags: direct membership only + uintptr(unsafe.Pointer(&buffer)), + uintptr(^uint32(0)), // MAX_PREFERRED_LENGTH + uintptr(unsafe.Pointer(&entries)), + uintptr(unsafe.Pointer(&total)), + ) + runtime.KeepAlive(name) + if status == nerrUserNotFound { + return false, nil + } + if err := netAPIStatus("NetUserGetLocalGroups", status); err != nil { + return false, err + } + if buffer == nil || entries == 0 { + return false, nil + } + defer procNetApiBufferFree.Call(uintptr(unsafe.Pointer(buffer))) + + privileged, err := privilegedLocalGroupNames() + if err != nil { + return false, err + } + groups := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buffer)), entries) + for _, group := range groups { + if group.Name == nil { + continue + } + if privileged[strings.ToLower(windows.UTF16PtrToString(group.Name))] { + return true, nil + } + } + return false, nil +} + +// privilegedLocalGroupNames resolves the local names of the groups a sandbox +// principal must not belong to. Resolved from well-known SIDs so the comparison +// survives a localised Windows install. +func privilegedLocalGroupNames() (map[string]bool, error) { + out := map[string]bool{} + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{ + windows.WinBuiltinAdministratorsSid, + windows.WinBuiltinPowerUsersSid, + windows.WinBuiltinBackupOperatorsSid, + } { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + // A group this build of Windows does not define is not a membership + // anyone can hold, so it cannot make an account privileged. + continue + } + account, _, _, err := sid.LookupAccount("") + if err != nil { + continue + } + out[strings.ToLower(account)] = true + } + if len(out) == 0 { + return nil, errors.New("could not resolve any privileged local group name") + } + return out, nil +} + +// resolveWindowsSandboxSID looks up the SID for a provisioned principal. The SID +// is the durable handle: account names can collide with a pre-existing local +// user, so every ACE and firewall rule is keyed to the SID rather than the name. +func resolveWindowsSandboxSID(username string) (*windows.SID, error) { + sid, _, accountType, err := windows.LookupSID("", username) + if err != nil { + return nil, fmt.Errorf("look up sandbox principal %q: %w", username, err) + } + if accountType != windows.SidTypeUser { + return nil, fmt.Errorf("sandbox principal %q resolves to a non-user account (type %d)", username, accountType) + } + return sid, nil +} + +// Indirected so a test can drive provisioning end to end and inject a failure +// at the two points that occur AFTER the account exists. Those are the paths +// whose return value the caller's rollback depends on. +// +// All four are seamed rather than just the last two: every step here needs an +// elevated caller and a real local account, so a test that only replaced the +// post-creation pair would never get past ensureWindowsSandboxGroup on an +// ordinary machine and would pass without reaching the code it names. +var ( + ensureWindowsSandboxGroupFn = ensureWindowsSandboxGroup + ensureWindowsSandboxUserFn = ensureWindowsSandboxUser + addWindowsSandboxUserToGroupFn = addWindowsSandboxUserToGroup + resolveWindowsSandboxSIDFn = resolveWindowsSandboxSID + resetWindowsSandboxUserPasswordFn = resetWindowsSandboxUserPassword + windowsSandboxUserIsManagedFn = windowsSandboxUserIsManaged + windowsSandboxUserIsPrivilegedFn = windowsSandboxUserIsPrivileged + grantWindowsSandboxLogonRightsFn = grantWindowsSandboxLogonRights + revokeWindowsSandboxLogonRightsFn = revokeWindowsSandboxLogonRights + // applyWindowsACLPlanFn is a seam so a test can pin the ORDER of setup's ACL + // work. The revocation below only prevents a stale grant if it runs before + // the plan that re-adds the current one; a test that exercised the revoke + // helper on its own would pass just as happily with the call site deleted. + applyWindowsACLPlanFn = applyWindowsACLPlan +) + +// provisionWindowsSandboxIdentity ensures the managed group and one sandbox +// principal for workspaceKey exist, and returns the identity plus the password +// the caller needs to mint a token with LogonUser. It is idempotent, so setup +// can run repeatedly. +// +// The password is returned rather than stored, so no credential is written to +// disk here; that happens a layer up where the secret has somewhere safe to +// live. The returned value is always the account's actual password, including +// when the account already existed, because that case is reset explicitly +// below. +func provisionWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, string, bool, error) { + if err := ensureWindowsSandboxGroupFn(); err != nil { + return windowsSandboxIdentity{}, "", false, err + } + username := windowsSandboxUserName(workspaceKey) + password, err := newWindowsSandboxPassword() + if err != nil { + return windowsSandboxIdentity{}, "", false, err + } + existed, err := ensureWindowsSandboxUserFn(username, password, workspaceKey) + if err != nil { + return windowsSandboxIdentity{}, "", false, err + } + if existed { + // Prove the account is ours before touching it. The name is derived from a + // workspace hash rather than discovered, so it can be occupied by an + // account that has nothing to do with Zero, whether by coincidence or + // because somebody created it deliberately. Adopting one means resetting + // its password, which is not something to do on the strength of a name + // matching a pattern we generate ourselves. + managed, err := windowsSandboxUserIsManagedFn(username, workspaceKey) + if err != nil { + return windowsSandboxIdentity{}, "", false, err + } + if !managed { + return windowsSandboxIdentity{}, "", false, fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, username) + } + // Ours by name and comment is not enough to adopt it. An account that also + // sits in Administrators (or Power Users, or Backup Operators) would give + // the sandbox the rights the sandbox exists to withhold: it could rewrite + // the ACLs confining it, read the secret locked to the invoking user, and + // stop Zero. Refuse rather than quietly take it over, and say which account + // so an operator can look at it. + privileged, err := windowsSandboxUserIsPrivilegedFn(username) + if err != nil { + return windowsSandboxIdentity{}, "", false, err + } + if privileged { + return windowsSandboxIdentity{}, "", false, fmt.Errorf("%w: %q", errWindowsSandboxPrivilegedAccount, username) + } + // Deliberately NOT resetting the password here. + // + // NetUserAdd left an existing account untouched, so the password above is + // not yet its password and something has to set it. Doing that here, at + // the top of provisioning, opened a window that lasted until the secret + // was written several steps later: a failure anywhere in between left a + // live account whose password nothing on disk knew, and because the + // account already existed the rollback correctly declined to delete it. + // The command path then read the absent secret as "not provisioned" and + // quietly fell back to the weaker backend, so the sandbox was downgraded + // for good with nothing to show for it. + // + // The caller rotates instead, immediately before committing the secret, + // which narrows that window to a single operation. Until it does, the + // account keeps its old password and the old secret on disk still + // authenticates, so a failure before that point costs nothing. + } + // Both failures below can happen AFTER NetUserAdd created the account, so the + // name has to come back with them. The caller's rollback deletes by + // identity.Username, and returning a zero identity alongside created=true + // asked it to delete "", which silently stranded the account this run had + // just made. Group attachment in particular is not a formality: it can fail + // under local policy, and it is the enforcement boundary, so a half-created + // principal is exactly the state worth not leaving behind. The SID is absent + // here, which the rollback already tolerates, since nothing has been granted + // to it yet. + if err := addWindowsSandboxUserToGroupFn(username); err != nil { + return windowsSandboxIdentity{Username: username}, "", !existed, err + } + sid, err := resolveWindowsSandboxSIDFn(username) + if err != nil { + return windowsSandboxIdentity{Username: username}, "", !existed, err + } + return windowsSandboxIdentity{Username: username, SID: sid}, password, !existed, nil +} + +// removeWindowsSandboxIdentity deletes a provisioned principal. Callers must +// revoke the principal's ACEs FIRST (see windowsPrincipalRevokePlan): deleting +// the account leaves any surviving ACE naming an unresolvable SID, which is what +// shows up in Explorer as an orphaned entry and is exactly the residue this +// model is meant to avoid. +// +// A missing account is success, so teardown converges the same way provisioning +// does. Requires an elevated caller. +func removeWindowsSandboxIdentity(username string) error { + name, err := windows.UTF16PtrFromString(username) + if err != nil { + return err + } + status, _, _ := procNetUserDel.Call(0, uintptr(unsafe.Pointer(name))) + return netAPIStatus("NetUserDel", status, nerrUserNotFound) +} + +// errWindowsSandboxIdentityUnavailable reports that no sandbox principal has +// been provisioned yet, so callers can fall back to the restricted-token +// backend instead of failing the command. +var errWindowsSandboxIdentityUnavailable = errors.New("no Zero sandbox principal is provisioned; run `zero sandbox setup` from an elevated terminal") + +// lookupWindowsSandboxIdentity resolves an already-provisioned principal without +// creating anything, so the unelevated command path can discover whether an +// identity exists. It returns errWindowsSandboxIdentityUnavailable when setup +// has not run. +func lookupWindowsSandboxIdentity(workspaceKey string) (windowsSandboxIdentity, error) { + username := windowsSandboxUserName(workspaceKey) + // Ownership is checked here as well as at provisioning, because the account + // NAME cannot carry the whole workspace key. + // + // The name keeps 11 characters of the digest; the comment holds all of it. + // Provisioning refuses a name whose comment names a different workspace, and + // without the same check here the workspace that LOST that race would still + // resolve the name to a SID and quietly use the other workspace's principal, + // its secret and its ACL identity. Setup would have failed for it, so this is + // the path that decides whether the refusal actually holds. + // + // A collision is very unlikely with real keys, roughly 2^-44 per pair, but the + // cost of being wrong is one workspace running as another's identity, and the + // check is one syscall on a path that is already doing several. + // SID resolution runs FIRST so "no such account" stays the unavailable + // sentinel. windowsSandboxUserIsManaged answers false for both an absent + // account and one belonging to someone else, so checking it before this would + // report an unprovisioned workspace as a name collision and turn the ordinary + // not-set-up case into an error the operator has to interpret. + sid, err := resolveWindowsSandboxSIDFn(username) + if err != nil { + return windowsSandboxIdentity{}, classifyWindowsSandboxLookupError(err) + } + managed, err := windowsSandboxUserIsManagedFn(username, workspaceKey) + if err != nil { + return windowsSandboxIdentity{}, err + } + if !managed { + return windowsSandboxIdentity{}, fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, username) + } + return windowsSandboxIdentity{Username: username, SID: sid}, nil +} + +// lookupWindowsSandboxPrincipalForCommand resolves the principal a command will +// actually run as, and refuses one that has since joined a privileged group. +// +// Provisioning already refuses a privileged account, but group membership is not +// frozen at setup: the account can be added to Administrators, Power Users or +// Backup Operators afterwards. Minting a token for it would hand the sandboxed +// command exactly the privileges the sandbox exists to withhold, so the check has +// to run again on the path that mints the token, not only on the path that +// created the account. +// +// Deliberately NOT folded into lookupWindowsSandboxIdentity: teardown resolves +// the same identity to revoke its logon rights before deleting it, and it must +// stay able to clean up an account that has become privileged rather than +// refusing to touch it. Refusing there would leave the very account this guards +// against permanently undeletable by Zero. +func lookupWindowsSandboxPrincipalForCommand(workspaceKey string) (windowsSandboxIdentity, error) { + identity, err := lookupWindowsSandboxIdentity(workspaceKey) + if err != nil { + return windowsSandboxIdentity{}, err + } + privileged, err := windowsSandboxUserIsPrivilegedFn(identity.Username) + if err != nil { + return windowsSandboxIdentity{}, err + } + if privileged { + return windowsSandboxIdentity{}, fmt.Errorf("%w: %q", errWindowsSandboxPrivilegedAccount, identity.Username) + } + return identity, nil +} + +// classifyWindowsSandboxLookupError decides whether a failed SID resolution +// means "setup has not run" or "this principal exists but is unusable". +// +// Only "no such account" is the former. Every other failure is a principal the +// caller must not paper over, including the deliberate refusal in +// resolveWindowsSandboxSID of a name squatted by a group or alias. Collapsing +// those into the unavailable sentinel would turn a real conflict into a silent +// fall back to the restricted token, which is exactly the case that should +// reach the operator rather than be absorbed. +// +// Split out from the lookup so the decision can be asserted on its own: the +// lookup derives its account name from a workspace key, so a test cannot hand +// it a name that resolves to a group. +func classifyWindowsSandboxLookupError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, windows.ERROR_NONE_MAPPED) { + return errWindowsSandboxIdentityUnavailable + } + return err +} diff --git a/internal/sandbox/windows_identity_windows_test.go b/internal/sandbox/windows_identity_windows_test.go new file mode 100644 index 000000000..11d64c741 --- /dev/null +++ b/internal/sandbox/windows_identity_windows_test.go @@ -0,0 +1,461 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "fmt" + "os" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// A local Windows account name is capped at 20 characters, so the derived name +// must truncate rather than produce a name NetUserAdd rejects. +func TestWindowsSandboxUserNameRespectsLengthLimit(t *testing.T) { + name := windowsSandboxUserName(strings.Repeat("a", 64)) + if len(name) > windowsSandboxUserNameMax { + t.Fatalf("name %q is %d chars, want at most %d", name, len(name), windowsSandboxUserNameMax) + } + if !strings.HasPrefix(name, windowsSandboxUserPrefix) { + t.Fatalf("name %q lost the managed prefix", name) + } +} + +// The same workspace must map to the same principal, otherwise re-running setup +// would accumulate a new local account every time. +func TestWindowsSandboxUserNameIsStable(t *testing.T) { + first := windowsSandboxUserName("abc123") + second := windowsSandboxUserName("abc123") + if first != second { + t.Fatalf("name is not stable: %q vs %q", first, second) + } + if other := windowsSandboxUserName("def456"); other == first { + t.Fatalf("different workspaces produced the same principal %q", first) + } +} + +// The key is sanitised to characters a local account name accepts, so a hash or +// path fragment cannot smuggle a separator or a space into the name. +func TestWindowsSandboxUserNameRejectsUnsafeCharacters(t *testing.T) { + name := windowsSandboxUserName(`C:\Users\me\proj ect`) + for _, r := range strings.TrimPrefix(name, windowsSandboxUserPrefix) { + isLower := r >= 'a' && r <= 'z' + isDigit := r >= '0' && r <= '9' + if !isLower && !isDigit { + t.Fatalf("name %q contains unsafe rune %q", name, r) + } + } + if name == windowsSandboxUserPrefix { + t.Fatal("sanitising removed every character, leaving a bare prefix") + } +} + +// An empty or fully-sanitised-away key must still yield a usable name rather +// than the bare prefix. +func TestWindowsSandboxUserNameHandlesEmptyKey(t *testing.T) { + for _, key := range []string{"", "///", " "} { + if got := windowsSandboxUserName(key); got == windowsSandboxUserPrefix { + t.Fatalf("key %q produced a bare prefix", key) + } + } +} + +// The password must be fresh per call and carry the character classes a default +// Windows complexity policy demands, or NetUserAdd fails with ERROR_PASSWORD_RESTRICTION. +func TestNewWindowsSandboxPasswordIsRandomAndComplex(t *testing.T) { + first, err := newWindowsSandboxPassword() + if err != nil { + t.Fatalf("generate: %v", err) + } + second, err := newWindowsSandboxPassword() + if err != nil { + t.Fatalf("generate: %v", err) + } + if first == second { + t.Fatal("two generated passwords are identical, so they are not random") + } + if len(first) < 12 { + t.Fatalf("password is only %d chars", len(first)) + } + var hasUpper, hasLower, hasDigit bool + for _, r := range first { + switch { + case r >= 'A' && r <= 'Z': + hasUpper = true + case r >= 'a' && r <= 'z': + hasLower = true + case r >= '0' && r <= '9': + hasDigit = true + } + } + if !hasUpper || !hasLower || !hasDigit { + t.Fatalf("password %q lacks a required character class", first) + } +} + +// "Already exists" is the normal result of re-running setup and must not surface +// as an error, while a genuine failure must. +func TestNetAPIStatusTreatsExistingAsSuccess(t *testing.T) { + if err := netAPIStatus("NetUserAdd", nerrSuccess); err != nil { + t.Fatalf("success status returned %v", err) + } + if err := netAPIStatus("NetUserAdd", nerrUserExists, nerrUserExists); err != nil { + t.Fatalf("existing user must be success, got %v", err) + } + if err := netAPIStatus("NetLocalGroupAdd", nerrGroupExists, nerrGroupExists, errorAliasExists); err != nil { + t.Fatalf("existing group must be success, got %v", err) + } + if err := netAPIStatus("NetUserAdd", 2245); err == nil { + t.Fatal("an unexpected status must surface as an error") + } +} + +// Access-denied is the status an unelevated run gets, and it must say so rather +// than reporting a bare number the user cannot act on. +func TestNetAPIStatusExplainsAccessDenied(t *testing.T) { + err := netAPIStatus("NetUserAdd", errorAccessDenied32) + if err == nil { + t.Fatal("access denied must be an error") + } + if !strings.Contains(err.Error(), "elevated") { + t.Fatalf("error %q should point at elevation", err) + } +} + +// The Win32 structs are passed to netapi32 as raw buffers, so their layout must +// match what the API expects. A wrong size means silent memory corruption. +func TestWindowsIdentityStructLayouts(t *testing.T) { + ptr := unsafe.Sizeof(uintptr(0)) + if got, want := unsafe.Sizeof(localGroupMembersInfo3{}), ptr; got != want { + t.Fatalf("LOCALGROUP_MEMBERS_INFO_3 size = %d, want %d", got, want) + } + if got, want := unsafe.Sizeof(localGroupInfo1{}), 2*ptr; got != want { + t.Fatalf("LOCALGROUP_INFO_1 size = %d, want %d", got, want) + } + // USER_INFO_1 is four pointers plus three DWORDs, with the compiler padding + // each DWORD pair up to pointer alignment on amd64. + if got := unsafe.Sizeof(userInfo1{}); got < 4*ptr { + t.Fatalf("USER_INFO_1 size = %d, smaller than its four pointer fields", got) + } + if unsafe.Offsetof(userInfo1{}.Password) != ptr { + t.Fatal("USER_INFO_1.Password must directly follow Name") + } +} + +// LSA_UNICODE_STRING counts BYTES, not runes, and excludes the NUL terminator +// from Length while including it in MaximumLength. Getting either wrong makes +// LsaAddAccountRights read past the buffer or silently match no right, so pin it. +func TestNewLSAStringUsesByteLengths(t *testing.T) { + buffer, err := windows.UTF16FromString("SeBatchLogonRight") + if err != nil { + t.Fatalf("encode: %v", err) + } + entry := newLSAString(buffer) + const runes uint16 = uint16(len("SeBatchLogonRight")) + if entry.Length != runes*2 { + t.Fatalf("Length = %d, want %d (bytes, excluding NUL)", entry.Length, runes*2) + } + if entry.MaximumLength != (runes+1)*2 { + t.Fatalf("MaximumLength = %d, want %d (bytes, including NUL)", entry.MaximumLength, (runes+1)*2) + } + if entry.Buffer == nil { + t.Fatal("Buffer must point at the encoded string") + } +} + +// An empty buffer must not produce a struct pointing at nothing with a nonzero +// length, which would hand LSA a wild pointer. +func TestNewLSAStringHandlesEmptyBuffer(t *testing.T) { + entry := newLSAString(nil) + if entry.Buffer != nil || entry.Length != 0 || entry.MaximumLength != 0 { + t.Fatalf("empty buffer produced %+v, want a zero value", entry) + } +} + +// The LSA structs are passed to advapi32 as raw buffers, so their sizes must +// match the Win32 definitions. +func TestLSAStructLayouts(t *testing.T) { + ptr := unsafe.Sizeof(uintptr(0)) + // LSA_UNICODE_STRING: two uint16 then a pointer, padded to pointer alignment. + if got, want := unsafe.Sizeof(lsaUnicodeString{}), 2*ptr; got != want { + t.Fatalf("LSA_UNICODE_STRING size = %d, want %d", got, want) + } + if unsafe.Offsetof(lsaUnicodeString{}.Buffer) != ptr { + t.Fatal("LSA_UNICODE_STRING.Buffer must sit at the second pointer slot") + } + var attributes lsaObjectAttributes + if unsafe.Sizeof(attributes) < 6*ptr-ptr { + t.Fatalf("LSA_OBJECT_ATTRIBUTES size = %d, smaller than its fields", unsafe.Sizeof(attributes)) + } + if unsafe.Offsetof(attributes.ObjectName) == 0 { + t.Fatal("LSA_OBJECT_ATTRIBUTES.ObjectName must not alias Length") + } +} + +// Provisioning creates real local accounts, so it only runs when explicitly +// opted into on an elevated machine. Everything above covers the logic that can +// be exercised without touching the account database. +func TestProvisionWindowsSandboxIdentityRoundTrip(t *testing.T) { + if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" { + // Spelled out per shell because `set VAR=1` is cmd syntax and silently + // sets a shell variable rather than an environment variable in + // PowerShell, which makes this skip look like the elevation check failing. + t.Skip("provisioning test not enabled: PowerShell `$env:ZERO_WINDOWS_IDENTITY_PROVISION_TEST = \"1\"`, cmd `set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1`, bash `export ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1` (also needs an elevated terminal)") + } + if !windowsProcessIsElevated() { + t.Skip("provisioning requires an elevated process") + } + // Starting clean keeps a failure here from being explained by residue from a + // previous run. Provisioning no longer resets an adopted account's password, + // so a leftover account would otherwise be adopted with a password this test + // never learns. + _ = removeWindowsSandboxIdentity(windowsSandboxUserName("ziptest01")) + + identity, password, _, err := provisionWindowsSandboxIdentity("ziptest01") + if err != nil { + t.Fatalf("provision: %v", err) + } + // Registered immediately after provisioning so every failure path below is + // covered. This test grants a real batch-logon right to a real local account; + // leaving either behind on a developer machine is not acceptable residue, and + // rights are revoked before the account so nothing is left keyed to a SID that + // no longer resolves. + t.Cleanup(func() { + if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { + t.Errorf("cleanup: revoke logon rights: %v", err) + } + if err := removeWindowsSandboxIdentity(identity.Username); err != nil { + t.Errorf("cleanup: remove principal: %v", err) + } + }) + if identity.SID == nil { + t.Fatal("provisioned identity has no SID") + } + if password == "" { + t.Fatal("provisioning returned an empty password") + } + // Re-running must converge on the same principal rather than failing or + // creating a second account. + again, secondPassword, _, err := provisionWindowsSandboxIdentity("ziptest01") + if err != nil { + t.Fatalf("second provision: %v", err) + } + if again.Username != identity.Username || !again.SID.Equals(identity.SID) { + t.Fatalf("provisioning is not idempotent: %s then %s", identity, again) + } + if secondPassword == "" { + t.Fatal("second provision returned an empty password") + } + // Deliberately NOT logging on with secondPassword. Provisioning does not + // rotate an adopted account any more, so that value is a fresh random string + // the account does not hold; rotation happens in + // provisionWindowsSandboxPrincipalForSetup, immediately before the secret is + // written, to keep the window where no stored password authenticates as small + // as possible. + // + // The guarantee worth asserting is therefore the one the setup path makes: + // after it returns, the stored secret logs the principal on. That covers + // rotation, the secret write and the logon right in one assertion, and it is + // the thing a broken re-setup would actually break. + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + WorkspaceRoots: []string{`C:\ziptest01`}, + } + setupIdentity, _, err := provisionWindowsSandboxPrincipalForSetup(config) + if err != nil { + t.Fatalf("setup provision: %v", err) + } + secretPath, err := windowsSandboxSecretPath(config.SandboxHome, setupIdentity.Username) + if err != nil { + t.Fatalf("secret path: %v", err) + } + storedPassword, err := readWindowsSandboxSecret(secretPath) + if err != nil { + t.Fatalf("read stored secret: %v", err) + } + token, err := logonWindowsSandboxPrincipal(setupIdentity.Username, storedPassword) + if err != nil { + t.Fatalf("logon with the secret the setup path stored: %v", err) + } + _ = token.Close() + t.Cleanup(func() { + _ = revokeWindowsSandboxLogonRights(setupIdentity.SID) + _ = removeWindowsSandboxIdentity(setupIdentity.Username) + }) + // Lookup must find what provisioning created. + found, err := lookupWindowsSandboxIdentity("ziptest01") + if err != nil { + t.Fatalf("lookup after provision: %v", err) + } + if !found.SID.Equals(identity.SID) { + t.Fatalf("lookup returned %s, want %s", found, identity) + } +} + +// The other half of the privileged chain: granting logon rights and actually +// minting a token. Provisioning proves the account exists; this proves it is +// USABLE, which is the part the runner depends on. +// +// The batch logon doubles as the assertion that LsaAddAccountRights worked. A +// LOGON32_LOGON_BATCH logon fails with ERROR_LOGON_TYPE_NOT_GRANTED (1385) +// unless SeBatchLogonRight is actually held, so a token coming back is proof the +// grant landed rather than merely that the call returned success. +// +// Creates a real local account and removes it again, so it is gated the same way +// as the provisioning round-trip. +func TestGrantLogonRightsAndMintPrincipalToken(t *testing.T) { + if os.Getenv("ZERO_WINDOWS_IDENTITY_PROVISION_TEST") != "1" { + t.Skip("provisioning test not enabled: PowerShell `$env:ZERO_WINDOWS_IDENTITY_PROVISION_TEST = \"1\"`, cmd `set ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1`, bash `export ZERO_WINDOWS_IDENTITY_PROVISION_TEST=1` (also needs an elevated terminal)") + } + if !windowsProcessIsElevated() { + t.Skip("granting logon rights requires an elevated process") + } + + const key = "ziplogon01" + // A leftover account from an interrupted run would keep its old password, + // which the freshly generated one will not match, so start from a clean slate. + _ = removeWindowsSandboxIdentity(windowsSandboxUserName(key)) + + identity, password, _, err := provisionWindowsSandboxIdentity(key) + if err != nil { + t.Fatalf("provision: %v", err) + } + t.Cleanup(func() { + // Rights first, then the account: the reverse order strands an LSA entry + // keyed to a SID that no longer resolves. + if err := revokeWindowsSandboxLogonRights(identity.SID); err != nil { + t.Errorf("cleanup: revoke logon rights: %v", err) + } + if err := removeWindowsSandboxIdentity(identity.Username); err != nil { + t.Errorf("cleanup: remove principal: %v", err) + } + }) + + // Exercises LsaAddAccountRights, including the LSA_UNICODE_STRING byte-length + // handling that nothing else has run. + if err := grantWindowsSandboxLogonRights(identity.SID); err != nil { + t.Fatalf("grant logon rights: %v", err) + } + // Idempotent: setup re-runs must not fail on rights the account already holds. + if err := grantWindowsSandboxLogonRights(identity.SID); err != nil { + t.Fatalf("granting logon rights twice must succeed: %v", err) + } + + token, err := logonWindowsSandboxPrincipal(identity.Username, password) + if err != nil { + t.Fatalf("logon as principal: %v", err) + } + defer token.Close() + + // The token must BE the principal. If this came back as the caller, the whole + // identity boundary would be an illusion and reads would still run as the user. + user, err := token.GetTokenUser() + if err != nil { + t.Fatalf("token user: %v", err) + } + if !user.User.Sid.Equals(identity.SID) { + t.Fatalf("token belongs to %s, want the principal %s", user.User.Sid, identity.SID) + } + t.Logf("minted a token for %s", identity) +} + +// A workspace with no provisioned principal must report the actionable +// "run setup" error rather than a raw lookup failure, so the command path can +// fall back instead of surfacing a Win32 code. +func TestLookupWindowsSandboxIdentityUnprovisioned(t *testing.T) { + _, err := lookupWindowsSandboxIdentity("nosuchworkspacekey9z") + if err == nil { + t.Skip("a principal for this key unexpectedly exists on this machine") + } + if err != errWindowsSandboxIdentityUnavailable { + t.Fatalf("error = %v, want errWindowsSandboxIdentityUnavailable", err) + } +} + +// A name that resolves to something other than a user account is a conflict, +// not an absent principal, and must not be reported as "setup has not run": the +// command path treats that sentinel as permission to fall back silently, so +// collapsing the two would hide a squatted account behind a quiet downgrade to +// the restricted token. +// +// Every machine already has well-known non-user names to test against, so this +// needs no privilege and no provisioning. +func TestLookupWindowsSandboxIdentityRejectsNonUserAccount(t *testing.T) { + // Groups that exist on any Windows install. Whichever resolves first is + // enough; localized machines may not carry the English name. + for _, group := range []string{"Administrators", "Users", "Guests"} { + sid, _, accountType, err := windows.LookupSID("", group) + if err != nil || sid == nil { + continue + } + if accountType == windows.SidTypeUser { + continue + } + resolveErr := func() error { + _, err := resolveWindowsSandboxSID(group) + return err + }() + if resolveErr == nil { + t.Fatalf("resolveWindowsSandboxSID(%q) accepted a non-user account (type %d)", group, accountType) + } + // The classification is the part that matters: the sentinel is what + // licenses the command path to fall back silently, so this refusal must + // survive it rather than be folded into it. + if classified := classifyWindowsSandboxLookupError(resolveErr); errors.Is(classified, errWindowsSandboxIdentityUnavailable) { + t.Fatalf("non-user account %q classified as unprovisioned, which would silently downgrade to the restricted token: %v", group, classified) + } + return + } + t.Skip("no well-known non-user account resolved on this machine") +} + +// The account name is derived from a workspace hash, not discovered, so it can +// be occupied by a local account that has nothing to do with Zero. Adopting one +// means resetting a stranger's password, so provisioning has to prove ownership +// first and refuse otherwise. +// +// Driven against real accounts every Windows install carries, which are +// definitively not ours. Unprivileged: it only has to establish that they are +// not classified as managed, so nothing is ever created or modified. +func TestWindowsSandboxUserIsManagedRefusesForeignAccounts(t *testing.T) { + checked := 0 + for _, name := range []string{"Administrator", "Guest", "DefaultAccount"} { + managed, err := windowsSandboxUserIsManaged(name, "workspacekey") + if err != nil { + // Localized or disabled installs may not carry every one of these. + continue + } + checked++ + if managed { + t.Fatalf("%q classified as a Zero sandbox principal; provisioning would reset its password", name) + } + } + if checked == 0 { + t.Skip("no well-known local account could be queried on this machine") + } + // An absent account must answer false rather than error, since provisioning + // asks this question about names that usually do not exist yet. + managed, err := windowsSandboxUserIsManaged("zero-sbx-nosuchacct", "workspacekey") + if err != nil { + t.Fatalf("querying a missing account: %v", err) + } + if managed { + t.Fatal("a missing account was classified as managed") + } +} + +// The refusal has to be a typed, recognisable collision rather than a generic +// failure, so setup can say what is wrong instead of reporting a Win32 status. +func TestWindowsSandboxNameCollisionIsTyped(t *testing.T) { + wrapped := fmt.Errorf("%w: %q", errWindowsSandboxNameCollision, "zero-sbx-dexample") + if !errors.Is(wrapped, errWindowsSandboxNameCollision) { + t.Fatal("collision error does not match its sentinel") + } + if !strings.Contains(wrapped.Error(), "not created by Zero") { + t.Fatalf("collision message = %q, want it to say the account is not ours", wrapped.Error()) + } +} diff --git a/internal/sandbox/windows_principal_jail_windows_test.go b/internal/sandbox/windows_principal_jail_windows_test.go new file mode 100644 index 000000000..873a710c3 --- /dev/null +++ b/internal/sandbox/windows_principal_jail_windows_test.go @@ -0,0 +1,77 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// The principal path must apply the write jail, not just hand over the account's +// own token. A LogonUser token carries every write the account's ambient +// memberships grant, so without this the profile's write roots are advisory. +// +// Driven with the process token as base because minting a real principal token +// needs an elevated, provisioned machine; the restriction machinery under test +// is identical either way. +func TestRestrictWindowsTokenJailsWritesOutsideCapabilitySIDs(t *testing.T) { + var base windows.Token + desired := uint32(windows.TOKEN_DUPLICATE | windows.TOKEN_QUERY | windows.TOKEN_ASSIGN_PRIMARY | + windows.TOKEN_ADJUST_DEFAULT | windows.TOKEN_ADJUST_SESSIONID | windows.TOKEN_ADJUST_PRIVILEGES) + if err := windows.OpenProcessToken(windows.CurrentProcess(), desired, &base); err != nil { + t.Skipf("cannot open the process token here: %v", err) + } + defer base.Close() + + // A capability SID granted nowhere near the probe directory. + capSID, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid) + if err != nil { + t.Fatal(err) + } + jailed, err := restrictWindowsTokenForCapabilitySIDs(base, []string{capSID.String()}, true) + if err != nil { + t.Skipf("cannot build a restricted token here: %v", err) + } + defer jailed.Close() + + // Setup assertion: the unrestricted process can write here, so a denial below + // is the jail and not a broken fixture. + dir := t.TempDir() + target := filepath.Join(dir, "written.txt") + if err := os.WriteFile(target, []byte("probe"), 0o600); err != nil { + t.Fatalf("SETUP INVALID: the test process itself cannot write %s: %v", target, err) + } + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + + config := WindowsSandboxCommandConfig{ + CommandCWD: dir, + WorkspaceRoots: []string{dir}, + Command: []string{"cmd", "/c", "echo probe> " + target}, + } + if _, err := runWindowsCommandAsUser(jailed, config); err != nil { + t.Fatalf("run under the jailed token: %v", err) + } + if _, err := os.Stat(target); err == nil { + t.Error("the jailed token wrote a path no capability SID covers; the write jail is not applied") + } +} + +// parseWindowsCapabilitySIDs must reject an empty list rather than build an +// unrestricted token, and must not leak the SIDs it already parsed on failure. +func TestParseWindowsCapabilitySIDsRejectsEmptyAndBadInput(t *testing.T) { + if _, err := parseWindowsCapabilitySIDs(nil); err == nil { + t.Error("empty SID list accepted; that would build a token with no restriction") + } + valid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid) + if err != nil { + t.Fatal(err) + } + if _, err := parseWindowsCapabilitySIDs([]string{valid.String(), "not-a-sid"}); err == nil { + t.Error("an unparseable SID was accepted") + } +} diff --git a/internal/sandbox/windows_principal_ledger.go b/internal/sandbox/windows_principal_ledger.go new file mode 100644 index 000000000..13705c74c --- /dev/null +++ b/internal/sandbox/windows_principal_ledger.go @@ -0,0 +1,160 @@ +package sandbox + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// A record of the paths a sandbox principal was last granted ACEs on. +// +// applyWindowsPrincipalACLs revokes this trustee before it re-applies, but the +// only paths it could name were the ones in the plan it was about to apply. A +// root that LEFT the policy is absent from that plan, so its ACE survived the +// re-setup marker validation forces and the principal kept write access the +// current policy no longer grants — the sandbox widened as a result of being +// tightened. Teardown repeated the same current-plan-only calculation, so it did +// not clean the leftover either. +// +// Nothing on Windows can answer "which paths hold an ACE for this SID" without +// walking every volume, so the grants have to be written down as they are made. +// +// Keyed by principal, beside the secret and for the same reason: one sandbox +// home serves every workspace on the machine, so a single shared file would let +// one workspace's setup overwrite another's record — reproducing exactly the +// stale-ACE bug this exists to close, one level up. + +const windowsPrincipalACLLedgerSchemaVersion = 1 + +const windowsPrincipalACLLedgerDirName = "windows-principal-acl" + +type windowsPrincipalACLLedger struct { + SchemaVersion int `json:"schemaVersion"` + Paths []string `json:"paths"` +} + +func windowsPrincipalACLLedgerPath(sandboxHome string, username string) (string, error) { + if strings.TrimSpace(sandboxHome) == "" { + return "", errors.New("windows principal ACL ledger: empty sandbox home") + } + if strings.TrimSpace(username) == "" { + return "", errors.New("windows principal ACL ledger: empty principal name") + } + // The same guard the secret path applies, against a caller passing something + // windowsSandboxUserName did not produce. + if strings.ContainsAny(username, `\/:`) || strings.Contains(username, "..") { + return "", fmt.Errorf("windows principal ACL ledger: unsafe principal name %q", username) + } + return filepath.Join(sandboxHome, windowsPrincipalACLLedgerDirName, username+".json"), nil +} + +// readWindowsPrincipalACLLedger returns the paths an earlier setup recorded for +// this principal. +// +// recorded is false for every reason the record cannot be trusted — absent, +// unreadable, malformed, or written to a schema this build does not know — and +// not merely for "absent". Collapsing them is deliberate: there is exactly one +// safe response to any of them, and it is the same one. The prior grant set is +// unknown, and a principal whose grants are unknown cannot be reused. Returning +// an error instead would invite a caller to report it and carry on with an empty +// prior set, which is the fail-open this record exists to close — the one case +// where the previous paths are not empty but unenumerable. +func readWindowsPrincipalACLLedger(sandboxHome string, username string) ([]string, bool) { + path, err := windowsPrincipalACLLedgerPath(sandboxHome, username) + if err != nil { + return nil, false + } + contents, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var ledger windowsPrincipalACLLedger + if err := json.Unmarshal(contents, &ledger); err != nil { + return nil, false + } + if ledger.SchemaVersion != windowsPrincipalACLLedgerSchemaVersion { + return nil, false + } + return trimNonEmptyStrings(ledger.Paths), true +} + +// writeWindowsPrincipalACLLedger records paths for this principal, replacing any +// previous record atomically so an interrupted write cannot leave a truncated +// file — which the reader would then treat as "no principal was ever granted +// anything", the very state it must never guess. +func writeWindowsPrincipalACLLedger(sandboxHome string, username string, paths []string) error { + path, err := windowsPrincipalACLLedgerPath(sandboxHome, username) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create windows principal ACL ledger dir: %w", err) + } + contents, err := json.MarshalIndent(windowsPrincipalACLLedger{ + SchemaVersion: windowsPrincipalACLLedgerSchemaVersion, + Paths: trimNonEmptyStrings(paths), + }, "", " ") + if err != nil { + return fmt.Errorf("marshal windows principal ACL ledger: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-principal-acl-*.tmp") + if err != nil { + return fmt.Errorf("create windows principal ACL ledger temp file: %w", err) + } + tmpPath := tmp.Name() + if _, err := tmp.Write(contents); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return fmt.Errorf("write windows principal ACL ledger temp file: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("close windows principal ACL ledger temp file: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("replace windows principal ACL ledger: %w", err) + } + return nil +} + +// removeWindowsPrincipalACLLedger drops the record. An absent one is not an +// error: this runs on the teardown path, where being gone is the goal. +func removeWindowsPrincipalACLLedger(sandboxHome string, username string) error { + path, err := windowsPrincipalACLLedgerPath(sandboxHome, username) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove windows principal ACL ledger: %w", err) + } + return nil +} + +// unionWindowsPrincipalACLPaths merges path sets for revocation, keeping the +// first spelling of each path. +// +// Deduplication uses the same case-insensitive key the ACL plans use, so a root +// recorded as C:\Ws by one setup and re-granted as c:\ws by the next is one path +// to revoke rather than two. +func unionWindowsPrincipalACLPaths(sets ...[]string) []string { + seen := make(map[string]struct{}) + union := make([]string, 0) + for _, set := range sets { + for _, path := range set { + key := windowsCapabilityPathKey(path) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + union = append(union, path) + } + } + return union +} diff --git a/internal/sandbox/windows_principal_ledger_test.go b/internal/sandbox/windows_principal_ledger_test.go new file mode 100644 index 000000000..26fd36065 --- /dev/null +++ b/internal/sandbox/windows_principal_ledger_test.go @@ -0,0 +1,174 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The point of the record is that a LATER setup can name a root the CURRENT +// policy no longer mentions, so the round trip has to survive the process that +// wrote it having no memory of the paths. +func TestPrincipalACLLedgerRoundTripsRecordedPaths(t *testing.T) { + home := t.TempDir() + paths := []string{`C:\ws\alpha`, `C:\ws\beta`, `C:\cache\runtime`} + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", paths); err != nil { + t.Fatalf("write: %v", err) + } + got, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01") + if !recorded { + t.Fatal("a record this process just wrote read back as untrusted") + } + if strings.Join(got, "|") != strings.Join(paths, "|") { + t.Errorf("read back %v, want %v", got, paths) + } +} + +// One sandbox home serves every workspace on the machine. If the record were a +// single shared file, workspace B's setup would overwrite workspace A's, and A's +// dropped roots would then be unnameable at the next re-setup — the same stale +// ACE this record exists to revoke, produced by the record itself. +func TestPrincipalACLLedgerIsPerPrincipal(t *testing.T) { + home := t.TempDir() + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`}); err != nil { + t.Fatalf("write alpha: %v", err) + } + if err := writeWindowsPrincipalACLLedger(home, "zerosbx02", []string{`C:\ws\beta`}); err != nil { + t.Fatalf("write beta: %v", err) + } + alpha, ok := readWindowsPrincipalACLLedger(home, "zerosbx01") + if !ok || len(alpha) != 1 || alpha[0] != `C:\ws\alpha` { + t.Errorf("first principal's record = %v (ok=%v); a second workspace's setup overwrote it", alpha, ok) + } +} + +// Every untrustworthy record has to read as untrustworthy, not as "nothing was +// ever granted". The caller retires the principal on false; treating a corrupt +// file as an empty prior set is the fail-open. +func TestPrincipalACLLedgerRefusesRecordsItCannotTrust(t *testing.T) { + for name, contents := range map[string]string{ + "truncated mid-write": `{"schemaVersion": 1, "pat`, + "not json at all": "\x00\x01garbage", + "a schema from later": `{"schemaVersion": 99, "paths": ["C:\\ws"]}`, + "a schema from before": `{"paths": ["C:\\ws"]}`, + } { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + path, err := windowsPrincipalACLLedgerPath(home, "zerosbx01") + if err != nil { + t.Fatalf("path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if paths, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01"); recorded { + t.Errorf("read a record it cannot interpret as trustworthy (%v); the caller would then revoke nothing", paths) + } + }) + } + // And an absent one, which is the ordinary first-setup case. + if _, recorded := readWindowsPrincipalACLLedger(t.TempDir(), "zerosbx01"); recorded { + t.Error("a missing record read as trusted") + } +} + +// A partial write must not be readable at all, which is why the file is renamed +// into place rather than written in situ: a reader that saw half a record would +// treat the missing half as never granted. +func TestPrincipalACLLedgerWriteIsAtomic(t *testing.T) { + home := t.TempDir() + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`, `C:\ws\beta`}); err != nil { + t.Fatalf("first write: %v", err) + } + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws\alpha`}); err != nil { + t.Fatalf("second write: %v", err) + } + path, err := windowsPrincipalACLLedgerPath(home, "zerosbx01") + if err != nil { + t.Fatalf("path: %v", err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + var ledger windowsPrincipalACLLedger + if err := json.Unmarshal(contents, &ledger); err != nil { + t.Fatalf("the replaced record did not parse: %v", err) + } + if len(ledger.Paths) != 1 { + t.Errorf("record = %v, want the second write to have replaced the first outright", ledger.Paths) + } + // No temp files left behind to be mistaken for a record later. + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatalf("read dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("ledger directory holds %d entries, want just the record", len(entries)) + } +} + +// The name comes from windowsSandboxUserName, but the path builder is the last +// thing between a caller and the filesystem, so it refuses anything that could +// escape the directory. +func TestPrincipalACLLedgerPathRefusesUnsafeNames(t *testing.T) { + for _, username := range []string{"", " ", `..\..\evil`, "a/b", `a\b`, "c:evil", "..", "zerosbx..01"} { + if _, err := windowsPrincipalACLLedgerPath(`C:\home`, username); err == nil { + t.Errorf("accepted principal name %q", username) + } + } + if _, err := windowsPrincipalACLLedgerPath("", "zerosbx01"); err == nil { + t.Error("accepted an empty sandbox home") + } + if _, err := windowsPrincipalACLLedgerPath(`C:\home`, "zerosbx01"); err != nil { + t.Errorf("rejected a name windowsSandboxUserName would produce: %v", err) + } +} + +// Removal is idempotent because teardown only cares that the record is gone, +// and a setup that failed before writing one must not make teardown fail too. +func TestPrincipalACLLedgerRemovalToleratesAnAbsentRecord(t *testing.T) { + home := t.TempDir() + if err := removeWindowsPrincipalACLLedger(home, "zerosbx01"); err != nil { + t.Fatalf("remove an absent record: %v", err) + } + if err := writeWindowsPrincipalACLLedger(home, "zerosbx01", []string{`C:\ws`}); err != nil { + t.Fatalf("write: %v", err) + } + if err := removeWindowsPrincipalACLLedger(home, "zerosbx01"); err != nil { + t.Fatalf("remove: %v", err) + } + if _, recorded := readWindowsPrincipalACLLedger(home, "zerosbx01"); recorded { + t.Error("the record survived removal") + } +} + +// The union is what setup revokes over. A path in both sets must be revoked +// once, and a root respelled between setups — Windows opens a path whatever its +// casing — is one path, not two. +func TestUnionPrincipalACLPathsDedupesTheWayTheACLPlansDo(t *testing.T) { + union := unionWindowsPrincipalACLPaths( + []string{`C:\Ws\Alpha`, `C:\ws\beta`, " "}, + []string{`c:\ws\alpha`, `C:\ws\gamma`, `C:/ws/beta`}, + ) + if len(union) != 3 { + t.Fatalf("union = %v, want three distinct paths", union) + } + // The first spelling wins: revocation needs a real path, and the recorded one + // is the spelling that was actually granted. + if union[0] != `C:\Ws\Alpha` { + t.Errorf("union[0] = %q, want the recorded spelling kept", union[0]) + } + // The recorded set comes first so a dropped root cannot be crowded out. + if union[1] != `C:\ws\beta` || union[2] != `C:\ws\gamma` { + t.Errorf("union = %v, want the recorded paths before the newly granted ones", union) + } + if got := unionWindowsPrincipalACLPaths(nil, nil); len(got) != 0 { + t.Errorf("union of nothing = %v, want empty", got) + } +} diff --git a/internal/sandbox/windows_principal_ledger_windows_test.go b/internal/sandbox/windows_principal_ledger_windows_test.go new file mode 100644 index 000000000..b71118cec --- /dev/null +++ b/internal/sandbox/windows_principal_ledger_windows_test.go @@ -0,0 +1,307 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +// The finding this record exists for, end to end and against real DACLs: setup +// grants two roots, the user removes one from their policy, setup runs again, +// and the removed root must not still name the principal. +// +// Both halves go through the PRODUCTION applyWindowsPrincipalACLs rather than +// the revoke helper, because the mechanism already worked — what did not was the +// call site's idea of which paths to revoke. It could only name the paths of the +// plan it was about to apply, and the dropped root is by definition absent from +// that plan, so the re-setup marker validation forces preserved the very ACE it +// was supposed to clear. +func TestReSetupRevokesARootTheNarrowedPolicyDropped(t *testing.T) { + home := t.TempDir() + username := "zerosbxregression" + root := t.TempDir() + kept := filepath.Join(root, "kept") + dropped := filepath.Join(root, "dropped") + for _, dir := range []string{kept, dropped} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + // Guests: a real, resolvable trustee this process is not a member of, so the + // ACEs below are observable without affecting the test process. + principal := "S-1-5-32-546" + + wide := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: kept}, {Root: dropped}}, + } + if _, err := applyWindowsPrincipalACLs(home, username, principal, wide, wide.WriteRoots); err != nil { + t.Fatalf("first setup: %v", err) + } + if !hasACEForTrustee(t, dropped, principal) { + t.Fatal("precondition: the first setup should have granted the root that is about to leave the policy") + } + + // The user narrows their policy and re-runs setup. Nothing in this call + // mentions the dropped root; only the record does. + narrow := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: kept}}, + } + if _, err := applyWindowsPrincipalACLs(home, username, principal, narrow, narrow.WriteRoots); err != nil { + t.Fatalf("re-setup: %v", err) + } + + if hasACEForTrustee(t, dropped, principal) { + t.Error("the principal kept its grant on a root the narrowed policy removed") + } + if !hasACEForTrustee(t, kept, principal) { + t.Error("revoking over the recorded paths also dropped the grant the narrowed policy still wants") + } + // And the record narrows with the policy, or it would accumulate every root + // the workspace has ever had. + recorded, ok := readWindowsPrincipalACLLedger(home, username) + if !ok { + t.Fatal("no record survived the re-setup") + } + if containsPathFold(recorded, dropped) { + t.Errorf("the record still names %q after the policy dropped it", dropped) + } +} + +// The record is written BEFORE any DACL changes and as the union, not after and +// as the new set. A crash between the grant and a post-hoc write would otherwise +// leave a record missing paths this run granted, stranding them at the next +// policy change — the same bug, one interruption away. +func TestPrincipalACLRecordCoversTheGrantBeforeItIsMade(t *testing.T) { + prevApply := applyWindowsACLPlanFn + t.Cleanup(func() { applyWindowsACLPlanFn = prevApply }) + + home := t.TempDir() + username := "zerosbxtwophase" + workspace := t.TempDir() + stale := filepath.Join(t.TempDir(), "left-the-policy") + if err := writeWindowsPrincipalACLLedger(home, username, []string{stale}); err != nil { + t.Fatalf("seed the record: %v", err) + } + + var atGrant []string + applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) { + if len(plan.Entries) > 0 && plan.Entries[0].Action != windowsACLRevoke { + atGrant, _ = readWindowsPrincipalACLLedger(home, username) + } + return func() error { return nil }, nil + } + + filesystem := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + } + if _, err := applyWindowsPrincipalACLs(home, username, "S-1-5-32-546", filesystem, filesystem.WriteRoots); err != nil { + t.Fatalf("applyWindowsPrincipalACLs: %v", err) + } + + if !containsPathFold(atGrant, stale) || !containsPathFold(atGrant, workspace) { + t.Errorf("record at grant time = %v, want the union of the recorded and newly granted paths", atGrant) + } + after, ok := readWindowsPrincipalACLLedger(home, username) + if !ok { + t.Fatal("no record after a successful setup") + } + if containsPathFold(after, stale) { + t.Errorf("record after = %v, want it narrowed to what is granted now", after) + } + if !containsPathFold(after, workspace) { + t.Errorf("record after = %v, want the granted workspace root", after) + } +} + +// A principal from an earlier setup whose grants were never recorded is the one +// case where the prior set is not empty but unenumerable, and carrying on with +// it is the fail-open: revocation would then cover only what the new plan +// happens to name. Retiring the account instead makes every ACE that cannot be +// found name a SID Windows never reuses. +func TestSetupRetiresAPrincipalWithNoRecordOfItsGrants(t *testing.T) { + for name, testCase := range map[string]struct { + seedRecord bool + identityFound bool + wantRetired int + }{ + "no record and a principal from an earlier setup": {identityFound: true, wantRetired: 1}, + "no record and nothing provisioned": {identityFound: false, wantRetired: 0}, + "a record to reconcile against": {seedRecord: true, identityFound: true, wantRetired: 0}, + } { + t.Run(name, func(t *testing.T) { + config := stubWindowsPrincipalSetup(t) + username := windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + if testCase.seedRecord { + if err := writeWindowsPrincipalACLLedger(config.SandboxHome, username, []string{`C:\ws\recorded`}); err != nil { + t.Fatalf("seed the record: %v", err) + } + } + + lookupWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, error) { + if testCase.identityFound { + return windowsSandboxIdentity{Username: username, SID: guestsSID(t)}, nil + } + return windowsSandboxIdentity{}, errWindowsSandboxIdentityUnavailable + } + retired := 0 + removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { + retired++ + return nil + } + + if _, err := setupWindowsSandboxPrincipal(config); err != nil { + t.Fatalf("setupWindowsSandboxPrincipal: %v", err) + } + if retired != testCase.wantRetired { + t.Errorf("retired the principal %d times, want %d", retired, testCase.wantRetired) + } + }) + } +} + +// A failure to retire has to fail the setup. Reporting success would leave the +// operator believing the sandbox is provisioned while a principal whose grants +// nobody can enumerate is still holding them. +func TestSetupFailsWhenAnUnrecordedPrincipalCannotBeRetired(t *testing.T) { + config := stubWindowsPrincipalSetup(t) + lookupWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, error) { + return windowsSandboxIdentity{Username: "zerosbx", SID: guestsSID(t)}, nil + } + removeWindowsSandboxPrincipalForSetupFn = func(WindowsSandboxCommandConfig) error { + return errors.New("account is in use") + } + if _, err := setupWindowsSandboxPrincipal(config); err == nil { + t.Fatal("setup reported success after failing to retire a principal it cannot reconcile") + } +} + +// Teardown had the same blind spot as setup: it computed the paths to revoke +// from the CURRENT policy, so retiring a principal cleared every ACE except the +// one on the root that had left the policy — and then deleted the account, which +// left that ACE naming a SID nothing could resolve to clean up later. +func TestTeardownRevokesRecordedPathsTheCurrentPolicyNoLongerNames(t *testing.T) { + home := t.TempDir() + workspace := t.TempDir() + dropped := filepath.Join(t.TempDir(), "left-the-policy") + config := WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + }, + } + username := windowsSandboxUserName(windowsSandboxWorkspaceKey(config.WorkspaceRoots)) + if err := writeWindowsPrincipalACLLedger(home, username, []string{dropped}); err != nil { + t.Fatalf("seed the record: %v", err) + } + + paths, err := windowsPrincipalRevocationPaths(config, "S-1-5-32-546") + if err != nil { + t.Fatalf("windowsPrincipalRevocationPaths: %v", err) + } + if !containsPathFold(paths, dropped) { + t.Errorf("teardown would revoke %v, missing the recorded root %q the policy no longer names", paths, dropped) + } + if !containsPathFold(paths, workspace) { + t.Errorf("teardown would revoke %v, missing the workspace the current policy grants", paths) + } +} + +// stubWindowsPrincipalSetup replaces every elevated call +// setupWindowsSandboxPrincipal makes, so the decision under test is reachable on +// a machine with nothing provisioned. +func stubWindowsPrincipalSetup(t *testing.T) WindowsSandboxCommandConfig { + t.Helper() + home := t.TempDir() + workspace := t.TempDir() + + prevLookup := lookupWindowsSandboxIdentityFn + prevRemove := removeWindowsSandboxPrincipalForSetupFn + prevProvision := provisionWindowsSandboxIdentityFn + prevGrant := grantWindowsSandboxLogonRightsFn + prevReset := resetWindowsSandboxUserPasswordFn + prevSecret := writeWindowsSandboxSecretFn + prevApply := applyWindowsACLPlanFn + prevCache := sandboxUserCacheDir + t.Cleanup(func() { + lookupWindowsSandboxIdentityFn = prevLookup + removeWindowsSandboxPrincipalForSetupFn = prevRemove + provisionWindowsSandboxIdentityFn = prevProvision + grantWindowsSandboxLogonRightsFn = prevGrant + resetWindowsSandboxUserPasswordFn = prevReset + writeWindowsSandboxSecretFn = prevSecret + applyWindowsACLPlanFn = prevApply + sandboxUserCacheDir = prevCache + }) + + provisionWindowsSandboxIdentityFn = func(key string) (windowsSandboxIdentity, string, bool, error) { + return windowsSandboxIdentity{Username: windowsSandboxUserName(key), SID: guestsSID(t)}, "pw", true, nil + } + grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { return nil } + resetWindowsSandboxUserPasswordFn = func(string, string) error { return nil } + writeWindowsSandboxSecretFn = func(string, string) error { return nil } + applyWindowsACLPlanFn = func(WindowsACLPlan) (func() error, error) { + return func() error { return nil }, nil + } + cache := t.TempDir() + sandboxUserCacheDir = func() (string, error) { return cache, nil } + + return WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + }, + } +} + +func guestsSID(t *testing.T) *windows.SID { + t.Helper() + sid, err := windows.StringToSid("S-1-5-32-546") + if err != nil { + t.Fatalf("StringToSid: %v", err) + } + return sid +} + +// containsPathFold compares the way the ACL plans do, which is the only +// comparison that means anything here. +// +// Comparing the raw spellings passed CI on nothing and failed on Windows: the +// plan builder runs every root through normalizeProfilePath, whose EvalSymlinks +// expands the 8.3 short name GitHub's runners hand out for TEMP, so the record +// holds C:\Users\runneradmin\... while t.TempDir() returned C:\Users\RUNNER~1\... +// and EqualFold called two spellings of one directory different paths. A +// developer whose TEMP has no short name never sees it. +// +// Production is self-consistent — both the recorded and the newly planned paths +// go through the same normalization — so this was only ever the test being +// naive about what "same path" means on Windows. +func containsPathFold(paths []string, want string) bool { + wanted := windowsCapabilityPathKey(normalizeProfilePath(want)) + if wanted == "" { + return false + } + for _, path := range paths { + if windowsCapabilityPathKey(normalizeProfilePath(path)) == wanted { + return true + } + } + return false +} diff --git a/internal/sandbox/windows_setup.go b/internal/sandbox/windows_setup.go index 3fc9634e8..e0e2de65f 100644 --- a/internal/sandbox/windows_setup.go +++ b/internal/sandbox/windows_setup.go @@ -15,13 +15,78 @@ import ( const WindowsSandboxSetupName = "zero-windows-sandbox-setup.exe" -const windowsSandboxSetupMarkerSchemaVersion = 4 +const windowsSandboxSetupMarkerSchemaVersion = 6 + +// windowsSandboxIdentityEnv opts a machine into the principal backend while it +// is still experimental. Provisioning is inert without it, so an existing +// install keeps the restricted-token behaviour until someone turns this on. +// +// Lives here, beside the setup protocol rather than beside the Windows-only +// runtime, because the opt-in is part of that protocol: it has to be readable on +// every platform so the setup args and the marker can carry it. +const windowsSandboxIdentityEnv = "ZERO_WINDOWS_SANDBOX_IDENTITY" + +// windowsSandboxIdentityEnabled reports whether the principal backend is opted +// into. An explicit entry in env is authoritative; otherwise the process +// environment decides. +func windowsSandboxIdentityEnabled(env map[string]string) bool { + if value, ok := env[windowsSandboxIdentityEnv]; ok { + return strings.TrimSpace(value) == "1" + } + return strings.TrimSpace(os.Getenv(windowsSandboxIdentityEnv)) == "1" +} + +// WindowsSandboxPrincipalOptIn resolves the principal opt-in for callers outside +// this package (the `zero sandbox setup` CLI and `zero doctor`), so both sides +// of the setup protocol read the opt-in the same way. Pass nil to consult the +// current process environment. +func WindowsSandboxPrincipalOptIn(env map[string]string) bool { + return windowsSandboxIdentityEnabled(env) +} + +func windowsSandboxPrincipalOptInValue(optIn bool) string { + if optIn { + return "1" + } + return "0" +} type WindowsSandboxSetupArgsOptions struct { SandboxHome string CommandCWD string WorkspaceRoots []string PermissionProfile PermissionProfile + // PrincipalOptIn is the caller's principal opt-in, serialized into the setup + // args. Elevated setup runs in its own process — a UAC-elevated one whose + // environment is not the caller's — so it must be told the value rather than + // left to sample an environment nobody set. + // + // Tri-state on purpose. nil means "this caller did not resolve the opt-in", + // and BuildWindowsSandboxSetupArgs then resolves it from the environment of + // the process building the args — which is the caller's own process, the one + // place where the ambient value is the value the operator typed. A plain bool + // could not say that: its zero value asserts "opted out", so every caller that + // simply did not know about this field would serialize `--sandbox-principal 0` + // while the command half still resolved the opt-in from its environment. Under + // a machine-wide opt-in the two halves would then disagree and marker + // validation would refuse every command — the same silent-disagreement bug + // this flag exists to remove, re-created one layer up. + // + // Set it only to override the environment (a caller holding a command's Env + // map, or a test pinning a value); leave it nil to mean "whatever this shell + // says", which is what `zero sandbox setup` and `zero doctor` want. + PrincipalOptIn *bool +} + +// principalOptIn resolves the tri-state. It runs inside +// BuildWindowsSandboxSetupArgs, i.e. in the caller's process, before the args +// cross the UAC boundary — so an unset caller still ships an explicit 0|1 that +// the elevated helper can trust. +func (options WindowsSandboxSetupArgsOptions) principalOptIn() bool { + if options.PrincipalOptIn != nil { + return *options.PrincipalOptIn + } + return windowsSandboxIdentityEnabled(nil) } type WindowsSandboxSetupConfig struct { @@ -29,6 +94,7 @@ type WindowsSandboxSetupConfig struct { CommandCWD string WorkspaceRoots []string PermissionProfile PermissionProfile + PrincipalOptIn bool } type WindowsSandboxSetupMarker struct { @@ -43,6 +109,21 @@ type WindowsSandboxSetupMarker struct { NetworkInfraHash string `json:"networkInfraHash"` OfflineFilterSID string `json:"offlineFilterSid"` NetworkFilters int `json:"networkFilters"` + // PrincipalOptIn records whether the run that wrote this marker provisioned a + // sandbox principal. Without it the two halves each sampled their own + // environment and could disagree silently — see + // ValidateWindowsSandboxSetupMarker. + PrincipalOptIn bool `json:"principalOptIn"` + // PrincipalPlanHash fingerprints the PRINCIPAL ACL plan, which ACLPlanHash + // above does not cover: that one hashes BuildWindowsACLPlan, the + // capability-SID plan, while principal grants are built separately by + // buildWindowsPrincipalACLPlan from the same profile. + // + // Without it, narrowing or removing a principal read root left setup looking + // current, so the old AllowRead ACEs stayed on disk with nothing to notice + // they no longer matched the policy. Empty when the principal backend is not + // opted into, which keeps the marker stable for the default install. + PrincipalPlanHash string `json:"principalPlanHash,omitempty"` } func WindowsSandboxSetupMarkerPath(sandboxHome string) string { @@ -74,6 +155,10 @@ func BuildWindowsSandboxSetupArgs(options WindowsSandboxSetupArgsOptions) ([]str "--sandbox-home", sandboxHome, "--command-cwd", commandCWD, "--permission-profile", string(profileJSON), + // Always explicit, never omitted-means-false: the elevated helper must be + // able to tell "the caller wants no principal" from "an older caller said + // nothing", and only the first of those is safe to run silently. + "--sandbox-principal", windowsSandboxPrincipalOptInValue(options.principalOptIn()), } for _, root := range workspaceRoots { args = append(args, "--workspace-root", root) @@ -117,6 +202,24 @@ func ParseWindowsSandboxSetupArgs(args []string) (WindowsSandboxSetupConfig, err } profileJSON = strings.TrimSpace(value) index = next + case "--sandbox-principal": + value, next, err := nextWindowsSandboxFlagValue(args, index) + if err != nil { + return WindowsSandboxSetupConfig{}, err + } + switch strings.TrimSpace(value) { + case "1": + config.PrincipalOptIn = true + case "0": + config.PrincipalOptIn = false + default: + // Refused rather than treated as off: a value this helper cannot read + // is a caller it does not understand, and guessing "no principal" + // there would provision a weaker sandbox than the caller asked for + // while reporting success. + return WindowsSandboxSetupConfig{}, fmt.Errorf("invalid --sandbox-principal %q, want 0 or 1", value) + } + index = next default: return WindowsSandboxSetupConfig{}, fmt.Errorf("unknown windows sandbox setup flag %q", arg) } @@ -148,22 +251,33 @@ func RunWindowsSandboxSetup(args []string, stderr io.Writer) int { return runWindowsSandboxSetup(config, stderr) } +// commandConfig is the command-shaped view the setup half plans against. Its Env +// carries the opt-in the caller serialized into the setup args, so every +// downstream windowsSandboxIdentityEnabled call — the gate that decides whether +// elevated setup provisions a principal at all — reads the caller's intent +// rather than sampling the elevated helper's own environment, which UAC does not +// inherit from the shell the user typed in. func (config WindowsSandboxSetupConfig) commandConfig() WindowsSandboxCommandConfig { return WindowsSandboxCommandConfig{ SandboxHome: config.SandboxHome, CommandCWD: config.CommandCWD, WorkspaceRoots: cloneStrings(config.WorkspaceRoots), PermissionProfile: config.PermissionProfile, + Env: map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(config.PrincipalOptIn)}, SandboxLevel: WindowsSandboxLevelRestrictedToken, } } +// WindowsSandboxSetupConfigFromCommand is how a command asks "was setup run for +// what I need?". It carries the command's own opt-in so marker validation can +// compare it against what setup actually provisioned. func WindowsSandboxSetupConfigFromCommand(config WindowsSandboxCommandConfig) WindowsSandboxSetupConfig { return WindowsSandboxSetupConfig{ SandboxHome: config.SandboxHome, CommandCWD: config.CommandCWD, WorkspaceRoots: cloneStrings(config.WorkspaceRoots), PermissionProfile: config.PermissionProfile, + PrincipalOptIn: windowsSandboxIdentityEnabled(config.Env), } } @@ -191,16 +305,55 @@ func BuildWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSa if len(infraPlan.IdentitySIDs) > 0 { offlineSID = infraPlan.IdentitySIDs[0] } + principalHash, err := windowsPrincipalPlanFingerprint(config) + if err != nil { + return WindowsSandboxSetupMarker{}, err + } return WindowsSandboxSetupMarker{ - SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, - ACLPlanHash: hash, - ACLPlanEntries: len(plan.Entries), - NetworkInfraHash: infraHash, - OfflineFilterSID: offlineSID, - NetworkFilters: len(infraPlan.Filters), + SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, + ACLPlanHash: hash, + ACLPlanEntries: len(plan.Entries), + NetworkInfraHash: infraHash, + OfflineFilterSID: offlineSID, + NetworkFilters: len(infraPlan.Filters), + PrincipalOptIn: config.PrincipalOptIn, + PrincipalPlanHash: principalHash, }, nil } +// windowsPrincipalPlanFingerprint hashes the principal ACL plan so a change to +// principal read or write roots invalidates setup. +// +// The SID is a fixed placeholder rather than the real principal's, deliberately. +// The account is recreated with a fresh SID on reprovision, so hashing the real +// one would make the fingerprint change every time the account is rebuilt even +// though the GRANTED PATHS are identical, and every command would then rerun +// setup. What must invalidate the marker is the set of paths and actions, which +// is exactly what this captures. +// +// Returns empty when the principal backend is not opted into, so the default +// install's marker is unchanged. +func windowsPrincipalPlanFingerprint(config WindowsSandboxSetupConfig) (string, error) { + if !config.PrincipalOptIn { + return "", nil + } + filesystem := config.commandConfig().PermissionProfile.FileSystem + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: windowsPrincipalFingerprintSID, + WriteRoots: filesystem.WriteRoots, + ReadRoots: filesystem.ReadRoots, + DenyRead: filesystem.DenyRead, + }) + if err != nil { + return "", fmt.Errorf("fingerprint windows principal ACL plan: %w", err) + } + return WindowsACLPlanHash(plan) +} + +// windowsPrincipalFingerprintSID is a placeholder trustee used only for hashing. +// It never reaches an ACE. +const windowsPrincipalFingerprintSID = "S-1-0-0" + func WriteWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) (WindowsSandboxSetupMarker, error) { marker, err := BuildWindowsSandboxSetupMarker(config) if err != nil { @@ -255,9 +408,44 @@ func ValidateWindowsSandboxSetupMarker(config WindowsSandboxSetupConfig) error { if actual.SchemaVersion != expected.SchemaVersion { return fmt.Errorf("windows sandbox setup is out of date: schema %d, want %d", actual.SchemaVersion, expected.SchemaVersion) } + // The two halves of the protocol run in different processes, so they can + // disagree about the principal opt-in. Refuse the command rather than pick a + // winner. + // + // The direction that matters is the first one: the opt-in is on, setup never + // provisioned an account, and the runtime's lookup declines with a nil error — + // so without this the command runs on the restricted token, which does not + // confine reads, while the operator believes a principal is isolating them. + // A sandbox that is weaker than advertised has to be loud. + // + // The reverse is refused too. It is not the dangerous direction — the command + // gets the well-worn restricted token it asked for — but setup did create a + // local account and grant it ACEs on the workspace, and letting commands run + // as if that had not happened leaves nothing to reconcile it. Both directions + // clear the same way: run `zero sandbox setup` again with the environment you + // actually want. + if actual.PrincipalOptIn != expected.PrincipalOptIn { + if expected.PrincipalOptIn { + return fmt.Errorf("windows sandbox setup is out of date: %s=1 asks for a sandbox principal, but setup provisioned none — "+ + "re-run `zero sandbox setup` from an elevated (Administrator) terminal with %s=1, or unset it to use the restricted-token sandbox", + windowsSandboxIdentityEnv, windowsSandboxIdentityEnv) + } + return fmt.Errorf("windows sandbox setup is out of date: setup provisioned a sandbox principal, but %s is not set for this command — "+ + "set %s=1, or re-run `zero sandbox setup` from an elevated (Administrator) terminal without it to retire the principal", + windowsSandboxIdentityEnv, windowsSandboxIdentityEnv) + } if actual.ACLPlanHash != expected.ACLPlanHash || actual.ACLPlanEntries != expected.ACLPlanEntries { return errors.New("windows sandbox setup is out of date: permission roots or deny lists changed") } + // The capability-SID plan above and the principal plan are built separately + // from the same profile, so the hash above does not cover principal grants. + // Without this check, removing a principal read root left setup looking + // current and the stale AllowRead ACE in place, which is the opposite of + // what narrowing a policy is supposed to do. + if actual.PrincipalPlanHash != expected.PrincipalPlanHash { + return errors.New("windows sandbox setup is out of date: sandbox principal grants changed — " + + "re-run `zero sandbox setup` from an elevated (Administrator) terminal so the old grants are revoked") + } // Mode-agnostic: validate the provisioned infrastructure, never the // per-command network mode — so an approved (allow) network command and an // ordinary (deny) command both validate against this one setup. diff --git a/internal/sandbox/windows_setup_principal_fingerprint_test.go b/internal/sandbox/windows_setup_principal_fingerprint_test.go new file mode 100644 index 000000000..3f724955f --- /dev/null +++ b/internal/sandbox/windows_setup_principal_fingerprint_test.go @@ -0,0 +1,112 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func principalFingerprintConfig(sandboxHome string, readRoots []string) WindowsSandboxSetupConfig { + workspace := filepath.FromSlash("/ws/project") + return WindowsSandboxSetupConfig{ + SandboxHome: sandboxHome, + CommandCWD: workspace, + WorkspaceRoots: []string{workspace}, + PrincipalOptIn: true, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + ReadRoots: readRoots, + }, + }, + } +} + +// Narrowing a principal's read roots MUST invalidate setup. +// +// ACLPlanHash covers BuildWindowsACLPlan, the capability-SID plan. Principal +// grants are built separately by buildWindowsPrincipalACLPlan from the same +// profile, so before this the marker was blind to them: remove a read root and +// setup still looked current while the AllowRead ACE stayed on disk. Narrowing +// a policy has to be able to take access away. +func TestSetupMarkerInvalidatesWhenPrincipalReadRootsShrink(t *testing.T) { + home := t.TempDir() + wide, err := BuildWindowsSandboxSetupMarker(principalFingerprintConfig(home, []string{ + filepath.FromSlash("/ws/project"), + filepath.FromSlash("/ws/extra-read"), + })) + if err != nil { + t.Fatalf("build wide marker: %v", err) + } + narrow, err := BuildWindowsSandboxSetupMarker(principalFingerprintConfig(home, []string{ + filepath.FromSlash("/ws/project"), + })) + if err != nil { + t.Fatalf("build narrow marker: %v", err) + } + + if wide.PrincipalPlanHash == "" { + t.Fatal("no principal fingerprint recorded while opted in, so principal grants are unfingerprinted") + } + if wide.PrincipalPlanHash == narrow.PrincipalPlanHash { + t.Error("dropping a principal read root did not change the fingerprint, so stale AllowRead ACEs survive a narrowed policy") + } +} + +// A stale principal fingerprint must be refused through the real validator, +// which reads the marker off disk, with a message that says what to do. +func TestValidateRefusesAChangedPrincipalFingerprint(t *testing.T) { + home := t.TempDir() + config := principalFingerprintConfig(home, []string{filepath.FromSlash("/ws/project")}) + if _, err := WriteWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("write marker: %v", err) + } + if err := ValidateWindowsSandboxSetupMarker(config); err != nil { + t.Fatalf("a freshly written marker did not validate, so this test cannot isolate the fingerprint: %v", err) + } + + // Rewrite only the principal fingerprint, the way a policy change would. + path := WindowsSandboxSetupMarkerPath(home) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read marker: %v", err) + } + var marker map[string]any + if err := json.Unmarshal(raw, &marker); err != nil { + t.Fatalf("parse marker: %v", err) + } + marker["principalPlanHash"] = "stale-hash-from-an-earlier-policy" + rewritten, err := json.Marshal(marker) + if err != nil { + t.Fatalf("marshal marker: %v", err) + } + if err := os.WriteFile(path, rewritten, 0o600); err != nil { + t.Fatalf("rewrite marker: %v", err) + } + + err = ValidateWindowsSandboxSetupMarker(config) + if err == nil { + t.Fatal("a marker whose principal grants no longer match the policy was accepted") + } + if !strings.Contains(err.Error(), "principal grants changed") { + t.Errorf("refused for the wrong reason: %v", err) + } +} + +// The default install must be untouched: opted out means no fingerprint, so the +// marker does not churn for the overwhelming majority of users. +func TestSetupMarkerHasNoPrincipalFingerprintWhenOptedOut(t *testing.T) { + config := principalFingerprintConfig(t.TempDir(), []string{filepath.FromSlash("/ws/project")}) + config.PrincipalOptIn = false + + marker, err := BuildWindowsSandboxSetupMarker(config) + if err != nil { + t.Fatalf("build marker: %v", err) + } + if marker.PrincipalPlanHash != "" { + t.Errorf("principal fingerprint %q recorded while opted out", marker.PrincipalPlanHash) + } +} diff --git a/internal/sandbox/windows_setup_test.go b/internal/sandbox/windows_setup_test.go index 0a3c6f044..94170e255 100644 --- a/internal/sandbox/windows_setup_test.go +++ b/internal/sandbox/windows_setup_test.go @@ -156,6 +156,170 @@ func TestWindowsSandboxSetupMarkerRejectsOldSchema(t *testing.T) { } } +// The principal opt-in has to travel in the setup args, because the elevated +// half runs in its own process: a UAC-elevated helper does not inherit the +// environment of the shell that asked for setup. Sampling the ambient +// environment there let the two halves disagree, so the serialized value must +// win over the environment in BOTH directions. +func TestWindowsSandboxSetupPrincipalOptInSurvivesElevatedEnvironment(t *testing.T) { + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkAllow}, + } + testCases := []struct { + name string + optIn bool + ambientEnv string + }{ + // The reported case: the caller's shell opted in, the elevated helper's + // environment has nothing. Without the serialized value setup provisions no + // principal and every later command silently falls back. + {name: "opted in, elevated environment empty", optIn: true, ambientEnv: ""}, + // The mirror: the elevated helper happens to have a machine-wide opt-in the + // caller did not ask for. Setup must not create an account on its own say-so. + {name: "opted out, elevated environment opted in", optIn: false, ambientEnv: "1"}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv(windowsSandboxIdentityEnv, testCase.ambientEnv) + optIn := testCase.optIn + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + PrincipalOptIn: &optIn, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + config, err := ParseWindowsSandboxSetupArgs(args) + if err != nil { + t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err) + } + if config.PrincipalOptIn != testCase.optIn { + t.Fatalf("parsed PrincipalOptIn = %v, want %v", config.PrincipalOptIn, testCase.optIn) + } + // This is the value the elevated setup gate actually reads before it + // decides to provision an account. + if got := windowsSandboxIdentityEnabled(config.commandConfig().Env); got != testCase.optIn { + t.Fatalf("elevated setup opt-in = %v, want %v (ambient %s=%q must not decide)", + got, testCase.optIn, windowsSandboxIdentityEnv, testCase.ambientEnv) + } + }) + } +} + +// A setup helper that cannot read the opt-in must refuse rather than default to +// "no principal": provisioning less than the caller asked for and reporting +// success is the silent downgrade this protocol exists to prevent. +func TestParseWindowsSandboxSetupArgsRejectsUnreadablePrincipalOptIn(t *testing.T) { + optIn := true + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: PermissionProfile{FileSystem: FileSystemPolicy{Kind: FileSystemRestricted}, Network: NetworkPolicy{Mode: NetworkDeny}}, + PrincipalOptIn: &optIn, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + for index, arg := range args { + if arg == "--sandbox-principal" { + args[index+1] = "yes" + } + } + if _, err := ParseWindowsSandboxSetupArgs(args); err == nil || !strings.Contains(err.Error(), "--sandbox-principal") { + t.Fatalf("ParseWindowsSandboxSetupArgs error = %v, want rejection of the unreadable opt-in", err) + } +} + +// Setup and the commands that follow it run in separate processes, so they can +// disagree about the opt-in. The marker records what setup provisioned and the +// command refuses on a mismatch — most of all when the command opted in and +// setup did not, because the runtime's principal lookup declines with a nil +// error and the command would otherwise run on the read-unconfined +// restricted-token backend while the operator believes a principal is isolating +// it. +func TestWindowsSandboxSetupMarkerRejectsPrincipalOptInMismatch(t *testing.T) { + // Neutral ambient environment: the disagreement under test is between the two + // recorded intents, not between either of them and this process. + t.Setenv(windowsSandboxIdentityEnv, "") + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkAllow}, + } + command := func(home string, env map[string]string) WindowsSandboxCommandConfig { + return WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + Env: env, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo"}, + } + } + testCases := []struct { + name string + setupOptIn bool + commandEnv map[string]string + wantError string + }{ + { + name: "command opts in, setup provisioned no principal", + setupOptIn: false, + commandEnv: map[string]string{windowsSandboxIdentityEnv: "1"}, + wantError: "asks for a sandbox principal, but setup provisioned none", + }, + { + name: "setup provisioned a principal, command opts out", + setupOptIn: true, + commandEnv: map[string]string{windowsSandboxIdentityEnv: "0"}, + wantError: "setup provisioned a sandbox principal", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + home := t.TempDir() + setupConfig := WindowsSandboxSetupConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + PrincipalOptIn: testCase.setupOptIn, + } + marker, err := WriteWindowsSandboxSetupMarker(setupConfig) + if err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + // Assert the setup half recorded what it was told before trusting what + // the command half makes of it. + if marker.PrincipalOptIn != testCase.setupOptIn { + t.Fatalf("marker PrincipalOptIn = %v, want %v", marker.PrincipalOptIn, testCase.setupOptIn) + } + // An agreeing command still validates, so the refusal below is about the + // disagreement and not about the marker being unusable. + agreeing := command(home, map[string]string{windowsSandboxIdentityEnv: windowsSandboxPrincipalOptInValue(testCase.setupOptIn)}) + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(agreeing)); err != nil { + t.Fatalf("agreeing command must validate against its own setup: %v", err) + } + + err = ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(home, testCase.commandEnv))) + if err == nil { + t.Fatalf("disagreeing command validated the marker, want refusal") + } + if !strings.Contains(err.Error(), testCase.wantError) { + t.Fatalf("validate error = %v, want it to contain %q", err, testCase.wantError) + } + if !strings.Contains(err.Error(), "zero sandbox setup") { + t.Fatalf("validate error = %v, want the remedy to name `zero sandbox setup`", err) + } + }) + } +} + func TestWindowsSandboxSetupConfigFromCommandPreservesProfileInputs(t *testing.T) { command := WindowsSandboxCommandConfig{ SandboxHome: t.TempDir(), @@ -181,6 +345,105 @@ func TestWindowsSandboxSetupConfigFromCommandPreservesProfileInputs(t *testing.T } } +// Serializing the opt-in makes it a field every caller of +// BuildWindowsSandboxSetupArgs could get wrong, so the field is a tri-state and +// its unset meaning is load-bearing: "consult the environment", never "opted +// out". The command half still resolves the opt-in from the process environment +// when its own Env carries no explicit entry, so if an unset setup caller +// asserted false instead, the two halves would disagree under a machine-wide +// opt-in and marker validation would refuse every command — safe, but it bricks +// the caller, and it re-creates the very disagreement this flag removes. That is +// exactly what the existing smoke callers +// (runner_windows_integration_test.go:43 and :52) do: they never set the field. +// +// This test runs on every GOOS and pins the unset default in both ambient +// states, so getting it backwards is a test failure here rather than a surprise +// on a real elevated machine. +func TestWindowsSandboxSetupArgsUnsetPrincipalOptInConsultsEnvironment(t *testing.T) { + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, WriteRoots: []WritableRoot{{Root: `C:\workspace`}}}, + Network: NetworkPolicy{Mode: NetworkAllow}, + } + // The command half as an ambient caller declares it: no explicit entry in Env, + // so it resolves the opt-in from the environment. + command := func(home string) WindowsSandboxCommandConfig { + return WindowsSandboxCommandConfig{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + SandboxLevel: WindowsSandboxLevelRestrictedToken, + Command: []string{"cmd.exe", "/c", "echo"}, + } + } + // setupMarkerFor runs the full caller path — build args, cross the (simulated) + // UAC boundary by re-parsing them, write the marker — so what is asserted is + // what an elevated helper would actually have provisioned. + setupMarkerFor := func(t *testing.T, home string, optIn *bool) WindowsSandboxSetupMarker { + t.Helper() + args, err := BuildWindowsSandboxSetupArgs(WindowsSandboxSetupArgsOptions{ + SandboxHome: home, + CommandCWD: `C:\workspace`, + WorkspaceRoots: []string{`C:\workspace`}, + PermissionProfile: profile, + PrincipalOptIn: optIn, + }) + if err != nil { + t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err) + } + config, err := ParseWindowsSandboxSetupArgs(args) + if err != nil { + t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err) + } + marker, err := WriteWindowsSandboxSetupMarker(config) + if err != nil { + t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err) + } + return marker + } + + for _, ambient := range []string{"1", ""} { + name := "machine-wide opt-in" + if ambient == "" { + name = "no opt-in" + } + t.Run(name, func(t *testing.T) { + t.Setenv(windowsSandboxIdentityEnv, ambient) + want := ambient == "1" + + // An unset caller must provision what the environment says. Assert the + // setup half recorded that before trusting the agreement below: a marker + // that recorded the wrong thing could still "agree" if the command half + // were broken in the same direction. + home := t.TempDir() + marker := setupMarkerFor(t, home, nil) + if marker.PrincipalOptIn != want { + t.Fatalf("unset caller recorded PrincipalOptIn = %v, want %v (ambient %s=%q decides)", + marker.PrincipalOptIn, want, windowsSandboxIdentityEnv, ambient) + } + if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(home))); err != nil { + t.Fatalf("an unset caller must agree with the ambient command half: %v", err) + } + + // And an explicit value still overrides the environment in both + // directions, or the tri-state would have no third state. + override := !want + overrideHome := t.TempDir() + overrideMarker := setupMarkerFor(t, overrideHome, &override) + if overrideMarker.PrincipalOptIn != override { + t.Fatalf("explicit caller recorded PrincipalOptIn = %v, want %v", overrideMarker.PrincipalOptIn, override) + } + err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(command(overrideHome))) + if err == nil { + t.Fatalf("an explicit opt-in of %v validated against an ambient command half of %v, want refusal", override, want) + } + if !strings.Contains(err.Error(), windowsSandboxIdentityEnv) { + t.Fatalf("validate error = %v, want it to name %s", err, windowsSandboxIdentityEnv) + } + }) + } +} + func TestWindowsACLPlanHashIsStableAcrossEntryOrder(t *testing.T) { left, err := WindowsACLPlanHash(WindowsACLPlan{Entries: []WindowsACLEntry{ {Action: WindowsACLDenyRead, Path: `C:\workspace\secret`, Capability: "S-1-5-21-3", Materialize: true}, diff --git a/internal/sandbox/windows_setup_windows.go b/internal/sandbox/windows_setup_windows.go index 888355397..ac8dc1243 100644 --- a/internal/sandbox/windows_setup_windows.go +++ b/internal/sandbox/windows_setup_windows.go @@ -35,6 +35,33 @@ func runWindowsSandboxSetup(config WindowsSandboxSetupConfig, stderr io.Writer) fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) return 1 } + // Provision this workspace's sandbox principal, when opted in. A principal is + // a separate local account, so it is created only on an explicit opt-in: it + // is visible in `net user`, and account creation is exactly the kind of thing + // endpoint protection and enterprise policy object to. Without the opt-in the + // capability-SID backend above is the whole of setup, unchanged. + if windowsSandboxIdentityEnabled(config.commandConfig().Env) { + principalRollback, err := setupWindowsSandboxPrincipal(config.commandConfig()) + if err != nil { + if rollbackErr := rollback(); rollbackErr != nil { + fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) + return 1 + } + fmt.Fprintln(stderr, WindowsSandboxSetupName+": "+err.Error()) + return 1 + } + // Fold the principal into the existing rollback so every later failure + // path undoes it too, rather than each one having to remember. + aclRollback := rollback + rollback = func() error { + principalErr := principalRollback() + aclErr := aclRollback() + if principalErr != nil { + return principalErr + } + return aclErr + } + } if err := applyWindowsNetworkPlan(networkPlan); err != nil { if rollbackErr := rollback(); rollbackErr != nil { fmt.Fprintf(stderr, "%s: %v; rollback failed: %v\n", WindowsSandboxSetupName, err, rollbackErr) diff --git a/internal/sandbox/windows_stale_ace_windows_test.go b/internal/sandbox/windows_stale_ace_windows_test.go new file mode 100644 index 000000000..b6920c897 --- /dev/null +++ b/internal/sandbox/windows_stale_ace_windows_test.go @@ -0,0 +1,207 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// applyWindowsACLPlan merges into the existing DACL, so narrowing a policy and +// re-running setup used to leave the wider ACEs in place next to the new ones. +// The principal kept access the current policy no longer grants — a silent +// widening of the sandbox produced by tightening it. +func TestRevokeDropsStalePrincipalACEsBeforeReapply(t *testing.T) { + root := t.TempDir() + kept := filepath.Join(root, "kept") + dropped := filepath.Join(root, "dropped") + for _, dir := range []string{kept, dropped} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + // Guests: a real, resolvable trustee that the test process is not a member + // of, so the ACEs below are observable without affecting this process. + principal := "S-1-5-32-546" + + // First setup: both roots writable. + wide, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principal, + WriteRoots: []WritableRoot{{Root: kept}, {Root: dropped}}, + }) + if err != nil { + t.Fatalf("wide plan: %v", err) + } + if _, err := applyWindowsACLPlan(wide); err != nil { + t.Fatalf("apply wide plan: %v", err) + } + if !hasACEForTrustee(t, dropped, principal) { + t.Fatal("precondition: the wide plan should have granted the root it is about to lose") + } + + // Policy narrows: "dropped" is no longer a write root. + narrow, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: principal, + WriteRoots: []WritableRoot{{Root: kept}}, + }) + if err != nil { + t.Fatalf("narrow plan: %v", err) + } + // Revocation has to cover the paths the OLD plan touched, not just the new + // one — the whole point is the path that left the policy. + if _, err := revokeWindowsPrincipalACEs(principal, windowsACLPlanPaths(wide)); err != nil { + t.Fatalf("revoke: %v", err) + } + if _, err := applyWindowsACLPlan(narrow); err != nil { + t.Fatalf("apply narrow plan: %v", err) + } + + if hasACEForTrustee(t, dropped, principal) { + t.Error("principal kept its grant on a root the narrowed policy removed") + } + if !hasACEForTrustee(t, kept, principal) { + t.Error("revocation also dropped the grant the narrowed policy still wants") + } +} + +// Revoking a path that was never created is cleanup with nothing to clean, not +// an error — setup would otherwise fail on any carveout git has not made yet. +func TestRevokeIgnoresPathsThatDoNotExist(t *testing.T) { + missing := filepath.Join(t.TempDir(), "never-created") + if _, err := revokeWindowsPrincipalACEs("S-1-5-32-546", []string{missing}); err != nil { + t.Fatalf("revoke over a missing path: %v", err) + } +} + +func hasACEForTrustee(t *testing.T, path string, trustee string) bool { + t.Helper() + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatalf("GetNamedSecurityInfo(%s): %v", path, err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatalf("DACL(%s): %v", path, err) + } + if dacl == nil { + return false + } + want, err := windows.StringToSid(trustee) + if err != nil { + t.Fatalf("StringToSid(%s): %v", trustee, err) + } + // Deny ACEs count here as much as allow ACEs: revocation is by trustee and + // drops both, so an assertion that only saw allows would call a leftover + // deny "revoked". + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var header *windows.ACE_HEADER + if err := windows.GetAce(dacl, index, (**windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(&header))); err != nil { + continue + } + ace := (*windows.ACCESS_ALLOWED_ACE)(unsafe.Pointer(header)) + switch ace.Header.AceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, windows.ACCESS_DENIED_ACE_TYPE: + default: + continue + } + if (*windows.SID)(unsafe.Pointer(&ace.SidStart)).Equals(want) { + return true + } + } + return false +} + +// The mechanism working is not the same as the production path using it. This +// pins the call site and its ORDER: revocation is only worth anything if it +// runs before the plan that re-adds the current grants. +func TestApplyPrincipalACLsRevokesBeforeApplying(t *testing.T) { + prevApply := applyWindowsACLPlanFn + t.Cleanup(func() { applyWindowsACLPlanFn = prevApply }) + + var actions []WindowsACLAction + applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) { + if len(plan.Entries) > 0 { + actions = append(actions, plan.Entries[0].Action) + } + return func() error { return nil }, nil + } + + workspace := t.TempDir() + filesystem := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + } + if _, err := applyWindowsPrincipalACLs(t.TempDir(), "zero-sbx-test", "S-1-5-32-546", filesystem, filesystem.WriteRoots); err != nil { + t.Fatalf("applyWindowsPrincipalACLs: %v", err) + } + + if len(actions) != 2 { + t.Fatalf("saw %d ACL plans (%v), want a revocation then the grants", len(actions), actions) + } + if actions[0] != windowsACLRevoke { + t.Errorf("first plan was %q, want the trustee revocation to go first", actions[0]) + } + if actions[1] == windowsACLRevoke { + t.Error("second plan was another revocation; the current grants were never applied") + } +} + +// The revocation's rollback has to be returned, not discarded. +// +// Discarding it was justified on the grounds that the only failure path from +// applyWindowsPrincipalACLs removes the principal outright, so restoring ACEs +// for a doomed account would be pointless. That holds only for a principal the +// run CREATED. #812 keeps an ADOPTED principal alive on failure rather than +// destroying a working account someone else provisioned — and then the discarded +// snapshot left it logged-on and unable to reach its own workspace, with its +// previous ACEs revoked and the new ones rolled back. +func TestApplyPrincipalACLsRollbackRestoresTheRevokedACEs(t *testing.T) { + prevApply := applyWindowsACLPlanFn + t.Cleanup(func() { applyWindowsACLPlanFn = prevApply }) + + var applied []WindowsACLAction + var reverted []WindowsACLAction + applyWindowsACLPlanFn = func(plan WindowsACLPlan) (func() error, error) { + action := WindowsACLAction("") + if len(plan.Entries) > 0 { + action = plan.Entries[0].Action + } + applied = append(applied, action) + return func() error { + reverted = append(reverted, action) + return nil + }, nil + } + + workspace := t.TempDir() + filesystem := FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + } + rollback, err := applyWindowsPrincipalACLs(t.TempDir(), "zero-sbx-test", "S-1-5-32-546", filesystem, filesystem.WriteRoots) + if err != nil { + t.Fatalf("applyWindowsPrincipalACLs: %v", err) + } + if len(applied) != 2 || applied[0] != windowsACLRevoke { + t.Fatalf("applied %v, want a revocation then the grants", applied) + } + if err := rollback(); err != nil { + t.Fatalf("rollback: %v", err) + } + + // Both halves must unwind, and in reverse order: the grant comes off first, + // then the ACEs the revocation removed go back. + if len(reverted) != 2 { + t.Fatalf("rollback reverted %v, want both the grant and the revocation", reverted) + } + if reverted[0] == windowsACLRevoke { + t.Error("rollback undid the revocation before the grant; the grant would survive") + } + if reverted[1] != windowsACLRevoke { + t.Errorf("rollback never restored the revoked ACEs, got %v", reverted) + } +} diff --git a/internal/sandbox/windows_stale_secret_windows_test.go b/internal/sandbox/windows_stale_secret_windows_test.go new file mode 100644 index 000000000..7e4e3ebef --- /dev/null +++ b/internal/sandbox/windows_stale_secret_windows_test.go @@ -0,0 +1,99 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +// "Absent beats stale" is the invariant the rollback's secret removal exists to +// keep: the command path treats a missing secret as not-provisioned and falls +// back, while a stale one fails the logon and reports a broken sandbox. +// +// So when the removal itself fails after a password rotation, the invariant was +// NOT restored — a credential for a password that no longer works is still on +// disk. Swallowing that error claims otherwise, and the operator finds out on +// the next command instead of from the setup that broke it. +func TestProvisionSurfacesFailedStaleSecretCleanup(t *testing.T) { + for name, testCase := range map[string]struct { + rotate bool + removeErr error + wantInErr string + wantRemove bool + }{ + "rotation happened and the stale secret cannot be removed": { + rotate: true, removeErr: errors.New("access is denied"), + wantRemove: true, wantInErr: "stale and could not be removed", + }, + "rotation happened and cleanup succeeds": { + rotate: true, wantRemove: true, + }, + } { + t.Run(name, func(t *testing.T) { + prevProvision := provisionWindowsSandboxIdentityFn + prevRemove := removeWindowsSandboxSecretFn + prevReset := resetWindowsSandboxUserPasswordFn + prevGrant := grantWindowsSandboxLogonRightsFn + prevWrite := writeWindowsSandboxSecretFn + t.Cleanup(func() { + provisionWindowsSandboxIdentityFn = prevProvision + removeWindowsSandboxSecretFn = prevRemove + resetWindowsSandboxUserPasswordFn = prevReset + grantWindowsSandboxLogonRightsFn = prevGrant + writeWindowsSandboxSecretFn = prevWrite + }) + + sid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + t.Fatal(err) + } + // created=false so the run ADOPTS an account and rotation applies. + provisionWindowsSandboxIdentityFn = func(string) (windowsSandboxIdentity, string, bool, error) { + return windowsSandboxIdentity{Username: "zero-sbx-test", SID: sid}, "pw", false, nil + } + grantWindowsSandboxLogonRightsFn = func(*windows.SID) error { return nil } + removed := false + removeWindowsSandboxSecretFn = func(string) error { + removed = true + return testCase.removeErr + } + // Rotate, then fail immediately after so undo runs with rotated=true. + resetWindowsSandboxUserPasswordFn = func(string, string) error { + if testCase.rotate { + return nil + } + return errors.New("no rotation") + } + + // The only step after rotation; failing it is what drives undo with + // rotated=true, which is the state the invariant is about. + writeWindowsSandboxSecretFn = func(string, string) error { + return errors.New("secret store refused") + } + + config := WindowsSandboxCommandConfig{ + SandboxHome: t.TempDir(), + CommandCWD: `C:\ws`, + WorkspaceRoots: []string{`C:\ws`}, + } + _, _, err = provisionWindowsSandboxPrincipalForSetup(config) + + if removed != testCase.wantRemove { + t.Fatalf("stale secret removal attempted = %v, want %v", removed, testCase.wantRemove) + } + if testCase.wantInErr == "" { + return + } + if err == nil { + t.Fatal("a failed stale-secret cleanup was swallowed") + } + if !strings.Contains(err.Error(), testCase.wantInErr) { + t.Fatalf("error = %q, want it to mention %q", err, testCase.wantInErr) + } + }) + } +} diff --git a/internal/sandbox/windows_token_windows.go b/internal/sandbox/windows_token_windows.go index a02e9b001..41e5ac53f 100644 --- a/internal/sandbox/windows_token_windows.go +++ b/internal/sandbox/windows_token_windows.go @@ -48,6 +48,54 @@ func (sid windowsLocalSID) close() { } } +// restrictWindowsTokenForCapabilitySIDs applies the same write jail to an +// arbitrary base token that createWindowsRestrictedTokenForCapabilitySIDs +// applies to the calling process's own. +// +// The sandbox principal path needs this. A LogonUser token is a full token for +// that account: the ACL plan can deny it at named paths, but it cannot revoke +// what the account's ambient memberships already grant, so an opted-in command +// could still write any path whose DACL admits BUILTIN\Users, Authenticated +// Users, or NT AUTHORITY\BATCH - C:\Users\Public\Documents being the obvious +// one - regardless of the profile's write roots. +// +// The caller must include the principal's OWN SID among the capability SIDs. +// The plan grants the workspace to that SID rather than to a capability SID, so +// without it the restricted-SID check has nothing to match and the principal +// loses its own workspace: a jail that locks out the inmate and no one else. +func restrictWindowsTokenForCapabilitySIDs(base windows.Token, capabilitySIDStrings []string, writeRestricted bool) (windows.Token, error) { + capabilitySIDs, err := parseWindowsCapabilitySIDs(capabilitySIDStrings) + if err != nil { + return 0, err + } + defer func() { + for _, sid := range capabilitySIDs { + sid.close() + } + }() + return createWindowsRestrictedTokenFromBase(base, capabilitySIDs, writeRestricted) +} + +// parseWindowsCapabilitySIDs converts SID strings, closing what it already +// allocated if one fails to parse. +func parseWindowsCapabilitySIDs(values []string) ([]windowsLocalSID, error) { + if len(values) == 0 { + return nil, errors.New("windows restricted token requires at least one capability SID") + } + parsed := make([]windowsLocalSID, 0, len(values)) + for _, value := range values { + sid, err := newWindowsLocalSID(value) + if err != nil { + for _, existing := range parsed { + existing.close() + } + return nil, fmt.Errorf("parse windows capability SID %q: %w", value, err) + } + parsed = append(parsed, sid) + } + return parsed, nil +} + func createWindowsRestrictedTokenForCapabilitySIDs(capabilitySIDStrings []string, writeRestricted bool) (windows.Token, error) { if len(capabilitySIDStrings) == 0 { return 0, errors.New("windows restricted token requires at least one capability SID") diff --git a/internal/sandbox/windows_unelevated.go b/internal/sandbox/windows_unelevated.go index 980664d6a..53eadc825 100644 --- a/internal/sandbox/windows_unelevated.go +++ b/internal/sandbox/windows_unelevated.go @@ -146,3 +146,7 @@ func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnele } return nil } + +// windowsACLPlanDeniedPath lives in windows_command_runner_windows.go, beside +// its only caller. Defining it here, in the portable file, would make it dead +// code on every non-Windows build and fail the static analysis gate. diff --git a/internal/sandbox/windows_unelevated_denied_windows_test.go b/internal/sandbox/windows_unelevated_denied_windows_test.go new file mode 100644 index 000000000..60b710279 --- /dev/null +++ b/internal/sandbox/windows_unelevated_denied_windows_test.go @@ -0,0 +1,53 @@ +//go:build windows + +package sandbox + +import ( + "errors" + "os" + "testing" +) + +// The diagnostic reads a path back out of an error string produced two +// functions away. That coupling is invisible to the compiler, so it is pinned +// here by driving the REAL producer rather than by hand-writing the message: +// if openWindowsACLTarget ever rewords its error, this fails instead of the +// diagnostic silently going quiet and users losing the one clue they had. +func TestDeniedPathIsRecoveredFromARealApplyFailure(t *testing.T) { + // A directory no ordinary user can re-DACL. Exactly the shape that bricked a + // workspace: present, in the plan, and impossible to apply. + const target = `C:\Windows\System32` + + _, _, err := applyWindowsACLPathGroup(windowsACLPathGroup{ + Path: target, + Entries: []WindowsACLEntry{{ + Action: WindowsACLAllowWrite, + Path: target, + Capability: testPrincipalSID, + }}, + }) + if err == nil { + t.Skip("this process can re-DACL System32, so it is elevated and cannot exercise the denial path") + } + if !errors.Is(err, os.ErrPermission) { + t.Skipf("failed for a reason other than access denial, nothing to extract here: %v", err) + } + + got := windowsACLPlanDeniedPath(err) + if got == "" { + t.Fatalf("no path recovered from a real access-denied apply failure, so the operator is told only that something was denied: %v", err) + } + if got != target { + t.Errorf("recovered %q, want %q", got, target) + } +} + +// Anything that is not an access denial must return empty, so the caller falls +// back to the generic message rather than naming an innocent path. +func TestDeniedPathIgnoresUnrelatedErrors(t *testing.T) { + for _, err := range []error{nil, errors.New(`open windows ACL target C:\somewhere: disk full`), os.ErrNotExist} { + if got := windowsACLPlanDeniedPath(err); got != "" { + t.Errorf("recovered %q from %v, want empty", got, err) + } + } +} diff --git a/internal/sandbox/windows_workspace_canonical_windows_test.go b/internal/sandbox/windows_workspace_canonical_windows_test.go new file mode 100644 index 000000000..0997c3113 --- /dev/null +++ b/internal/sandbox/windows_workspace_canonical_windows_test.go @@ -0,0 +1,225 @@ +//go:build windows + +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Setup grants the principal a runtime tree; every command derives that tree +// again from the workspace root. If the two normalize differently the grant +// lands somewhere nothing reads, and the only symptom is a bare ACCESS_DENIED +// on the first cache write. +// +// This needs no symlink and no privilege. Windows opens a path whatever its +// casing, and Engine.resolveCommandDir runs EvalSymlinks (runner.go) which +// canonicalizes it, while setup used to only Clean. +func TestSetupRuntimeRootMatchesTheCommandPathForADifferentlyCasedRoot(t *testing.T) { + base := t.TempDir() + workspace := filepath.Join(base, "MyWorkspace") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + lowered := strings.ToLower(workspace) + if _, err := os.Stat(lowered); err != nil { + t.Skipf("filesystem is case-sensitive here: %v", err) + } + if lowered == workspace { + t.Skip("temp path has no case to differ on") + } + + // What the command path ends up keyed to, per resolveCommandDir. + commandRoot := lowered + if resolved, err := filepath.EvalSymlinks(filepath.Clean(lowered)); err == nil { + commandRoot = resolved + } + if commandRoot == lowered { + t.Skip("EvalSymlinks changed nothing on this host; no divergence to assert") + } + + // Drive the PRODUCTION derivation, not the helper. A test that called + // canonicalSandboxWorkspaceRoot directly would pass just as happily + // with setup still doing filepath.Clean, which is exactly the bug. + fromSetup, err := windowsSandboxRuntimeRootPath(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{lowered}, + CommandCWD: lowered, + }) + if err != nil { + t.Fatalf("windowsSandboxRuntimeRootPath: %v", err) + } + cacheRoot, err := sandboxUserCacheDir() + if err != nil { + t.Fatalf("sandboxUserCacheDir: %v", err) + } + fromCommand, err := sandboxRuntimeRootFor(commandRoot, filepath.Clean(cacheRoot)) + if err != nil { + t.Fatalf("sandboxRuntimeRootFor(command): %v", err) + } + if fromSetup != fromCommand { + t.Errorf("setup grants a runtime tree commands never use:\n setup: %s\n command: %s", fromSetup, fromCommand) + } +} + +// A root whose final segments do not exist still normalizes: the existing +// ancestor resolves and the missing remainder is re-appended. +// +// This asserted the whole cleaned path unchanged at first, which was the +// all-or-nothing behaviour the ancestor walk replaced. Windows CI failed it — +// correctly — because RUNNER~1 resolved to runneradmin while never-created +// stayed put, which is exactly the behaviour the walk exists to produce. +func TestCanonicalWorkspaceRootResolvesTheExistingAncestor(t *testing.T) { + parent := t.TempDir() + missing := filepath.Join(parent, "never-created", "deeper") + + got := canonicalSandboxWorkspaceRoot(missing) + want := filepath.Join(canonicalSandboxWorkspaceRoot(parent), "never-created", "deeper") + if got != want { + t.Errorf("canonical(%q) = %q, want %q", missing, got, want) + } + // The missing segments must survive rather than be dropped to the ancestor. + if !strings.HasSuffix(got, filepath.Join("never-created", "deeper")) { + t.Errorf("canonical(%q) = %q, lost the segments that do not exist yet", missing, got) + } + if canonicalSandboxWorkspaceRoot(" ") != "" { + t.Error("a blank root should stay blank, not become the process directory") + } +} + +// The pair has to agree, not just each side individually. CI caught this the +// hard way: canonicalizing only the setup side made setup and +// prepareSandboxRuntime disagree on a Windows runner, whose TEMP is an 8.3 +// short path that resolution expands. Lowercasing reproduces the same class of +// non-canonical spelling without needing a short name or any privilege. +func TestSetupAndPrepareRuntimeAgreeOnANonCanonicalRoot(t *testing.T) { + base := t.TempDir() + workspace := filepath.Join(base, "MyWs") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + lowered := strings.ToLower(workspace) + if _, err := os.Stat(lowered); err != nil { + t.Skipf("filesystem is case-sensitive here: %v", err) + } + + granted, err := setupWindowsSandboxRuntimeRoot(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{lowered}, + CommandCWD: lowered, + }) + if err != nil { + t.Fatalf("setupWindowsSandboxRuntimeRoot: %v", err) + } + state, release, err := prepareSandboxRuntime(lowered) + if err != nil { + t.Fatalf("prepareSandboxRuntime: %v", err) + } + if release != nil { + defer release() + } + if filepath.Clean(granted) != filepath.Clean(state.Root) { + t.Errorf("setup granted %q but commands write to %q", granted, state.Root) + } +} + +// The carveout shape has to survive a non-canonical root too. The first fix +// rebuilt the spec paths from the RESOLVED write root and compared them against +// subpaths that could not resolve (.git/config does not exist yet), so on a +// short-name or differently-cased path the match missed and .git/config went +// back to being created as a directory. +func TestGitConfigCarveoutShapeSurvivesANonCanonicalRoot(t *testing.T) { + base := t.TempDir() + workspace := filepath.Join(base, "MyWs") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + lowered := strings.ToLower(workspace) + if _, err := os.Stat(lowered); err != nil { + t.Skipf("filesystem is case-sensitive here: %v", err) + } + + plan, err := buildWindowsPrincipalACLPlan(windowsPrincipalACLInput{ + PrincipalSID: "S-1-5-32-546", + WriteRoots: []WritableRoot{{ + Root: lowered, + ReadOnlySubpaths: gitMetadataWriteCarveouts(lowered), + }}, + }) + if err != nil { + t.Fatalf("buildWindowsPrincipalACLPlan: %v", err) + } + found := false + for _, entry := range plan.Entries { + if !strings.EqualFold(filepath.Base(entry.Path), "config") { + continue + } + found = true + if !entry.MaterializeFile { + t.Errorf(".git/config entry %q lost its file shape on a non-canonical root", entry.Path) + } + } + if !found { + t.Fatal("no .git/config entry in the plan") + } +} + +// Teardown must name the runtime tree without creating anything. The comment +// on windowsPrincipalTeardownPaths claimed that and it was false: the resolver +// it used ends in sandboxRuntimeRootFor, whose fallback calls os.MkdirTemp, so +// a workspace whose cache root sits inside it made setup's cleanup path create +// a fresh temp directory on its way out — and a useless one, since the fallback +// root is random per process and never matches what commands used. +func TestTeardownPathDerivationCreatesNothing(t *testing.T) { + workspace := t.TempDir() + // Force the branch that falls back: cache root inside the workspace. + original := sandboxUserCacheDir + sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil } + t.Cleanup(func() { sandboxUserCacheDir = original }) + + before := tempDirEntryCount(t) + + // Drive the PRODUCTION teardown path, not the helper. Calling the resolver + // directly passes just as happily with the call site reverted to the one + // that creates. + paths, err := windowsPrincipalTeardownPaths(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{workspace}, + CommandCWD: workspace, + PermissionProfile: PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + WriteRoots: []WritableRoot{{Root: workspace}}, + }, + }, + }, "S-1-5-32-546") + if err != nil { + t.Fatalf("windowsPrincipalTeardownPaths: %v", err) + } + if len(paths) == 0 { + t.Error("teardown named no paths at all; the workspace root should still be revoked") + } + if after := tempDirEntryCount(t); after != before { + t.Errorf("temp directory gained %d entries; naming the paths must not create one", after-before) + } + + // And the setup resolver, which is allowed to create, still does. + created, err := windowsSandboxRuntimeRootPath(WindowsSandboxCommandConfig{ + WorkspaceRoots: []string{workspace}, + CommandCWD: workspace, + }) + if err != nil { + t.Fatalf("windowsSandboxRuntimeRootPath: %v", err) + } + if created == "" { + t.Error("setup's resolver should still fall back to a usable tree") + } +} + +func tempDirEntryCount(t *testing.T) int { + t.Helper() + entries, err := os.ReadDir(os.TempDir()) + if err != nil { + t.Fatalf("read temp dir: %v", err) + } + return len(entries) +} diff --git a/internal/tui/keybindings.go b/internal/tui/keybindings.go index f9e841bdf..4a63c66ca 100644 --- a/internal/tui/keybindings.go +++ b/internal/tui/keybindings.go @@ -341,6 +341,7 @@ var reservedBindings = []struct { {parseBinding("esc"), "cancel / close"}, {parseBinding("enter"), "submit"}, {parseBinding("shift+tab"), "cycle permission mode"}, + {parseBinding("ctrl+g"), "confirm unsafe mode (only while it is offered)"}, {parseBinding("tab"), "navigation / completion"}, {parseBinding("backspace"), "composer edit / attachment removal"}, {parseBinding("up"), "history/navigation"}, diff --git a/internal/tui/model.go b/internal/tui/model.go index 916b26221..f9c004181 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -126,8 +126,18 @@ type model struct { agentOptions agent.Options notifier *notify.Notifier permissionMode agent.PermissionMode - selfCorrectTests bool - reasoningEffort modelregistry.ReasoningEffort + // unsafeArmed means the last keypress was a shift+tab that offered unsafe + // mode, and the next one commits it. Unsafe turns permission prompts off + // entirely, so it is the one mode that must not be reachable by a single + // keypress landing on it. + // + // Cleared unconditionally at the top of the key handler and re-armed only by + // the shift+tab branch, so forgetting a path clears it rather than leaving it + // set. A stale flag would turn a later innocent shift+tab into a silent drop + // into unsafe, which is exactly the accident the arming exists to prevent. + unsafeArmed bool + selfCorrectTests bool + reasoningEffort modelregistry.ReasoningEffort // Active execution profile (set by /profile; applies to the NEXT run). // The displaced/applied pairs let a switch or /profile balanced restore // exactly what the profile replaced while leaving later manual overrides @@ -1348,6 +1358,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if !keyIs(msg, tea.KeyEnter) { m.lastCharTime = now } + // Disarm unsafe mode for EVERY keypress, before any branch can return, + // and let only the shift+tab branch below re-arm from this local. The + // inversion is deliberate: clearing in each of the many paths that should + // cancel would mean a missed one leaves the flag set, and a stale flag + // turns a later innocent shift+tab into a silent drop into unsafe. This + // way a path nobody thought about cancels the arm, which is the harmless + // direction to be wrong in. + unsafeWasArmed := m.unsafeArmed + m.unsafeArmed = false // Enter the solid-while-typing state right away: only composerBlinkMsg // evaluates the typing threshold, so if the blink phase had just hidden // the caret, the typed character would render caret-less for up to a @@ -1666,13 +1685,24 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.pendingAskUser != nil { return m.moveAskUserTab(-1), nil } - // shift+tab toggles the permission mode between Auto and Ask (Unsafe - // is intentionally not reachable by a casual keypress — see - // nextPermissionMode), but only when nothing modal is up: a permission - // prompt, ask_user questionnaire, or open picker all take precedence - // and let the key fall through to their own handlers below. + // shift+tab cycles the permission mode, but only when nothing modal is + // up: a permission prompt, ask_user questionnaire, or open picker all + // take precedence and let the key fall through to their own handlers. + // + // Unsafe is in the cycle but takes two presses to reach, because it + // turns permission prompts off entirely. See advancePermissionMode. + if m.noBlockingModal() { + m.permissionMode, m.unsafeArmed = advancePermissionMode(m.permissionMode, unsafeWasArmed) + return m, nil + } + case keyCtrl(msg, 'g') && unsafeWasArmed: + // Confirms an unsafe offer raised by the shift+tab immediately before + // this. Guarded on unsafeWasArmed in the case itself rather than inside + // the body, so without a live offer this key is not consumed at all and + // falls through to whatever would normally handle it. That is what + // keeps ctrl+g from being a standalone shortcut into unsafe mode. if m.noBlockingModal() { - m.permissionMode = nextPermissionMode(m.permissionMode) + m.permissionMode, _ = confirmUnsafePermissionMode(m.permissionMode, unsafeWasArmed) return m, nil } case m.keyMatch(m.keyBindings.cycleReasoning, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 't') }): diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index b7b66529b..f00b78605 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1919,11 +1919,18 @@ func TestShiftTabCyclesPermissionMode(t *testing.T) { m := newModel(context.Background(), Options{PermissionMode: agent.PermissionModeAuto}) m.width = 96 - // shift+tab toggles Auto<->Ask only; Unsafe is intentionally NOT reachable by - // a casual keypress (it disables permission prompts). + // The cycle has three positions now: Auto, Ask, and an unsafe OFFER that + // sits on Ask until it is either confirmed with the dedicated key or + // declined by pressing on. So returning to Auto costs two presses, which is + // simply what a third position means. + // + // shift+tab itself must still never land on Unsafe, however many times it is + // pressed; that is asserted inside the loop and again in + // TestHoldingShiftTabNeverCommitsUnsafe. for _, want := range []agent.PermissionMode{ - agent.PermissionModeAsk, - agent.PermissionModeAuto, + agent.PermissionModeAsk, // Auto -> Ask + agent.PermissionModeAsk, // offers unsafe, stays on Ask + agent.PermissionModeAuto, // declines the offer, completes the cycle } { updated, cmd := m.Update(testKeyShift(tea.KeyTab)) m = updated.(model) @@ -2728,17 +2735,72 @@ func testSessionStore(t *testing.T) *sessions.Store { }) } -func TestNextPermissionModeFoldsUnsafeToAsk(t *testing.T) { - if got := nextPermissionMode(agent.PermissionModeAuto); got != agent.PermissionModeAsk { - t.Fatalf("Auto -> %s, want Ask", got) +// shift+tab alone must never reach unsafe, however many times it is pressed, +// and the cycle must stay complete. An earlier draft made the second press +// commit unsafe, which silently removed Ask -> Auto from the cycle: this walks +// the whole loop so that cannot come back unnoticed. +func TestPermissionModeCycleNeverReachesUnsafeOnItsOwn(t *testing.T) { + mode := agent.PermissionModeAuto + offered := false + seen := map[agent.PermissionMode]bool{mode: true} + + for press := 0; press < 8; press++ { + mode, offered = advancePermissionMode(mode, offered) + if mode == agent.PermissionModeUnsafe { + t.Fatalf("press %d landed on Unsafe with shift+tab alone", press+1) + } + seen[mode] = true } - if got := nextPermissionMode(agent.PermissionModeAsk); got != agent.PermissionModeAuto { - t.Fatalf("Ask -> %s, want Auto", got) + for _, want := range []agent.PermissionMode{agent.PermissionModeAuto, agent.PermissionModeAsk} { + if !seen[want] { + t.Errorf("%s is unreachable by cycling, so the toggle lost a mode", want) + } } - // Unsafe must fold to the STRICTER Ask, never Auto (toggling an Unsafe session - // must not make it less strict). - if got := nextPermissionMode(agent.PermissionModeUnsafe); got != agent.PermissionModeAsk { - t.Fatalf("Unsafe -> %s, want Ask", got) +} + +// Declining is what keeps the cycle whole: the press after an offer continues +// to Auto rather than committing. +func TestDecliningTheUnsafeOfferContinuesTheCycle(t *testing.T) { + mode, offered := advancePermissionMode(agent.PermissionModeAsk, false) + if mode != agent.PermissionModeAsk || !offered { + t.Fatalf("first press from Ask = (%s, offered=%v), want (Ask, true)", mode, offered) + } + mode, offered = advancePermissionMode(mode, offered) + if mode != agent.PermissionModeAuto || offered { + t.Fatalf("second press = (%s, offered=%v), want (Auto, false)", mode, offered) + } +} + +// The confirm key is inert without a live offer. This is the property that +// stops ctrl+g being a one-key shortcut into unsafe. +func TestConfirmDoesNothingWithoutALiveOffer(t *testing.T) { + for _, mode := range []agent.PermissionMode{agent.PermissionModeAuto, agent.PermissionModeAsk} { + got, confirmed := confirmUnsafePermissionMode(mode, false) + if got != mode || confirmed { + t.Errorf("confirm with no offer from %s = (%s, %v), want unchanged", mode, got, confirmed) + } + } + got, confirmed := confirmUnsafePermissionMode(agent.PermissionModeAsk, true) + if got != agent.PermissionModeUnsafe || !confirmed { + t.Errorf("confirm with a live offer = (%s, %v), want (Unsafe, true)", got, confirmed) + } +} + +// Leaving unsafe must be one press and must never need confirming: getting +// stricter is always allowed to be easy. +func TestLeavingUnsafeIsOnePress(t *testing.T) { + mode, offered := advancePermissionMode(agent.PermissionModeUnsafe, false) + if mode != agent.PermissionModeAuto || offered { + t.Fatalf("Unsafe -> (%s, offered=%v), want (Auto, false)", mode, offered) + } +} + +// An unrecognized mode folds to the stricter of the two prompt-respecting +// modes, so a bad value can never resolve into something looser. +func TestUnknownPermissionModeFoldsToAsk(t *testing.T) { + mode, offered := advancePermissionMode(agent.PermissionMode("nonsense"), false) + if mode != agent.PermissionModeAsk || offered { + t.Fatalf("unknown -> (%s, offered=%v), want (Ask, false)", mode, offered) } } diff --git a/internal/tui/permission_mode_arm_test.go b/internal/tui/permission_mode_arm_test.go new file mode 100644 index 000000000..59ad873ec --- /dev/null +++ b/internal/tui/permission_mode_arm_test.go @@ -0,0 +1,121 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/agent" +) + +// These drive the REAL key handler rather than the pure cycle functions. +// +// The pure functions are easy to get right. The dangerous part is the offer +// state living on the model across keypresses: if any path fails to clear it, a +// later innocent ctrl+g commits unsafe mode with nobody having decided +// anything, and nothing would say so. The disarm is written as an unconditional +// clear at the top of the key handler with only shift+tab re-arming, and these +// exist to prove that inversion actually holds through the handler. + +func armedModel(t *testing.T) model { + t.Helper() + m := model{permissionMode: agent.PermissionModeAsk} + armed := pressKey(t, m, tea.Key{Code: tea.KeyTab, Mod: tea.ModShift}) + if !armed.unsafeArmed { + t.Fatal("shift+tab from Ask did not raise the unsafe offer") + } + if armed.permissionMode != agent.PermissionModeAsk { + t.Fatalf("mode changed to %s while merely offering unsafe", armed.permissionMode) + } + return armed +} + +func pressKey(t *testing.T, m model, key tea.Key) model { + t.Helper() + next, _ := m.updateModel(tea.KeyPressMsg(key)) + got, ok := next.(model) + if !ok { + t.Fatalf("updateModel returned %T, want model", next) + } + return got +} + +// The confirm key immediately after the offer commits unsafe. This is the +// feature working. +func TestConfirmKeyCommitsUnsafeRightAfterTheOffer(t *testing.T) { + m := pressKey(t, armedModel(t), tea.Key{Code: 'g', Mod: tea.ModCtrl}) + if m.permissionMode != agent.PermissionModeUnsafe { + t.Fatalf("mode = %s after confirming, want unsafe", m.permissionMode) + } + if m.unsafeArmed { + t.Error("offer still live after being accepted") + } +} + +// THE ONE THAT MATTERS. Any other key in between must cancel the offer, so the +// confirm key afterwards does nothing. Each of these is a separate path through +// the handler, and every one of them has to clear. +func TestAnyOtherKeyCancelsTheUnsafeOffer(t *testing.T) { + for name, key := range map[string]tea.Key{ + "printable": {Code: 'a', Text: "a"}, + "space": {Code: tea.KeySpace, Text: " "}, + "escape": {Code: tea.KeyEscape}, + "enter": {Code: tea.KeyEnter}, + "backspace": {Code: tea.KeyBackspace}, + "plain tab": {Code: tea.KeyTab}, + "up arrow": {Code: tea.KeyUp}, + "unrelated ctl": {Code: 'b', Mod: tea.ModCtrl}, + } { + t.Run(name, func(t *testing.T) { + m := pressKey(t, armedModel(t), key) + if m.unsafeArmed { + t.Fatalf("%s left the unsafe offer live, so a later ctrl+g would commit it silently", name) + } + // And prove the consequence rather than trusting the flag. + m = pressKey(t, m, tea.Key{Code: 'g', Mod: tea.ModCtrl}) + if m.permissionMode == agent.PermissionModeUnsafe { + t.Fatalf("ctrl+g after %s entered unsafe mode with no live offer", name) + } + }) + } +} + +// The confirm key with no offer at all must be inert, so it cannot be used as a +// standalone shortcut into unsafe. +func TestConfirmKeyAloneNeverEntersUnsafe(t *testing.T) { + for _, start := range []agent.PermissionMode{agent.PermissionModeAuto, agent.PermissionModeAsk} { + m := model{permissionMode: start} + for press := 0; press < 3; press++ { + m = pressKey(t, m, tea.Key{Code: 'g', Mod: tea.ModCtrl}) + if m.permissionMode == agent.PermissionModeUnsafe { + t.Fatalf("ctrl+g alone from %s reached unsafe on press %d", start, press+1) + } + } + } +} + +// Repeated shift+tab must never commit unsafe, only offer and then decline. +// Someone holding the key down must not end up with prompts disabled. +func TestHoldingShiftTabNeverCommitsUnsafe(t *testing.T) { + m := model{permissionMode: agent.PermissionModeAuto} + for press := 0; press < 10; press++ { + m = pressKey(t, m, tea.Key{Code: tea.KeyTab, Mod: tea.ModShift}) + if m.permissionMode == agent.PermissionModeUnsafe { + t.Fatalf("shift+tab alone reached unsafe on press %d", press+1) + } + } +} + +// The offer must be visible, name the key, and not claim the mode has changed. +func TestOfferLabelNamesTheConfirmKey(t *testing.T) { + label, _ := armedModel(t).modeLabel() + if label == "unsafe" { + t.Fatal("the offer renders as though unsafe is already active") + } + for _, want := range []string{"unsafe", "ctrl+g"} { + if !strings.Contains(label, want) { + t.Errorf("offer label %q does not mention %q", label, want) + } + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 44c9e3df1..c4e1adfc8 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -306,26 +306,61 @@ func providerDisplayNameIsGenericCustom(name string) bool { } } -// nextPermissionMode toggles between the two prompt-respecting modes: -// Auto ⇄ Ask. Unsafe (which disables permission prompts entirely) is -// deliberately NOT reachable by a casual keypress — a single shift+tab landing -// on it would let prompt-required tools run with no decision. Unsafe stays an -// explicit opt-in (the launch/--skip-permissions-unsafe path), not a UI toggle. -// Unsafe is folded back to Ask so the toggle always lands somewhere safe. -func nextPermissionMode(mode agent.PermissionMode) agent.PermissionMode { +// advancePermissionMode is one shift+tab press. It returns the mode to land on +// and whether unsafe is being OFFERED after this press. +// +// shift+tab always advances the cycle and never commits unsafe. That is the +// property worth protecting: unsafe turns permission prompts off entirely, so +// no repeat of a navigation key may land on it. Committing takes a separate, +// deliberate key (see confirmUnsafePermissionMode). +// +// The cycle stays complete. From Ask the first press OFFERS unsafe while +// staying on Ask, and a second press declines the offer and continues to Auto, +// so every mode is still reachable with shift+tab alone. An earlier draft of +// this made the second press commit unsafe, which silently removed Ask -> Auto +// from the cycle entirely. +// +// Leaving unsafe is one press and never gated. Getting stricter should never +// need confirming. +func advancePermissionMode(mode agent.PermissionMode, offered bool) (agent.PermissionMode, bool) { switch mode { case agent.PermissionModeAuto: - return agent.PermissionModeAsk + return agent.PermissionModeAsk, false case agent.PermissionModeAsk: - return agent.PermissionModeAuto + if offered { + return agent.PermissionModeAuto, false + } + return agent.PermissionModeAsk, true + case agent.PermissionModeUnsafe: + return agent.PermissionModeAuto, false default: - // Anything else (incl. an externally-set Unsafe) folds to Ask — the stricter - // landing, so toggling never makes an Unsafe session less strict. - return agent.PermissionModeAsk + // Anything else folds to Ask, the stricter landing, so an unrecognized + // mode can never resolve into a less strict one. + return agent.PermissionModeAsk, false } } +// confirmUnsafePermissionMode commits unsafe, but only from a live offer. +// +// The offer is cleared by every keypress that is not this one, so confirming +// has to immediately follow the shift+tab that raised it. Without a live offer +// this does nothing at all, which is what stops the confirm key from being a +// standalone shortcut into unsafe. +func confirmUnsafePermissionMode(mode agent.PermissionMode, offered bool) (agent.PermissionMode, bool) { + if !offered { + return mode, false + } + return agent.PermissionModeUnsafe, true +} + func (m model) modeLabel() (string, lipgloss.Style) { + // The offer renders in the unsafe style rather than the current mode's, so + // it is unmistakable before it is accepted, and it names the exact key. It + // reads as a question because nothing has changed yet: the session is still + // in whatever mode it was, and any other key declines. + if m.unsafeArmed { + return "unsafe? ctrl+g to confirm", zeroTheme.modeUnsafe + } switch m.permissionMode { case agent.PermissionModeAuto: return "auto-approve", zeroTheme.modeAuto