Files
zero/internal/cli/sandbox_test.go
55fa05f0ab fix(sandbox): deny reads of Zero credential stores (#681)
* fix(sandbox): deny reads of zero credential stores

* fix(sandbox): address PR review comments

- Deny reads of mcp-oauth-tokens.json.secret in the default Zero
  config candidates: file-backed oauth stores keep their encryption
  secret in a sibling .secret file, so the default MCP token store
  needs the same protection as the override paths (CodeRabbit).
- Clarify that explicit credential-file overrides are still filtered
  by on-disk existence like every other candidate (Copilot).
- Align the zeroUserConfigDir doc comment with config.UserConfigDir:
  macOS honors XDG_CONFIG_HOME before falling back to ~/.config
  (Copilot).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sandbox): deny the whole Zero config dir, not itemized filenames

Address code review on PR #681:

- Deny credentialDenyReadPaths' Zero-config candidate as the containing
  directory instead of an itemized filename list. The token/credential/
  config stores each publish through a randomly-named .tmp-<pid>-<nanos>
  sibling before their atomic rename, and the legacy MCP token store
  leaves a mcp-oauth-tokens.json.migrated backup after importing it —
  none of those names were covered by the old itemized list, so a
  sandboxed command could read them under the read-all posture.

- Stop dropping default candidates that don't exist yet at profile-build
  time. A store created later in a long-lived sandboxed session (e.g. a
  concurrent ) previously got no deny rule at all; every
  backend already treats a deny rule over a not-yet-existing path as a
  harmless no-op that still takes effect once the path appears.

- Resolve ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH overrides
  the same way the token stores resolve them (relative-to-cwd, no ~
  expansion) instead of through normalizeProfilePath, which tilde-expands
  and so could derive a deny path different from where the store actually
  writes.

Adds regression coverage for the directory-wide deny (including the
migrated backup and synthetic temp-file siblings), for building the
profile before the store directory exists, and for the tilde-override
resolution mismatch.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(sandbox): address macOS deny-path test and config parity

Recompute the expected Zero config deny path after MkdirAll and resolve
the temp base with EvalSymlinks so macOS /var -> /private/var does not
flake TestPermissionProfileDeniesZeroCredentialFiles. Add a parity test
that sandbox.zeroUserConfigDir stays aligned with config.UserConfigDir.

Co-authored-by: PierrunoYT <[email protected]>

* fix(sandbox): harden credential deny paths

* test(cli): normalize credential deny baseline

* fix(sandbox): close credential path gaps

* test(sandbox): normalize command credential path

* fix(sandbox): handle absent credential paths safely

* fix(sandbox): protect MCP OAuth siblings

* fix(sandbox): protect OAuth lock siblings

* docs(sandbox): clarify Linux future-path limits

* test(sandbox): probe credential store reads in the Linux smoke test

The smoke test wired HOME/XDG_CONFIG_HOME at credential directories but never
attempted a read through the sandbox, so it could not catch a real-bwrap
regression for the paths this change protects. Write a secret into ~/.aws and
$XDG_CONFIG_HOME/zero and add both to the deny-read table.

* fix(sandbox): keep the credential deny usable, current, and unguessable

Four problems with the credential deny baseline, all in the same mechanism.

The whole-directory deny over $XDG_CONFIG_HOME/zero also hid the supported
user plugin, specialist, and command roots, whose commands and scripts are
executed through this sandbox, so an installed user plugin could no longer
start. Narrowing the deny to itemized files was not an option: the stores
publish through temporary names and a concurrent login can add a store that
did not exist when the profile was built, and only a directory rule covers
both. The directory deny therefore stays, and the non-secret subtrees are
carved back out through a new DenyReadCarveouts field: seatbelt re-allows
them after the deny (SBPL is last-match-wins) plus ancestor metadata so path
resolution can reach them, and bubblewrap masks the directory with --perms
111 instead of 000 -- unlistable but traversable -- and re-binds the carveout
read-only before the tmpfs is remounted read-only.

A relative ZERO_OAUTH_TOKENS_PATH, ZERO_MCP_OAUTH_TOKENS_PATH, or
XDG_CONFIG_HOME was resolved against the command directory only, while
oauth.ResolveStorePath and mcp.ResolveTokenStorePath call filepath.Abs, i.e.
resolve against the Zero process working directory. A command run from a
subdirectory therefore had the wrong path denied and the real store stayed
readable under the read-all posture. Relative candidates now resolve against
both directories, since the process that resolves the value is not
necessarily the one that writes the store. The regression test runs the
command from a different directory than the process, which the previous
tests did not.

