Files
zero/internal/sandbox/manager_test.go
euxaristia 6fc1220f6a fix(sandbox): scrub sensitive credentials from sandbox environment (#660)
* fix(windows): retry atomic file renames on Access is denied / Sharing violation

* test(swarm): add unit tests for rename retry and non-retryable errors

* test(swarm): skip TestMailboxRenameRetry on non-Windows platforms

* fix(sandbox): scrub sensitive provider credentials from sandbox env

* refactor(sandbox): unify environment creation and reduce lines of code

* fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend

* style(sandbox,swarm): remove stray blank lines flagged by gofmt

* refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog

Address CodeRabbit review feedback:

- Add internal/fsutil.RenameWithRetry with an injectable rename override and
  move the four duplicated renameWithRetry/isWindowsSharingViolation copies
  (cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test
  seam by passing its field through. Add fsutil tests for the Windows retry
  and non-retryable paths.
- Remove the orphaned metadata.json.tmp in cron writeJob when the rename
  permanently fails, matching the sibling writers.
- Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a
  static list of non-catalog secrets, so new providers are scrubbed
  automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay
  because they are pointers, not secrets.

* fix(sandbox): deny sandboxed reads of cloud credential stores by default

Address CodeRabbit finding that scrubbed env pointers still left the
underlying credential files readable under the read-all workspace posture:

- Add credentialDenyReadPaths: default profile-level deny-read entries for
  ~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS
  target file, wired into both permission profile builders so the seatbelt
  and Linux helper backends enforce them. Entries are stat-filtered and a
  store nested under a user AllowRead entry is dropped, keeping an explicit
  opt-out.
- Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated
  execution, and skip Windows so the profile deny list does not flip the
  runner off the WRITE_RESTRICTED token path.
- Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE
  since the deny-read rule on ~/.aws is the real protection and the SDKs
  fall back to the default profile regardless.
- Add credential deny path tests, extend the scrub test, relax the profile
  test's exact deny count to containment.

* test(cli): pin HOME in sandbox policy golden test

The default credential-store deny-read entries depend on what exists in the
real home directory (the macOS CI image ships ~/.aws), which leaked host
paths into the policy JSON golden comparison. Point HOME at an empty temp
directory so the golden stays hermetic.

* fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations

Address CodeRabbit review feedback:

- credentialDenyReadPaths no longer returns early when the home lookup
  fails; home-based candidates become conditional and the
  GOOGLE_APPLICATION_CREDENTIALS target is always considered.
- fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33),
  matching the mcp lock handling; helper renamed to
  isWindowsSharingOrLockViolation.
- Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock
  violation step in the retry sequence.

* fix(sandbox): tighten env-scrub test assertion and add missing secret keys

TestScrubSensitiveEnv only checked an allowlist plus a length count, so a
duplicate allowed key silently replacing a required one would still pass.
Compare the exact expected key/value map instead.

Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to
scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read
from the environment but were missing from the scrub list, leaving them
readable by sandboxed commands when set in the parent shell.
2026-07-14 21:46:10 +08:00

421 lines
17 KiB
Go

package sandbox
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
denyRead := filepath.Join(workspace, "private")
denyWrite := filepath.Join(workspace, "readonly")
if err := mkdirAll(denyRead, denyWrite); err != nil {
t.Fatal(err)
}
scope, err := NewScope(workspace, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
}
policy := DefaultPolicy()
policy.DenyRead = []string{denyRead}
policy.DenyWrite = []string{denyWrite}
profile := PermissionProfileFromPolicy(workspace, policy, scope)
if profile.FileSystem.Kind != FileSystemRestricted {
t.Fatalf("filesystem kind = %q, want restricted", profile.FileSystem.Kind)
}
roots := scope.Roots()
if len(profile.FileSystem.WriteRoots) != len(roots) {
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, roots)
}
for i, root := range roots {
if profile.FileSystem.WriteRoots[i].Root != root {
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, roots)
}
}
if !stringSliceContains(profile.FileSystem.ReadRoots, profileRootPath()) {
t.Fatalf("read roots = %#v, want full read root %q", profile.FileSystem.ReadRoots, profileRootPath())
}
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".zero") || !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".agents") {
t.Fatalf("protected metadata names = %#v, want workspace metadata protected", profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
}
resolvedRoot := profile.FileSystem.WriteRoots[0].Root
wantGitCarveouts := []string{filepath.Join(resolvedRoot, ".git", "hooks"), filepath.Join(resolvedRoot, ".git", "config")}
for _, want := range wantGitCarveouts {
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, want) {
t.Fatalf("read-only subpaths = %#v, want git metadata carveout %q", profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, want)
}
}
// DenyRead may also carry default credential-store entries when the host
// has them, so assert containment rather than an exact count. Compare the
// normalized (symlink-resolved) form the profile stores.
if !stringSliceContains(profile.FileSystem.DenyRead, normalizeProfilePaths([]string{denyRead})[0]) || len(profile.FileSystem.DenyWrite) != 1 {
t.Fatalf("deny paths = %#v / %#v, want configured entries present", profile.FileSystem.DenyRead, profile.FileSystem.DenyWrite)
}
if profile.Network.Mode != NetworkDeny {
t.Fatalf("network profile = %#v, want deny", profile.Network)
}
if !profile.RequiresPlatformSandbox() {
t.Fatal("workspace-write profile must require a platform sandbox")
}
}
func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T) {
tmpdir := t.TempDir()
if runtime.GOOS == "windows" {
t.Setenv("TEMP", tmpdir)
t.Setenv("TMP", tmpdir)
} else {
t.Setenv("TMPDIR", tmpdir)
}
workspace := t.TempDir()
profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil)
if !writeRootsContain(profile.FileSystem.WriteRoots, workspace) {
t.Fatalf("write roots = %#v, want workspace %q", profile.FileSystem.WriteRoots, workspace)
}
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
}
// /tmp is a default temp write root on POSIX only (see
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
// against the current drive, so a stray C:\tmp must not turn this on.
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
}
}
func writeRootsContain(roots []WritableRoot, want string) bool {
want = normalizeProfilePath(want)
for _, root := range roots {
if normalizeProfilePath(root.Root) == want {
return true
}
}
return false
}
func TestUnknownNetworkModeFailsClosed(t *testing.T) {
for _, mode := range []NetworkMode{"scoped", "proxy"} {
if got := NormalizeNetworkMode(mode); got != NetworkDeny {
t.Fatalf("NormalizeNetworkMode(%s) = %q, want %q", mode, got, NetworkDeny)
}
}
profile := PermissionProfileFromPolicy(t.TempDir(), Policy{
Mode: ModeEnforce,
Network: NetworkMode("scoped"),
EnforceWorkspace: true,
}, nil)
if profile.Network.Mode != NetworkDeny {
t.Fatalf("unknown network mode profile = %#v, want deny", profile.Network)
}
if !shouldUnshareLinuxNetwork(NetworkPolicy{Mode: NetworkMode("scoped")}) {
t.Fatal("unknown Linux network mode must unshare network")
}
}
func TestPermissionProfileFromDisabledPolicyDoesNotRequirePlatformSandbox(t *testing.T) {
policy := DefaultPolicy()
policy.Mode = ModeDisabled
profile := PermissionProfileFromPolicy(t.TempDir(), policy, nil)
if profile.FileSystem.Kind != FileSystemUnrestricted || profile.Network.Mode != NetworkAllow {
t.Fatalf("disabled profile = %#v, want unrestricted filesystem and allow network", profile)
}
if profile.RequiresPlatformSandbox() {
t.Fatalf("disabled profile must not require platform sandbox: %#v", profile)
}
}
func TestSandboxManagerBuildsExecutionRequestFromProfile(t *testing.T) {
backend := Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox", Platform: "linux"}
policy := DefaultPolicy()
profile := PermissionProfileFromPolicy("/workspace", policy, nil)
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: "/workspace",
Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: "/workspace"},
Policy: policy,
Profile: profile,
Preference: SandboxPreferenceAuto,
ValidateExecution: true,
})
if err != nil {
t.Fatalf("BuildExecutionRequest: %v", err)
}
if request.TargetBackend != BackendLinuxBwrap || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("execution request = %#v, want native linux-bwrap wrapping", request)
}
if request.PermissionProfile.FileSystem.Kind != FileSystemRestricted || !request.RequiresPlatformSandbox {
t.Fatalf("execution request profile = %#v, requires=%t", request.PermissionProfile, request.RequiresPlatformSandbox)
}
}
func TestSandboxManagerBuildsCommandPlanThroughLinuxHelper(t *testing.T) {
backend := Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox", Platform: "linux"}
policy := DefaultPolicy()
policy.BlockUnixSockets = true
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "linux", Backend: backend})
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: "/workspace",
Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: "/workspace/nested"},
Policy: policy,
Profile: PermissionProfileFromPolicy("/workspace", policy, nil),
Preference: SandboxPreferenceAuto,
ValidateExecution: true,
})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if !plan.Wrapped || plan.Name != "/usr/bin/zero-linux-sandbox" || plan.TargetBackend != BackendLinuxBwrap {
t.Fatalf("command plan = %#v, want native linux helper wrapper", plan)
}
if plan.EnforcementLevel != EnforcementNative {
t.Fatalf("command metadata = %#v, want helper backend with native enforcement", plan)
}
assertArgsContainSequence(t, plan.Args, "--sandbox-policy-cwd", "/workspace")
assertArgsContainSequence(t, plan.Args, "--command-cwd", "/workspace/nested")
assertArgsContainSequence(t, plan.Args, "--block-unix-sockets")
assertArgsContainSequence(t, plan.Args, "--", "/bin/sh", "-c", "pwd")
}
func TestSandboxManagerBuildsCommandPlanThroughWindowsRunner(t *testing.T) {
// This exercises the native wrapped path, which requires the workspace to be
// sandbox-initialized; stub the marker present (otherwise it degrades).
restore := windowsSandboxInitialized
t.Cleanup(func() { windowsSandboxInitialized = restore })
windowsSandboxInitialized = func() bool { return true }
backend := Backend{Name: BackendWindowsRestrictedToken, Available: true, Executable: `C:\zero\zero-windows-command-runner.exe`, Platform: "windows"}
policy := DefaultPolicy()
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend})
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: `C:\workspace`,
Command: CommandSpec{Name: "cmd.exe", Args: []string{"/d", "/s", "/c", "dir"}, Dir: `C:\workspace\src`, Env: []string{"PATH=C:\\Tools", "TERM=xterm"}},
Policy: policy,
Profile: PermissionProfileFromPolicy(`C:\workspace`, policy, nil),
Preference: SandboxPreferenceAuto,
ValidateExecution: true,
})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if !plan.Wrapped || plan.Name != `C:\zero\zero-windows-command-runner.exe` || plan.TargetBackend != BackendWindowsRestrictedToken {
t.Fatalf("command plan = %#v, want native windows command runner wrapper", plan)
}
if plan.EnforcementLevel != EnforcementNative {
t.Fatalf("command metadata = %#v, want native restricted-token backend", plan)
}
assertArgsContainSequence(t, plan.Args, "--command-cwd", `C:\workspace\src`)
assertArgsContainSequence(t, plan.Args, "--sandbox-home")
assertArgsContainSequence(t, plan.Args, "--windows-sandbox-level", string(WindowsSandboxLevelRestrictedToken))
assertArgsContainSequence(t, plan.Args, "--workspace-root", `C:\workspace`)
assertArgsContainSequence(t, plan.Args, "--", "cmd.exe", "/d", "/s", "/c", "dir")
config, err := ParseWindowsSandboxCommandArgs(plan.Args)
if err != nil {
t.Fatalf("ParseWindowsSandboxCommandArgs: %v", err)
}
if config.SandboxHome == "" || config.CommandCWD != `C:\workspace\src` || len(config.WorkspaceRoots) != 1 || config.WorkspaceRoots[0] != `C:\workspace` {
t.Fatalf("parsed roots = %#v cwd=%q, want workspace root and command cwd", config.WorkspaceRoots, config.CommandCWD)
}
if config.PermissionProfile.FileSystem.Kind != FileSystemRestricted || config.PermissionProfile.Network.Mode != NetworkDeny {
t.Fatalf("parsed permission profile = %#v, want restricted deny profile", config.PermissionProfile)
}
if config.Env[EnvSandboxed] != "1" || config.Env[EnvSandboxBackend] != string(BackendWindowsRestrictedToken) || config.Env["COMSPEC"] == "" {
t.Fatalf("parsed env = %#v, want sandbox markers and COMSPEC", config.Env)
}
}
func TestSandboxManagerDegradesUnavailableCommandPlan(t *testing.T) {
policy := DefaultPolicy()
backend := Backend{Name: BackendUnavailable, Platform: "windows", Fallback: true, Message: "native sandbox unavailable"}
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend})
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: `C:\workspace`,
Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "dir"}, Dir: `C:\workspace`},
Policy: policy,
Profile: PermissionProfileFromPolicy(`C:\workspace`, policy, nil),
Preference: SandboxPreferenceAuto,
ValidateExecution: true,
})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded || plan.DowngradeReason != "native sandbox unavailable" {
t.Fatalf("plan = %#v, want degraded direct plan", plan)
}
}
func TestSandboxManagerSelectsPlatformBackend(t *testing.T) {
tests := []struct {
name string
goos string
lookupName string
lookupPath string
setupPath string
want BackendName
wantTarget BackendName
}{
{name: "linux", goos: "linux", lookupName: LinuxSandboxHelperName, lookupPath: "/usr/bin/zero-linux-sandbox", want: BackendLinuxBwrap, wantTarget: BackendLinuxBwrap},
{name: "macos", goos: "darwin", lookupName: "sandbox-exec", lookupPath: "/usr/bin/sandbox-exec", want: BackendMacOSSeatbelt, wantTarget: BackendMacOSSeatbelt},
{name: "windows", goos: "windows", lookupName: WindowsSandboxCommandRunnerName, lookupPath: `C:\zero\zero-windows-command-runner.exe`, setupPath: `C:\zero\zero-windows-sandbox-setup.exe`, want: BackendWindowsRestrictedToken, wantTarget: BackendWindowsRestrictedToken},
{name: "unsupported", goos: "plan9", want: BackendUnavailable, wantTarget: BackendUnavailable},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manager := NewSandboxManager(SandboxManagerOptions{
GOOS: test.goos,
LookupExecutable: func(name string) (string, error) {
if name == test.lookupName && test.lookupPath != "" {
return test.lookupPath, nil
}
if test.goos == "linux" && name == "bwrap" {
return "/usr/bin/bwrap", nil
}
if name == WindowsSandboxSetupName && test.setupPath != "" {
return test.setupPath, nil
}
return "", errors.New("missing")
},
})
backend := manager.Backend()
if backend.Name != test.want {
t.Fatalf("backend = %#v, want %q", backend, test.want)
}
if backend.TargetBackend() != test.wantTarget {
t.Fatalf("target backend = %q, want %q for %#v", backend.TargetBackend(), test.wantTarget, backend)
}
})
}
}
func TestSandboxManagerInfersPlatformFromExplicitBackend(t *testing.T) {
tests := []struct {
name string
backend BackendName
wantGOOS string
}{
{name: "linux helper", backend: BackendLinuxBwrap, wantGOOS: "linux"},
{name: "macos seatbelt", backend: BackendMacOSSeatbelt, wantGOOS: "darwin"},
{name: "windows runner", backend: BackendWindowsRestrictedToken, wantGOOS: "windows"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manager := NewSandboxManager(SandboxManagerOptions{
Backend: Backend{Name: test.backend, Available: true, Executable: "sandbox-helper"},
})
if manager.goos != test.wantGOOS || manager.backend.Platform != test.wantGOOS {
t.Fatalf("manager = %#v, want platform/goos %q", manager, test.wantGOOS)
}
})
}
}
func TestSelectBackendDelegatesToSandboxManagerSelection(t *testing.T) {
backend := SelectBackend(BackendOptions{
GOOS: "linux",
LookupExecutable: func(name string) (string, error) {
if name == LinuxSandboxHelperName {
return "/usr/bin/zero-linux-sandbox", nil
}
if name == "bwrap" {
return "/usr/bin/bwrap", nil
}
return "", errors.New("missing")
},
})
managerBackend := NewSandboxManager(SandboxManagerOptions{
GOOS: "linux",
LookupExecutable: func(name string) (string, error) {
if name == LinuxSandboxHelperName {
return "/usr/bin/zero-linux-sandbox", nil
}
if name == "bwrap" {
return "/usr/bin/bwrap", nil
}
return "", errors.New("missing")
},
}).Backend()
if !reflect.DeepEqual(backend, managerBackend) {
t.Fatalf("SelectBackend = %#v, manager backend = %#v", backend, managerBackend)
}
}
func TestSandboxManagerFailsClosedWhenNativeRequiredAndUnavailable(t *testing.T) {
policy := DefaultPolicy()
profile := PermissionProfileFromPolicy("/workspace", policy, nil)
_, err := NewSandboxManager(SandboxManagerOptions{
GOOS: "windows",
Backend: Backend{Name: BackendUnavailable, Platform: "windows", Fallback: true},
}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: "/workspace",
Command: CommandSpec{Name: "cmd.exe", Dir: "/workspace"},
Policy: policy,
Profile: profile,
Preference: SandboxPreferenceRequire,
ValidateExecution: true,
})
if !errors.Is(err, errNativeSandboxUnavailable) {
t.Fatalf("BuildExecutionRequest error = %v, want native sandbox unavailable", err)
}
}
func mkdirAll(paths ...string) error {
for _, path := range paths {
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
}
return nil
}
func TestCredentialDenyReadPathsIn(t *testing.T) {
home := t.TempDir()
awsDir := filepath.Join(home, ".aws")
gcloudDir := filepath.Join(home, ".config", "gcloud")
if err := mkdirAll(awsDir, gcloudDir); err != nil {
t.Fatal(err)
}
keyFile := filepath.Join(home, "sa-key.json")
if err := os.WriteFile(keyFile, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
paths := credentialDenyReadPathsIn(home, keyFile, nil)
for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) {
if !stringSliceContains(paths, want) {
t.Errorf("credential deny paths = %#v, want %q included", paths, want)
}
}
// A path the host does not have is dropped, not emitted blind.
if stringSliceContains(paths, filepath.Join(home, ".azure")) {
t.Errorf("credential deny paths = %#v, must not include the absent ~/.azure", paths)
}
// An explicit AllowRead entry covering a store is an opt-out.
optedOut := credentialDenyReadPathsIn(home, keyFile, []string{awsDir})
if stringSliceContains(optedOut, normalizeProfilePaths([]string{awsDir})[0]) {
t.Errorf("credential deny paths = %#v, want AllowRead opt-out to drop ~/.aws", optedOut)
}
if !stringSliceContains(optedOut, normalizeProfilePaths([]string{keyFile})[0]) {
t.Errorf("credential deny paths = %#v, want unrelated entries kept after opt-out", optedOut)
}
if got := credentialDenyReadPathsIn(" ", "", nil); len(got) != 0 {
t.Errorf("credential deny paths for blank home = %#v, want none", got)
}
// The GOOGLE_APPLICATION_CREDENTIALS target stays protected even when no
// home directory is resolvable.
homeless := credentialDenyReadPathsIn("", keyFile, nil)
if !stringSliceContains(homeless, normalizeProfilePaths([]string{keyFile})[0]) {
t.Errorf("credential deny paths without home = %#v, want key file included", homeless)
}
}