diff --git a/internal/cli/sandbox_test.go b/internal/cli/sandbox_test.go index 16489f407..3415f3ae5 100644 --- a/internal/cli/sandbox_test.go +++ b/internal/cli/sandbox_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" @@ -483,14 +484,16 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) { } func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) { - // Point HOME at an empty directory so the default credential-store - // deny-read entries (which depend on what exists in the real home, e.g. - // ~/.aws on the macOS CI image) cannot leak host paths into the golden - // comparison. + // Point all user-config roots at an empty directory so the platform-specific + // credential deny baseline can be asserted without leaking host paths into + // the platform-neutral golden comparison. emptyHome := t.TempDir() t.Setenv("HOME", emptyHome) t.Setenv("USERPROFILE", emptyHome) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(emptyHome, ".config")) t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + t.Setenv("ZERO_OAUTH_TOKENS_PATH", "") + t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "") store := newSandboxTestStore(t) workspace := t.TempDir() deps := appDeps{ @@ -518,7 +521,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) { got := stdout.String() got = replacePathToken(got, workspace, "$WORKSPACE") got = replacePathToken(got, store.FilePath(), "$GRANTS") - gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace) + gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace, emptyHome) wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json")) if err != nil { t.Fatalf("read golden: %v", err) @@ -528,7 +531,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) { } } -func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte { +func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte { t.Helper() scope, err := sandbox.NewScope(workspace, nil) if err != nil { @@ -549,6 +552,42 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp plan, _ := value.(map[string]any)["plan"].(map[string]any) profile, _ := plan["permissionProfile"].(map[string]any) fileSystem, _ := profile["fileSystem"].(map[string]any) + wantDenyRead := []string(nil) + if runtime.GOOS != "windows" { + credentialHome := emptyHome + if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil { + credentialHome = resolved + } + wantDenyRead = []string{ + filepath.Join(credentialHome, ".aws"), + filepath.Join(credentialHome, ".config", "gcloud"), + filepath.Join(credentialHome, ".azure"), + filepath.Join(credentialHome, ".config", "zero"), + } + } + if gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"]); !reflect.DeepEqual(gotDenyRead, wantDenyRead) { + t.Fatalf("manager credential deny baseline = %#v, want %#v", gotDenyRead, wantDenyRead) + } + wantCarveouts := []string(nil) + wantEnsureDirs := []string(nil) + if runtime.GOOS != "windows" { + zeroDir := wantDenyRead[len(wantDenyRead)-1] + wantCarveouts = []string{ + filepath.Join(zeroDir, "plugins"), + filepath.Join(zeroDir, "specialists"), + filepath.Join(zeroDir, "commands"), + } + wantEnsureDirs = []string{zeroDir} + } + if gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"]); !reflect.DeepEqual(gotCarveouts, wantCarveouts) { + t.Fatalf("manager credential carveouts = %#v, want %#v", gotCarveouts, wantCarveouts) + } + if gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"]); !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) { + t.Fatalf("manager credential ensure dirs = %#v, want %#v", gotEnsureDirs, wantEnsureDirs) + } + delete(fileSystem, "denyReadIfExists") + delete(fileSystem, "denyReadCarveouts") + delete(fileSystem, "ensureDenyReadDirs") fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots) fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots) normalized, err := json.MarshalIndent(value, "", " ") @@ -558,6 +597,22 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp return append(normalized, '\n') } +func jsonStringSlice(value any) []string { + values, ok := value.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(values)) + for _, value := range values { + text, ok := value.(string) + if !ok { + return nil + } + out = append(out, text) + } + return out +} + func filterJSONStringRoots(value any, excluded map[string]struct{}) any { roots, ok := value.([]any) if !ok { diff --git a/internal/oauth/encrypt.go b/internal/oauth/encrypt.go index ad1a6ddee..ed821924c 100644 --- a/internal/oauth/encrypt.go +++ b/internal/oauth/encrypt.go @@ -140,17 +140,14 @@ func writeNewSecretFile(path string) ([]byte, error) { if _, err := io.ReadFull(rand.Reader, secret); err != nil { return nil, fmt.Errorf("oauth: generate token secret: %w", err) } - dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") + // The encryption key is published exactly like the token blob: through the + // protected per-store directory, under a random name a sandboxed process + // cannot guess, wait for, or rename away. + tmp, tmpPath, err := createPublicationFile(path) if err != nil { return nil, fmt.Errorf("oauth: create token secret temp file: %w", err) } - tmpPath := tmp.Name() defer os.Remove(tmpPath) - if err := tmp.Chmod(0o600); err != nil { - _ = tmp.Close() - return nil, fmt.Errorf("oauth: chmod token secret temp file: %w", err) - } if _, werr := tmp.Write(secret); werr != nil { _ = tmp.Close() return nil, fmt.Errorf("oauth: write token secret: %w", werr) diff --git a/internal/oauth/store.go b/internal/oauth/store.go index 951e9616f..1bc7f1dc8 100644 --- a/internal/oauth/store.go +++ b/internal/oauth/store.go @@ -409,15 +409,53 @@ func (b fileBlob) write(data []byte) error { if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil { return err } - tempPath := fmt.Sprintf("%s.tmp-%d-%d", b.path, os.Getpid(), time.Now().UnixNano()) - if err := os.WriteFile(tempPath, data, 0o600); err != nil { + temp, tempPath, err := createPublicationFile(b.path) + if err != nil { return err } - if err := os.Rename(tempPath, b.path); err != nil { - _ = os.Remove(tempPath) + defer os.Remove(tempPath) + if _, err := temp.Write(data); err != nil { + _ = temp.Close() return err } - return nil + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempPath, b.path) +} + +// PublicationDirSuffix names the per-store directory a token store publishes new +// contents through. Sandbox profiles deny it by name (see +// internal/sandbox.credentialPublicationDir), which is why the directory name is +// derived from the store path while the file inside it is randomly named: the +// deterministic part is what a deny rule can reference, and the random part is +// what stops a same-user process from waiting for the plaintext to appear at a +// path it can open or rename away. +const PublicationDirSuffix = ".publish" + +// PublicationDir returns the publication directory for a store path. +func PublicationDir(path string) string { return path + PublicationDirSuffix } + +// createPublicationFile creates the randomly-named 0600 file that path's next +// contents are written to before being renamed into place. It lives in +// PublicationDir(path) — same filesystem as path, so the rename stays atomic — +// and the directory is created 0700 if it does not exist yet. +func createPublicationFile(path string) (*os.File, string, error) { + dir := PublicationDir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, "", err + } + temp, err := os.CreateTemp(dir, "publish-*") + if err != nil { + return nil, "", err + } + if err := temp.Chmod(0o600); err != nil { + name := temp.Name() + _ = temp.Close() + _ = os.Remove(name) + return nil, "", err + } + return temp, temp.Name(), nil } func (b fileBlob) withLock(now func() time.Time, fn func() error) error { diff --git a/internal/oauth/store_test.go b/internal/oauth/store_test.go index d160f5a7e..494f35a02 100644 --- a/internal/oauth/store_test.go +++ b/internal/oauth/store_test.go @@ -91,6 +91,60 @@ func TestStoreFileMode0600(t *testing.T) { } } +// TestStorePublishesThroughProtectedDirectory pins the publication contract the +// sandbox profile relies on: the plaintext blob is never written to a path a +// same-user process can predict (a fixed `.tmp` sibling), only to a random name +// inside the directory the profile denies by name, and nothing is left behind. +func TestStorePublishesThroughProtectedDirectory(t *testing.T) { + s, path := newTestStore(t) + if err := os.WriteFile(path+".tmp", []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + if err := s.Save(ProviderKey("x"), Token{AccessToken: "secret"}); err != nil { + t.Fatalf("Save: %v", err) + } + // A predictable sibling is never used, so a pre-existing one is untouched + // rather than becoming the file the plaintext passes through. + if data, err := os.ReadFile(path + ".tmp"); err != nil || string(data) != "stale" { + t.Fatalf("fixed sibling data = %q, err = %v, want the untouched placeholder", data, err) + } + assertEmptyPublicationDir(t, path) +} + +func TestEncryptedStorePublishesSecretThroughProtectedDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "tokens.json") + if err := os.WriteFile(path+".secret.tmp", []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + s, err := NewStore(StoreOptions{FilePath: path, Storage: "encrypted-file"}) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + if err := s.Save(ProviderKey("x"), Token{AccessToken: "secret"}); err != nil { + t.Fatalf("Save: %v", err) + } + if data, err := os.ReadFile(path + ".secret.tmp"); err != nil || string(data) != "stale" { + t.Fatalf("fixed secret sibling data = %q, err = %v, want the untouched placeholder", data, err) + } + assertEmptyPublicationDir(t, path) + assertEmptyPublicationDir(t, path+".secret") +} + +// assertEmptyPublicationDir asserts the store's publication directory exists +// (so the sandbox has a mount target to mask on Linux) and holds no leftover +// copy of the secret it published. +func assertEmptyPublicationDir(t *testing.T, storePath string) { + t.Helper() + dir := PublicationDir(storePath) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read publication dir %s: %v", dir, err) + } + if len(entries) != 0 { + t.Fatalf("publication dir %s = %v, want empty after publish", dir, entries) + } +} + func TestStoreMalformedFailsClosed(t *testing.T) { s, path := newTestStore(t) if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { diff --git a/internal/sandbox/export_test.go b/internal/sandbox/export_test.go index cd0de0438..62ed5a2ef 100644 --- a/internal/sandbox/export_test.go +++ b/internal/sandbox/export_test.go @@ -2,6 +2,7 @@ package sandbox import ( + "os" "path/filepath" "strings" ) @@ -58,7 +59,11 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root}) } } - fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...)) + fs.DenyRead = normalizeProfilePaths(policy.DenyRead) + credentials := finalizeCredentialDenyPaths(credentialDenyReadPaths(policy, "", os.Environ()), fs.DenyRead) + fs.DenyReadIfExists = credentials.Paths + fs.DenyReadCarveouts = credentials.Carveouts + fs.EnsureDenyReadDirs = credentials.EnsureDirs fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite) return PermissionProfile{ FileSystem: fs, diff --git a/internal/sandbox/landlock_linux.go b/internal/sandbox/landlock_linux.go index 1d51d6a35..1796373c8 100644 --- a/internal/sandbox/landlock_linux.go +++ b/internal/sandbox/landlock_linux.go @@ -48,7 +48,7 @@ func validateLandlockProfile(profile PermissionProfile) error { if fs.Kind != FileSystemRestricted { return nil } - if len(fs.DenyRead) > 0 { + if len(fs.DenyRead) > 0 || len(fs.DenyReadIfExists) > 0 { return errors.New("deny-read paths require the bubblewrap helper mode") } if len(fs.DenyWrite) > 0 { diff --git a/internal/sandbox/linux_helper.go b/internal/sandbox/linux_helper.go index 1be897810..227c7d602 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -277,7 +277,24 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst args = appendReadOnlyLinuxPathArgs(args, path) } for _, path := range fs.DenyRead { - args = appendUnreadableLinuxPathArgs(args, path) + args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) + } + // The profile includes only trusted, process-environment-derived directories + // here. Command-controlled credential roots remain deny-if-present and must + // never cause host filesystem mutations before sandbox launch. + ensureLinuxDenyReadDirs(fs.EnsureDenyReadDirs) + for _, path := range fs.DenyReadIfExists { + if !pathExists(path) { + // A baseline credential path is emitted for every run, so an absent + // entry is the common case on a fresh machine — a third-party store + // such as ~/.aws that Zero must not create. The read-all profile starts + // from a read-only host-root bind where bubblewrap cannot create a + // missing mount destination, and masking the nearest existing parent + // could hide HOME, /tmp, or the workspace. Path-based backends + // (seatbelt) still deny these paths before they exist. + continue + } + args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) } return linuxBwrapFilesystemPlan{ Args: args, @@ -353,15 +370,54 @@ func appendReadOnlyLinuxPathArgs(args []string, path string) []string { return append(args, "--perms", "555", "--tmpfs", path, "--remount-ro", path) } -func appendUnreadableLinuxPathArgs(args []string, path string) []string { - path = strings.TrimSpace(path) +func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string { + path = normalizeProfilePath(path) if path == "" { return args } if info, err := os.Stat(path); err == nil && !info.IsDir() { return append(args, "--ro-bind", "/dev/null", path) } - return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path) + nested := nestedCarveoutPaths(path, carveouts) + if len(nested) == 0 { + return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path) + } + // A carveout has to stay reachable, and traversing into a directory needs the + // execute bit, so the mask is 111 (--x--x--x) instead of 000: the directory's + // contents remain unlistable and unreadable, while an explicitly re-bound + // subpath below it can still be resolved. The binds must precede the + // --remount-ro, which is what freezes the tmpfs. + args = append(args, "--perms", "111", "--tmpfs", path) + for _, carveout := range nested { + if info, err := os.Lstat(carveout); err == nil && info.IsDir() { + args = append(args, "--ro-bind", carveout, carveout) + } + } + return append(args, "--remount-ro", path) +} + +// nestedCarveoutPaths returns the carveouts that sit strictly inside root, +// shallowest first so a parent bind is created before a nested one. +func nestedCarveoutPaths(root string, carveouts []string) []string { + if len(carveouts) == 0 { + return nil + } + out := credentialCarveoutPaths([]string{root}, carveouts) + sort.SliceStable(out, func(i, j int) bool { return pathDepth(out[i]) < pathDepth(out[j]) }) + return dedupeStrings(out) +} + +// ensureLinuxDenyReadDirs creates trusted Zero-process directories a deny mask +// needs to exist for. Best effort: a failure leaves the path unmasked and never +// blocks the command. +func ensureLinuxDenyReadDirs(dirs []string) { + for _, dir := range dirs { + dir = strings.TrimSpace(dir) + if dir == "" || pathExists(dir) { + continue + } + _ = os.MkdirAll(dir, 0o700) + } } func shouldUnshareLinuxNetwork(policy NetworkPolicy) bool { diff --git a/internal/sandbox/linux_helper_test.go b/internal/sandbox/linux_helper_test.go index 077de65da..f3edd61eb 100644 --- a/internal/sandbox/linux_helper_test.go +++ b/internal/sandbox/linux_helper_test.go @@ -2,6 +2,7 @@ package sandbox import ( "encoding/json" + "errors" "os" "path/filepath" "reflect" @@ -265,6 +266,145 @@ func TestLinuxBwrapFilesystemPlanPreservesMissingProtectedMetadata(t *testing.T) } } +func TestLinuxBwrapSkipsMissingCredentialBaselines(t *testing.T) { + root := t.TempDir() + missingCredential := filepath.Join(root, "home", ".config", "zero") + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + WriteRoots: []WritableRoot{{Root: root}}, + DenyReadIfExists: []string{missingCredential}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + plan := buildLinuxBwrapFilesystemPlan(profile) + if stringSliceContains(plan.Args, missingCredential) { + t.Fatalf("absent credential baseline must not become a mount target: %#v", plan.Args) + } + if stringSliceContains(plan.ProtectedCreateTargets, missingCredential) { + t.Fatalf("credential baselines are not workspace metadata create targets: %#v", plan.ProtectedCreateTargets) + } + if _, err := os.Stat(filepath.Dir(missingCredential)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("building the plan materialized a host path: %v", err) + } + + if err := os.MkdirAll(missingCredential, 0o700); err != nil { + t.Fatalf("MkdirAll credential dir: %v", err) + } + plan = buildLinuxBwrapFilesystemPlan(profile) + normalizedCredential := normalizeProfilePath(missingCredential) + assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", normalizedCredential, "--remount-ro", normalizedCredential) +} + +// TestLinuxBwrapCreatesOwnedCredentialDirsBeforeMasking covers the long-lived +// session race: bubblewrap cannot mount over a path that does not exist, so a +// store written after the namespace was assembled would stay readable through +// the live read-only host-root bind. Zero's own directories are therefore +// created up front and masked, unlike third-party stores it must not create. +func TestLinuxBwrapCreatesOwnedCredentialDirsBeforeMasking(t *testing.T) { + root := t.TempDir() + ownedDir := filepath.Join(root, "config", "zero") + thirdParty := filepath.Join(root, "home", ".aws") + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + WriteRoots: []WritableRoot{{Root: root}}, + DenyReadIfExists: []string{ownedDir, thirdParty}, + EnsureDenyReadDirs: []string{ownedDir}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + plan := buildLinuxBwrapFilesystemPlan(profile) + if info, err := os.Stat(ownedDir); err != nil || !info.IsDir() { + t.Fatalf("owned credential dir was not created: err=%v", err) + } + normalizedOwnedDir := normalizeProfilePath(ownedDir) + assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", normalizedOwnedDir, "--remount-ro", normalizedOwnedDir) + if stringSliceContains(plan.Args, thirdParty) { + t.Fatalf("absent third-party store must stay unmounted and uncreated: %#v", plan.Args) + } + if _, err := os.Stat(thirdParty); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("third-party store must not be created by the sandbox: %v", err) + } +} + +// TestLinuxBwrapKeepsCarveoutsReachableInsideMaskedDir covers the user plugin +// root inside the denied Zero config directory: the mask has to keep the +// traverse bit and re-bind the carveout, otherwise an installed user plugin +// cannot be executed through the sandbox at all. +func TestLinuxBwrapKeepsCarveoutsReachableInsideMaskedDir(t *testing.T) { + root := t.TempDir() + credentialDir := filepath.Join(root, "config", "zero") + pluginRoot := filepath.Join(credentialDir, "plugins") + if err := os.MkdirAll(pluginRoot, 0o700); err != nil { + t.Fatal(err) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + WriteRoots: []WritableRoot{{Root: root}}, + DenyReadIfExists: []string{credentialDir}, + DenyReadCarveouts: []string{pluginRoot}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + plan := buildLinuxBwrapFilesystemPlan(profile) + normalizedCredentialDir := normalizeProfilePath(credentialDir) + normalizedPluginRoot := normalizeCredentialCarveoutPath(pluginRoot) + // 111 rather than 000: a 000 directory cannot be traversed, so the re-bound + // subpath below it would be unreachable. Contents stay unlistable either way. + assertArgsContainSequence(t, plan.Args, "--perms", "111", "--tmpfs", normalizedCredentialDir) + assertArgsContainSequence(t, plan.Args, "--ro-bind", normalizedPluginRoot, normalizedPluginRoot) + assertArgsContainSequence(t, plan.Args, "--remount-ro", normalizedCredentialDir) + bindIdx := argsSequenceIndex(plan.Args, "--ro-bind", normalizedPluginRoot, normalizedPluginRoot) + remountIdx := argsSequenceIndex(plan.Args, "--remount-ro", normalizedCredentialDir) + if bindIdx < 0 || remountIdx < 0 || bindIdx > remountIdx { + t.Fatalf("carveout bind (%d) must precede the tmpfs remount-ro (%d): %#v", bindIdx, remountIdx, plan.Args) + } +} + +func TestLinuxBwrapDoesNotBindSymlinkCarveout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + root := t.TempDir() + credentialDir := filepath.Join(root, "config", "zero") + if err := os.MkdirAll(credentialDir, 0o700); err != nil { + t.Fatal(err) + } + secret := filepath.Join(credentialDir, "oauth-tokens.json") + if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + pluginRoot := filepath.Join(credentialDir, "plugins") + if err := os.Symlink(secret, pluginRoot); err != nil { + t.Fatal(err) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + WriteRoots: []WritableRoot{{Root: root}}, + DenyReadIfExists: []string{credentialDir}, + DenyReadCarveouts: []string{pluginRoot}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + plan := buildLinuxBwrapFilesystemPlan(profile) + normalizedCredentialDir := normalizeProfilePath(credentialDir) + assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", normalizedCredentialDir, "--remount-ro", normalizedCredentialDir) + if argsContainSequence(plan.Args, "--ro-bind", pluginRoot, pluginRoot) || argsContainSequence(plan.Args, "--ro-bind", secret, secret) { + t.Fatalf("symlink carveout was rebound into credential mask: %#v", plan.Args) + } +} + func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) { profile := PermissionProfile{ FileSystem: FileSystemPolicy{ diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index a2b8550f2..7a658ab9a 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -7,8 +7,20 @@ import ( "reflect" "runtime" "testing" + + "github.com/Gitlawb/zero/internal/oauth" ) +// TestCredentialPublicationDirSuffixMatchesStore keeps the duplicated suffix in +// step with the store that creates the directory: if they ever diverge, the +// profile denies a directory the store does not publish through, and the +// plaintext token passes through an unprotected path instead. +func TestCredentialPublicationDirSuffixMatchesStore(t *testing.T) { + if credentialPublicationDirSuffix != oauth.PublicationDirSuffix { + t.Fatalf("publication dir suffix = %q, want oauth.PublicationDirSuffix %q", credentialPublicationDirSuffix, oauth.PublicationDirSuffix) + } +} + func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) { workspace := t.TempDir() extra := t.TempDir() @@ -378,43 +390,520 @@ 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 { + zeroDir := filepath.Join(home, "config", "zero") + if err := mkdirAll(awsDir, gcloudDir, zeroDir); err != nil { t.Fatal(err) } keyFile := filepath.Join(home, "sa-key.json") - if err := os.WriteFile(keyFile, []byte("{}"), 0o600); err != nil { + oauthDir := filepath.Join(home, "oauth-store") + mcpDir := filepath.Join(home, "mcp-store") + if err := mkdirAll(oauthDir, mcpDir); err != nil { t.Fatal(err) } + oauthOverride := filepath.Join(oauthDir, "tokens.json") + mcpOverride := filepath.Join(mcpDir, "tokens.json") + overrideFiles := []string{ + oauthOverride, + oauthOverride + ".tmp", + oauthOverride + ".lockfile", + oauthOverride + ".secret", + oauthOverride + ".secret.tmp", + oauthOverride + ".secret.lock", + mcpOverride, + mcpOverride + ".tmp", + mcpOverride + ".lockfile", + mcpOverride + ".secret", + mcpOverride + ".secret.tmp", + mcpOverride + ".secret.lock", + mcpOverride + ".migrated", + } + // The migrated legacy MCP token backup and the atomic-write temp siblings + // every store publishes before its rename; none of these are itemized by + // name, so they only stay protected if the whole zeroDir is denied. + zeroFiles := []string{ + filepath.Join(zeroDir, "config.json"), + filepath.Join(zeroDir, "credentials.json"), + filepath.Join(zeroDir, "credentials.enc"), + filepath.Join(zeroDir, "credentials.enc.secret"), + filepath.Join(zeroDir, "oauth-tokens.json"), + filepath.Join(zeroDir, "oauth-tokens.json.secret"), + filepath.Join(zeroDir, "mcp-oauth-tokens.json"), + filepath.Join(zeroDir, "mcp-oauth-tokens.json.secret"), + filepath.Join(zeroDir, "mcp-oauth-tokens.json.migrated"), + filepath.Join(zeroDir, "oauth-tokens.json.tmp-1234-5678"), + filepath.Join(zeroDir, "credentials.enc.9-1.tmp"), + filepath.Join(zeroDir, ".zero-config-1.tmp"), + } + for _, path := range append(append([]string{keyFile}, overrideFiles...), zeroFiles...) { + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } - paths := credentialDenyReadPathsIn(home, keyFile, nil) - for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) { + options := credentialPathOptions{ + Homes: []string{home}, + GoogleCredentials: []string{keyFile}, + ZeroConfigDirs: []string{filepath.Join(home, "config")}, + OAuthTokens: []string{oauthOverride}, + MCPOAuthTokens: []string{mcpOverride}, + } + credentials := credentialDenyReadPathsIn(options, nil) + paths := credentials.Paths + wantPaths := append([]string{awsDir, gcloudDir, keyFile, zeroDir}, overrideFiles...) + for _, want := range normalizeProfilePaths(wantPaths) { if !stringSliceContains(paths, want) { t.Errorf("credential deny paths = %#v, want %q included", paths, want) } } + // The store publishes through a per-store directory whose name is derived + // from the store path, so it can be denied even though the random file name + // inside it cannot be. + for _, want := range normalizeProfilePaths([]string{oauthOverride + ".publish", mcpOverride + ".publish"}) { + if !stringSliceContains(paths, want) { + t.Errorf("credential deny paths = %#v, want publication directory %q included", paths, want) + } + } + // Zero owns its config directory and the publication directories, so the + // mount-based backend may create them to guarantee a mask exists. + for _, want := range normalizeProfilePaths([]string{zeroDir, oauthOverride + ".publish", mcpOverride + ".publish"}) { + if !stringSliceContains(credentials.EnsureDirs, want) { + t.Errorf("credential ensure dirs = %#v, want %q", credentials.EnsureDirs, want) + } + } + if stringSliceContains(credentials.EnsureDirs, normalizeProfilePaths([]string{awsDir})[0]) { + t.Errorf("credential ensure dirs = %#v, must not create third-party stores", credentials.EnsureDirs) + } + // The user plugin/specialist/command roots live in the denied config + // directory and are executed through the sandbox, so they stay readable. + for _, name := range []string{"plugins", "specialists", "commands"} { + want := filepath.Join(zeroDir, name) + if !stringSliceContains(credentials.Carveouts, normalizeProfilePaths([]string{want})[0]) { + t.Errorf("credential carveouts = %#v, want %q", credentials.Carveouts, want) + } + } + // zeroFiles is covered by the zeroDir subpath deny above, not by an + // itemized entry — including the never-enumerated migrated backup and + // temp-write siblings. + for _, zeroFile := range zeroFiles { + if stringSliceContains(paths, normalizeProfilePaths([]string{zeroFile})[0]) { + t.Errorf("credential deny paths = %#v, want itemized %q dropped in favor of the zeroDir subpath rule", paths, zeroFile) + } + } - // 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) + // A default candidate absent from disk at profile-build time is still + // emitted so pathname-policy backends can reserve it. Mount-based Linux + // retains the same profile baseline but can mask only paths that exist when + // Bubblewrap assembles the namespace. + if !stringSliceContains(paths, normalizeProfilePath(filepath.Join(home, ".azure"))) { + t.Errorf("credential deny paths = %#v, want the not-yet-existing ~/.azure included", 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) + optedOut := credentialDenyReadPathsIn(options, []string{awsDir, zeroDir}) + if stringSliceContains(optedOut.Paths, normalizeProfilePaths([]string{awsDir})[0]) { + t.Errorf("credential deny paths = %#v, want AllowRead opt-out to drop ~/.aws", optedOut.Paths) + } + if stringSliceContains(optedOut.Paths, normalizeProfilePaths([]string{zeroDir})[0]) { + t.Errorf("credential deny paths = %#v, want AllowRead opt-out to drop %q", optedOut.Paths, zeroDir) + } + if !stringSliceContains(optedOut.Paths, normalizeProfilePaths([]string{keyFile})[0]) { + t.Errorf("credential deny paths = %#v, want unrelated entries kept after opt-out", optedOut.Paths) + } + // Nothing is created or carved out for a directory that is no longer denied. + if stringSliceContains(optedOut.EnsureDirs, normalizeProfilePaths([]string{zeroDir})[0]) { + t.Errorf("credential ensure dirs = %#v, want the opted-out config dir dropped", optedOut.EnsureDirs) } - if !stringSliceContains(optedOut, normalizeProfilePaths([]string{keyFile})[0]) { - t.Errorf("credential deny paths = %#v, want unrelated entries kept after opt-out", optedOut) + if stringSliceContains(optedOut.Carveouts, normalizeProfilePaths([]string{filepath.Join(zeroDir, "plugins")})[0]) { + t.Errorf("credential carveouts = %#v, want no allow-back inside an opted-out deny", optedOut.Carveouts) } - if got := credentialDenyReadPathsIn(" ", "", nil); len(got) != 0 { - t.Errorf("credential deny paths for blank home = %#v, want none", got) + if got := credentialDenyReadPathsIn(credentialPathOptions{}, nil); len(got.Paths) != 0 { + t.Errorf("credential deny paths for blank home = %#v, want none", got.Paths) } // 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) + homeless := credentialDenyReadPathsIn(credentialPathOptions{GoogleCredentials: []string{keyFile}}, nil) + if !stringSliceContains(homeless.Paths, normalizeProfilePaths([]string{keyFile})[0]) { + t.Errorf("credential deny paths without home = %#v, want key file included", homeless.Paths) + } +} + +// TestCredentialDenyReadPathsInOverrideMatchesStoreResolution reproduces the +// audit finding that a relative-and-tilde ZERO_OAUTH_TOKENS_PATH / +// ZERO_MCP_OAUTH_TOKENS_PATH override produced a deny rule for a DIFFERENT +// path than the one the token stores actually resolve (oauth.ResolveStorePath +// / mcp.ResolveTokenStorePath never expand "~"; they resolve a relative +// override literally against the working directory), leaving the real file +// unprotected. +func TestCredentialPathOptionsResolveAgainstCommandDirectory(t *testing.T) { + commandDir := t.TempDir() + override := "~/relative-tilde-tokens.json" + options := credentialPathOptionsFromEnvironment(credentialCommandBaseDirs(commandDir), []string{ + "HOME=", + "USERPROFILE=" + filepath.Join(commandDir, "profile-home"), + "XDG_CONFIG_HOME=~/literal-xdg", + "ZERO_OAUTH_TOKENS_PATH=" + override, + "ZERO_MCP_OAUTH_TOKENS_PATH=mcp/tokens.json", + }) + paths := credentialDenyReadPathsIn(options, nil).Paths + + wantHome := filepath.Join(commandDir, "profile-home") + if !stringSliceContains(options.Homes, wantHome) { + t.Fatalf("homes = %#v, want USERPROFILE fallback %q", options.Homes, wantHome) + } + wantConfig := filepath.Join(commandDir, "~", "literal-xdg") + if !stringSliceContains(options.ZeroConfigDirs, wantConfig) { + t.Fatalf("config dirs = %#v, want command-relative literal XDG path %q", options.ZeroConfigDirs, wantConfig) + } + for _, want := range []string{ + filepath.Join(wantConfig, "zero"), + filepath.Join(commandDir, override), + filepath.Join(commandDir, override) + ".tmp", + filepath.Join(commandDir, override) + ".lockfile", + filepath.Join(commandDir, override) + ".secret.lock", + filepath.Join(commandDir, "mcp", "tokens.json"), + filepath.Join(commandDir, "mcp", "tokens.json.tmp"), + filepath.Join(commandDir, "mcp", "tokens.json.lockfile"), + filepath.Join(commandDir, "mcp", "tokens.json.secret"), + filepath.Join(commandDir, "mcp", "tokens.json.secret.tmp"), + filepath.Join(commandDir, "mcp", "tokens.json.secret.lock"), + filepath.Join(commandDir, "mcp", "tokens.json.migrated"), + } { + if !stringSliceContains(paths, normalizeProfilePath(want)) { + t.Errorf("credential deny paths = %#v, want command-relative root %q", paths, want) + } + } +} + +func TestCredentialDenyReadPathsInConfigDirMatchesLiteralXDGResolution(t *testing.T) { + configDir := "~/literal-xdg" + commandDir := t.TempDir() + resolvedConfigDirs := credentialPathOptionsFromEnvironment(credentialCommandBaseDirs(commandDir), []string{"XDG_CONFIG_HOME=" + configDir}).ZeroConfigDirs + paths := credentialDenyReadPathsIn(credentialPathOptions{ZeroConfigDirs: resolvedConfigDirs}, nil).Paths + + want := filepath.Join(commandDir, configDir, "zero") + if !stringSliceContains(resolvedConfigDirs, filepath.Dir(want)) { + t.Fatalf("zero credential config dirs = %#v, want literal XDG resolution %q", resolvedConfigDirs, filepath.Dir(want)) + } + if !stringSliceContains(paths, normalizeProfilePath(want)) { + t.Fatalf("credential deny paths = %#v, want literal XDG resolution %q", paths, want) + } + if expanded := normalizeProfilePaths([]string{filepath.Join(configDir, "zero")})[0]; expanded != want && stringSliceContains(paths, expanded) { + t.Fatalf("credential deny paths = %#v, must not use tilde-expanded XDG path %q", paths, expanded) + } +} + +func TestBuildCommandPlanUsesCommandCredentialContext(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + workspace := t.TempDir() + commandDir := filepath.Join(workspace, "nested") + if err := os.MkdirAll(commandDir, 0o755); err != nil { + t.Fatal(err) + } + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{Name: BackendUnavailable, Platform: runtime.GOOS}, + }) + plan, err := engine.BuildCommandPlan(CommandSpec{ + Name: "true", + Dir: commandDir, + Env: []string{ + "HOME=" + filepath.Join(workspace, "home"), + "ZERO_OAUTH_TOKENS_PATH=credentials/tokens.json", + }, + }) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(plan.Dir, "credentials", "tokens.json") + if !stringSliceContains(plan.PermissionProfile.FileSystem.DenyReadIfExists, want) { + t.Fatalf("DenyReadIfExists = %#v, want command-relative override %q", plan.PermissionProfile.FileSystem.DenyReadIfExists, want) + } + if stringSliceContains(plan.PermissionProfile.FileSystem.DenyReadIfExists, filepath.Dir(want)) { + t.Fatalf("DenyReadIfExists = %#v, must not mask override parent %q", plan.PermissionProfile.FileSystem.DenyReadIfExists, filepath.Dir(want)) + } +} + +// TestBuildCommandPlanDeniesRelativeOverrideAtProcessAndCommandDir covers the +// bypass a command-directory-only resolution left open: oauth.ResolveStorePath +// and mcp.ResolveTokenStorePath call filepath.Abs, so a relative override names +// a file under the ZERO PROCESS working directory, while a sandboxed command +// runs with its own cwd. Denying only the command-relative path left the real +// store readable under the read-all posture. +func TestBuildCommandPlanDeniesRelativeOverrideAtProcessAndCommandDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + workspace := resolvedTempDir(t) + processDir := filepath.Join(workspace, "process") + commandDir := filepath.Join(workspace, "process", "nested") + if err := mkdirAll(commandDir); err != nil { + t.Fatal(err) + } + originalDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(processDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(originalDir) }) + t.Setenv("ZERO_OAUTH_TOKENS_PATH", "tokens.json") + + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{Name: BackendUnavailable, Platform: runtime.GOOS}, + }) + plan, err := engine.BuildCommandPlan(CommandSpec{ + Name: "true", + Dir: commandDir, + Env: []string{"HOME=" + filepath.Join(workspace, "home"), "ZERO_OAUTH_TOKENS_PATH=tokens.json"}, + }) + if err != nil { + t.Fatal(err) + } + // The store the Zero process actually writes. + processStore := filepath.Join(processDir, "tokens.json") + // The path a sandboxed child (e.g. a nested Zero) would resolve instead. + commandStore := filepath.Join(commandDir, "tokens.json") + for _, want := range []string{processStore, commandStore} { + if !stringSliceContains(plan.PermissionProfile.FileSystem.DenyReadIfExists, normalizeProfilePath(want)) { + t.Fatalf("DenyReadIfExists = %#v, want relative override resolved to %q", plan.PermissionProfile.FileSystem.DenyReadIfExists, want) + } + } + // Neither resolution may mask the parent directory itself: that would hide the + // workspace or a temp root from every sandboxed command. + for _, unwanted := range []string{processDir, commandDir} { + if stringSliceContains(plan.PermissionProfile.FileSystem.DenyReadIfExists, normalizeProfilePath(unwanted)) { + t.Fatalf("DenyReadIfExists = %#v, must not mask override parent %q", plan.PermissionProfile.FileSystem.DenyReadIfExists, unwanted) + } + } +} + +func TestCredentialCarveoutsUseNormalizedParentForMissingChildren(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + realConfig := filepath.Join(t.TempDir(), "real-config") + if err := os.MkdirAll(realConfig, 0o700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(t.TempDir(), "config-link") + if err := os.Symlink(realConfig, alias); err != nil { + t.Fatal(err) + } + + credentials := credentialDenyReadPathsIn(credentialPathOptions{ZeroConfigDirs: []string{alias}}, nil) + wantZeroDir := normalizeProfilePath(filepath.Join(realConfig, "zero")) + if !stringSliceContains(credentials.Paths, wantZeroDir) { + t.Fatalf("credential deny paths = %#v, want canonical missing Zero dir %q", credentials.Paths, wantZeroDir) + } + if _, err := os.Stat(wantZeroDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("test requires the Zero dir to remain absent, got %v", err) + } + for _, name := range zeroConfigReadCarveoutNames { + want := filepath.Join(wantZeroDir, name) + if !stringSliceContains(credentials.Carveouts, want) { + t.Errorf("credential carveouts = %#v, want lexical child of canonical root %q", credentials.Carveouts, want) + } + } +} + +func TestCredentialCarveoutsRejectSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + configDir := t.TempDir() + zeroDir := filepath.Join(configDir, "zero") + if err := os.MkdirAll(zeroDir, 0o700); err != nil { + t.Fatal(err) + } + secret := filepath.Join(zeroDir, "oauth-tokens.json") + if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + pluginRoot := filepath.Join(zeroDir, "plugins") + if err := os.Symlink(secret, pluginRoot); err != nil { + t.Fatal(err) + } + + credentials := credentialDenyReadPathsIn(credentialPathOptions{ZeroConfigDirs: []string{configDir}}, nil) + normalizedZeroDir := normalizeProfilePath(zeroDir) + if stringSliceContains(credentials.Carveouts, filepath.Join(normalizedZeroDir, "plugins")) || stringSliceContains(credentials.Carveouts, normalizeProfilePath(secret)) { + t.Fatalf("credential carveouts = %#v, must not re-allow a symlink or its credential target", credentials.Carveouts) + } + if want := filepath.Join(normalizedZeroDir, "specialists"); !stringSliceContains(credentials.Carveouts, want) { + t.Fatalf("credential carveouts = %#v, want unrelated fixed subtree %q", credentials.Carveouts, want) + } +} + +func TestPermissionProfileUnionsProcessAndCommandCredentialRootsWithoutCreatingCommandDirs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + parentHome := t.TempDir() + parentConfig := filepath.Join(parentHome, "config") + parentToken := filepath.Join(parentHome, "trusted-store", "tokens.json") + t.Setenv("HOME", parentHome) + t.Setenv("USERPROFILE", parentHome) + t.Setenv("XDG_CONFIG_HOME", parentConfig) + t.Setenv("ZERO_OAUTH_TOKENS_PATH", parentToken) + t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + + workspace := t.TempDir() + childHome := filepath.Join(workspace, "child-home") + childConfig := filepath.Join(childHome, "config") + childToken := filepath.Join(workspace, "child-store", "tokens.json") + engine := NewEngine(EngineOptions{ + WorkspaceRoot: workspace, + Policy: DefaultPolicy(), + Backend: Backend{Name: BackendUnavailable, Platform: runtime.GOOS}, + }) + plan, err := engine.BuildCommandPlan(CommandSpec{ + Name: "true", + Dir: workspace, + Env: []string{ + "HOME=" + childHome, + "XDG_CONFIG_HOME=" + childConfig, + "ZERO_OAUTH_TOKENS_PATH=" + childToken, + }, + }) + if err != nil { + t.Fatal(err) + } + fs := plan.PermissionProfile.FileSystem + parentZero := filepath.Join(parentConfig, "zero") + childZero := filepath.Join(childConfig, "zero") + for _, want := range []string{parentZero, childZero, parentToken, childToken} { + if !stringSliceContains(fs.DenyReadIfExists, normalizeProfilePath(want)) { + t.Errorf("DenyReadIfExists = %#v, want process/command root %q", fs.DenyReadIfExists, want) + } + } + for _, want := range []string{parentZero, parentToken + credentialPublicationDirSuffix} { + if !stringSliceContains(fs.EnsureDenyReadDirs, normalizeProfilePath(want)) { + t.Errorf("EnsureDenyReadDirs = %#v, want trusted process dir %q", fs.EnsureDenyReadDirs, want) + } + } + for _, unwanted := range []string{childZero, childToken + credentialPublicationDirSuffix} { + if stringSliceContains(fs.EnsureDenyReadDirs, normalizeProfilePath(unwanted)) { + t.Errorf("EnsureDenyReadDirs = %#v, must not include command-controlled dir %q", fs.EnsureDenyReadDirs, unwanted) + } + } + + _ = buildLinuxBwrapFilesystemPlan(plan.PermissionProfile) + if info, err := os.Stat(parentZero); err != nil || !info.IsDir() { + t.Fatalf("trusted process credential dir was not created: info=%v err=%v", info, err) + } + for _, unwanted := range []string{childZero, childToken + credentialPublicationDirSuffix} { + if _, err := os.Stat(unwanted); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("command-controlled credential dir %q was created on the host: %v", unwanted, err) + } + } +} + +func TestPermissionProfileDropsAutomaticMasksCoveredByUserDeny(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("ZERO_OAUTH_TOKENS_PATH", "") + t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + policy := DefaultPolicy() + policy.DenyRead = []string{home} + + profile := PermissionProfileFromPolicy(t.TempDir(), policy, nil) + fs := profile.FileSystem + if len(fs.DenyReadIfExists) != 0 || len(fs.DenyReadCarveouts) != 0 || len(fs.EnsureDenyReadDirs) != 0 { + t.Fatalf("automatic credential fields beneath user deny were retained: paths=%#v carveouts=%#v ensure=%#v", fs.DenyReadIfExists, fs.DenyReadCarveouts, fs.EnsureDenyReadDirs) + } + zeroDir := filepath.Join(home, ".config", "zero") + plan := buildLinuxBwrapFilesystemPlan(profile) + if stringSliceContains(plan.Args, zeroDir) { + t.Fatalf("bwrap args contain a nested automatic mount beneath user deny %q: %#v", home, plan.Args) + } +} + +func TestPermissionProfileDropsRedundantAutomaticMasksInsideZeroDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + home := t.TempDir() + configHome := filepath.Join(home, ".config") + zeroDir := filepath.Join(configHome, "zero") + tokenPath := filepath.Join(zeroDir, "oauth-tokens.json") + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath) + t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + + profile := PermissionProfileFromPolicy(t.TempDir(), DefaultPolicy(), nil) + fs := profile.FileSystem + if !stringSliceContains(fs.DenyReadIfExists, normalizeProfilePath(zeroDir)) { + t.Fatalf("DenyReadIfExists = %#v, want Zero directory mask", fs.DenyReadIfExists) + } + for _, path := range credentialTokenStorePaths(tokenPath) { + if stringSliceContains(fs.DenyReadIfExists, normalizeProfilePath(path)) { + t.Fatalf("DenyReadIfExists = %#v, redundant nested mask %q must be covered by Zero directory", fs.DenyReadIfExists, path) + } + } + if stringSliceContains(fs.EnsureDenyReadDirs, normalizeProfilePath(tokenPath+credentialPublicationDirSuffix)) { + t.Fatalf("EnsureDenyReadDirs = %#v, redundant nested publication dir must not be created", fs.EnsureDenyReadDirs) + } +} + +func TestPermissionProfileIncludesZeroCredentialPaths(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + // Resolve the temp base up front so macOS /var -> /private/var does not + // diverge between the pre-mkdir Clean fallback and the post-mkdir + // EvalSymlinks success path inside normalizeProfilePath. + configHome := resolvedTempDir(t) + t.Setenv("XDG_CONFIG_HOME", configHome) + zeroDir := filepath.Join(configHome, "zero") + + // Build the profile before the store directory exists to verify that profile + // derivation retains the baseline candidate. Pathname-policy backends can + // reserve it immediately; mount-based Linux applies it only if the path + // exists when Bubblewrap assembles the namespace. + profile := PermissionProfileFromPolicy(t.TempDir(), DefaultPolicy(), nil) + want := normalizeProfilePaths([]string{zeroDir})[0] + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, want) { + t.Fatalf("DenyReadIfExists = %#v, want Zero config directory %q even before it exists", profile.FileSystem.DenyReadIfExists, want) + } + + if err := os.MkdirAll(zeroDir, 0o700); err != nil { + t.Fatal(err) + } + secret := filepath.Join(zeroDir, "oauth-tokens.json") + if err := os.WriteFile(secret, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + migrated := filepath.Join(zeroDir, "mcp-oauth-tokens.json.migrated") + if err := os.WriteFile(migrated, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + + // Re-derive after the files exist: the same directory rule covers both + // the known store filename and the never-itemized migrated backup. + // Recompute want once the directory exists so EvalSymlinks can resolve + // the full path (macOS would otherwise compare a pre-mkdir Clean path + // against a post-mkdir /private/var form). + profile = PermissionProfileFromPolicy(t.TempDir(), DefaultPolicy(), nil) + want = normalizeProfilePaths([]string{zeroDir})[0] + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, want) { + t.Fatalf("DenyReadIfExists = %#v, want Zero config directory %q", profile.FileSystem.DenyReadIfExists, want) } } diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 2bd186719..2a326ab22 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -22,13 +22,29 @@ type PermissionProfile struct { } type FileSystemPolicy struct { - Kind FileSystemPolicyKind `json:"kind"` - ReadRoots []string `json:"readRoots,omitempty"` - WriteRoots []WritableRoot `json:"writeRoots,omitempty"` - DenyRead []string `json:"denyRead,omitempty"` - DenyWrite []string `json:"denyWrite,omitempty"` - IncludePlatformRoots bool `json:"includePlatformRoots,omitempty"` - AllowTemp bool `json:"allowTemp,omitempty"` + Kind FileSystemPolicyKind `json:"kind"` + ReadRoots []string `json:"readRoots,omitempty"` + WriteRoots []WritableRoot `json:"writeRoots,omitempty"` + DenyRead []string `json:"denyRead,omitempty"` + // DenyReadIfExists contains best-effort baseline paths. Backends with + // path-based policies can protect future paths; mount-based Linux only + // masks entries that exist when the namespace is assembled. + DenyReadIfExists []string `json:"denyReadIfExists,omitempty"` + // DenyReadCarveouts are subtrees that stay readable INSIDE a denied root. + // They exist so a directory-level credential deny can also cover the files + // a store publishes (arbitrary temporary names, files created later in the + // session) without hiding the supported non-secret subtrees that live in the + // same directory — Zero's user plugin/specialist/command roots, whose + // commands and scripts are themselves executed through the sandbox. + DenyReadCarveouts []string `json:"denyReadCarveouts,omitempty"` + // EnsureDenyReadDirs are directories Zero owns that a mount-based backend + // may create (0700) so a mask exists for them. bubblewrap cannot mount over + // a path that is absent when the namespace is assembled, so without this a + // store created mid-session would be readable by an already-running sandbox. + EnsureDenyReadDirs []string `json:"ensureDenyReadDirs,omitempty"` + DenyWrite []string `json:"denyWrite,omitempty"` + IncludePlatformRoots bool `json:"includePlatformRoots,omitempty"` + AllowTemp bool `json:"allowTemp,omitempty"` } type WritableRoot struct { @@ -70,6 +86,10 @@ func gitMetadataWriteCarveouts(root string) []string { } func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile { + return permissionProfileFromPolicy(workspaceRoot, policy, scope, "", nil) +} + +func permissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope, credentialCommandDir string, credentialEnv []string) PermissionProfile { if policy.Mode == "" { policy = DefaultPolicy() } @@ -93,12 +113,18 @@ func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco ProtectedMetadataNames: append([]string{}, sandboxFullyProtectedMetadataNames...), }) } + userDenyRead := normalizeProfilePaths(policy.DenyRead) + credentials := credentialDenyReadPaths(policy, credentialCommandDir, credentialEnv) + credentials = finalizeCredentialDenyPaths(credentials, userDenyRead) return PermissionProfile{ FileSystem: FileSystemPolicy{ Kind: FileSystemRestricted, ReadRoots: readRoots, WriteRoots: writeRoots, - DenyRead: dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...)), + DenyRead: userDenyRead, + DenyReadIfExists: credentials.Paths, + DenyReadCarveouts: credentials.Carveouts, + EnsureDenyReadDirs: credentials.EnsureDirs, DenyWrite: normalizeProfilePaths(policy.DenyWrite), IncludePlatformRoots: true, AllowTemp: true, @@ -144,10 +170,22 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop return dedupeStrings(readRoots) } +// credentialDenyPaths is the credential baseline a profile derives from the +// environment: the paths to deny reads on, the known directory paths among +// them, the trusted non-secret subtrees that stay readable, and the trusted +// Zero-owned directories a mount-based backend may create so its mask exists. +type credentialDenyPaths struct { + Paths []string + Carveouts []string + EnsureDirs []string + Dirs []string +} + // credentialDenyReadPaths returns default deny-read entries for well-known -// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file -// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read -// cloud secrets under the read-all workspace posture. Two deliberate limits: +// cloud credential stores, the file GOOGLE_APPLICATION_CREDENTIALS points to, +// and Zero's own config/credential/token directory so sandboxed commands +// cannot read secrets under the read-all workspace posture. Four deliberate +// limits: // // - Windows is skipped: a non-empty profile DenyRead switches the Windows // runner onto the capability-SID/ACL deny path and away from the @@ -155,55 +193,424 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop // once the Windows deny-read model is settled. // - A candidate nested under a user-configured AllowRead entry is dropped, // so `allowRead: ["~/.aws"]` remains an explicit opt-out. +// - Candidates are emitted whether or not they currently exist on disk. +// Pathname-policy backends such as Seatbelt can enforce future paths; +// mount-based Linux masks a path only if it exists when the namespace is +// assembled, which is why directories derived from Zero's own process +// environment are also reported as EnsureDirs (command-controlled roots +// never are) and why third-party stores such as ~/.aws stay best-effort. +// - Zero's own config directory is denied WHOLE, with the supported +// non-secret subtrees carved back out. Only a directory-level rule covers +// the temporary names its stores publish through and the files a concurrent +// login creates mid-session; the carveouts keep the user plugin, +// specialist, and command roots readable, since those are executed through +// the sandbox. // // These are profile-level rules only; they are intentionally NOT merged into // Policy.DenyRead, whose emptiness gates escalated (unsandboxed) execution and // must keep reflecting user configuration alone. -func credentialDenyReadPaths(policy Policy) []string { +func credentialDenyReadPaths(policy Policy, commandDir string, commandEnv []string) credentialDenyPaths { if runtime.GOOS == "windows" { - return nil + return credentialDenyPaths{} + } + + // Only roots derived from Zero's own process environment may produce + // carveouts or host directories. CommandSpec.Env can contain project-controlled + // MCP environment overrides, so it contributes deny-if-present paths only. + processBaseDirs := credentialProcessBaseDirs() + trusted := credentialDenyReadPathsIn( + credentialPathOptionsFromEnvironment(processBaseDirs, os.Environ()), + policy.AllowRead, + ) + appendUntrusted := func(options credentialPathOptions) { + paths := credentialDenyReadPathsIn(options, policy.AllowRead) + trusted.Paths = append(trusted.Paths, paths.Paths...) + trusted.Dirs = append(trusted.Dirs, paths.Dirs...) + } + commandBaseDirs := credentialCommandBaseDirs(commandDir) + if len(commandBaseDirs) > 0 { + // A nested Zero inherits the process environment but resolves relative + // values from the sandboxed command's cwd. + appendUntrusted(credentialPathOptionsFromEnvironment(commandBaseDirs, os.Environ())) + } + if commandEnv != nil { + // A supplied environment may also be consumed by code in the running Zero + // process before exec, so conservatively deny both process- and + // command-relative resolutions. These remain untrusted: neither resolution + // contributes carveouts or host directory creation. + baseDirs := dedupeStrings(append(append([]string{}, processBaseDirs...), commandBaseDirs...)) + appendUntrusted(credentialPathOptionsFromEnvironment(baseDirs, commandEnv)) + } + trusted.Paths = dedupeStrings(trusted.Paths) + trusted.Dirs = dedupeStrings(trusted.Dirs) + return trusted +} + +// zeroConfigReadCarveoutNames are the supported non-secret subtrees of +// /zero. Their contents are extension code and prompts that a +// sandboxed command legitimately executes or reads (a user plugin's tool +// command lives below the plugin root), so the credential deny must not hide +// them. Nothing here holds a secret: credentials, tokens, and config live in +// files directly under /zero, not in these subtrees. +var zeroConfigReadCarveoutNames = []string{"plugins", "specialists", "commands"} + +// credentialProcessBaseDirs returns the directory the running Zero process uses +// to resolve relative credential overrides. +func credentialProcessBaseDirs() []string { + if cwd, err := os.Getwd(); err == nil && strings.TrimSpace(cwd) != "" { + return []string{filepath.Clean(cwd)} + } + return nil +} + +// credentialCommandBaseDirs returns the directory a sandboxed child (including +// a nested Zero) uses to resolve inherited relative credential overrides. +func credentialCommandBaseDirs(commandDir string) []string { + if dir := strings.TrimSpace(commandDir); dir != "" { + return []string{filepath.Clean(dir)} + } + return nil +} + +func credentialPathOptionsFromEnvironment(baseDirs []string, env []string) credentialPathOptions { + homes := resolveCredentialOverridePaths(credentialEnvValue(env, "HOME"), baseDirs) + if len(homes) == 0 { + homes = resolveCredentialOverridePaths(credentialEnvValue(env, "USERPROFILE"), baseDirs) + } + if len(homes) == 0 { + home, err := os.UserHomeDir() + if err == nil { + homes = resolveCredentialOverridePaths(home, baseDirs) + } + } + configDirs := resolveCredentialOverridePaths(credentialEnvValue(env, "XDG_CONFIG_HOME"), baseDirs) + if len(configDirs) == 0 { + for _, home := range homes { + configDirs = append(configDirs, filepath.Join(home, ".config")) + } + } + return credentialPathOptions{ + Homes: homes, + GoogleCredentials: resolveCredentialOverridePaths(credentialEnvValue(env, "GOOGLE_APPLICATION_CREDENTIALS"), baseDirs), + ZeroConfigDirs: dedupeStrings(configDirs), + OAuthTokens: resolveCredentialOverridePaths(credentialEnvValue(env, "ZERO_OAUTH_TOKENS_PATH"), baseDirs), + MCPOAuthTokens: resolveCredentialOverridePaths(credentialEnvValue(env, "ZERO_MCP_OAUTH_TOKENS_PATH"), baseDirs), } - // A failed home lookup only drops the home-based candidates; the - // GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless. - home, _ := os.UserHomeDir() - return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead) +} + +func credentialEnvValue(env []string, key string) string { + value := "" + for _, entry := range env { + name, candidate, ok := strings.Cut(entry, "=") + if ok && name == key { + value = candidate + } + } + return value +} + +type credentialPathOptions struct { + Homes []string + GoogleCredentials []string + ZeroConfigDirs []string + OAuthTokens []string + MCPOAuthTokens []string } // credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths, // separated so tests can exercise it against a synthetic home directory. -func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string { +func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string) credentialDenyPaths { var candidates []string - if home = strings.TrimSpace(home); home != "" { - candidates = append(candidates, + var carveouts []string + var ensureDirs []string + var dirs []string + for _, home := range options.Homes { + if strings.TrimSpace(home) == "" { + continue + } + homeDirs := []string{ filepath.Join(home, ".aws"), filepath.Join(home, ".config", "gcloud"), filepath.Join(home, ".azure"), - ) + } + candidates = append(candidates, homeDirs...) + dirs = append(dirs, homeDirs...) } - if target := strings.TrimSpace(googleCredentials); target != "" { - candidates = append(candidates, target) + candidates = append(candidates, options.GoogleCredentials...) + for _, configDir := range options.ZeroConfigDirs { + if strings.TrimSpace(configDir) == "" { + continue + } + // Deny the whole directory rather than an itemized file list: Zero's + // credential, token, and config stores publish through temporary siblings + // before an atomic rename, the legacy MCP store leaves a + // mcp-oauth-tokens.json.migrated backup behind, and a concurrent login can + // add a store that did not exist when this profile was built. Only a + // directory rule covers all three. Zero owns this directory, so it is also + // an EnsureDir: bubblewrap cannot mask a path that is absent when the + // namespace is assembled. + // Normalize the denied root first, then derive its fixed children + // lexically. This keeps nonexistent carveouts under the same canonical + // parent (for example macOS /var -> /private/var) without following a + // plugins/specialists/commands symlink into a credential file. + zeroDir := normalizeProfilePath(filepath.Join(configDir, "zero")) + if zeroDir == "" { + continue + } + candidates = append(candidates, zeroDir) + dirs = append(dirs, zeroDir) + ensureDirs = append(ensureDirs, zeroDir) + for _, name := range zeroConfigReadCarveoutNames { + carveouts = append(carveouts, filepath.Join(zeroDir, name)) + } + } + for _, tokenPath := range options.OAuthTokens { + candidates = append(candidates, credentialTokenStorePaths(tokenPath)...) + publicationDirs := credentialPublicationDirs(tokenPath) + dirs = append(dirs, publicationDirs...) + ensureDirs = append(ensureDirs, publicationDirs...) + } + for _, tokenPath := range options.MCPOAuthTokens { + candidates = append(candidates, credentialTokenStorePaths(tokenPath)...) + // The legacy store renames itself aside after importing into the unified + // store, leaving a readable copy of the same tokens behind. + candidates = append(candidates, tokenPath+".migrated") + publicationDirs := credentialPublicationDirs(tokenPath) + dirs = append(dirs, publicationDirs...) + ensureDirs = append(ensureDirs, publicationDirs...) } allowRoots := normalizeProfilePaths(allowRead) out := make([]string, 0, len(candidates)) for _, path := range normalizeProfilePaths(candidates) { - // Only stores that actually exist on this host need a deny rule. - if _, err := os.Stat(path); err != nil { + if credentialPathReincluded(allowRoots, path) { continue } - reincluded := false - for _, allow := range allowRoots { - if pathWithinRoot(allow, path) { - reincluded = true + out = append(out, path) + } + return credentialDenyPaths{ + Paths: out, + Carveouts: credentialCarveoutPaths(out, carveouts), + EnsureDirs: credentialRetainedDirs(out, normalizeProfilePaths(ensureDirs)), + Dirs: credentialRetainedDirs(out, normalizeProfilePaths(dirs)), + } +} + +// credentialTokenStorePaths returns the deny entries for one token-store path: +// the store, its lock siblings, its encryption-key sibling, and the directory +// it publishes new contents through. The names are fixed so an override outside +// Zero's config directory is protected by exact rules instead of hiding an +// arbitrary parent such as the workspace or /tmp. +func credentialTokenStorePaths(tokenPath string) []string { + if strings.TrimSpace(tokenPath) == "" { + return nil + } + paths := []string{ + tokenPath, + tokenPath + ".lockfile", + tokenPath + ".secret", + tokenPath + ".secret.lock", + // Left behind by a Zero older than the publication directories below. + tokenPath + ".tmp", + tokenPath + ".secret.tmp", + } + return append(paths, credentialPublicationDirs(tokenPath)...) +} + +// credentialPublicationDirs are the per-store directories the OAuth stores +// create their randomly-named temporary file in (see oauth.PublicationDir) — +// one for the token blob, one for its encryption key. The directory NAME is +// derived from the store path so the profile can deny it up front, while the +// random name inside it is what keeps a sandboxed same-user process from +// opening, or renaming away, the file that briefly holds the plaintext. +func credentialPublicationDirs(tokenPath string) []string { + if strings.TrimSpace(tokenPath) == "" { + return nil + } + return []string{ + tokenPath + credentialPublicationDirSuffix, + tokenPath + ".secret" + credentialPublicationDirSuffix, + } +} + +// credentialPublicationDirSuffix mirrors oauth.PublicationDirSuffix, duplicated +// because internal/mcp depends on this package and internal/oauth must stay +// importable from both. +const credentialPublicationDirSuffix = ".publish" + +// pathsOutsideRoots drops every path that lies within one of roots. +func pathsOutsideRoots(paths []string, roots []string) []string { + if len(paths) == 0 || len(roots) == 0 { + return paths + } + out := make([]string, 0, len(paths)) + for _, path := range paths { + if credentialPathReincluded(roots, path) { + continue + } + out = append(out, path) + } + return out +} + +// pathsOutsideOverlappingRoots drops paths that contain, or are contained by, a +// root. A credential carveout is an allow-back rule, so either overlap could +// weaken a user deny after Seatbelt's last-match-wins evaluation. +func pathsOutsideOverlappingRoots(paths []string, roots []string) []string { + if len(paths) == 0 || len(roots) == 0 { + return paths + } + out := make([]string, 0, len(paths)) + for _, path := range paths { + overlaps := false + for _, root := range roots { + if pathWithinRoot(root, path) || pathWithinRoot(path, root) { + overlaps = true break } } - if !reincluded { + if !overlaps { out = append(out, path) } } return out } +func credentialPathReincluded(allowRoots []string, path string) bool { + for _, allow := range allowRoots { + if pathWithinRoot(allow, path) { + return true + } + } + return false +} + +// credentialCarveoutPaths keeps only the carveouts that sit inside a path that +// is actually denied, so an AllowRead opt-out that removed the deny does not +// leave a stray allow-back rule behind. +func credentialCarveoutPaths(denied []string, carveouts []string) []string { + if len(carveouts) == 0 { + return nil + } + denied = normalizeProfilePaths(denied) + out := make([]string, 0, len(carveouts)) + for _, entry := range carveouts { + carveout := normalizeCredentialCarveoutPath(entry) + if carveout == "" { + continue + } + for _, deny := range denied { + if carveout != deny && pathWithinRoot(deny, carveout) { + out = append(out, carveout) + break + } + } + } + return dedupeStrings(out) +} + +// normalizeCredentialCarveoutPath canonicalizes the parent while preserving +// the fixed terminal name. That keeps the allow under the same canonical root +// as its deny (for example macOS /var -> /private/var) without ever following a +// plugins/specialists/commands symlink to a credential target. +func normalizeCredentialCarveoutPath(entry string) string { + carveout := normalizeProfilePathLexically(entry) + if carveout == "" { + return "" + } + // A missing fixed subtree may be installed later by trusted host code, but + // an existing entry must be a real directory. In particular, never turn a + // plugins symlink into an allow rule for its credential-file target. + if info, err := os.Lstat(carveout); err == nil { + if !info.IsDir() { + return "" + } + } else if !os.IsNotExist(err) { + return "" + } + parent := normalizeProfilePath(filepath.Dir(carveout)) + if parent == "" { + return "" + } + return filepath.Join(parent, filepath.Base(carveout)) +} + +// credentialRetainedDirs keeps only directories that remain exact deny entries. +func credentialRetainedDirs(denied []string, dirs []string) []string { + if len(dirs) == 0 { + return nil + } + out := make([]string, 0, len(dirs)) + for _, dir := range dirs { + for _, deny := range denied { + if dir == deny { + out = append(out, dir) + break + } + } + } + return dedupeStrings(out) +} + +// finalizeCredentialDenyPaths gives user denies precedence and removes nested +// automatic mounts that an outer automatic directory mask already covers. +// Children inside a retained carveout stay explicit denies, because the +// carveout re-allows that subtree. +func finalizeCredentialDenyPaths(credentials credentialDenyPaths, userDenyRead []string) credentialDenyPaths { + credentials.Paths = pathsOutsideRoots(credentials.Paths, userDenyRead) + credentials.Carveouts = pathsOutsideOverlappingRoots(credentials.Carveouts, userDenyRead) + credentials.Dirs = credentialRetainedDirs(credentials.Paths, credentials.Dirs) + credentials.Carveouts = credentialCarveoutPaths(credentials.Paths, credentials.Carveouts) + + var paths []string + for _, path := range credentials.Paths { + covered := false + for _, dir := range credentials.Dirs { + if path != dir && pathWithinRoot(dir, path) && !credentialPathReincluded(credentials.Carveouts, path) { + covered = true + break + } + } + if !covered { + paths = append(paths, path) + } + } + credentials.Paths = dedupeStrings(paths) + credentials.Dirs = credentialRetainedDirs(credentials.Paths, credentials.Dirs) + credentials.Carveouts = credentialCarveoutPaths(credentials.Paths, credentials.Carveouts) + credentials.EnsureDirs = credentialRetainedDirs(credentials.Paths, credentials.EnsureDirs) + return credentials +} + +// resolveCredentialOverridePaths mirrors the token stores' own override +// resolution (oauth.ResolveStorePath, mcp.ResolveTokenStorePath — reimplemented +// here rather than imported because internal/mcp depends on this package): the +// value is used literally, NOT tilde-expanded the way normalizeProfilePath +// expands other candidates. Using normalizeProfilePath here would derive a deny +// path that doesn't match where the store actually writes — e.g. +// ZERO_OAUTH_TOKENS_PATH=~/x resolves to /~/x on disk (the store never +// expands "~"), but normalizeProfilePath would deny $HOME/x instead, leaving +// the real file unprotected. +// +// A relative value yields one candidate per supplied base dir because the +// process that resolves it is not necessarily the one that writes the store. +func resolveCredentialOverridePaths(override string, baseDirs []string) []string { + override = strings.TrimSpace(override) + if override == "" { + return nil + } + if filepath.IsAbs(override) { + return []string{filepath.Clean(override)} + } + out := make([]string, 0, len(baseDirs)) + for _, baseDir := range baseDirs { + if strings.TrimSpace(baseDir) == "" { + continue + } + out = append(out, filepath.Clean(filepath.Join(baseDir, override))) + } + return dedupeStrings(out) +} + // userGitConfigReadPaths returns the user's global git config FILES so a // sandboxed git can read identity and config (user.name/email, aliases) instead // of failing with "unable to access ~/.gitconfig". It is deliberately the config @@ -261,6 +668,36 @@ func normalizeProfilePaths(entries []string) []string { } func normalizeProfilePath(entry string) string { + absolute := normalizeProfilePathLexically(entry) + if absolute == "" { + return "" + } + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + return resolved + } + + // EvalSymlinks requires the whole path to exist. Resolve the deepest existing + // ancestor and append the missing tail lexically so future deny paths still + // match canonical aliases such as macOS /var -> /private/var. + current := absolute + var tail []string + for { + parent := filepath.Dir(current) + if parent == current { + return filepath.Clean(absolute) + } + tail = append([]string{filepath.Base(current)}, tail...) + current = parent + if resolved, err := filepath.EvalSymlinks(current); err == nil { + return filepath.Join(append([]string{resolved}, tail...)...) + } + } +} + +// normalizeProfilePathLexically expands and absolutizes a profile path without +// resolving symlinks. Credential carveouts use it so their fixed lexical name +// can never become an allow rule for a symlink target. +func normalizeProfilePathLexically(entry string) string { trimmed := strings.TrimSpace(entry) if trimmed == "" { return "" @@ -276,8 +713,5 @@ func normalizeProfilePath(entry string) string { if err != nil { return "" } - if resolved, err := filepath.EvalSymlinks(absolute); err == nil { - return resolved - } return filepath.Clean(absolute) } diff --git a/internal/sandbox/profile_test.go b/internal/sandbox/profile_test.go new file mode 100644 index 000000000..5decbe9af --- /dev/null +++ b/internal/sandbox/profile_test.go @@ -0,0 +1,123 @@ +package sandbox_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/oauth" + "github.com/Gitlawb/zero/internal/sandbox" +) + +func TestCredentialDeniesMatchTokenStoreFallbacks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + workspace, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + profileHome := filepath.Join(workspace, "profile-home") + envMap := map[string]string{"HOME": "", "USERPROFILE": profileHome, "XDG_CONFIG_HOME": ""} + oauthPath, err := oauth.ResolveStorePath(envMap) + if err != nil { + t.Fatal(err) + } + mcpPath, err := mcp.ResolveTokenStorePath(envMap) + if err != nil { + t.Fatal(err) + } + engine := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: workspace, + Policy: sandbox.DefaultPolicy(), + Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: runtime.GOOS}, + }) + plan, err := engine.BuildCommandPlan(sandbox.CommandSpec{ + Name: "true", + Dir: workspace, + Env: []string{"HOME=", "USERPROFILE=" + profileHome, "XDG_CONFIG_HOME="}, + }) + if err != nil { + t.Fatal(err) + } + for _, storePath := range []string{oauthPath, mcpPath} { + want := filepath.Dir(storePath) + if !containsPath(plan.PermissionProfile.FileSystem.DenyReadIfExists, want) { + t.Fatalf("DenyReadIfExists = %#v, want token-store root %q", plan.PermissionProfile.FileSystem.DenyReadIfExists, want) + } + } +} + +// TestCredentialDeniesMatchRelativeTokenOverridesFromStoreResolution runs the +// sandboxed command from a DIFFERENT directory than the Zero process, which is +// what makes this meaningful: the stores resolve a relative override with +// filepath.Abs against the process working directory, so that is the path the +// profile has to deny. +func TestCredentialDeniesMatchRelativeTokenOverridesFromStoreResolution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + workspace := t.TempDir() + commandDir := filepath.Join(workspace, "nested") + if err := os.MkdirAll(commandDir, 0o755); err != nil { + t.Fatal(err) + } + originalDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + // The Zero process stays in the workspace root while the command runs in the + // nested directory, so the two resolutions differ. + if err := os.Chdir(workspace); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(originalDir) }() + + envMap := map[string]string{ + "HOME": filepath.Join(workspace, "home"), + "ZERO_OAUTH_TOKENS_PATH": "oauth/tokens.json", + "ZERO_MCP_OAUTH_TOKENS_PATH": "mcp/tokens.json", + } + oauthPath, err := oauth.ResolveStorePath(envMap) + if err != nil { + t.Fatal(err) + } + mcpPath, err := mcp.ResolveTokenStorePath(envMap) + if err != nil { + t.Fatal(err) + } + engine := sandbox.NewEngine(sandbox.EngineOptions{ + WorkspaceRoot: workspace, + Policy: sandbox.DefaultPolicy(), + Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: runtime.GOOS}, + }) + plan, err := engine.BuildCommandPlan(sandbox.CommandSpec{ + Name: "true", + Dir: commandDir, + Env: []string{ + "HOME=" + envMap["HOME"], + "ZERO_OAUTH_TOKENS_PATH=" + envMap["ZERO_OAUTH_TOKENS_PATH"], + "ZERO_MCP_OAUTH_TOKENS_PATH=" + envMap["ZERO_MCP_OAUTH_TOKENS_PATH"], + }, + }) + if err != nil { + t.Fatal(err) + } + for _, storePath := range []string{oauthPath, mcpPath} { + if !containsPath(plan.PermissionProfile.FileSystem.DenyReadIfExists, storePath) { + t.Fatalf("DenyReadIfExists = %#v, want override path %q", plan.PermissionProfile.FileSystem.DenyReadIfExists, storePath) + } + } +} + +func containsPath(paths []string, want string) bool { + want = filepath.Clean(want) + for _, path := range paths { + if filepath.Clean(path) == want { + return true + } + } + return false +} diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 5c5f5d833..23714e174 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -178,7 +178,7 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) { if policy.Mode == ModeDisabled { preference = SandboxPreferenceForbid } - profile := PermissionProfileFromPolicy(workspaceRoot, policy, engine.scope) + profile := permissionProfileFromPolicy(workspaceRoot, policy, engine.scope, spec.Dir, spec.Env) var runtimeCleanup func() if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled { runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot) @@ -661,6 +661,10 @@ func seatbeltProfileFromPermissionProfile(profile PermissionProfile, policy Poli writeRule, } rules = append(rules, denyReadRules(profile.FileSystem)...) + // SBPL is last-match-wins, so the carveouts MUST follow the deny rules above: + // they re-include the supported non-secret subtrees of a directory-level + // credential deny (Zero's user plugin/specialist/command roots). + rules = append(rules, denyReadCarveoutRules(profile.FileSystem)...) rules = append(rules, writeRootCarveoutDenyRules(profile.FileSystem)...) rules = append(rules, denyWriteRulesFromPaths(profile.FileSystem.DenyWrite)...) rules = append(rules, networkRule) @@ -788,7 +792,37 @@ func seatbeltProtectedMetadataRegex(root string, name string) string { } func denyReadRules(fs FileSystemPolicy) []string { - return denySeatbeltPathRules("file-read*", fs.DenyRead) + return denySeatbeltPathRules("file-read*", dedupeStrings(append(append([]string{}, fs.DenyRead...), fs.DenyReadIfExists...))) +} + +// denyReadCarveoutRules re-allows reads for the non-secret subtrees of a denied +// credential directory. Writes are untouched: the credential directory is not a +// write root, so the profile's write rule keeps denying them. Only +// DenyReadCarveouts entries are emitted, and the profile builder derives those +// exclusively from Zero's own config directory (never from a user-configured +// DenyRead root), so no user deny is weakened here. +func denyReadCarveoutRules(fs FileSystemPolicy) []string { + denied := dedupeStrings(append(append([]string{}, fs.DenyRead...), fs.DenyReadIfExists...)) + resolved := credentialCarveoutPaths(denied, fs.DenyReadCarveouts) + if len(resolved) == 0 { + return nil + } + out := make([]string, 0, len(resolved)+1) + for _, path := range resolved { + escaped := sandboxProfileString(path) + out = append(out, + `(allow file-read* file-test-existence (subpath "`+escaped+`"))`, + `(allow file-read* file-test-existence (literal "`+escaped+`"))`, + ) + } + // Resolving a path into the carveout also needs stat on its ancestors, and the + // deny rule above covers the denied directory itself. This grants metadata + // only, so the denied directory stays unreadable and unlistable — the same + // split seatbeltReadRule already relies on for deeply nested read roots. + if ancestors := seatbeltAncestorMetadataRule(resolved); ancestors != "" { + out = append(out, ancestors) + } + return out } func writeRootCarveoutDenyRules(fs FileSystemPolicy) []string { diff --git a/internal/sandbox/runner_linux_integration_test.go b/internal/sandbox/runner_linux_integration_test.go index 8352cebf2..560c9c2f8 100644 --- a/internal/sandbox/runner_linux_integration_test.go +++ b/internal/sandbox/runner_linux_integration_test.go @@ -23,12 +23,36 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) { t.Skipf("Linux sandbox backend unavailable: %s", backend.Message) } root := t.TempDir() - if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { - t.Fatalf("Mkdir .git: %v", err) + if err := os.MkdirAll(filepath.Join(root, ".git", "hooks"), 0o755); err != nil { + t.Fatalf("Mkdir .git/hooks: %v", err) } if err := os.WriteFile(filepath.Join(root, ".git", "config"), []byte("[core]\n"), 0o644); err != nil { t.Fatalf("WriteFile .git/config: %v", err) } + credentialHome := t.TempDir() + configHome := filepath.Join(credentialHome, "config") + for _, path := range []string{ + filepath.Join(credentialHome, ".aws"), + filepath.Join(credentialHome, ".config", "gcloud"), + filepath.Join(credentialHome, ".azure"), + filepath.Join(configHome, "zero"), + } { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("Mkdir credential path: %v", err) + } + } + // Secrets inside those stores, so the deny-read table below probes a real read + // rather than only the directory's presence. + awsCredentials := filepath.Join(credentialHome, ".aws", "credentials") + if err := os.WriteFile(awsCredentials, []byte("[default]\naws_secret_access_key = leaked\n"), 0o600); err != nil { + t.Fatalf("WriteFile aws credentials: %v", err) + } + zeroTokens := filepath.Join(configHome, "zero", "oauth-tokens.json") + if err := os.WriteFile(zeroTokens, []byte(`{"access_token":"leaked"}`+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile zero tokens: %v", err) + } + t.Setenv("HOME", credentialHome) + t.Setenv("XDG_CONFIG_HOME", configHome) secretDir := t.TempDir() if err := os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("hidden\n"), 0o644); err != nil { t.Fatalf("WriteFile secret: %v", err) @@ -66,6 +90,130 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) { t.Fatalf("allowed smoke command failed: %v\n%s", runErr, output) } + t.Run("fresh home and non-git workspace launch", func(t *testing.T) { + freshRoot := t.TempDir() + freshHome := t.TempDir() + freshEngine := NewEngine(EngineOptions{WorkspaceRoot: freshRoot, Policy: DefaultPolicy(), Backend: backend}) + output, runErr := runLinuxSandboxSmokeCommand(t, freshEngine, CommandSpec{ + Name: "/bin/sh", + Args: []string{"-c", "echo ok > launched"}, + Dir: freshRoot, + Env: []string{"HOME=" + freshHome, "XDG_CONFIG_HOME=" + filepath.Join(freshHome, ".config")}, + }) + if runErr != nil { + t.Fatalf("fresh environment failed to launch: %v\n%s", runErr, output) + } + }) + + // The mid-session race the EnsureDenyReadDirs contract exists for: nothing + // under $XDG_CONFIG_HOME exists when the plan is built, so bubblewrap would + // have had no mount destination to mask. The sandbox creates Zero's own + // directory first, and the token written by this test WHILE the sandboxed + // command is already running must stay invisible to it. + t.Run("credential store created during the session stays hidden", func(t *testing.T) { + raceHome := t.TempDir() + raceConfig := filepath.Join(raceHome, "config") + raceStore := filepath.Join(raceConfig, "zero") + tokenPath := filepath.Join(raceStore, "oauth-tokens.json") + raceRoot := t.TempDir() + started := filepath.Join(raceRoot, "started") + ready := filepath.Join(raceRoot, "ready") + done := make(chan struct{}) + go func() { + defer close(done) + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(started); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + // The namespace is assembled by now, so this write is the concurrent + // login the sandbox must not be able to read. + if err := os.MkdirAll(raceStore, 0o700); err == nil { + _ = os.WriteFile(tokenPath, []byte("racyleak\n"), 0o600) + } + _ = os.WriteFile(ready, []byte("1\n"), 0o600) + }() + raceEngine := NewEngine(EngineOptions{WorkspaceRoot: raceRoot, Policy: DefaultPolicy(), Backend: backend}) + output, _ := runLinuxSandboxSmokeCommand(t, raceEngine, CommandSpec{ + Name: "/bin/sh", + Args: []string{"-c", strings.Join([]string{ + "echo 1 > " + shellQuote(started), + "i=0", + "while [ ! -e " + shellQuote(ready) + " ] && [ \"$i\" -lt 2000 ]; do i=$((i+1)); sleep 0.01; done", + "if cat " + shellQuote(tokenPath) + " 2>/dev/null | grep -q racyleak; then echo MIDSESSION_CREDENTIAL_READ_SUCCEEDED; fi", + }, "\n")}, + Dir: raceRoot, + Env: []string{"HOME=" + raceHome, "XDG_CONFIG_HOME=" + raceConfig}, + }) + <-done + if strings.Contains(string(output), "MIDSESSION_CREDENTIAL_READ_SUCCEEDED") { + t.Fatalf("token created during the session was readable: %s", output) + } + if _, err := os.Stat(tokenPath); err != nil { + t.Fatalf("test did not create the token it probes for: %v", err) + } + }) + + // The user plugin root shares the denied credential directory, and its + // commands are executed through this sandbox, so it must stay readable. + t.Run("user plugin root inside the denied config dir stays readable", func(t *testing.T) { + pluginHome := t.TempDir() + pluginConfig := filepath.Join(pluginHome, "config") + pluginRoot := filepath.Join(pluginConfig, "zero", "plugins", "demo") + if err := os.MkdirAll(pluginRoot, 0o700); err != nil { + t.Fatalf("MkdirAll plugin root: %v", err) + } + manifest := filepath.Join(pluginRoot, "plugin.json") + if err := os.WriteFile(manifest, []byte(`{"name":"demo"}`+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile plugin manifest: %v", err) + } + secret := filepath.Join(pluginConfig, "zero", "oauth-tokens.json") + if err := os.WriteFile(secret, []byte(`{"access_token":"leaked"}`+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile zero tokens: %v", err) + } + pluginEngine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend}) + output, runErr := runLinuxSandboxSmokeCommand(t, pluginEngine, CommandSpec{ + Name: "/bin/sh", + Args: []string{"-c", strings.Join([]string{ + "set -eu", + "grep -q demo " + shellQuote(manifest), + "if cat " + shellQuote(secret) + " 2>/dev/null | grep -q leaked; then echo CARVEOUT_LEAKED_SIBLING; exit 42; fi", + }, "\n")}, + Dir: root, + Env: []string{"HOME=" + pluginHome, "XDG_CONFIG_HOME=" + pluginConfig}, + }) + if runErr != nil { + t.Fatalf("plugin root inside the denied config dir was not readable: %v\n%s", runErr, output) + } + if strings.Contains(string(output), "CARVEOUT_LEAKED_SIBLING") { + t.Fatalf("carveout exposed a credential sibling: %s", output) + } + }) + + t.Run("credential store created after launch stays hidden on the next run", func(t *testing.T) { + lateHome := t.TempDir() + lateConfig := filepath.Join(lateHome, "config") + lateStore := filepath.Join(lateConfig, "zero") + if err := os.MkdirAll(lateStore, 0o700); err != nil { + t.Fatalf("MkdirAll late credential store: %v", err) + } + if err := os.WriteFile(filepath.Join(lateStore, "oauth-tokens.json"), []byte("token\n"), 0o600); err != nil { + t.Fatalf("WriteFile late token: %v", err) + } + lateEngine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend}) + output, runErr := runLinuxSandboxSmokeCommand(t, lateEngine, CommandSpec{ + Name: "/bin/sh", + Args: []string{"-c", "cat " + shellQuote(filepath.Join(lateStore, "oauth-tokens.json"))}, + Dir: root, + Env: []string{"HOME=" + lateHome, "XDG_CONFIG_HOME=" + lateConfig}, + }) + if runErr == nil || strings.Contains(string(output), "token") { + t.Fatalf("credential store created before this run stayed readable: err=%v output=%s", runErr, output) + } + }) + for _, tc := range []struct { name string script string @@ -91,6 +239,16 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) { script: "if echo leak > .git/config 2>/dev/null; then echo METADATA_WRITE_SUCCEEDED; exit 42; fi", marker: "METADATA_WRITE_SUCCEEDED", }, + { + name: "cloud credential store read", + script: "if cat " + shellQuote(awsCredentials) + " 2>/dev/null | grep -q leaked; then echo CLOUD_CREDENTIAL_READ_SUCCEEDED; exit 42; fi", + marker: "CLOUD_CREDENTIAL_READ_SUCCEEDED", + }, + { + name: "zero credential store read", + script: "if cat " + shellQuote(zeroTokens) + " 2>/dev/null | grep -q leaked; then echo ZERO_CREDENTIAL_READ_SUCCEEDED; exit 42; fi", + marker: "ZERO_CREDENTIAL_READ_SUCCEEDED", + }, } { t.Run(tc.name, func(t *testing.T) { output, _ := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{ diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index dde080333..6b65e9809 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -508,6 +508,80 @@ func TestSeatbeltProfileProtectsMetadataAndDenyOrdering(t *testing.T) { } } +// TestSeatbeltProfileRendersCredentialBaselineAndCarveouts pins the rendering of +// the credential baseline, which the struct-level tests do not cover: a +// DenyReadIfExists entry must reach the profile as a real deny rule (dropping +// the field from denyReadRules would silently make every credential store +// readable again on macOS), and a DenyReadCarveouts entry must be re-allowed +// AFTER it, since SBPL is last-match-wins. +func TestSeatbeltProfileRendersCredentialBaselineAndCarveouts(t *testing.T) { + credentialDir := filepath.Join(t.TempDir(), "zero") + pluginRoot := filepath.Join(credentialDir, "plugins") + if err := os.MkdirAll(pluginRoot, 0o700); err != nil { + t.Fatal(err) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{"/"}, + WriteRoots: []WritableRoot{{Root: "/repo"}}, + DenyReadIfExists: []string{credentialDir}, + DenyReadCarveouts: []string{pluginRoot}, + AllowTemp: true, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + sbpl := seatbeltProfileFromPermissionProfile(profile, Policy{Mode: ModeEnforce}, "") + denyRule := `(deny file-read* (subpath "` + sandboxProfileString(normalizeProfilePath(credentialDir)) + `"))` + allowRule := `(allow file-read* file-test-existence (subpath "` + sandboxProfileString(normalizeProfilePath(pluginRoot)) + `"))` + denyIdx := strings.Index(sbpl, denyRule) + allowIdx := strings.Index(sbpl, allowRule) + if denyIdx < 0 { + t.Fatalf("Seatbelt profile missing credential baseline deny %q:\n%s", denyRule, sbpl) + } + if allowIdx < 0 { + t.Fatalf("Seatbelt profile missing carveout allow %q:\n%s", allowRule, sbpl) + } + if allowIdx < denyIdx { + t.Fatalf("carveout allow (%d) must follow the deny (%d) to win under last-match-wins:\n%s", allowIdx, denyIdx, sbpl) + } +} + +func TestSeatbeltProfileDoesNotRenderSymlinkCarveout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + credentialDir := filepath.Join(t.TempDir(), "zero") + if err := os.MkdirAll(credentialDir, 0o700); err != nil { + t.Fatal(err) + } + secret := filepath.Join(credentialDir, "oauth-tokens.json") + if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + pluginRoot := filepath.Join(credentialDir, "plugins") + if err := os.Symlink(secret, pluginRoot); err != nil { + t.Fatal(err) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{"/"}, + DenyReadIfExists: []string{credentialDir}, + DenyReadCarveouts: []string{pluginRoot}, + }, + Network: NetworkPolicy{Mode: NetworkDeny}, + } + + sbpl := seatbeltProfileFromPermissionProfile(profile, Policy{Mode: ModeEnforce}, "") + for _, path := range []string{pluginRoot, secret, normalizeProfilePath(pluginRoot), normalizeProfilePath(secret)} { + allow := `(allow file-read* file-test-existence (subpath "` + sandboxProfileString(path) + `"))` + if strings.Contains(sbpl, allow) { + t.Fatalf("Seatbelt profile re-allowed symlink carveout path %q:\n%s", path, sbpl) + } + } +} + // TestSeatbeltProfileAllowsGitWritesExceptHooksAndConfig locks in the fix for // git subprocesses (fetch, commit, add, ...) failing under the sandbox: the // default profile must stop write-denying the whole .git tree and only carve @@ -579,6 +653,29 @@ func TestSandboxExecCommandPlanUsesUniquePerPlanDenialTag(t *testing.T) { } } +func TestSeatbeltCompatibilityProfileUsesCredentialEnvironment(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows credential deny-read is tracked separately") + } + override := filepath.Join(t.TempDir(), "mcp-tokens.json") + t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", override) + + profile := seatbeltCompatibilityPermissionProfile([]string{"/ws"}, DefaultPolicy()) + for _, want := range []string{ + override, + override + ".tmp", + override + ".lockfile", + override + ".secret", + override + ".secret.tmp", + override + ".secret.lock", + override + ".migrated", + } { + if !stringSliceContains(profile.FileSystem.DenyReadIfExists, normalizeProfilePath(want)) { + t.Fatalf("DenyReadIfExists = %#v, want environment override sibling %q", profile.FileSystem.DenyReadIfExists, want) + } + } +} + func TestSandboxExecProfileGrantsSignalAndMachLookup(t *testing.T) { profile := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "") @@ -655,6 +752,11 @@ func TestResolveCommandDirAllowsExtraRootCwd(t *testing.T) { func TestLinuxHelperPlanPreservesRealExtraRootCwd(t *testing.T) { workspace := t.TempDir() extra := tempDirOutsideDefaultTemp(t) + credentialHome := filepath.Join(workspace, "credential-home") + configHome := filepath.Join(credentialHome, "config") + if err := os.MkdirAll(filepath.Join(configHome, "zero"), 0o700); err != nil { + t.Fatal(err) + } scope, err := NewScope(workspace, []string{extra}) if err != nil { t.Fatalf("NewScope: %v", err) @@ -666,7 +768,7 @@ func TestLinuxHelperPlanPreservesRealExtraRootCwd(t *testing.T) { Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"}, }) resolvedExtra := scope.Roots()[1] - plan, err := engine.BuildCommandPlan(CommandSpec{Name: "true", Dir: extra}) + plan, err := engine.BuildCommandPlan(CommandSpec{Name: "true", Dir: extra, Env: []string{"HOME=" + credentialHome, "XDG_CONFIG_HOME=" + configHome}}) if err != nil { t.Fatalf("BuildCommandPlan: %v", err) }