bubblewrap cannot mount over a path that is absent when the namespace is
assembled, so a store created mid-session stayed readable through the live
read-only host-root bind for the rest of a long-lived sandbox session. The
directories Zero owns -- its config directory and the per-store publication
directories -- are now reported as EnsureDenyReadDirs and created (0700)
before the namespace is assembled, so their mask always exists. Third-party
stores such as ~/.aws are still skipped when absent: Zero must not create
them, and that limitation stays documented on the field.

The fixed <store>.tmp publication sibling made the plaintext token, and via
the same path its encryption key, observable to a same-user sandboxed process
with write access to an override parent: it could wait for the known name and
rename the opened inode into an allowed path. Both stores now publish through
a per-store <store>.publish directory with a randomly-named file inside. The
directory name is derived from the store path, so the profile can deny it up
front, while the random name inside is what cannot be waited for or renamed
away. A test keeps the duplicated suffix in step with oauth.PublicationDirSuffix.

Also adds the seatbelt render coverage that was missing: dropping
DenyReadIfExists from denyReadRules made every credential store readable
again on macOS without failing a single test.

* fix(sandbox): close credential deny review gaps

Preserve process credential roots alongside command overrides while limiting host precreation and read carveouts to trusted process-derived paths.

Canonicalize future credential roots through existing path aliases, reject symlink carveouts, and collapse automatic masks already covered by user or automatic directory denies. Normalize the policy golden and add regression coverage across profile, Seatbelt, and bubblewrap rendering.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): normalize credential carveout paths

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): canonicalize backend credential masks

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* test(sandbox): normalize backend mask expectations

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): fail closed for replaceable token stores

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): allow keyring token path overrides

Amp-Thread-ID: https://ampcode.com/threads/T-019fbdfa-8469-731e-87c0-b26b7641cf59
Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): harden command credential roots

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-c723-74d9-a323-026040b94436
Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): close credential deny review gaps

Reapply credential denies after Seatbelt extension carveouts and model the legacy MCP token path as a migration input rather than an atomic writer.

Retain Bubblewrap fail-closed markers for OAuth outputs exposed through readable carveouts, and add regressions for both platform policies.

Amp-Thread-ID: https://ampcode.com/threads/T-019fbf55-f6b0-71fc-b384-652ba32b9645
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: PierrunoYT <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 07:33:29 +08:00

