diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 8e8fc6947..b8afc97f7 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1617,9 +1617,15 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re func sandboxDeniedShellResult(result tools.Result) bool { if result.ExecutionOutcome != nil { - // Typed denials must be handled by their exact capability path. Never - // turn a structured narrow denial into the legacy unrestricted retry. - return false + // Only a typed platform denial that explicitly requests unrestricted + // recovery may enter the legacy retry path. Narrow capability denials + // remain on their exact capability path. + denial := result.ExecutionOutcome.Denial + return denial != nil && + denial.Source == execution.DenialSourcePlatformSandbox && + denial.Capability.Kind == execution.CapabilityUnrestricted && + denial.Recoverable && + denial.NextAction == execution.DenialNextActionRequestApproval } return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true" } diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 86258f432..16e9234f6 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -64,6 +64,22 @@ func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) { t.Fatal("narrow protected-metadata denial must not become an unrestricted retry") } + nonRecoverableDenial := protectedDenial + nonRecoverableDenial.ExecutionOutcome = &execution.Outcome{ + State: execution.StateDenied, + Kind: execution.OutcomeEnforcementDenied, + Denial: &execution.Denial{ + Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"}, + Source: execution.DenialSourcePlatformSandbox, + Reason: "sandbox enforcement failed closed", + Recoverable: false, + NextAction: execution.DenialNextActionRequestApproval, + }, + } + if sandboxRestrictedShellRetryCandidate(call, nil, nonRecoverableDenial, options) { + t.Fatal("non-recoverable sandbox denial must not become an unrestricted retry") + } + networkDenial := protectedDenial networkDenial.ExecutionOutcome = &execution.Outcome{ State: execution.StateDenied, @@ -288,6 +304,18 @@ func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any) return tools.Result{ Status: tools.StatusError, Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system", + ExecutionOutcome: &execution.Outcome{ + State: execution.StateDenied, + Kind: execution.OutcomeEnforcementDenied, + Exit: &execution.Exit{Code: 1}, + Denial: &execution.Denial{ + Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"}, + Source: execution.DenialSourcePlatformSandbox, + Reason: "sandbox blocked command execution", + Recoverable: true, + NextAction: execution.DenialNextActionRequestApproval, + }, + }, Meta: map[string]string{ "exit_code": "1", tools.SandboxLikelyDeniedMeta: "true", diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index a2b8550f2..566bdf7c7 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "reflect" "runtime" + "strings" "testing" ) @@ -378,7 +379,31 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { home := t.TempDir() awsDir := filepath.Join(home, ".aws") gcloudDir := filepath.Join(home, ".config", "gcloud") - if err := mkdirAll(awsDir, gcloudDir); err != nil { + npmrc := filepath.Join(home, ".npmrc") + ghHosts := filepath.Join(home, ".config", "gh", "hosts.yml") + netrc := filepath.Join(home, ".netrc") + dockerConfig := filepath.Join(home, ".docker", "config.json") + kubeConfig := filepath.Join(home, ".kube", "config") + zeroConfig := filepath.Join(home, ".config", "zero") + if err := mkdirAll( + awsDir, + gcloudDir, + filepath.Dir(ghHosts), + filepath.Dir(dockerConfig), + filepath.Dir(kubeConfig), + filepath.Dir(zeroConfig), + ); err != nil { + t.Fatal(err) + } + for _, path := range []string{npmrc, ghHosts, netrc, dockerConfig, kubeConfig, zeroConfig} { + if path == zeroConfig { + continue + } + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(zeroConfig, 0o755); err != nil { t.Fatal(err) } keyFile := filepath.Join(home, "sa-key.json") @@ -387,7 +412,17 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { } paths := credentialDenyReadPathsIn(home, keyFile, nil) - for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) { + for _, want := range normalizeProfilePaths([]string{ + awsDir, + gcloudDir, + keyFile, + npmrc, + ghHosts, + netrc, + dockerConfig, + kubeConfig, + zeroConfig, + }) { if !stringSliceContains(paths, want) { t.Errorf("credential deny paths = %#v, want %q included", paths, want) } @@ -418,3 +453,58 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { t.Errorf("credential deny paths without home = %#v, want key file included", homeless) } } + +func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T) { + root := t.TempDir() + configHome := filepath.Join(root, "xdg") + npmrc := filepath.Join(root, "npm", "userconfig") + ghConfigDir := filepath.Join(root, "gh") + cloudSDKConfig := filepath.Join(root, "gcloud") + netrc := filepath.Join(root, "netrc") + dockerConfigDir := filepath.Join(root, "docker") + kubeConfigA := filepath.Join(root, "kube", "a") + kubeConfigB := filepath.Join(root, "kube", "b") + zeroConfig := filepath.Join(configHome, "zero") + filePaths := []string{ + npmrc, + filepath.Join(ghConfigDir, "hosts.yml"), + netrc, + filepath.Join(dockerConfigDir, "config.json"), + kubeConfigA, + kubeConfigB, + zeroConfig, + } + if err := os.MkdirAll(cloudSDKConfig, 0o755); err != nil { + t.Fatal(err) + } + for _, path := range filePaths { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if path == zeroConfig { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + continue + } + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + + got := credentialDenyReadPathsForEnvironment(credentialPathEnvironment{ + ConfigHome: configHome, + CloudSDKConfig: cloudSDKConfig, + NPMUserConfig: npmrc, + GHConfigDir: ghConfigDir, + Netrc: netrc, + DockerConfigDir: dockerConfigDir, + KubeConfig: strings.Join([]string{kubeConfigA, kubeConfigB}, string(filepath.ListSeparator)), + }, nil) + wantPaths := append(filePaths, cloudSDKConfig) + for _, want := range normalizeProfilePaths(wantPaths) { + if !stringSliceContains(got, want) { + t.Errorf("credential deny paths = %#v, want override %q included", got, want) + } + } +} diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 2bd186719..8eca4c9d7 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -145,9 +145,10 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop } // credentialDenyReadPaths returns default deny-read entries for well-known -// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file -// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read -// cloud secrets under the read-all workspace posture. Two deliberate limits: +// credential stores so sandboxed commands cannot read secrets under the +// read-all workspace posture. This includes tool configuration files that can +// carry credentials and are now discoverable through the preserved caller +// environment. Two deliberate limits: // // - Windows is skipped: a non-empty profile DenyRead switches the Windows // runner onto the capability-SID/ACL deny path and away from the @@ -166,23 +167,100 @@ func credentialDenyReadPaths(policy Policy) []string { // A failed home lookup only drops the home-based candidates; the // GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless. home, _ := os.UserHomeDir() - return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead) + return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{ + Home: home, + ConfigHome: os.Getenv("XDG_CONFIG_HOME"), + CloudSDKConfig: os.Getenv("CLOUDSDK_CONFIG"), + GoogleCredentials: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), + NPMUserConfig: firstNonEmpty(os.Getenv("NPM_CONFIG_USERCONFIG"), os.Getenv("npm_config_userconfig")), + GHConfigDir: os.Getenv("GH_CONFIG_DIR"), + Netrc: os.Getenv("NETRC"), + DockerConfigDir: os.Getenv("DOCKER_CONFIG"), + KubeConfig: os.Getenv("KUBECONFIG"), + }, policy.AllowRead) } // credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths, // separated so tests can exercise it against a synthetic home directory. func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string { + return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{ + Home: home, + GoogleCredentials: googleCredentials, + }, allowRead) +} + +type credentialPathEnvironment struct { + Home string + ConfigHome string + CloudSDKConfig string + GoogleCredentials string + NPMUserConfig string + GHConfigDir string + Netrc string + DockerConfigDir string + KubeConfig string +} + +func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowRead []string) []string { var candidates []string - if home = strings.TrimSpace(home); home != "" { + home := strings.TrimSpace(env.Home) + configHome := strings.TrimSpace(env.ConfigHome) + if configHome == "" && home != "" { + configHome = filepath.Join(home, ".config") + } + if home != "" { candidates = append(candidates, filepath.Join(home, ".aws"), - filepath.Join(home, ".config", "gcloud"), filepath.Join(home, ".azure"), ) } - if target := strings.TrimSpace(googleCredentials); target != "" { + npmUserConfig := strings.TrimSpace(env.NPMUserConfig) + if npmUserConfig == "" && home != "" { + npmUserConfig = filepath.Join(home, ".npmrc") + } + netrc := strings.TrimSpace(env.Netrc) + if netrc == "" && home != "" { + netrc = filepath.Join(home, ".netrc") + } + dockerConfigDir := strings.TrimSpace(env.DockerConfigDir) + if dockerConfigDir == "" && home != "" { + dockerConfigDir = filepath.Join(home, ".docker") + } + candidates = append(candidates, npmUserConfig, netrc) + if dockerConfigDir != "" { + candidates = append(candidates, filepath.Join(dockerConfigDir, "config.json")) + } + if configHome != "" { + candidates = append(candidates, + filepath.Join(configHome, "zero"), + ) + } + cloudSDKConfig := strings.TrimSpace(env.CloudSDKConfig) + if cloudSDKConfig == "" && configHome != "" { + cloudSDKConfig = filepath.Join(configHome, "gcloud") + } + if cloudSDKConfig != "" { + candidates = append(candidates, cloudSDKConfig) + } + ghConfigDir := strings.TrimSpace(env.GHConfigDir) + if ghConfigDir == "" && configHome != "" { + ghConfigDir = filepath.Join(configHome, "gh") + } + if ghConfigDir != "" { + candidates = append(candidates, filepath.Join(ghConfigDir, "hosts.yml")) + } + if target := strings.TrimSpace(env.GoogleCredentials); target != "" { candidates = append(candidates, target) } + kubeConfig := strings.TrimSpace(env.KubeConfig) + if kubeConfig == "" && home != "" { + kubeConfig = filepath.Join(home, ".kube", "config") + } + for _, path := range filepath.SplitList(kubeConfig) { + if path = strings.TrimSpace(path); path != "" { + candidates = append(candidates, path) + } + } allowRoots := normalizeProfilePaths(allowRead) out := make([]string, 0, len(candidates)) for _, path := range normalizeProfilePaths(candidates) { @@ -206,11 +284,12 @@ func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead // userGitConfigReadPaths returns the user's global git config FILES so a // sandboxed git can read identity and config (user.name/email, aliases) instead -// of failing with "unable to access ~/.gitconfig". It is deliberately the config -// files only — not the ~/.config/git directory, which can hold an XDG credential -// store — so credentials and the rest of HOME stay unreadable. Granted at the -// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the -// HOME-dependent paths don't leak into the platform-agnostic policy snapshot. +// of failing with "unable to access ~/.gitconfig". On macOS, where reads are +// allow-listed, granting only these files avoids exposing the surrounding +// configuration directory. Linux uses a read-all profile with explicit +// credential deny rules. The paths are granted at the macOS seatbelt read rule +// rather than the cross-platform PermissionProfile so HOME-dependent paths +// don't leak into the platform-agnostic policy snapshot. func userGitConfigReadPaths() []string { home, err := os.UserHomeDir() if err != nil || strings.TrimSpace(home) == "" { diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index a738396fc..4a5fdfc9a 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -27,13 +27,10 @@ var fallbackSandboxRuntimes = struct { }{roots: make(map[string]string)} type SandboxRuntime struct { - Root string `json:"root,omitempty"` - Home string `json:"home,omitempty"` - Cache string `json:"cache,omitempty"` - Config string `json:"config,omitempty"` - Data string `json:"data,omitempty"` - State string `json:"state,omitempty"` - Temp string `json:"temp,omitempty"` + Root string `json:"root,omitempty"` + Cache string `json:"cache,omitempty"` + Data string `json:"data,omitempty"` + Temp string `json:"temp,omitempty"` } func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) { @@ -75,21 +72,15 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) } }() runtimeState := SandboxRuntime{ - Root: root, - Home: filepath.Join(root, "home"), - Cache: filepath.Join(root, "cache"), - Config: filepath.Join(root, "config"), - Data: filepath.Join(root, "data"), - State: filepath.Join(root, "state"), - Temp: filepath.Join(root, "tmp"), + Root: root, + Cache: filepath.Join(root, "cache"), + Data: filepath.Join(root, "data"), + Temp: filepath.Join(root, "tmp"), } directories := []string{ runtimeState.Root, - runtimeState.Home, runtimeState.Cache, - runtimeState.Config, runtimeState.Data, - runtimeState.State, runtimeState.Temp, filepath.Join(runtimeState.Cache, "npm"), filepath.Join(runtimeState.Cache, "yarn"), @@ -207,16 +198,11 @@ func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []str return env } overrides := []string{ - "HOME=" + runtimeState.Home, "XDG_CACHE_HOME=" + runtimeState.Cache, - "XDG_CONFIG_HOME=" + runtimeState.Config, - "XDG_DATA_HOME=" + runtimeState.Data, - "XDG_STATE_HOME=" + runtimeState.State, "TMPDIR=" + runtimeState.Temp, "TMP=" + runtimeState.Temp, "TEMP=" + runtimeState.Temp, "npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"), - "NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"), "YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"), "COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"), "PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"), diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index 999ad2fd3..b707f67f2 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -2,7 +2,9 @@ package sandbox import ( "os" + "os/exec" "path/filepath" + "strings" "testing" "time" ) @@ -22,12 +24,18 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) { if pathWithinRoot(workspace, runtimeState.Root) { t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace) } - for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} { + for _, path := range []string{runtimeState.Cache, runtimeState.Data, runtimeState.Temp, filepath.Join(runtimeState.Data, "cargo")} { info, err := os.Stat(path) if err != nil || !info.IsDir() { t.Fatalf("managed runtime directory %q was not prepared: %v", path, err) } } + for _, name := range []string{"home", "config", "state"} { + path := filepath.Join(runtimeState.Root, name) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("obsolete synthetic runtime directory %q exists: %v", path, err) + } + } } func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) { @@ -116,34 +124,63 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) { } } -func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) { +func TestSandboxRuntimeEnvironmentPreservesUserConfiguration(t *testing.T) { root := filepath.Join(t.TempDir(), "runtime") runtimeState := SandboxRuntime{ - Root: root, - Home: filepath.Join(root, "home"), - Cache: filepath.Join(root, "cache"), - Config: filepath.Join(root, "config"), - Data: filepath.Join(root, "data"), - State: filepath.Join(root, "state"), - Temp: filepath.Join(root, "tmp"), + Root: root, + Cache: filepath.Join(root, "cache"), + Data: filepath.Join(root, "data"), + Temp: filepath.Join(root, "tmp"), } env := sandboxRuntimeEnvironment([]string{ - "HOME=/workspace", + "HOME=/home/user", "XDG_CACHE_HOME=/host/cache", + "XDG_CONFIG_HOME=/home/user/.config", + "XDG_DATA_HOME=/home/user/.local/share", + "XDG_STATE_HOME=/home/user/.local/state", + "NPM_CONFIG_USERCONFIG=/home/user/.npmrc", "PATH=/usr/bin", }, &runtimeState) for key, want := range map[string]string{ - "HOME": runtimeState.Home, - "XDG_CACHE_HOME": runtimeState.Cache, - "XDG_CONFIG_HOME": runtimeState.Config, - "XDG_DATA_HOME": runtimeState.Data, - "XDG_STATE_HOME": runtimeState.State, - "TMPDIR": runtimeState.Temp, - "npm_config_cache": filepath.Join(runtimeState.Cache, "npm"), - "NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"), - "YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"), - "COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"), + "HOME": "/home/user", + "XDG_CONFIG_HOME": "/home/user/.config", + "XDG_DATA_HOME": "/home/user/.local/share", + "XDG_STATE_HOME": "/home/user/.local/state", + "NPM_CONFIG_USERCONFIG": "/home/user/.npmrc", + } { + if got := envListValue(env, key, ""); got != want { + t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env) + } + } +} + +func TestSandboxRuntimeEnvironmentUsesManagedWritableState(t *testing.T) { + root := filepath.Join(t.TempDir(), "runtime") + runtimeState := SandboxRuntime{ + Root: root, + Cache: filepath.Join(root, "cache"), + Data: filepath.Join(root, "data"), + Temp: filepath.Join(root, "tmp"), + } + env := sandboxRuntimeEnvironment([]string{ + "XDG_CACHE_HOME=/host/cache", + "TMPDIR=/host/tmp", + "PATH=/usr/bin", + }, &runtimeState) + + for key, want := range map[string]string{ + "XDG_CACHE_HOME": runtimeState.Cache, + "TMPDIR": runtimeState.Temp, + "TMP": runtimeState.Temp, + "TEMP": runtimeState.Temp, + "npm_config_cache": filepath.Join(runtimeState.Cache, "npm"), + "YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"), + "COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"), + "PIP_CACHE_DIR": filepath.Join(runtimeState.Cache, "pip"), + "GOCACHE": filepath.Join(runtimeState.Cache, "go-build"), + "GOMODCACHE": filepath.Join(runtimeState.Data, "go-mod"), + "CARGO_HOME": filepath.Join(runtimeState.Data, "cargo"), } { if got := envListValue(env, key, ""); got != want { t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env) @@ -154,9 +191,93 @@ func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) { } } +func TestSandboxRuntimeEnvironmentPreservesGitGlobalIgnore(t *testing.T) { + git, err := exec.LookPath("git") + if err != nil { + t.Skip("git is unavailable") + } + root := t.TempDir() + home := filepath.Join(root, "home") + configHome := filepath.Join(home, ".config") + repository := filepath.Join(root, "repository") + runtimeRoot := filepath.Join(root, "runtime") + for _, directory := range []string{ + filepath.Join(configHome, "git"), + repository, + filepath.Join(runtimeRoot, "cache"), + filepath.Join(runtimeRoot, "data"), + filepath.Join(runtimeRoot, "tmp"), + } { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(configHome, "git", "ignore"), []byte("ignored.txt\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "ignored.txt"), []byte("ignored\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "visible.txt"), []byte("visible\n"), 0o600); err != nil { + t.Fatal(err) + } + + runtimeState := SandboxRuntime{ + Root: runtimeRoot, + Cache: filepath.Join(runtimeRoot, "cache"), + Data: filepath.Join(runtimeRoot, "data"), + Temp: filepath.Join(runtimeRoot, "tmp"), + } + inherited := append(os.Environ(), + "GIT_CONFIG_GLOBAL="+filepath.Join(root, "missing-global-config"), + "GIT_DIR="+filepath.Join(root, "wrong-repository"), + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=core.excludesFile", + "GIT_CONFIG_VALUE_0="+filepath.Join(root, "missing-ignore"), + ) + env := sandboxRuntimeEnvironment(upsertEnvList(withoutGitEnvironmentOverrides(inherited), + "HOME="+home, + "XDG_CONFIG_HOME="+configHome, + "GIT_CONFIG_NOSYSTEM=1", + "GIT_TERMINAL_PROMPT=0", + ), &runtimeState) + runGit := func(args ...string) string { + t.Helper() + command := exec.Command(git, args...) + command.Dir = repository + command.Env = env + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) + } + return string(output) + } + runGit("init", "--quiet") + status := runGit("status", "--short", "--untracked-files=all") + if strings.Contains(status, "ignored.txt") { + t.Fatalf("global ignore was not honored:\n%s", status) + } + if !strings.Contains(status, "visible.txt") { + t.Fatalf("non-ignored file is missing from status:\n%s", status) + } +} + +func withoutGitEnvironmentOverrides(env []string) []string { + out := make([]string, 0, len(env)) + for _, value := range env { + key, _, ok := strings.Cut(value, "=") + if ok && strings.HasPrefix(strings.ToUpper(key), "GIT_") { + continue + } + out = append(out, value) + } + return out +} + func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) { workspace := t.TempDir() cacheRoot := t.TempDir() + t.Setenv("HOME", filepath.Join(t.TempDir(), "home")) original := sandboxUserCacheDir sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } t.Cleanup(func() { sandboxUserCacheDir = original }) @@ -189,8 +310,8 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) { } else if !inUse { t.Fatal("command plan runtime must be marked in use") } - if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home { - t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home) + if got := envListValue(plan.Env, "HOME", ""); got != os.Getenv("HOME") { + t.Fatalf("HOME = %q, want caller home %q", got, os.Getenv("HOME")) } foundWriteRoot := false for _, root := range plan.PermissionProfile.FileSystem.WriteRoots { diff --git a/internal/tools/exec_command_test.go b/internal/tools/exec_command_test.go index 8b4f11c8f..b2513b0db 100644 --- a/internal/tools/exec_command_test.go +++ b/internal/tools/exec_command_test.go @@ -316,6 +316,26 @@ func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) { } } +func TestExecCommandDoesNotInferNativeSandboxDenialFromOutput(t *testing.T) { + result := execToolResult(execToolResultInput{ + commandText: "touch \"$HOME/probe\"", + output: "touch: cannot touch '/home/user/probe': Read-only file system", + exited: true, + exitCode: 1, + enforcement: execution.Enforcement{Backend: string(sandbox.BackendLinuxBwrap), Level: string(sandbox.EnforcementNative)}, + }) + + if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure { + t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome) + } + if result.ExecutionOutcome.Denial != nil { + t.Fatalf("command output created a typed denial: %#v", result.ExecutionOutcome.Denial) + } + if result.Meta[SandboxLikelyDeniedMeta] == "true" { + t.Fatalf("command output created sandbox-denial metadata: %#v", result.Meta) + } +} + func executionRequestHasCapability(request execution.Request, kind execution.CapabilityKind, scope string) bool { for _, capability := range request.Capabilities { if capability.Kind == kind && (scope == "" || capability.Scope == scope) {