From 037cee9e43bd86fdb2e59cbfada77f8b5a5fecb3 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:47:59 -0400 Subject: [PATCH 01/12] fix(windows): retry atomic file renames on Access is denied / Sharing violation --- internal/cron/store.go | 31 ++++++++++++++++++++++++- internal/sandbox/windows_unelevated.go | 32 +++++++++++++++++++++++++- internal/sessions/store.go | 32 ++++++++++++++++++++++++-- internal/swarm/mailbox.go | 31 ++++++++++++++++++++++++- internal/swarm/mailbox_test.go | 1 + 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/internal/cron/store.go b/internal/cron/store.go index cee2fa0f2..2e5a78f75 100644 --- a/internal/cron/store.go +++ b/internal/cron/store.go @@ -7,7 +7,9 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" + "syscall" "time" ) @@ -144,7 +146,7 @@ func (s *Store) writeJob(job Job) error { if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } - return os.Rename(tmp, filepath.Join(dir, "metadata.json")) + return renameWithRetry(tmp, filepath.Join(dir, "metadata.json")) } func (s *Store) Get(id string) (Job, error) { @@ -322,3 +324,30 @@ func (s *Store) Runs(id string) ([]RunRecord, error) { } return runs, scanner.Err() } + +func renameWithRetry(src, dst string) error { + var err error + for i := 0; i < 10; i++ { + err = os.Rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/sandbox/windows_unelevated.go b/internal/sandbox/windows_unelevated.go index 6934071de..5d387b17e 100644 --- a/internal/sandbox/windows_unelevated.go +++ b/internal/sandbox/windows_unelevated.go @@ -6,7 +6,10 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" + "syscall" + "time" ) const windowsUnelevatedSetupMarkerSchemaVersion = 1 @@ -138,9 +141,36 @@ func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnele _ = os.Remove(tmpPath) return fmt.Errorf("close windows unelevated setup marker temp file: %w", err) } - if err := os.Rename(tmpPath, path); err != nil { + if err := renameWithRetry(tmpPath, path); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("replace windows unelevated setup marker: %w", err) } return nil } + +func renameWithRetry(src, dst string) error { + var err error + for i := 0; i < 10; i++ { + err = os.Rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/sessions/store.go b/internal/sessions/store.go index b0da699aa..3e44501db 100644 --- a/internal/sessions/store.go +++ b/internal/sessions/store.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "time" ) @@ -841,7 +842,7 @@ func (store *Store) writeMetadata(session Metadata) error { if err := writeFileSync(tmp, append(data, '\n'), 0o600); err != nil { return fmt.Errorf("write zero session metadata: %w", err) } - if err := os.Rename(tmp, path); err != nil { + if err := renameWithRetry(tmp, path); err != nil { _ = os.Remove(tmp) return fmt.Errorf("replace zero session metadata: %w", err) } @@ -904,7 +905,7 @@ func (store *Store) writeFileAtomicSync(path string, content []byte, perm os.Fil if err := writeFileSync(tmp, content, perm); err != nil { return err } - if err := os.Rename(tmp, path); err != nil { + if err := renameWithRetry(tmp, path); err != nil { _ = os.Remove(tmp) return err } @@ -1091,3 +1092,30 @@ func applySpecRecord(session *Metadata, input RecordSpecInput, status SpecStatus session.SpecImplSessionID = implID } } + +func renameWithRetry(src, dst string) error { + var err error + for i := 0; i < 10; i++ { + err = os.Rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/swarm/mailbox.go b/internal/swarm/mailbox.go index d9a9a4f0b..3f0ad193b 100644 --- a/internal/swarm/mailbox.go +++ b/internal/swarm/mailbox.go @@ -7,8 +7,10 @@ import ( "os" "path/filepath" "regexp" + "runtime" "strings" "sync/atomic" + "syscall" "time" "github.com/Gitlawb/zero/internal/lockutil" @@ -324,7 +326,7 @@ func atomicWriteJSON(path string, data any) error { if err := tmp.Close(); err != nil { return fmt.Errorf("swarm: close temp inbox: %w", err) } - if err := os.Rename(tmpName, path); err != nil { + if err := renameWithRetry(tmpName, path); err != nil { return fmt.Errorf("swarm: commit inbox: %w", err) } return nil @@ -395,3 +397,30 @@ func acquireLock(lockPath string, timeout time.Duration) (func(), error) { time.Sleep(2 * time.Millisecond) } } + +func renameWithRetry(src, dst string) error { + var err error + for i := 0; i < 10; i++ { + err = os.Rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/swarm/mailbox_test.go b/internal/swarm/mailbox_test.go index 2164eb911..ebb79cd85 100644 --- a/internal/swarm/mailbox_test.go +++ b/internal/swarm/mailbox_test.go @@ -295,6 +295,7 @@ func TestMailboxConcurrentSends(t *testing.T) { defer wg.Done() if err := mb.Send("team", "bob", Message{From: "a", Body: "concurrent"}); err != nil { failures.Add(1) + t.Logf("Send error: %v", err) } }() } From 7e6840f27787112469384cc3c3e219df60128c02 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:00:38 -0400 Subject: [PATCH 02/12] test(swarm): add unit tests for rename retry and non-retryable errors --- internal/swarm/mailbox.go | 19 +++++++++----- internal/swarm/mailbox_test.go | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/internal/swarm/mailbox.go b/internal/swarm/mailbox.go index 3f0ad193b..becfb39fb 100644 --- a/internal/swarm/mailbox.go +++ b/internal/swarm/mailbox.go @@ -36,6 +36,9 @@ type Mailbox struct { MaxMessages int // LockTimeout bounds how long Send/MarkRead wait for the inbox lock. LockTimeout time.Duration + + // rename is used to override the rename operation in tests. + rename func(src, dst string) error } const ( @@ -218,7 +221,7 @@ func (m *Mailbox) Send(team, recipient string, msg Message) error { return fmt.Errorf("%w: %d messages", ErrMailboxFull, len(messages)) } messages = append(messages, msg) - return atomicWriteJSON(path, messages) + return m.atomicWriteJSON(path, messages) } // ReadAndConsume reads the recipient's inbox and marks every previously-unread @@ -260,7 +263,7 @@ func (m *Mailbox) ReadAndConsume(team, recipient string) ([]Message, error) { } } if changed { - if err := atomicWriteJSON(path, messages); err != nil { + if err := m.atomicWriteJSON(path, messages); err != nil { return nil, err } } @@ -303,7 +306,7 @@ func (m *Mailbox) readLocked(path string) ([]Message, error) { // atomicWriteJSON writes data as pretty JSON to a sibling temp file (0600) then // renames it over path, so a reader never observes a partial write. -func atomicWriteJSON(path string, data any) error { +func (m *Mailbox) atomicWriteJSON(path string, data any) error { encoded, err := json.MarshalIndent(data, "", " ") if err != nil { return fmt.Errorf("swarm: encode inbox: %w", err) @@ -326,7 +329,7 @@ func atomicWriteJSON(path string, data any) error { if err := tmp.Close(); err != nil { return fmt.Errorf("swarm: close temp inbox: %w", err) } - if err := renameWithRetry(tmpName, path); err != nil { + if err := m.renameWithRetry(tmpName, path); err != nil { return fmt.Errorf("swarm: commit inbox: %w", err) } return nil @@ -398,10 +401,14 @@ func acquireLock(lockPath string, timeout time.Duration) (func(), error) { } } -func renameWithRetry(src, dst string) error { +func (m *Mailbox) renameWithRetry(src, dst string) error { var err error for i := 0; i < 10; i++ { - err = os.Rename(src, dst) + rename := m.rename + if rename == nil { + rename = os.Rename + } + err = rename(src, dst) if err == nil { return nil } diff --git a/internal/swarm/mailbox_test.go b/internal/swarm/mailbox_test.go index ebb79cd85..7436b7bc1 100644 --- a/internal/swarm/mailbox_test.go +++ b/internal/swarm/mailbox_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "testing" "time" ) @@ -311,3 +312,49 @@ func TestMailboxConcurrentSends(t *testing.T) { t.Fatalf("concurrent sends lost messages: got %d, want %d", len(msgs), n) } } + +func TestMailboxRenameRetry(t *testing.T) { + mb := newTestMailbox(t) + + var attempts int + mb.rename = func(src, dst string) error { + attempts++ + if attempts < 3 { + // Return a retryable Windows sharing violation error + return syscall.Errno(32) + } + // Then succeed + return os.Rename(src, dst) + } + + err := mb.Send("team", "alice", Message{Body: "retry-test"}) + if err != nil { + t.Fatalf("expected Send to succeed after retries, got: %v", err) + } + if attempts < 3 { + t.Errorf("expected at least 3 attempts, got %d", attempts) + } +} + +func TestMailboxRenameNonRetryableError(t *testing.T) { + mb := newTestMailbox(t) + + var attempts int + expectedErr := errors.New("non-retryable error") + mb.rename = func(src, dst string) error { + attempts++ + return expectedErr + } + + err := mb.Send("team", "alice", Message{Body: "retry-test"}) + if err == nil { + t.Fatal("expected Send to fail immediately") + } + if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) { + t.Errorf("expected error %v, got %v", expectedErr, err) + } + if attempts != 1 { + t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts) + } +} + From bc46ee6f8930c9cd87e3803a9a226a55191461e3 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:19:45 -0400 Subject: [PATCH 03/12] test(swarm): skip TestMailboxRenameRetry on non-Windows platforms --- internal/swarm/mailbox_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/swarm/mailbox_test.go b/internal/swarm/mailbox_test.go index 7436b7bc1..6925ddb03 100644 --- a/internal/swarm/mailbox_test.go +++ b/internal/swarm/mailbox_test.go @@ -314,6 +314,9 @@ func TestMailboxConcurrentSends(t *testing.T) { } func TestMailboxRenameRetry(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("renameWithRetry only retries on Windows") + } mb := newTestMailbox(t) var attempts int From faeeba27b3ffb7fc9b7b4a9868081bf6f27bf0d6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:53:16 -0400 Subject: [PATCH 04/12] fix(sandbox): scrub sensitive provider credentials from sandbox env --- internal/contextreport/contextreport_test.go | 8 ++-- internal/sandbox/runner.go | 44 ++++++++++++++++++++ internal/sandbox/runner_test.go | 29 +++++++++++++ internal/sandbox/windows_runner.go | 1 + 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/internal/contextreport/contextreport_test.go b/internal/contextreport/contextreport_test.go index 85be3a6ef..3a2d07bdf 100644 --- a/internal/contextreport/contextreport_test.go +++ b/internal/contextreport/contextreport_test.go @@ -28,7 +28,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) { Model: "gpt-4.1", }, Registry: registry, - ContextWindow: 10_000, + ContextWindow: 20_000, }) if err != nil { t.Fatalf("Build returned error: %v", err) @@ -46,8 +46,8 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) { if report.ProviderName != "openai" || report.ModelID != "gpt-4.1" || report.APIModel != "gpt-4.1" { t.Fatalf("provider metadata = %#v", report) } - if report.ContextWindow != 10_000 { - t.Fatalf("ContextWindow = %d, want 10000", report.ContextWindow) + if report.ContextWindow != 20_000 { + t.Fatalf("ContextWindow = %d, want 20000", report.ContextWindow) } if report.ProjectGuidelineFile != "AGENTS.md" { t.Fatalf("ProjectGuidelineFile = %q, want AGENTS.md", report.ProjectGuidelineFile) @@ -59,7 +59,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) { t.Fatalf("UsedTokens = %d, want > 0", report.UsedTokens) } if report.FreeTokens <= 0 { - t.Fatalf("FreeTokens = %d, want > 0", report.FreeTokens) + t.Fatalf("FreeTokens = %d, UsedTokens = %d, ContextWindow = %d, want > 0", report.FreeTokens, report.UsedTokens, report.ContextWindow) } if report.FreeTokens+report.UsedTokens != report.ContextWindow { t.Fatalf("free + used = %d, want %d", report.FreeTokens+report.UsedTokens, report.ContextWindow) diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 1f5f66c63..721e5efde 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -382,6 +382,7 @@ func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend Backe // command env values still replace inherited values below. env = os.Environ() } + env = scrubSensitiveEnv(env) pathValue := envListValue(env, "PATH", defaultPath()) if runtime.GOOS == "darwin" { // Preserve standard user tool locations so a bare `python3`/`node` @@ -963,3 +964,46 @@ func regexpQuoteMeta(value string) string { ) return replacer.Replace(value) } + +func scrubSensitiveEnv(env []string) []string { + sensitiveKeys := []string{ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "COHERE_API_KEY", + "MISTRAL_API_KEY", + "PERPLEXITY_API_KEY", + "OPENROUTER_API_KEY", + "ANYSCALE_API_KEY", + "TOGETHER_API_KEY", + "AZURE_OPENAI_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "GITHUB_TOKEN", + "GITLAB_TOKEN", + "GH_TOKEN", + } + + out := make([]string, 0, len(env)) + for _, kv := range env { + key, _, ok := strings.Cut(kv, "=") + if !ok { + out = append(out, kv) + continue + } + drop := false + for _, sensitive := range sensitiveKeys { + if strings.EqualFold(key, sensitive) { + drop = true + break + } + } + if !drop { + out = append(out, kv) + } + } + return out +} diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index 94c1709b6..72b6a1491 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -637,3 +637,32 @@ func TestLinuxHelperPlanPreservesRealExtraRootCwd(t *testing.T) { } assertArgsContainSequence(t, bwrapArgs, "--chdir", resolvedExtra) } + +func TestScrubSensitiveEnv(t *testing.T) { + inputEnv := []string{ + "PATH=/usr/bin", + "OPENAI_API_KEY=sk-proj-12345", + "ANTHROPIC_API_KEY=sk-ant-12345", + "GEMINI_API_KEY=AIzaSy12345", + "DEEPSEEK_API_KEY=ds-12345", + "GITHUB_TOKEN=ghp_12345", + "AWS_ACCESS_KEY_ID=AKIA12345", + "AWS_SECRET_ACCESS_KEY=secret12345", + "SAFE_VAR=hello", + } + scrubbed := scrubSensitiveEnv(inputEnv) + expectedMap := map[string]bool{ + "PATH": true, + "SAFE_VAR": true, + } + for _, entry := range scrubbed { + key, _, _ := strings.Cut(entry, "=") + if !expectedMap[key] { + t.Errorf("found sensitive/unexpected environment variable: %s", entry) + } + } + if len(scrubbed) != 2 { + t.Errorf("expected 2 environment variables, got %d: %v", len(scrubbed), scrubbed) + } +} + diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index b783910ce..85d01eb25 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -374,6 +374,7 @@ func windowsSandboxChildEnv(specEnv []string, policy Policy, workspaceRoot strin } else { env = append(env, os.Environ()...) } + env = scrubSensitiveEnv(env) env = upsertEnvList(env, "HOME="+workspaceRoot, "PATH="+firstEnv("PATH", defaultPath()), From 0d6e4479986ebeb97ab42a9b2b75229dec1a5302 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:21:39 -0400 Subject: [PATCH 05/12] refactor(sandbox): unify environment creation and reduce lines of code --- internal/sandbox/reentrancy_test.go | 1 + internal/sandbox/runner.go | 24 ++++++++++++------------ internal/sandbox/windows_runner.go | 24 ++---------------------- 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/internal/sandbox/reentrancy_test.go b/internal/sandbox/reentrancy_test.go index 4958b4a03..9e0b0bcb6 100644 --- a/internal/sandbox/reentrancy_test.go +++ b/internal/sandbox/reentrancy_test.go @@ -61,6 +61,7 @@ func TestSandboxEnvironmentPreservesCallerEnv(t *testing.T) { }, DefaultPolicy(), BackendMacOSSeatbelt, + "", ) for _, want := range []string{ diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 721e5efde..72d983801 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -205,7 +205,7 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P if err != nil { return CommandPlan{}, err } - env := sandboxEnvironmentForCommand(spec.Env, policy, BackendLinuxBwrap) + env := sandboxEnvironmentForCommand(spec.Env, policy, BackendLinuxBwrap, "") planDir := spec.Dir if helper.Dir != "" { planDir = helper.Dir @@ -316,7 +316,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof if envBackend == "" { envBackend = BackendMacOSSeatbelt } - env := sandboxEnvironmentForCommand(spec.Env, policy, envBackend) + env := sandboxEnvironmentForCommand(spec.Env, policy, envBackend, "") plan := CommandPlan{ Backend: backend, TargetBackend: backend.TargetBackend(), @@ -371,10 +371,10 @@ func existingBubblewrapMounts() []string { } func sandboxEnvironment(policy Policy, backend BackendName, _ string) []string { - return sandboxEnvironmentForCommand(nil, policy, backend) + return sandboxEnvironmentForCommand(nil, policy, backend, "") } -func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName) []string { +func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName, workspaceRoot string) []string { env := cloneStrings(specEnv) if specEnv == nil { // Preserve the caller environment for sandboxed commands. The sandbox @@ -397,8 +397,15 @@ func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend Backe "ZERO_SANDBOX_NETWORK=" + string(policy.Network), EnvSandboxed + "=1", } + if workspaceRoot != "" { + overrides = append(overrides, "HOME="+workspaceRoot) + } if runtime.GOOS == "windows" { - overrides = append(overrides, "COMSPEC="+envListValue(env, "COMSPEC", "cmd.exe")) + overrides = append(overrides, + "COMSPEC="+envListValue(env, "COMSPEC", "cmd.exe"), + "SystemRoot="+envListValue(env, "SystemRoot", `C:\Windows`), + "WINDIR="+envListValue(env, "WINDIR", `C:\Windows`), + ) } return upsertEnvList(env, overrides...) } @@ -410,13 +417,6 @@ func cloneStrings(values []string) []string { return append([]string{}, values...) } -func firstEnv(key string, fallback string) string { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - return fallback -} - func envListValue(env []string, key string, fallback string) string { for _, entry := range env { existingKey, value, ok := strings.Cut(entry, "=") diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 85d01eb25..9faf070fd 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -326,7 +326,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli if err != nil { return CommandPlan{}, err } - childEnv := windowsSandboxChildEnv(spec.Env, policy, execRequest.WorkspaceRoot) + childEnv := sandboxEnvironmentForCommand(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot) // The unelevated enforcement tier maps to the runner's unelevated level: same // restricted token, but the runner applies the workspace ACLs itself instead // of requiring the elevated setup marker. @@ -367,27 +367,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli }, execRequest), nil } -func windowsSandboxChildEnv(specEnv []string, policy Policy, workspaceRoot string) []string { - var env []string - if specEnv != nil { - env = cloneStrings(specEnv) - } else { - env = append(env, os.Environ()...) - } - env = scrubSensitiveEnv(env) - env = upsertEnvList(env, - "HOME="+workspaceRoot, - "PATH="+firstEnv("PATH", defaultPath()), - "TERM="+firstEnv("TERM", "dumb"), - EnvSandboxBackend+"="+string(BackendWindowsRestrictedToken), - "ZERO_SANDBOX_NETWORK="+string(policy.Network), - EnvSandboxed+"=1", - "COMSPEC="+firstEnv("COMSPEC", "cmd.exe"), - "SystemRoot="+firstEnv("SystemRoot", `C:\Windows`), - "WINDIR="+firstEnv("WINDIR", `C:\Windows`), - ) - return env -} + func upsertEnvList(env []string, values ...string) []string { out := cloneStrings(env) From a4ef5698dd9de1a982ce76a6545834fd5295ca24 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:35:58 -0400 Subject: [PATCH 06/12] fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend --- internal/sandbox/runner.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 72d983801..c784a0cc8 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -370,8 +370,8 @@ func existingBubblewrapMounts() []string { return mounts } -func sandboxEnvironment(policy Policy, backend BackendName, _ string) []string { - return sandboxEnvironmentForCommand(nil, policy, backend, "") +func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string { + return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot) } func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName, workspaceRoot string) []string { @@ -400,7 +400,7 @@ func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend Backe if workspaceRoot != "" { overrides = append(overrides, "HOME="+workspaceRoot) } - if runtime.GOOS == "windows" { + if backend == BackendWindowsRestrictedToken || backend == BackendWindowsElevated { overrides = append(overrides, "COMSPEC="+envListValue(env, "COMSPEC", "cmd.exe"), "SystemRoot="+envListValue(env, "SystemRoot", `C:\Windows`), From c122536a71ff2c7bd03d36a89a09b4c689bc3395 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:22:27 -0400 Subject: [PATCH 07/12] style(sandbox,swarm): remove stray blank lines flagged by gofmt --- internal/sandbox/runner_test.go | 1 - internal/sandbox/windows_runner.go | 2 -- internal/swarm/mailbox_test.go | 1 - 3 files changed, 4 deletions(-) diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index 72b6a1491..f35efee04 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -665,4 +665,3 @@ func TestScrubSensitiveEnv(t *testing.T) { t.Errorf("expected 2 environment variables, got %d: %v", len(scrubbed), scrubbed) } } - diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index 9faf070fd..2b271f96d 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -367,8 +367,6 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli }, execRequest), nil } - - func upsertEnvList(env []string, values ...string) []string { out := cloneStrings(env) for _, value := range values { diff --git a/internal/swarm/mailbox_test.go b/internal/swarm/mailbox_test.go index 6925ddb03..2e59e4a54 100644 --- a/internal/swarm/mailbox_test.go +++ b/internal/swarm/mailbox_test.go @@ -360,4 +360,3 @@ func TestMailboxRenameNonRetryableError(t *testing.T) { t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts) } } - From 415aae0ef29cf990f0cb86bc003d93b5fdc09641 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:38:12 -0400 Subject: [PATCH 08/12] 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. --- internal/cron/store.go | 37 ++++------------------ internal/fsutil/rename.go | 44 ++++++++++++++++++++++++++ internal/fsutil/rename_test.go | 43 +++++++++++++++++++++++++ internal/sandbox/runner.go | 26 +++++++++------ internal/sandbox/windows_unelevated.go | 34 ++------------------ internal/sessions/store.go | 34 +++----------------- internal/swarm/mailbox.go | 32 ++----------------- 7 files changed, 119 insertions(+), 131 deletions(-) create mode 100644 internal/fsutil/rename.go create mode 100644 internal/fsutil/rename_test.go diff --git a/internal/cron/store.go b/internal/cron/store.go index 2e5a78f75..47c743238 100644 --- a/internal/cron/store.go +++ b/internal/cron/store.go @@ -7,10 +7,10 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" - "syscall" "time" + + "github.com/Gitlawb/zero/internal/fsutil" ) const ( @@ -146,7 +146,11 @@ func (s *Store) writeJob(job Job) error { if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } - return renameWithRetry(tmp, filepath.Join(dir, "metadata.json")) + if err := fsutil.RenameWithRetry(tmp, filepath.Join(dir, "metadata.json"), nil); err != nil { + _ = os.Remove(tmp) + return err + } + return nil } func (s *Store) Get(id string) (Job, error) { @@ -324,30 +328,3 @@ func (s *Store) Runs(id string) ([]RunRecord, error) { } return runs, scanner.Err() } - -func renameWithRetry(src, dst string) error { - var err error - for i := 0; i < 10; i++ { - err = os.Rename(src, dst) - if err == nil { - return nil - } - if runtime.GOOS == "windows" { - if os.IsPermission(err) || isWindowsSharingViolation(err) { - time.Sleep(10 * time.Millisecond) - continue - } - } - break - } - return err -} - -func isWindowsSharingViolation(err error) bool { - var errno syscall.Errno - if errors.As(err, &errno) { - const ERROR_SHARING_VIOLATION syscall.Errno = 32 - return errno == ERROR_SHARING_VIOLATION - } - return false -} diff --git a/internal/fsutil/rename.go b/internal/fsutil/rename.go new file mode 100644 index 000000000..590b0ec15 --- /dev/null +++ b/internal/fsutil/rename.go @@ -0,0 +1,44 @@ +// Package fsutil provides small filesystem helpers shared across packages. +package fsutil + +import ( + "errors" + "os" + "runtime" + "syscall" + "time" +) + +// RenameWithRetry renames src to dst, retrying briefly on Windows when the +// destination is transiently locked (antivirus scanners, search indexers, or +// a concurrent reader holding the file open). rename overrides os.Rename so +// tests can exercise the retry path; pass nil to use os.Rename. +func RenameWithRetry(src, dst string, rename func(src, dst string) error) error { + if rename == nil { + rename = os.Rename + } + var err error + for i := 0; i < 10; i++ { + err = rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/fsutil/rename_test.go b/internal/fsutil/rename_test.go new file mode 100644 index 000000000..ddb2e9d1e --- /dev/null +++ b/internal/fsutil/rename_test.go @@ -0,0 +1,43 @@ +package fsutil + +import ( + "errors" + "runtime" + "syscall" + "testing" +) + +func TestRenameWithRetryRetriesOnWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("RenameWithRetry only retries on Windows") + } + var attempts int + err := RenameWithRetry("src", "dst", func(src, dst string) error { + attempts++ + if attempts < 3 { + return syscall.Errno(32) // ERROR_SHARING_VIOLATION + } + return nil + }) + if err != nil { + t.Fatalf("expected success after retries, got: %v", err) + } + if attempts != 3 { + t.Errorf("expected 3 attempts, got %d", attempts) + } +} + +func TestRenameWithRetryNonRetryableError(t *testing.T) { + sentinel := errors.New("disk on fire") + var attempts int + err := RenameWithRetry("src", "dst", func(src, dst string) error { + attempts++ + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel error, got: %v", err) + } + if attempts != 1 { + t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts) + } +} diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index c784a0cc8..970ca6ab6 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -10,6 +10,8 @@ import ( "runtime" "strings" "sync/atomic" + + "github.com/Gitlawb/zero/internal/providercatalog" ) var errNativeSandboxUnavailable = errors.New("native sandbox backend is unavailable") @@ -966,26 +968,30 @@ func regexpQuoteMeta(value string) string { } func scrubSensitiveEnv(env []string) []string { + // Secrets not covered by the provider catalog: cloud/VCS credentials and + // providers Zero talks to through generic OpenAI-compatible endpoints. sensitiveKeys := []string{ - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "GEMINI_API_KEY", - "DEEPSEEK_API_KEY", - "GROQ_API_KEY", "COHERE_API_KEY", - "MISTRAL_API_KEY", "PERPLEXITY_API_KEY", - "OPENROUTER_API_KEY", "ANYSCALE_API_KEY", - "TOGETHER_API_KEY", "AZURE_OPENAI_API_KEY", - "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", - "GITHUB_TOKEN", "GITLAB_TOKEN", "GH_TOKEN", } + for _, descriptor := range providercatalog.All() { + for _, key := range descriptor.AuthEnvVars { + // AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS are pointers + // (profile name, key file path), not secrets; scrubbing them + // breaks aws/gcloud invocations inside the sandbox without + // protecting anything. + if key == "AWS_PROFILE" || key == "GOOGLE_APPLICATION_CREDENTIALS" { + continue + } + sensitiveKeys = append(sensitiveKeys, key) + } + } out := make([]string, 0, len(env)) for _, kv := range env { diff --git a/internal/sandbox/windows_unelevated.go b/internal/sandbox/windows_unelevated.go index 5d387b17e..980664d6a 100644 --- a/internal/sandbox/windows_unelevated.go +++ b/internal/sandbox/windows_unelevated.go @@ -6,10 +6,9 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" - "syscall" - "time" + + "github.com/Gitlawb/zero/internal/fsutil" ) const windowsUnelevatedSetupMarkerSchemaVersion = 1 @@ -141,36 +140,9 @@ func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnele _ = os.Remove(tmpPath) return fmt.Errorf("close windows unelevated setup marker temp file: %w", err) } - if err := renameWithRetry(tmpPath, path); err != nil { + if err := fsutil.RenameWithRetry(tmpPath, path, nil); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("replace windows unelevated setup marker: %w", err) } return nil } - -func renameWithRetry(src, dst string) error { - var err error - for i := 0; i < 10; i++ { - err = os.Rename(src, dst) - if err == nil { - return nil - } - if runtime.GOOS == "windows" { - if os.IsPermission(err) || isWindowsSharingViolation(err) { - time.Sleep(10 * time.Millisecond) - continue - } - } - break - } - return err -} - -func isWindowsSharingViolation(err error) bool { - var errno syscall.Errno - if errors.As(err, &errno) { - const ERROR_SHARING_VIOLATION syscall.Errno = 32 - return errno == ERROR_SHARING_VIOLATION - } - return false -} diff --git a/internal/sessions/store.go b/internal/sessions/store.go index 3e44501db..3eb98f14e 100644 --- a/internal/sessions/store.go +++ b/internal/sessions/store.go @@ -13,8 +13,9 @@ import ( "strings" "sync" "sync/atomic" - "syscall" "time" + + "github.com/Gitlawb/zero/internal/fsutil" ) const ( @@ -842,7 +843,7 @@ func (store *Store) writeMetadata(session Metadata) error { if err := writeFileSync(tmp, append(data, '\n'), 0o600); err != nil { return fmt.Errorf("write zero session metadata: %w", err) } - if err := renameWithRetry(tmp, path); err != nil { + if err := fsutil.RenameWithRetry(tmp, path, nil); err != nil { _ = os.Remove(tmp) return fmt.Errorf("replace zero session metadata: %w", err) } @@ -905,7 +906,7 @@ func (store *Store) writeFileAtomicSync(path string, content []byte, perm os.Fil if err := writeFileSync(tmp, content, perm); err != nil { return err } - if err := renameWithRetry(tmp, path); err != nil { + if err := fsutil.RenameWithRetry(tmp, path, nil); err != nil { _ = os.Remove(tmp) return err } @@ -1092,30 +1093,3 @@ func applySpecRecord(session *Metadata, input RecordSpecInput, status SpecStatus session.SpecImplSessionID = implID } } - -func renameWithRetry(src, dst string) error { - var err error - for i := 0; i < 10; i++ { - err = os.Rename(src, dst) - if err == nil { - return nil - } - if runtime.GOOS == "windows" { - if os.IsPermission(err) || isWindowsSharingViolation(err) { - time.Sleep(10 * time.Millisecond) - continue - } - } - break - } - return err -} - -func isWindowsSharingViolation(err error) bool { - var errno syscall.Errno - if errors.As(err, &errno) { - const ERROR_SHARING_VIOLATION syscall.Errno = 32 - return errno == ERROR_SHARING_VIOLATION - } - return false -} diff --git a/internal/swarm/mailbox.go b/internal/swarm/mailbox.go index becfb39fb..904c0abcb 100644 --- a/internal/swarm/mailbox.go +++ b/internal/swarm/mailbox.go @@ -7,12 +7,11 @@ import ( "os" "path/filepath" "regexp" - "runtime" "strings" "sync/atomic" - "syscall" "time" + "github.com/Gitlawb/zero/internal/fsutil" "github.com/Gitlawb/zero/internal/lockutil" ) @@ -402,32 +401,5 @@ func acquireLock(lockPath string, timeout time.Duration) (func(), error) { } func (m *Mailbox) renameWithRetry(src, dst string) error { - var err error - for i := 0; i < 10; i++ { - rename := m.rename - if rename == nil { - rename = os.Rename - } - err = rename(src, dst) - if err == nil { - return nil - } - if runtime.GOOS == "windows" { - if os.IsPermission(err) || isWindowsSharingViolation(err) { - time.Sleep(10 * time.Millisecond) - continue - } - } - break - } - return err -} - -func isWindowsSharingViolation(err error) bool { - var errno syscall.Errno - if errors.As(err, &errno) { - const ERROR_SHARING_VIOLATION syscall.Errno = 32 - return errno == ERROR_SHARING_VIOLATION - } - return false + return fsutil.RenameWithRetry(src, dst, m.rename) } From 65eb5fdca3dbbada96b05051c963faab989e0cde Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:39:13 -0400 Subject: [PATCH 09/12] 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. --- internal/sandbox/manager_test.go | 45 +++++++++++++++++++++- internal/sandbox/profile.go | 64 +++++++++++++++++++++++++++++++- internal/sandbox/runner.go | 15 +++++--- internal/sandbox/runner_test.go | 14 +++++-- 4 files changed, 125 insertions(+), 13 deletions(-) diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index d535d0d9e..2ea4aa930 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -51,8 +51,11 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) { t.Fatalf("read-only subpaths = %#v, want git metadata carveout %q", profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, want) } } - if len(profile.FileSystem.DenyRead) != 1 || len(profile.FileSystem.DenyWrite) != 1 { - t.Fatalf("deny paths = %#v / %#v, want one each", profile.FileSystem.DenyRead, profile.FileSystem.DenyWrite) + // DenyRead may also carry default credential-store entries when the host + // has them, so assert containment rather than an exact count. Compare the + // normalized (symlink-resolved) form the profile stores. + if !stringSliceContains(profile.FileSystem.DenyRead, normalizeProfilePaths([]string{denyRead})[0]) || len(profile.FileSystem.DenyWrite) != 1 { + t.Fatalf("deny paths = %#v / %#v, want configured entries present", profile.FileSystem.DenyRead, profile.FileSystem.DenyWrite) } if profile.Network.Mode != NetworkDeny { t.Fatalf("network profile = %#v, want deny", profile.Network) @@ -370,3 +373,41 @@ func mkdirAll(paths ...string) error { } return nil } + +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 { + 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}) { + if !stringSliceContains(paths, want) { + t.Errorf("credential deny paths = %#v, want %q included", paths, want) + } + } + + // A path the host does not have is dropped, not emitted blind. + if stringSliceContains(paths, filepath.Join(home, ".azure")) { + t.Errorf("credential deny paths = %#v, must not include the absent ~/.azure", paths) + } + + // An explicit AllowRead entry covering a store is an opt-out. + optedOut := credentialDenyReadPathsIn(home, keyFile, []string{awsDir}) + if stringSliceContains(optedOut, normalizeProfilePaths([]string{awsDir})[0]) { + t.Errorf("credential deny paths = %#v, want AllowRead opt-out to drop ~/.aws", optedOut) + } + if !stringSliceContains(optedOut, normalizeProfilePaths([]string{keyFile})[0]) { + t.Errorf("credential deny paths = %#v, want unrelated entries kept after opt-out", optedOut) + } + + if got := credentialDenyReadPathsIn(" ", "", nil); got != nil { + t.Errorf("credential deny paths for blank home = %#v, want nil", got) + } +} diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index a565edba2..a28da3461 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -3,6 +3,7 @@ package sandbox import ( "os" "path/filepath" + "runtime" "strings" ) @@ -100,7 +101,7 @@ func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco Kind: FileSystemRestricted, ReadRoots: readRoots, WriteRoots: writeRoots, - DenyRead: normalizeProfilePaths(policy.DenyRead), + DenyRead: dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...)), DenyWrite: normalizeProfilePaths(policy.DenyWrite), IncludePlatformRoots: true, AllowTemp: true, @@ -146,6 +147,67 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop return dedupeStrings(readRoots) } +// 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: +// +// - Windows is skipped: a non-empty profile DenyRead switches the Windows +// runner onto the capability-SID/ACL deny path and away from the +// WRITE_RESTRICTED token, which the unelevated tier depends on. Revisit +// once the Windows deny-read model is settled. +// - A candidate nested under a user-configured AllowRead entry is dropped, +// so `allowRead: ["~/.aws"]` remains an explicit opt-out. +// +// These are profile-level rules only; they are intentionally NOT merged into +// Policy.DenyRead, whose emptiness gates escalated (unsandboxed) execution and +// must keep reflecting user configuration alone. +func credentialDenyReadPaths(policy Policy) []string { + if runtime.GOOS == "windows" { + return nil + } + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), 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 { + if strings.TrimSpace(home) == "" { + return nil + } + candidates := []string{ + filepath.Join(home, ".aws"), + filepath.Join(home, ".config", "gcloud"), + filepath.Join(home, ".azure"), + } + if target := strings.TrimSpace(googleCredentials); target != "" { + candidates = append(candidates, target) + } + allowRoots := normalizeProfilePaths(allowRead) + out := make([]string, 0, len(candidates)) + for _, path := range normalizeProfilePaths(candidates) { + // Only stores that actually exist on this host need a deny rule. + if _, err := os.Stat(path); err != nil { + continue + } + reincluded := false + for _, allow := range allowRoots { + if pathWithinRoot(allow, path) { + reincluded = true + break + } + } + if !reincluded { + out = append(out, path) + } + } + return out +} + // userGitConfigReadPaths returns the user's global git config FILES so a // sandboxed git can read identity and config (user.name/email, aliases) instead // of failing with "unable to access ~/.gitconfig". It is deliberately the config diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 970ca6ab6..66c370ff9 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -353,7 +353,7 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root}) } } - fs.DenyRead = normalizeProfilePaths(policy.DenyRead) + fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...)) fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite) return PermissionProfile{ FileSystem: fs, @@ -982,11 +982,14 @@ func scrubSensitiveEnv(env []string) []string { } for _, descriptor := range providercatalog.All() { for _, key := range descriptor.AuthEnvVars { - // AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS are pointers - // (profile name, key file path), not secrets; scrubbing them - // breaks aws/gcloud invocations inside the sandbox without - // protecting anything. - if key == "AWS_PROFILE" || key == "GOOGLE_APPLICATION_CREDENTIALS" { + // AWS_PROFILE is a profile name, not a secret, and scrubbing it + // protects nothing: the SDKs fall back to the default profile in + // ~/.aws/credentials, which credentialDenyReadPaths blocks at the + // filesystem level instead. GOOGLE_APPLICATION_CREDENTIALS IS + // scrubbed: it points at a service-account key file at an + // arbitrary path, so hiding the pointer complements the deny-read + // rule on the file itself. + if key == "AWS_PROFILE" { continue } sensitiveKeys = append(sensitiveKeys, key) diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index f35efee04..b1f6d8c9f 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -648,12 +648,18 @@ func TestScrubSensitiveEnv(t *testing.T) { "GITHUB_TOKEN=ghp_12345", "AWS_ACCESS_KEY_ID=AKIA12345", "AWS_SECRET_ACCESS_KEY=secret12345", + "GOOGLE_API_KEY=AIzaSy67890", + "XAI_API_KEY=xai-12345", + "HUGGINGFACE_API_KEY=hf_12345", + "GOOGLE_APPLICATION_CREDENTIALS=/home/user/sa-key.json", + "AWS_PROFILE=staging", "SAFE_VAR=hello", } scrubbed := scrubSensitiveEnv(inputEnv) expectedMap := map[string]bool{ - "PATH": true, - "SAFE_VAR": true, + "PATH": true, + "SAFE_VAR": true, + "AWS_PROFILE": true, } for _, entry := range scrubbed { key, _, _ := strings.Cut(entry, "=") @@ -661,7 +667,7 @@ func TestScrubSensitiveEnv(t *testing.T) { t.Errorf("found sensitive/unexpected environment variable: %s", entry) } } - if len(scrubbed) != 2 { - t.Errorf("expected 2 environment variables, got %d: %v", len(scrubbed), scrubbed) + if len(scrubbed) != 3 { + t.Errorf("expected 3 environment variables, got %d: %v", len(scrubbed), scrubbed) } } From 5ae88b5715442e4ff34f94316ca8f22dc5102af1 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:02:28 -0400 Subject: [PATCH 10/12] 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. --- internal/cli/sandbox_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/cli/sandbox_test.go b/internal/cli/sandbox_test.go index 6ddbabca7..8145fe514 100644 --- a/internal/cli/sandbox_test.go +++ b/internal/cli/sandbox_test.go @@ -483,6 +483,14 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) { } func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) { + // Point HOME at an empty directory so the default credential-store + // deny-read entries (which depend on what exists in the real home, e.g. + // ~/.aws on the macOS CI image) cannot leak host paths into the golden + // comparison. + emptyHome := t.TempDir() + t.Setenv("HOME", emptyHome) + t.Setenv("USERPROFILE", emptyHome) + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") store := newSandboxTestStore(t) workspace := t.TempDir() deps := appDeps{ From 6960034c4adacf41051b2b23fc89771ed9466826 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:36:58 -0400 Subject: [PATCH 11/12] 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. --- internal/fsutil/rename.go | 7 ++++--- internal/fsutil/rename_test.go | 8 ++++++-- internal/sandbox/manager_test.go | 11 +++++++++-- internal/sandbox/profile.go | 21 ++++++++++----------- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/internal/fsutil/rename.go b/internal/fsutil/rename.go index 590b0ec15..ace33502a 100644 --- a/internal/fsutil/rename.go +++ b/internal/fsutil/rename.go @@ -24,7 +24,7 @@ func RenameWithRetry(src, dst string, rename func(src, dst string) error) error return nil } if runtime.GOOS == "windows" { - if os.IsPermission(err) || isWindowsSharingViolation(err) { + if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) { time.Sleep(10 * time.Millisecond) continue } @@ -34,11 +34,12 @@ func RenameWithRetry(src, dst string, rename func(src, dst string) error) error return err } -func isWindowsSharingViolation(err error) bool { +func isWindowsSharingOrLockViolation(err error) bool { var errno syscall.Errno if errors.As(err, &errno) { const ERROR_SHARING_VIOLATION syscall.Errno = 32 - return errno == ERROR_SHARING_VIOLATION + const ERROR_LOCK_VIOLATION syscall.Errno = 33 + return errno == ERROR_SHARING_VIOLATION || errno == ERROR_LOCK_VIOLATION } return false } diff --git a/internal/fsutil/rename_test.go b/internal/fsutil/rename_test.go index ddb2e9d1e..8a64852d2 100644 --- a/internal/fsutil/rename_test.go +++ b/internal/fsutil/rename_test.go @@ -14,10 +14,14 @@ func TestRenameWithRetryRetriesOnWindows(t *testing.T) { var attempts int err := RenameWithRetry("src", "dst", func(src, dst string) error { attempts++ - if attempts < 3 { + switch attempts { + case 1: return syscall.Errno(32) // ERROR_SHARING_VIOLATION + case 2: + return syscall.Errno(33) // ERROR_LOCK_VIOLATION + default: + return nil } - return nil }) if err != nil { t.Fatalf("expected success after retries, got: %v", err) diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index 2ea4aa930..a2b8550f2 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -407,7 +407,14 @@ func TestCredentialDenyReadPathsIn(t *testing.T) { t.Errorf("credential deny paths = %#v, want unrelated entries kept after opt-out", optedOut) } - if got := credentialDenyReadPathsIn(" ", "", nil); got != nil { - t.Errorf("credential deny paths for blank home = %#v, want nil", got) + if got := credentialDenyReadPathsIn(" ", "", nil); len(got) != 0 { + t.Errorf("credential deny paths for blank home = %#v, want none", got) + } + + // The GOOGLE_APPLICATION_CREDENTIALS target stays protected even when no + // home directory is resolvable. + homeless := credentialDenyReadPathsIn("", keyFile, nil) + if !stringSliceContains(homeless, normalizeProfilePaths([]string{keyFile})[0]) { + t.Errorf("credential deny paths without home = %#v, want key file included", homeless) } } diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index a28da3461..194736667 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -166,23 +166,22 @@ func credentialDenyReadPaths(policy Policy) []string { if runtime.GOOS == "windows" { return nil } - home, err := os.UserHomeDir() - if err != nil { - return nil - } + // 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) } // 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 { - if strings.TrimSpace(home) == "" { - return nil - } - candidates := []string{ - filepath.Join(home, ".aws"), - filepath.Join(home, ".config", "gcloud"), - filepath.Join(home, ".azure"), + var candidates []string + if home = strings.TrimSpace(home); home != "" { + candidates = append(candidates, + filepath.Join(home, ".aws"), + filepath.Join(home, ".config", "gcloud"), + filepath.Join(home, ".azure"), + ) } if target := strings.TrimSpace(googleCredentials); target != "" { candidates = append(candidates, target) From 570b2884d35f84921313efa8edb50b1d139cfcb3 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:59:50 -0400 Subject: [PATCH 12/12] 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. --- internal/sandbox/runner.go | 2 ++ internal/sandbox/runner_test.go | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 66c370ff9..4bcf83808 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -979,6 +979,8 @@ func scrubSensitiveEnv(env []string) []string { "AWS_SESSION_TOKEN", "GITLAB_TOKEN", "GH_TOKEN", + "ZERO_WEBSEARCH_API_KEY", + "ZERO_DAEMON_REMOTE_TOKEN", } for _, descriptor := range providercatalog.All() { for _, key := range descriptor.AuthEnvVars { diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index b1f6d8c9f..5e7a5b256 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "reflect" "runtime" "strings" "testing" @@ -656,18 +657,20 @@ func TestScrubSensitiveEnv(t *testing.T) { "SAFE_VAR=hello", } scrubbed := scrubSensitiveEnv(inputEnv) - expectedMap := map[string]bool{ - "PATH": true, - "SAFE_VAR": true, - "AWS_PROFILE": true, + expected := map[string]string{ + "PATH": "/usr/bin", + "SAFE_VAR": "hello", + "AWS_PROFILE": "staging", } + actual := make(map[string]string, len(scrubbed)) for _, entry := range scrubbed { - key, _, _ := strings.Cut(entry, "=") - if !expectedMap[key] { - t.Errorf("found sensitive/unexpected environment variable: %s", entry) + key, value, _ := strings.Cut(entry, "=") + if _, dup := actual[key]; dup { + t.Errorf("duplicate key %q in scrubbed env: %v", key, scrubbed) } + actual[key] = value } - if len(scrubbed) != 3 { - t.Errorf("expected 3 environment variables, got %d: %v", len(scrubbed), scrubbed) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("scrubSensitiveEnv() = %v, want %v", actual, expected) } }