1103 lines
41 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestRunSandboxGrantsAllowListDenyRevokeAndClear(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "grants", "allow", "write_file", "--reason", "workspace edits", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("allow exit = %d, stderr %q", exitCode, stderr.String())
}
var allowPayload struct {
Grant sandbox.Grant `json:"grant"`
}
if err := json.Unmarshal(stdout.Bytes(), &allowPayload); err != nil {
t.Fatalf("decode allow JSON: %v\n%s", err, stdout.String())
}
if allowPayload.Grant.ToolName != "write_file" || allowPayload.Grant.Decision != sandbox.GrantAllow {
t.Fatalf("unexpected allow payload: %#v", allowPayload)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sandbox", "grants", "deny", "bash", "--reason=network blocked"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("deny exit = %d, stderr %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "bash") || !strings.Contains(stdout.String(), "deny") {
t.Fatalf("unexpected deny text: %q", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sandbox", "grants", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("list exit = %d, stderr %q", exitCode, stderr.String())
}
var listPayload struct {
Grants []sandbox.Grant `json:"grants"`
}
if err := json.Unmarshal(stdout.Bytes(), &listPayload); err != nil {
t.Fatalf("decode list JSON: %v\n%s", err, stdout.String())
}
if len(listPayload.Grants) != 2 || listPayload.Grants[0].ToolName != "bash" || listPayload.Grants[1].ToolName != "write_file" {
t.Fatalf("unexpected sorted grants: %#v", listPayload.Grants)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sandbox", "grants", "revoke", "bash", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("revoke exit = %d, stderr %q", exitCode, stderr.String())
}
var revokePayload struct {
Revoked int `json:"revoked"`
}
if err := json.Unmarshal(stdout.Bytes(), &revokePayload); err != nil {
t.Fatalf("decode revoke JSON: %v\n%s", err, stdout.String())
}
if revokePayload.Revoked != 1 {
t.Fatalf("revoked = %d, want 1", revokePayload.Revoked)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sandbox", "grants", "clear", "--json"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("clear without confirm exit = %d, want usage", exitCode)
}
if !strings.Contains(stderr.String(), "--confirm") {
t.Fatalf("expected confirm error, got %q", stderr.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sandbox", "grants", "clear", "--confirm", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("clear exit = %d, stderr %q", exitCode, stderr.String())
}
var clearPayload struct {
Cleared int `json:"cleared"`
}
if err := json.Unmarshal(stdout.Bytes(), &clearPayload); err != nil {
t.Fatalf("decode clear JSON: %v\n%s", err, stdout.String())
}
if clearPayload.Cleared != 1 {
t.Fatalf("cleared = %d, want 1", clearPayload.Cleared)
}
}
func TestRunSandboxGrantsListIncludesCommandPrefixes(t *testing.T) {
store := newSandboxTestStore(t)
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}, Reason: "status checks"}); err != nil {
t.Fatalf("GrantCommandPrefix: %v", err)
}
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "grants", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("list json exit = %d, stderr %q", exitCode, stderr.String())
}
var payload struct {
CommandPrefixes []sandbox.CommandPrefixGrant `json:"commandPrefixes"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode list JSON: %v\n%s", err, stdout.String())
}
if len(payload.CommandPrefixes) != 1 || !reflect.DeepEqual(payload.CommandPrefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("unexpected commandPrefixes payload: %#v", payload.CommandPrefixes)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sandbox", "grants", "list"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("list text exit = %d, stderr %q", exitCode, stderr.String())
}
for _, want := range []string{"bash", "`git status`", "command-prefix", "status checks"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("list text = %q, missing %q", stdout.String(), want)
}
}
}
func TestRunSandboxGrantsCreateAndRevokeByPath(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
path := filepath.Join(t.TempDir(), "secret.txt")
var stdout, stderr bytes.Buffer
run := func(args ...string) int {
stdout.Reset()
stderr.Reset()
return runWithDeps(args, &stdout, &stderr, deps)
}
// An exact-path grant and a tool-wide grant for the same tool.
if exit := run("sandbox", "grants", "allow", "write_file", "--path", path); exit != exitSuccess {
t.Fatalf("allow --path exit = %d, stderr %q", exit, stderr.String())
}
if exit := run("sandbox", "grants", "allow", "write_file"); exit != exitSuccess {
t.Fatalf("allow tool-wide exit = %d, stderr %q", exit, stderr.String())
}
// Revoking by path removes only the path-scoped grant.
if exit := run("sandbox", "grants", "revoke", "write_file", "--path", path, "--json"); exit != exitSuccess {
t.Fatalf("revoke --path exit = %d, stderr %q", exit, stderr.String())
}
var payload struct {
Revoked int `json:"revoked"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode revoke JSON: %v\n%s", err, stdout.String())
}
if payload.Revoked != 1 {
t.Fatalf("revoked = %d, want 1", payload.Revoked)
}
grants, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(grants) != 1 || grants[0].ScopeKind != sandbox.ScopeToolWide {
t.Fatalf("expected only the tool-wide grant to remain, got %#v", grants)
}
}
func TestRunSandboxGrantsRejectsEmptyPath(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
var stdout, stderr bytes.Buffer
run := func(args ...string) int {
stdout.Reset()
stderr.Reset()
return runWithDeps(args, &stdout, &stderr, deps)
}
// Seed a tool-wide grant first so a buggy "revoke all for tool" or "allow
// tool-wide" from a rejected call would actually change the store and be caught
// (a revoke-all on an empty store is a silent no-op).
if _, err := store.Grant(sandbox.GrantInput{ToolName: "write_file", Decision: sandbox.GrantAllow}); err != nil {
t.Fatalf("seed grant: %v", err)
}
// An explicit but empty --path must fail closed rather than silently widening
// an allow to tool-wide or a revoke to all-grants-for-tool. Check immutability
// after EACH rejected call so a mutation in one isn't masked by a later call.
for _, args := range [][]string{
{"sandbox", "grants", "allow", "write_file", "--path", ""},
{"sandbox", "grants", "allow", "write_file", "--path="},
{"sandbox", "grants", "revoke", "write_file", "--path", ""},
{"sandbox", "grants", "revoke", "write_file", "--path="},
} {
before, err := store.List()
if err != nil {
t.Fatalf("%v: List before: %v", args, err)
}
if exit := run(args...); exit == exitSuccess {
t.Fatalf("%v: expected a usage error for an empty --path, got success", args)
}
// Either rejection path is acceptable (the `--path=` form hits the
// non-empty check; the `--path ""` form is rejected as a missing value) —
// what matters is that an empty --path never silently widens scope.
if !strings.Contains(stderr.String(), "path") {
t.Fatalf("%v: stderr should explain the empty --path, got %q", args, stderr.String())
}
after, err := store.List()
if err != nil {
t.Fatalf("%v: List after: %v", args, err)
}
if !reflect.DeepEqual(after, before) {
t.Fatalf("%v: rejected call mutated grants; before=%#v after=%#v", args, before, after)
}
}
// Only the seeded grant remains — nothing added or removed by the rejected calls.
grants, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(grants) != 1 || grants[0].ScopeKind != sandbox.ScopeToolWide {
t.Fatalf("expected only the seeded tool-wide grant to remain, got %#v", grants)
}
}
func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendUnavailable,
Platform: "windows",
Fallback: true,
Message: "Windows sandbox command runner is not available",
}
},
}
for _, args := range [][]string{
{"sandbox", "policy"},
{"sandbox", "policy", "--json"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if strings.Contains(strings.Join(args, " "), "--json") {
var payload struct {
Policy sandbox.Policy `json:"policy"`
Backend sandbox.Backend `json:"backend"`
Plan struct {
TargetBackend sandbox.BackendName `json:"targetBackend"`
CommandWrapped bool `json:"commandWrapped"`
EnforcementLevel sandbox.EnforcementLevel `json:"enforcementLevel"`
DowngradeReason string `json:"downgradeReason"`
SupportLevel string `json:"supportLevel"`
Capabilities []sandbox.BackendCapability `json:"capabilities"`
Restrictions []string `json:"restrictions"`
Warnings []string `json:"warnings"`
} `json:"plan"`
Grants string `json:"grantsPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode policy JSON: %v\n%s", err, stdout.String())
}
if payload.Policy.Mode != sandbox.ModeEnforce || payload.Backend.Name != sandbox.BackendUnavailable || payload.Grants == "" {
t.Fatalf("unexpected policy JSON: %#v", payload)
}
if payload.Backend.Platform != "windows" || !payload.Backend.Fallback || payload.Backend.NativeIsolation || payload.Backend.CommandWrapping {
t.Fatalf("unexpected backend capability JSON: %#v", payload.Backend)
}
if payload.Plan.SupportLevel != string(sandbox.BackendSupportUnavailable) {
t.Fatalf("support level = %q, want unavailable", payload.Plan.SupportLevel)
}
if payload.Plan.TargetBackend != sandbox.BackendWindowsRestrictedToken || payload.Plan.CommandWrapped || payload.Plan.EnforcementLevel != sandbox.EnforcementDegraded || payload.Plan.DowngradeReason == "" {
t.Fatalf("unexpected manager baseline fields: %#v", payload.Plan)
}
if sandboxPolicyCapabilityStatus(payload.Plan.Capabilities, "native_process_isolation") != sandbox.CapabilityUnavailable {
t.Fatalf("expected native isolation unavailable, got %#v", payload.Plan.Capabilities)
}
if !sandboxPolicyRestrictionContains(payload.Plan.Restrictions, "native process isolation unavailable on windows") {
t.Fatalf("expected JSON plan to document Windows fallback, got %#v", payload.Plan.Restrictions)
}
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows sandbox command runner is not available") {
t.Fatalf("expected JSON warnings to document Windows fallback, got %#v", payload.Plan.Warnings)
}
} else {
output := stdout.String()
for _, want := range []string{
"Zero sandbox policy",
"backend: unavailable",
"target_backend: windows-restricted-token",
"support_level: unavailable",
"command_wrapped: false",
"enforcement_level: degraded",
"downgrade_reason: Windows sandbox command runner is not available",
"backend_fallback: true",
"backend_command_wrapping: false",
"backend_native_isolation: false",
"backend_platform: windows",
"Windows sandbox command runner is not available",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected policy text to contain %q, got %q", want, output)
}
}
}
})
}
}
func TestHiddenWindowsSandboxSubcommandsSelfDispatch(t *testing.T) {
// The runner/setup sentinels are routed before normal CLI parsing, straight
// into the sandbox helper mains — never the unknown-command path. With no
// helper-specific flags they fail their own arg validation (non-zero), but
// crucially the message is the helper's, proving the dispatch.
for _, tc := range []struct {
name string
arg string
want string
}{
{"runner", sandbox.WindowsCommandRunnerSubcommand, sandbox.WindowsSandboxCommandRunnerName},
{"setup", sandbox.WindowsSandboxSetupSubcommand, sandbox.WindowsSandboxSetupName},
} {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exit := runWithDeps([]string{tc.arg}, &stdout, &stderr, appDeps{})
if exit == 0 {
t.Fatalf("expected non-zero exit from the helper's own arg validation, got 0")
}
if !strings.Contains(stderr.String(), tc.want) {
t.Fatalf("stderr = %q, want the %s helper's message (proves self-dispatch)", stderr.String(), tc.want)
}
if strings.Contains(stderr.String(), "unknown command") {
t.Fatalf("sentinel %q fell through to normal CLI dispatch: %q", tc.arg, stderr.String())
}
})
}
}
func TestRunSandboxSetupRunsWindowsHelper(t *testing.T) {
workspace := t.TempDir()
runnerDir := t.TempDir()
runnerPath := filepath.Join(runnerDir, sandbox.WindowsSandboxCommandRunnerName)
var gotPath string
var gotArgs []string
deps := appDeps{
getwd: func() (string, error) { return workspace, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Sandbox: config.SandboxConfig{Network: "allow"},
}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendWindowsRestrictedToken,
Available: true,
Platform: "windows",
Executable: runnerPath,
CommandWrapping: true,
NativeIsolation: true,
}
},
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
gotPath = path
gotArgs = append([]string{}, args...)
return nil
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "setup", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
}
// The setup helper is now resolved independently of backend.Executable: an
// adjacent zero-windows-sandbox-setup.exe in release, else self-dispatch via
// the running binary. Under `go test` no sibling helper exists, so it
// self-dispatches: path is the running binary and the first arg is the hidden
// setup subcommand, followed by the real setup args.
wantPath, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
if gotPath != wantPath {
t.Fatalf("setup helper path = %q, want self-dispatch binary %q", gotPath, wantPath)
}
if len(gotArgs) == 0 || gotArgs[0] != sandbox.WindowsSandboxSetupSubcommand {
t.Fatalf("setup args = %#v, want leading %q subcommand", gotArgs, sandbox.WindowsSandboxSetupSubcommand)
}
config, err := sandbox.ParseWindowsSandboxSetupArgs(gotArgs[1:])
if err != nil {
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
}
if config.CommandCWD != workspace || len(config.WorkspaceRoots) != 1 || config.WorkspaceRoots[0] != workspace {
t.Fatalf("setup args config = %#v, want workspace cwd/root", config)
}
if config.PermissionProfile.Network.Mode != sandbox.NetworkAllow {
t.Fatalf("setup network profile = %#v, want allow", config.PermissionProfile.Network)
}
var payload sandboxSetupResult
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode setup JSON: %v\n%s", err, stdout.String())
}
if !payload.Ran || payload.Helper != wantPath || payload.Backend != sandbox.BackendWindowsRestrictedToken {
t.Fatalf("setup payload = %#v, want ran Windows helper", payload)
}
}
func TestRunSandboxSetupNoopsForNonWindowsBackend(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}
},
runSandboxSetupHelper: func(string, []string, io.Writer, io.Writer) error {
t.Fatal("setup helper should not run for non-Windows backend")
return nil
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "setup"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "No native sandbox setup is required for linux") {
t.Fatalf("setup output = %q, want non-Windows no-op message", stdout.String())
}
}
func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}, appDeps{}); got != nil {
t.Fatal("linux backend should not enable the TUI sandbox setup command")
}
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: "windows", Available: false}, appDeps{}); got != nil {
t.Fatal("unavailable Windows backend should not enable the TUI sandbox setup command")
}
got := tuiSandboxSetupCommand(sandbox.Backend{
Name: sandbox.BackendWindowsRestrictedToken,
Platform: "windows",
Available: true,
Executable: filepath.Join(t.TempDir(), sandbox.WindowsSandboxCommandRunnerName),
CommandWrapping: true,
NativeIsolation: true,
}, appDeps{})
if got == nil {
t.Fatal("native Windows backend should enable the TUI sandbox setup command")
}
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// 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("CLOUDSDK_CONFIG", "")
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
t.Setenv("NPM_CONFIG_USERCONFIG", "")
t.Setenv("npm_config_userconfig", "")
t.Setenv("GH_CONFIG_DIR", "")
t.Setenv("NETRC", "")
t.Setenv("DOCKER_CONFIG", "")
t.Setenv("KUBECONFIG", "")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", "")
t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "")
store := newSandboxTestStore(t)
workspace := t.TempDir()
deps := appDeps{
getwd: func() (string, error) { return workspace, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendUnavailable,
Platform: "windows",
Fallback: true,
Message: "Windows sandbox command runner is not available",
}
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "policy", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
}
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
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)
}
if !jsonValuesEqual(t, wantBytes, gotBytes) {
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), string(gotBytes))
}
}
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte {
t.Helper()
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope(%q): %v", workspace, err)
}
roots := scope.Roots()
if len(roots) <= 1 {
return gotBytes
}
tempRoots := map[string]struct{}{}
for _, root := range roots[1:] {
tempRoots[root] = struct{}{}
}
var value any
if err := json.Unmarshal(gotBytes, &value); err != nil {
t.Fatalf("decode got JSON for normalization: %v\n%s", err, string(gotBytes))
}
plan, _ := value.(map[string]any)["plan"].(map[string]any)
profile, _ := plan["permissionProfile"].(map[string]any)
fileSystem, _ := profile["fileSystem"].(map[string]any)
wantDenyRead := []string(nil)
credentialHome := emptyHome
if runtime.GOOS != "windows" {
if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil {
credentialHome = resolved
}
wantDenyRead = []string{
filepath.Join(credentialHome, ".aws"),
filepath.Join(credentialHome, ".azure"),
// git's cleartext credential stores, in both the home and XDG
// layouts (#816). Listed here so the exported policy JSON is what
// catches a regression: this baseline is the contract a user reads
// with `zero sandbox policy --json`.
filepath.Join(credentialHome, ".git-credentials"),
filepath.Join(credentialHome, ".config", "git", "credentials"),
filepath.Join(credentialHome, ".npmrc"),
filepath.Join(credentialHome, ".netrc"),
filepath.Join(credentialHome, ".kube", "config"),
filepath.Join(credentialHome, ".docker", "config.json"),
filepath.Join(credentialHome, ".config", "gh", "hosts.yml"),
filepath.Join(credentialHome, ".config", "gcloud"),
filepath.Join(credentialHome, ".config", "zero"),
}
}
gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"])
sort.Strings(gotDenyRead)
sort.Strings(wantDenyRead)
if !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 := filepath.Join(credentialHome, ".config", "zero")
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, "", " ")
if err != nil {
t.Fatalf("encode normalized policy JSON: %v", err)
}
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 {
return value
}
out := make([]any, 0, len(roots))
for _, root := range roots {
text, ok := root.(string)
if !ok {
out = append(out, root)
continue
}
if _, skip := excluded[text]; !skip {
out = append(out, root)
}
}
return out
}
func filterJSONWriteRoots(value any, excluded map[string]struct{}) any {
roots, ok := value.([]any)
if !ok {
return value
}
out := make([]any, 0, len(roots))
for _, root := range roots {
entry, ok := root.(map[string]any)
if !ok {
out = append(out, root)
continue
}
text, _ := entry["root"].(string)
if _, skip := excluded[text]; !skip {
out = append(out, root)
}
}
return out
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
t.Helper()
var wantValue any
if err := json.Unmarshal(wantBytes, &wantValue); err != nil {
t.Fatalf("decode wanted JSON: %v", err)
}
var gotValue any
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
}
normalizePortableJSONRootString(&wantValue)
normalizePortableJSONRootString(&gotValue)
return reflect.DeepEqual(wantValue, gotValue)
}
func normalizePortableJSONRootString(value *any) {
switch current := (*value).(type) {
case string:
// Normalize OS-specific path separators so the shared golden file
// matches on both Unix (slash) and Windows (backslash) runners.
// readOnlySubpaths carve-outs are built with filepath.Join, which
// renders as backslash paths on Windows.
if current != "" {
*value = strings.ReplaceAll(current, `\`, "/")
}
case []any:
for index := range current {
normalizePortableJSONRootString(&current[index])
}
case map[string]any:
for key := range current {
entry := current[key]
normalizePortableJSONRootString(&entry)
current[key] = entry
}
}
}
func replacePathToken(value string, path string, token string) string {
replace := func(value string, path string) string {
value = strings.ReplaceAll(value, path, token)
if encoded, err := json.Marshal(path); err == nil && len(encoded) >= 2 {
value = strings.ReplaceAll(value, string(encoded[1:len(encoded)-1]), token)
}
return value
}
if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path {
value = replace(value, resolved)
}
return replace(value, path)
}
func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendUnavailable,
Platform: "darwin",
Fallback: true,
Message: "native sandbox unavailable",
}
},
}
t.Run("text", func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("effective exit = %d, stderr %q", exitCode, stderr.String())
}
output := stdout.String()
for _, want := range []string{
"Zero effective sandbox policy",
"mode: enforce",
"network: deny",
"enforce_workspace: true",
"interactive_command_guard: enabled",
"support_level: unavailable",
} {
if !strings.Contains(output, want) {
t.Fatalf("effective text missing %q, got %q", want, output)
}
}
})
t.Run("json", func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "policy", "--effective", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("effective json exit = %d, stderr %q", exitCode, stderr.String())
}
var payload struct {
Policy struct {
Mode string `json:"mode"`
Network string `json:"network"`
EnforceWorkspace bool `json:"enforceWorkspace"`
} `json:"policy"`
Backend struct {
Name string `json:"name"`
} `json:"backend"`
Plan struct {
SupportLevel string `json:"supportLevel"`
} `json:"plan"`
Guards struct {
InteractiveCommand bool `json:"interactiveCommand"`
Network bool `json:"network"`
Workspace bool `json:"workspace"`
} `json:"guards"`
GrantsPath string `json:"grantsPath"`
PermissionProfileNote string `json:"permissionProfileNote"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
}
if payload.Policy.Mode != "enforce" || payload.Policy.Network != "deny" {
t.Fatalf("unexpected effective policy: %#v", payload.Policy)
}
if !payload.Policy.EnforceWorkspace {
t.Fatalf("expected workspace guard enabled: %#v", payload.Policy)
}
if !payload.Guards.InteractiveCommand {
t.Fatalf("expected guards reported: %#v", payload.Guards)
}
if payload.Plan.SupportLevel != string(sandbox.BackendSupportUnavailable) || payload.GrantsPath == "" {
t.Fatalf("unexpected effective plan/grants: %#v %q", payload.Plan, payload.GrantsPath)
}
if payload.PermissionProfileNote != permissionProfileScopeNote {
t.Fatalf("permissionProfileNote = %q, want %q", payload.PermissionProfileNote, permissionProfileScopeNote)
}
})
}
func TestEffectiveSandboxPolicyListsWriteRoots(t *testing.T) {
output := formatEffectiveSandboxPolicy("/ws", sandbox.DefaultPolicy(), sandbox.Backend{}, sandbox.BackendPlan{}, resolveSandboxGuards(sandbox.DefaultPolicy()), "/grants", []string{"/ws", "/extra"}, nil)
if !strings.Contains(output, "write_roots: /ws, /extra") {
t.Fatalf("expected write_roots line, got:\n%s", output)
}
if !strings.Contains(output, "enforce_workspace: true\nwrite_roots: /ws, /extra") {
t.Fatalf("write_roots should directly follow enforce_workspace, got:\n%s", output)
}
if !strings.Contains(output, "permission_profile_note: "+permissionProfileScopeNote) {
t.Fatalf("effective text missing permission profile scope warning:\n%s", output)
}
if strings.Contains(output, "write_roots_error") {
t.Fatalf("unexpected write_roots_error line without an error:\n%s", output)
}
}
func TestEffectiveSandboxPolicyShowsWriteRootsError(t *testing.T) {
scopeErr := errors.New(`write root "/gone": write root must exist`)
output := formatEffectiveSandboxPolicy("/ws", sandbox.DefaultPolicy(), sandbox.Backend{}, sandbox.BackendPlan{}, resolveSandboxGuards(sandbox.DefaultPolicy()), "/grants", []string{"/ws"}, scopeErr)
if !strings.Contains(output, "write_roots: /ws") {
t.Fatalf("expected fallback write_roots line, got:\n%s", output)
}
if !strings.Contains(output, `write_roots_error: write root "/gone": write root must exist`) {
t.Fatalf("expected write_roots_error line, got:\n%s", output)
}
}
func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
store := newSandboxTestStore(t)
extra := tempDirOutsideDefaultTemp(t)
resolvedExtra, err := filepath.EvalSymlinks(extra)
if err != nil {
t.Fatalf("EvalSymlinks(%q) returned error: %v", extra, err)
}
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Sandbox: config.SandboxConfig{AdditionalWriteRoots: []string{extra}}}, nil
},
}
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("effective exit = %d, stderr %q", code, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "write_roots: ") {
t.Fatalf("effective text missing write_roots line:\n%s", output)
}
if !strings.Contains(output, resolvedExtra) {
t.Fatalf("write_roots should include the configured extra root %q:\n%s", resolvedExtra, output)
}
if strings.Contains(output, "write_roots_error") {
t.Fatalf("unexpected write_roots_error for valid roots:\n%s", output)
}
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"sandbox", "policy", "--effective", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("effective json exit = %d, stderr %q", code, stderr.String())
}
var payload struct {
WriteRoots []string `json:"writeRoots"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
}
if len(payload.WriteRoots) < 2 {
t.Fatalf("writeRoots = %#v, want workspace root, extra root, and default temp roots", payload.WriteRoots)
}
if !containsString(payload.WriteRoots, resolvedExtra) {
t.Fatalf("writeRoots = %#v, want configured extra root %q", payload.WriteRoots, resolvedExtra)
}
if strings.Contains(stdout.String(), "writeRootsError") {
t.Fatalf("unexpected writeRootsError key for valid roots:\n%s", stdout.String())
}
}
func TestRunSandboxPolicyEffectiveWriteRootsFailSoft(t *testing.T) {
store := newSandboxTestStore(t)
missing := filepath.Join(t.TempDir(), "does-not-exist")
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{Sandbox: config.SandboxConfig{AdditionalWriteRoots: []string{missing}}}, nil
},
}
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("effective exit = %d, want success (stale config must fail soft), stderr %q", code, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "write_roots_error: ") {
t.Fatalf("expected visible write_roots_error line for stale config entry:\n%s", output)
}
if !strings.Contains(output, "write_roots: ") {
t.Fatalf("expected workspace-only write_roots fallback line:\n%s", output)
}
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"sandbox", "policy", "--effective", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("effective json exit = %d, want success (stale config must fail soft), stderr %q", code, stderr.String())
}
var payload struct {
WriteRoots []string `json:"writeRoots"`
WriteRootsError string `json:"writeRootsError"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
}
if payload.WriteRootsError == "" {
t.Fatalf("expected writeRootsError in JSON for stale config entry:\n%s", stdout.String())
}
if !strings.Contains(payload.WriteRootsError, missing) {
t.Fatalf("writeRootsError = %q, want it to name the stale root %q", payload.WriteRootsError, missing)
}
if len(payload.WriteRoots) == 0 {
t.Fatalf("writeRoots = %#v, want workspace/default-temp fallback", payload.WriteRoots)
}
if containsString(payload.WriteRoots, missing) {
t.Fatalf("writeRoots = %#v, must not include stale root %q", payload.WriteRoots, missing)
}
}
func TestRunSandboxPolicyEffectiveHelpListed(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "policy", "--help"}, &stdout, &stderr, appDeps{})
if exitCode != exitSuccess {
t.Fatalf("help exit = %d, stderr %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "--effective") {
t.Fatalf("policy help should document --effective, got %q", stdout.String())
}
}
func TestRunSandboxHelpDoesNotOpenStore(t *testing.T) {
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) {
t.Fatal("newSandboxStore should not be called for help")
return nil, nil
}}
for _, args := range [][]string{
{"sandbox", "--help"},
{"sandbox", "grants", "--help"},
{"sandbox", "grants", "allow", "--help"},
{"sandbox", "policy", "--help"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("help exit = %d, stderr %q", exitCode, stderr.String())
}
if stdout.Len() == 0 {
t.Fatalf("expected help output")
}
})
}
}
func TestRunSandboxPolicyTextOmitsMaxAutonomy(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
}
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"sandbox", "policy"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("policy exit = %d, stderr %q", code, stderr.String())
}
if strings.Contains(stdout.String(), "max_autonomy") {
t.Fatalf("policy text should omit max_autonomy:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("effective policy exit = %d, stderr %q", code, stderr.String())
}
if strings.Contains(stdout.String(), "max_autonomy") {
t.Fatalf("effective policy text should omit max_autonomy:\n%s", stdout.String())
}
}
func TestRunSandboxPolicySurfacesResolveConfigError(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q", "maybe")
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "policy"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("policy exit = %d, want provider exit %d (resolve error surfaced, not silent DefaultPolicy fallback)", exitCode, exitProvider)
}
if !strings.Contains(stderr.String(), "invalid sandbox.network") {
t.Fatalf("expected surfaced resolve error in stderr, got %q", stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout on resolve error, got %q", stdout.String())
}
}
func TestApplyConfiguredSandboxPolicyDiagnosticsFlags(t *testing.T) {
base := sandbox.DefaultPolicy()
if base.BlockUnixSockets || base.MonitorDenials {
t.Fatalf("precondition: diagnostic flags must default off")
}
// Omitted keys leave the (off) defaults untouched.
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.BlockUnixSockets || got.MonitorDenials {
t.Fatalf("empty config must not enable diagnostic flags: %#v", got)
}
// The flags opt in.
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{
BlockUnixSockets: true,
MonitorDenials: true,
})
if !got.BlockUnixSockets || !got.MonitorDenials {
t.Fatalf("diagnostic config not applied to policy: %#v", got)
}
}
func newSandboxTestStore(t *testing.T) *sandbox.GrantStore {
t.Helper()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
Now: fixedCLITime("2026-06-05T14:45:00Z"),
})
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
return store
}
func sandboxPolicyRestrictionContains(restrictions []string, value string) bool {
for _, restriction := range restrictions {
if strings.Contains(restriction, value) {
return true
}
}
return false
}
func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key string) sandbox.CapabilityStatus {
for _, capability := range capabilities {
if capability.Key == key {
return capability.Status
}
}
return ""
}
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
disabled := false
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
}
// Omitted (nil) and explicit true keep the default enforcing mode.
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
t.Fatal("omitted enabled must not disable the sandbox")
}
enabled := true
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
t.Fatal("enabled:true must not disable the sandbox")
}
}