mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* fix(windows): retry atomic file renames on Access is denied / Sharing violation * test(swarm): add unit tests for rename retry and non-retryable errors * test(swarm): skip TestMailboxRenameRetry on non-Windows platforms * fix(sandbox): scrub sensitive provider credentials from sandbox env * refactor(sandbox): unify environment creation and reduce lines of code * fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend * style(sandbox,swarm): remove stray blank lines flagged by gofmt * refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog Address CodeRabbit review feedback: - Add internal/fsutil.RenameWithRetry with an injectable rename override and move the four duplicated renameWithRetry/isWindowsSharingViolation copies (cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test seam by passing its field through. Add fsutil tests for the Windows retry and non-retryable paths. - Remove the orphaned metadata.json.tmp in cron writeJob when the rename permanently fails, matching the sibling writers. - Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a static list of non-catalog secrets, so new providers are scrubbed automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay because they are pointers, not secrets. * fix(sandbox): deny sandboxed reads of cloud credential stores by default Address CodeRabbit finding that scrubbed env pointers still left the underlying credential files readable under the read-all workspace posture: - Add credentialDenyReadPaths: default profile-level deny-read entries for ~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS target file, wired into both permission profile builders so the seatbelt and Linux helper backends enforce them. Entries are stat-filtered and a store nested under a user AllowRead entry is dropped, keeping an explicit opt-out. - Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated execution, and skip Windows so the profile deny list does not flip the runner off the WRITE_RESTRICTED token path. - Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE since the deny-read rule on ~/.aws is the real protection and the SDKs fall back to the default profile regardless. - Add credential deny path tests, extend the scrub test, relax the profile test's exact deny count to containment. * test(cli): pin HOME in sandbox policy golden test The default credential-store deny-read entries depend on what exists in the real home directory (the macOS CI image ships ~/.aws), which leaked host paths into the policy JSON golden comparison. Point HOME at an empty temp directory so the golden stays hermetic. * fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations Address CodeRabbit review feedback: - credentialDenyReadPaths no longer returns early when the home lookup fails; home-based candidates become conditional and the GOOGLE_APPLICATION_CREDENTIALS target is always considered. - fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33), matching the mcp lock handling; helper renamed to isWindowsSharingOrLockViolation. - Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock violation step in the retry sequence. * fix(sandbox): tighten env-scrub test assertion and add missing secret keys TestScrubSensitiveEnv only checked an allowlist plus a length count, so a duplicate allowed key silently replacing a required one would still pass. Compare the exact expected key/value map instead. Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read from the environment but were missing from the scrub list, leaving them readable by sandboxed commands when set in the parent shell.
1004 lines
37 KiB
Go
1004 lines
37 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"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 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.
|
|
emptyHome := t.TempDir()
|
|
t.Setenv("HOME", emptyHome)
|
|
t.Setenv("USERPROFILE", emptyHome)
|
|
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
|
|
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)
|
|
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) []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)
|
|
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 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(¤t[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"`
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
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, "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 ""
|
|
}
|