From 99c74d9b86b60ca58dc71ca2210f97a60e99b257 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Fri, 24 Jul 2026 12:23:38 +0530 Subject: [PATCH 1/8] fix(sandbox): preserve user config and retry denied writes Keep user identity and configuration paths discoverable while retaining managed cache and temporary storage. Classify native sandbox failures as typed recoverable denials so approved commands can retry outside the sandbox. Add regression coverage for global Git ignores, typed denial handling, and permission retry behavior. --- internal/agent/loop.go | 6 +- internal/agent/loop_test.go | 12 +++ internal/sandbox/runtime_state.go | 32 ++---- internal/sandbox/runtime_state_test.go | 139 +++++++++++++++++++++---- internal/tools/bash.go | 9 ++ internal/tools/exec_command.go | 8 ++ internal/tools/exec_command_test.go | 24 +++++ internal/tools/sandbox_denial.go | 42 +++++++- internal/tools/sandbox_denial_test.go | 24 +++++ 9 files changed, 248 insertions(+), 48 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 8e8fc6947..a806cc2c0 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1619,7 +1619,11 @@ 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 + denial := result.ExecutionOutcome.Denial + return denial != nil && + denial.Source == execution.DenialSourcePlatformSandbox && + denial.Capability.Kind == execution.CapabilityUnrestricted && + 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..23c4cb853 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -288,6 +288,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/runtime_state.go b/internal/sandbox/runtime_state.go index a738396fc..12c61ade5 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"), @@ -97,7 +88,6 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) filepath.Join(runtimeState.Cache, "pip"), filepath.Join(runtimeState.Cache, "go-build"), filepath.Join(runtimeState.Data, "go-mod"), - filepath.Join(runtimeState.Data, "cargo"), } for _, directory := range directories { if err := os.MkdirAll(directory, 0o700); err != nil { @@ -207,22 +197,16 @@ 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"), "GOCACHE=" + filepath.Join(runtimeState.Cache, "go-build"), "GOMODCACHE=" + filepath.Join(runtimeState.Data, "go-mod"), - "CARGO_HOME=" + filepath.Join(runtimeState.Data, "cargo"), } return upsertEnvList(env, overrides...) } diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index 999ad2fd3..b2ba8bb9e 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,7 +24,7 @@ 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} { info, err := os.Stat(path) if err != nil || !info.IsDir() { t.Fatalf("managed runtime directory %q was not prepared: %v", path, err) @@ -116,34 +118,64 @@ 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", + "CARGO_HOME=/home/user/.cargo", "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", + "CARGO_HOME": "/home/user/.cargo", + } { + 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"), } { if got := envListValue(env, key, ""); got != want { t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env) @@ -154,9 +186,72 @@ 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"), + } + env := sandboxRuntimeEnvironment(upsertEnvList(os.Environ(), + "HOME="+home, + "XDG_CONFIG_HOME="+configHome, + ), &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 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 +284,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/bash.go b/internal/tools/bash.go index 2a40e2934..35b1f0f73 100644 --- a/internal/tools/bash.go +++ b/internal/tools/bash.go @@ -181,6 +181,15 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS } return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true) } + if adapterReport.Denial == nil && reportErr == nil { + adapterReport.Denial = markLikelySandboxDenial( + meta, + plan.Wrapped && plan.EnforcementLevel == zeroSandbox.EnforcementNative, + exitCode, + stdoutText, + stderrText, + ) + } if err != nil { if exitCode < 0 { result := Result{ diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index b1b902b3d..3322a12f0 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -437,6 +437,14 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu for key, value := range input.sandboxMeta { meta[key] = value } + if input.exited && !input.interrupted && input.report.Denial == nil && input.reportErr == nil { + input.report.Denial = markLikelySandboxDenial( + meta, + input.enforcement.Level == string(zeroSandbox.EnforcementNative), + input.exitCode, + input.output, + ) + } outcome := execExecutionOutcome(input) if input.exited { meta["exit_code"] = strconv.Itoa(input.exitCode) diff --git a/internal/tools/exec_command_test.go b/internal/tools/exec_command_test.go index 8b4f11c8f..466c2a7db 100644 --- a/internal/tools/exec_command_test.go +++ b/internal/tools/exec_command_test.go @@ -316,6 +316,30 @@ func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) { } } +func TestExecCommandInfersNativeSandboxDenialFromOutput(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.StateDenied || result.ExecutionOutcome.Kind != execution.OutcomeEnforcementDenied { + t.Fatalf("execution outcome = %#v, want denied/enforcement_denied", result.ExecutionOutcome) + } + denial := result.ExecutionOutcome.Denial + if denial == nil || + denial.Source != execution.DenialSourcePlatformSandbox || + denial.Capability.Kind != execution.CapabilityUnrestricted || + denial.NextAction != execution.DenialNextActionRequestApproval { + t.Fatalf("inferred denial = %#v, want recoverable platform-sandbox unrestricted denial", denial) + } + if result.Meta[SandboxDenialKeywordMeta] != "read-only file system" { + t.Fatalf("denial metadata = %#v, want read-only filesystem keyword", 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) { diff --git a/internal/tools/sandbox_denial.go b/internal/tools/sandbox_denial.go index ab9fa7dd1..64ffe2a44 100644 --- a/internal/tools/sandbox_denial.go +++ b/internal/tools/sandbox_denial.go @@ -1,6 +1,20 @@ package tools -import "github.com/Gitlawb/zero/internal/execution" +import ( + "strings" + + "github.com/Gitlawb/zero/internal/execution" +) + +var sandboxDenialKeywords = []string{ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", +} // markStructuredSandboxDenial mirrors typed adapter facts into legacy metadata // for presentation and backward-compatible session readers. Classification is @@ -17,3 +31,29 @@ func markStructuredSandboxDenial(meta map[string]string, denial execution.Denial meta["sandbox_denial_scope"] = denial.Capability.Scope } } + +func markLikelySandboxDenial(meta map[string]string, sandboxed bool, exitCode int, outputSections ...string) *execution.Denial { + if meta == nil || !sandboxed || exitCode == 0 { + return nil + } + for _, section := range outputSections { + lower := strings.ToLower(section) + for _, keyword := range sandboxDenialKeywords { + if strings.Contains(lower, keyword) { + 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[SandboxLikelyDeniedMeta] = "true" + meta[SandboxDenialKindMeta] = SandboxDenialKindSandbox + meta[SandboxDenialReasonMeta] = denial.Reason + meta[SandboxDenialKeywordMeta] = keyword + return denial + } + } + } + return nil +} diff --git a/internal/tools/sandbox_denial_test.go b/internal/tools/sandbox_denial_test.go index 51d53fc8a..78de0f6a0 100644 --- a/internal/tools/sandbox_denial_test.go +++ b/internal/tools/sandbox_denial_test.go @@ -16,3 +16,27 @@ func TestStructuredSandboxDenialMetadata(t *testing.T) { t.Fatalf("structured metadata = %#v", meta) } } + +func TestLikelySandboxDenialMetadataFromCommandOutput(t *testing.T) { + for _, test := range []struct { + name string + sandboxed bool + exitCode int + output string + want bool + }{ + {name: "sandboxed read-only filesystem", sandboxed: true, exitCode: 1, output: "touch: Read-only file system", want: true}, + {name: "sandboxed permission denied", sandboxed: true, exitCode: 1, output: "mkdir: Permission denied", want: true}, + {name: "successful command", sandboxed: true, exitCode: 0, output: "Read-only file system", want: false}, + {name: "unsandboxed command", sandboxed: false, exitCode: 1, output: "Read-only file system", want: false}, + {name: "ordinary application failure", sandboxed: true, exitCode: 1, output: "invalid package name", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + meta := map[string]string{} + markLikelySandboxDenial(meta, test.sandboxed, test.exitCode, test.output) + if got := meta[SandboxLikelyDeniedMeta] == "true"; got != test.want { + t.Fatalf("sandbox denial = %t, want %t; meta=%#v", got, test.want, meta) + } + }) + } +} From c2429d00acf15ed851494f6d3d09e57aac8ba1de Mon Sep 17 00:00:00 2001 From: anandh8x Date: Fri, 24 Jul 2026 12:45:00 +0530 Subject: [PATCH 2/8] fix(sandbox): tighten denial retry safeguards --- internal/agent/loop.go | 1 + internal/agent/loop_test.go | 16 ++++++++++++++ internal/sandbox/runtime_state_test.go | 29 +++++++++++++++++++++++++- internal/tools/sandbox_denial.go | 2 -- internal/tools/sandbox_denial_test.go | 10 +++++++-- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index a806cc2c0..d129f2f3f 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1623,6 +1623,7 @@ func sandboxDeniedShellResult(result tools.Result) bool { 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 23c4cb853..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, diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index b2ba8bb9e..d41fe9b6f 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -30,6 +30,12 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) { 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) { @@ -223,9 +229,18 @@ func TestSandboxRuntimeEnvironmentPreservesGitGlobalIgnore(t *testing.T) { Data: filepath.Join(runtimeRoot, "data"), Temp: filepath.Join(runtimeRoot, "tmp"), } - env := sandboxRuntimeEnvironment(upsertEnvList(os.Environ(), + 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() @@ -248,6 +263,18 @@ func TestSandboxRuntimeEnvironmentPreservesGitGlobalIgnore(t *testing.T) { } } +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() diff --git a/internal/tools/sandbox_denial.go b/internal/tools/sandbox_denial.go index 64ffe2a44..9a1a63aee 100644 --- a/internal/tools/sandbox_denial.go +++ b/internal/tools/sandbox_denial.go @@ -11,9 +11,7 @@ var sandboxDenialKeywords = []string{ "permission denied", "read-only file system", "seccomp", - "sandbox", "landlock", - "failed to write file", } // markStructuredSandboxDenial mirrors typed adapter facts into legacy metadata diff --git a/internal/tools/sandbox_denial_test.go b/internal/tools/sandbox_denial_test.go index 78de0f6a0..3e3963ebc 100644 --- a/internal/tools/sandbox_denial_test.go +++ b/internal/tools/sandbox_denial_test.go @@ -28,12 +28,18 @@ func TestLikelySandboxDenialMetadataFromCommandOutput(t *testing.T) { {name: "sandboxed read-only filesystem", sandboxed: true, exitCode: 1, output: "touch: Read-only file system", want: true}, {name: "sandboxed permission denied", sandboxed: true, exitCode: 1, output: "mkdir: Permission denied", want: true}, {name: "successful command", sandboxed: true, exitCode: 0, output: "Read-only file system", want: false}, - {name: "unsandboxed command", sandboxed: false, exitCode: 1, output: "Read-only file system", want: false}, + {name: "unsandboxed read-only command", sandboxed: false, exitCode: 1, output: "Read-only file system", want: false}, + {name: "unsandboxed permission failure", sandboxed: false, exitCode: 1, output: "mkdir: Permission denied", want: false}, + {name: "generic sandbox message", sandboxed: true, exitCode: 1, output: "application sandbox configuration is invalid", want: false}, + {name: "generic write failure", sandboxed: true, exitCode: 1, output: "failed to write file: invalid destination", want: false}, {name: "ordinary application failure", sandboxed: true, exitCode: 1, output: "invalid package name", want: false}, } { t.Run(test.name, func(t *testing.T) { meta := map[string]string{} - markLikelySandboxDenial(meta, test.sandboxed, test.exitCode, test.output) + denial := markLikelySandboxDenial(meta, test.sandboxed, test.exitCode, test.output) + if got := denial != nil; got != test.want { + t.Fatalf("typed sandbox denial = %t, want %t; denial=%#v", got, test.want, denial) + } if got := meta[SandboxLikelyDeniedMeta] == "true"; got != test.want { t.Fatalf("sandbox denial = %t, want %t; meta=%#v", got, test.want, meta) } From 3b9be6060eadb935cf4aa6fb3ba655d90d7e3b3b Mon Sep 17 00:00:00 2001 From: anandh8x Date: Fri, 24 Jul 2026 12:55:38 +0530 Subject: [PATCH 3/8] test(sandbox): assert denial contract --- internal/tools/sandbox_denial_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/tools/sandbox_denial_test.go b/internal/tools/sandbox_denial_test.go index 3e3963ebc..1693046aa 100644 --- a/internal/tools/sandbox_denial_test.go +++ b/internal/tools/sandbox_denial_test.go @@ -40,6 +40,19 @@ func TestLikelySandboxDenialMetadataFromCommandOutput(t *testing.T) { if got := denial != nil; got != test.want { t.Fatalf("typed sandbox denial = %t, want %t; denial=%#v", got, test.want, denial) } + if test.want { + if denial.Source != execution.DenialSourcePlatformSandbox || + denial.Capability.Kind != execution.CapabilityUnrestricted || + denial.Capability.Scope != "host" || + !denial.Recoverable || + denial.NextAction != execution.DenialNextActionRequestApproval { + t.Fatalf("unexpected denial shape: %#v", denial) + } + if meta[SandboxDenialReasonMeta] != denial.Reason || + meta[SandboxDenialKeywordMeta] == "" { + t.Fatalf("incomplete denial metadata: %#v", meta) + } + } if got := meta[SandboxLikelyDeniedMeta] == "true"; got != test.want { t.Fatalf("sandbox denial = %t, want %t; meta=%#v", got, test.want, meta) } From 7dd3d575d8263a489c95867dd023153448930fe1 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Sun, 26 Jul 2026 13:55:19 +0530 Subject: [PATCH 4/8] fix(sandbox): trust typed denials and restore Cargo state Stop promoting command output keywords into recoverable unrestricted denials, so only structured adapter facts can enter the approved unsandboxed retry path. Restore the managed CARGO_HOME directory and environment override so Cargo remains writable while the caller's real home stays read-only. --- internal/sandbox/runtime_state.go | 2 ++ internal/sandbox/runtime_state_test.go | 5 ++- internal/tools/bash.go | 9 ------ internal/tools/exec_command.go | 8 ----- internal/tools/exec_command_test.go | 18 +++++------ internal/tools/sandbox_denial.go | 40 +----------------------- internal/tools/sandbox_denial_test.go | 43 -------------------------- 7 files changed, 12 insertions(+), 113 deletions(-) diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go index 12c61ade5..4a5fdfc9a 100644 --- a/internal/sandbox/runtime_state.go +++ b/internal/sandbox/runtime_state.go @@ -88,6 +88,7 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) filepath.Join(runtimeState.Cache, "pip"), filepath.Join(runtimeState.Cache, "go-build"), filepath.Join(runtimeState.Data, "go-mod"), + filepath.Join(runtimeState.Data, "cargo"), } for _, directory := range directories { if err := os.MkdirAll(directory, 0o700); err != nil { @@ -207,6 +208,7 @@ func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []str "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"), } return upsertEnvList(env, overrides...) } diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index d41fe9b6f..b707f67f2 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -24,7 +24,7 @@ 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.Cache, runtimeState.Data, 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) @@ -139,7 +139,6 @@ func TestSandboxRuntimeEnvironmentPreservesUserConfiguration(t *testing.T) { "XDG_DATA_HOME=/home/user/.local/share", "XDG_STATE_HOME=/home/user/.local/state", "NPM_CONFIG_USERCONFIG=/home/user/.npmrc", - "CARGO_HOME=/home/user/.cargo", "PATH=/usr/bin", }, &runtimeState) @@ -149,7 +148,6 @@ func TestSandboxRuntimeEnvironmentPreservesUserConfiguration(t *testing.T) { "XDG_DATA_HOME": "/home/user/.local/share", "XDG_STATE_HOME": "/home/user/.local/state", "NPM_CONFIG_USERCONFIG": "/home/user/.npmrc", - "CARGO_HOME": "/home/user/.cargo", } { if got := envListValue(env, key, ""); got != want { t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env) @@ -182,6 +180,7 @@ func TestSandboxRuntimeEnvironmentUsesManagedWritableState(t *testing.T) { "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) diff --git a/internal/tools/bash.go b/internal/tools/bash.go index 35b1f0f73..2a40e2934 100644 --- a/internal/tools/bash.go +++ b/internal/tools/bash.go @@ -181,15 +181,6 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS } return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true) } - if adapterReport.Denial == nil && reportErr == nil { - adapterReport.Denial = markLikelySandboxDenial( - meta, - plan.Wrapped && plan.EnforcementLevel == zeroSandbox.EnforcementNative, - exitCode, - stdoutText, - stderrText, - ) - } if err != nil { if exitCode < 0 { result := Result{ diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index 3322a12f0..b1b902b3d 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -437,14 +437,6 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu for key, value := range input.sandboxMeta { meta[key] = value } - if input.exited && !input.interrupted && input.report.Denial == nil && input.reportErr == nil { - input.report.Denial = markLikelySandboxDenial( - meta, - input.enforcement.Level == string(zeroSandbox.EnforcementNative), - input.exitCode, - input.output, - ) - } outcome := execExecutionOutcome(input) if input.exited { meta["exit_code"] = strconv.Itoa(input.exitCode) diff --git a/internal/tools/exec_command_test.go b/internal/tools/exec_command_test.go index 466c2a7db..b2513b0db 100644 --- a/internal/tools/exec_command_test.go +++ b/internal/tools/exec_command_test.go @@ -316,7 +316,7 @@ func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) { } } -func TestExecCommandInfersNativeSandboxDenialFromOutput(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", @@ -325,18 +325,14 @@ func TestExecCommandInfersNativeSandboxDenialFromOutput(t *testing.T) { enforcement: execution.Enforcement{Backend: string(sandbox.BackendLinuxBwrap), Level: string(sandbox.EnforcementNative)}, }) - if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateDenied || result.ExecutionOutcome.Kind != execution.OutcomeEnforcementDenied { - t.Fatalf("execution outcome = %#v, want denied/enforcement_denied", result.ExecutionOutcome) + 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) } - denial := result.ExecutionOutcome.Denial - if denial == nil || - denial.Source != execution.DenialSourcePlatformSandbox || - denial.Capability.Kind != execution.CapabilityUnrestricted || - denial.NextAction != execution.DenialNextActionRequestApproval { - t.Fatalf("inferred denial = %#v, want recoverable platform-sandbox unrestricted denial", denial) + if result.ExecutionOutcome.Denial != nil { + t.Fatalf("command output created a typed denial: %#v", result.ExecutionOutcome.Denial) } - if result.Meta[SandboxDenialKeywordMeta] != "read-only file system" { - t.Fatalf("denial metadata = %#v, want read-only filesystem keyword", result.Meta) + if result.Meta[SandboxLikelyDeniedMeta] == "true" { + t.Fatalf("command output created sandbox-denial metadata: %#v", result.Meta) } } diff --git a/internal/tools/sandbox_denial.go b/internal/tools/sandbox_denial.go index 9a1a63aee..ab9fa7dd1 100644 --- a/internal/tools/sandbox_denial.go +++ b/internal/tools/sandbox_denial.go @@ -1,18 +1,6 @@ package tools -import ( - "strings" - - "github.com/Gitlawb/zero/internal/execution" -) - -var sandboxDenialKeywords = []string{ - "operation not permitted", - "permission denied", - "read-only file system", - "seccomp", - "landlock", -} +import "github.com/Gitlawb/zero/internal/execution" // markStructuredSandboxDenial mirrors typed adapter facts into legacy metadata // for presentation and backward-compatible session readers. Classification is @@ -29,29 +17,3 @@ func markStructuredSandboxDenial(meta map[string]string, denial execution.Denial meta["sandbox_denial_scope"] = denial.Capability.Scope } } - -func markLikelySandboxDenial(meta map[string]string, sandboxed bool, exitCode int, outputSections ...string) *execution.Denial { - if meta == nil || !sandboxed || exitCode == 0 { - return nil - } - for _, section := range outputSections { - lower := strings.ToLower(section) - for _, keyword := range sandboxDenialKeywords { - if strings.Contains(lower, keyword) { - 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[SandboxLikelyDeniedMeta] = "true" - meta[SandboxDenialKindMeta] = SandboxDenialKindSandbox - meta[SandboxDenialReasonMeta] = denial.Reason - meta[SandboxDenialKeywordMeta] = keyword - return denial - } - } - } - return nil -} diff --git a/internal/tools/sandbox_denial_test.go b/internal/tools/sandbox_denial_test.go index 1693046aa..51d53fc8a 100644 --- a/internal/tools/sandbox_denial_test.go +++ b/internal/tools/sandbox_denial_test.go @@ -16,46 +16,3 @@ func TestStructuredSandboxDenialMetadata(t *testing.T) { t.Fatalf("structured metadata = %#v", meta) } } - -func TestLikelySandboxDenialMetadataFromCommandOutput(t *testing.T) { - for _, test := range []struct { - name string - sandboxed bool - exitCode int - output string - want bool - }{ - {name: "sandboxed read-only filesystem", sandboxed: true, exitCode: 1, output: "touch: Read-only file system", want: true}, - {name: "sandboxed permission denied", sandboxed: true, exitCode: 1, output: "mkdir: Permission denied", want: true}, - {name: "successful command", sandboxed: true, exitCode: 0, output: "Read-only file system", want: false}, - {name: "unsandboxed read-only command", sandboxed: false, exitCode: 1, output: "Read-only file system", want: false}, - {name: "unsandboxed permission failure", sandboxed: false, exitCode: 1, output: "mkdir: Permission denied", want: false}, - {name: "generic sandbox message", sandboxed: true, exitCode: 1, output: "application sandbox configuration is invalid", want: false}, - {name: "generic write failure", sandboxed: true, exitCode: 1, output: "failed to write file: invalid destination", want: false}, - {name: "ordinary application failure", sandboxed: true, exitCode: 1, output: "invalid package name", want: false}, - } { - t.Run(test.name, func(t *testing.T) { - meta := map[string]string{} - denial := markLikelySandboxDenial(meta, test.sandboxed, test.exitCode, test.output) - if got := denial != nil; got != test.want { - t.Fatalf("typed sandbox denial = %t, want %t; denial=%#v", got, test.want, denial) - } - if test.want { - if denial.Source != execution.DenialSourcePlatformSandbox || - denial.Capability.Kind != execution.CapabilityUnrestricted || - denial.Capability.Scope != "host" || - !denial.Recoverable || - denial.NextAction != execution.DenialNextActionRequestApproval { - t.Fatalf("unexpected denial shape: %#v", denial) - } - if meta[SandboxDenialReasonMeta] != denial.Reason || - meta[SandboxDenialKeywordMeta] == "" { - t.Fatalf("incomplete denial metadata: %#v", meta) - } - } - if got := meta[SandboxLikelyDeniedMeta] == "true"; got != test.want { - t.Fatalf("sandbox denial = %t, want %t; meta=%#v", got, test.want, meta) - } - }) - } -} From 968d0830f1a279e7839b3d78a27bdd09cee2f1e4 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Mon, 27 Jul 2026 00:32:10 +0530 Subject: [PATCH 5/8] fix(sandbox): mask preserved credential configs Preserving caller configuration made credential-bearing tool files discoverable under Linux's read-all sandbox profile. Add default and environment-overridden npm, GitHub CLI, netrc, Docker, Kubernetes, and Zero config paths to the credential deny set, with regression coverage. Clarify the typed-denial retry and platform read-model comments without changing the approval behavior. --- internal/agent/loop.go | 5 +- internal/sandbox/manager_test.go | 76 ++++++++++++++++++++++++- internal/sandbox/profile.go | 95 ++++++++++++++++++++++++++++---- 3 files changed, 160 insertions(+), 16 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index d129f2f3f..b8afc97f7 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1617,8 +1617,9 @@ 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. + // 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 && diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index a2b8550f2..66b81d334 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,16 +379,44 @@ 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", "config.json") + 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 err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } keyFile := filepath.Join(home, "sa-key.json") if err := os.WriteFile(keyFile, []byte("{}"), 0o600); err != nil { t.Fatal(err) } paths := credentialDenyReadPathsIn(home, keyFile, nil) - for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) { + 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 +447,46 @@ 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") + 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", "config.json") + paths := []string{ + npmrc, + filepath.Join(ghConfigDir, "hosts.yml"), + netrc, + filepath.Join(dockerConfigDir, "config.json"), + kubeConfigA, + kubeConfigB, + zeroConfig, + } + for _, path := range paths { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + + got := credentialDenyReadPathsForEnvironment(credentialPathEnvironment{ + ConfigHome: configHome, + NPMUserConfig: npmrc, + GHConfigDir: ghConfigDir, + Netrc: netrc, + DockerConfigDir: dockerConfigDir, + KubeConfig: strings.Join([]string{kubeConfigA, kubeConfigB}, string(filepath.ListSeparator)), + }, nil) + for _, want := range normalizeProfilePaths(paths) { + 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..54fcf47ec 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,92 @@ 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"), + 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 + 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, "gcloud"), + filepath.Join(configHome, "zero", "config.json"), + ) + } + 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 +276,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) == "" { From 538b31ed75e5a48046e1691371588763293f596e Mon Sep 17 00:00:00 2001 From: anandh8x Date: Mon, 27 Jul 2026 01:22:20 +0530 Subject: [PATCH 6/8] fix(sandbox): protect custom gcloud config CLOUDSDK_CONFIG can relocate gcloud credentials outside the XDG-derived default. Include the configured directory in the credential deny profile and cover the override with a regression test. --- internal/sandbox/manager_test.go | 12 +++++++++--- internal/sandbox/profile.go | 10 +++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index 66b81d334..db7d48a75 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -453,12 +453,13 @@ func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T 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", "config.json") - paths := []string{ + filePaths := []string{ npmrc, filepath.Join(ghConfigDir, "hosts.yml"), netrc, @@ -467,7 +468,10 @@ func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T kubeConfigB, zeroConfig, } - for _, path := range paths { + 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) } @@ -478,13 +482,15 @@ func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T 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) - for _, want := range normalizeProfilePaths(paths) { + 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 54fcf47ec..ff2e1ff2c 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -170,6 +170,7 @@ func credentialDenyReadPaths(policy Policy) []string { 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"), @@ -191,6 +192,7 @@ func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead type credentialPathEnvironment struct { Home string ConfigHome string + CloudSDKConfig string GoogleCredentials string NPMUserConfig string GHConfigDir string @@ -230,10 +232,16 @@ func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowR } if configHome != "" { candidates = append(candidates, - filepath.Join(configHome, "gcloud"), filepath.Join(configHome, "zero", "config.json"), ) } + 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") From bb103b2bc233efdffdae021caf91fcc3944e5e57 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Mon, 27 Jul 2026 14:09:52 +0530 Subject: [PATCH 7/8] fix(sandbox): deny zero credential directory --- internal/sandbox/manager_test.go | 5 ++++- internal/sandbox/profile.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index db7d48a75..458b43638 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -458,7 +458,7 @@ func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T dockerConfigDir := filepath.Join(root, "docker") kubeConfigA := filepath.Join(root, "kube", "a") kubeConfigB := filepath.Join(root, "kube", "b") - zeroConfig := filepath.Join(configHome, "zero", "config.json") + zeroConfig := filepath.Join(configHome, "zero") filePaths := []string{ npmrc, filepath.Join(ghConfigDir, "hosts.yml"), @@ -475,6 +475,9 @@ func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } + if path == zeroConfig { + continue + } if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index ff2e1ff2c..8eca4c9d7 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -232,7 +232,7 @@ func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowR } if configHome != "" { candidates = append(candidates, - filepath.Join(configHome, "zero", "config.json"), + filepath.Join(configHome, "zero"), ) } cloudSDKConfig := strings.TrimSpace(env.CloudSDKConfig) From 9fc61fc433002036a597a5d0f6edb357444b2293 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Mon, 27 Jul 2026 14:51:00 +0530 Subject: [PATCH 8/8] test(sandbox): keep zero credential directory deny path expectations --- internal/sandbox/manager_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index 458b43638..566bdf7c7 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -384,7 +384,7 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { netrc := filepath.Join(home, ".netrc") dockerConfig := filepath.Join(home, ".docker", "config.json") kubeConfig := filepath.Join(home, ".kube", "config") - zeroConfig := filepath.Join(home, ".config", "zero", "config.json") + zeroConfig := filepath.Join(home, ".config", "zero") if err := mkdirAll( awsDir, gcloudDir, @@ -396,10 +396,16 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { 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") if err := os.WriteFile(keyFile, []byte("{}"), 0o600); err != nil { t.Fatal(err) @@ -476,6 +482,9 @@ func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T 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 {