Skip to content

fix(sandbox): preserve user config and retry denied writes - #801

Merged
anandh8x merged 9 commits into
mainfrom
fix/preserve-user-environment
Jul 27, 2026
Merged

fix(sandbox): preserve user config and retry denied writes#801
anandh8x merged 9 commits into
mainfrom
fix/preserve-user-environment

Conversation

@anandh8x

@anandh8x anandh8x commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • preserve the caller's home, configuration, data, and state paths so sandboxed tools discover normal user settings
  • keep cache and temporary writes isolated in the managed sandbox runtime
  • classify native sandbox failures as typed, recoverable denials and prompt before retrying outside the sandbox
  • remove unused synthetic home/config/state runtime directories

Behavior

Sandboxed commands now honor user configuration such as Git global ignore rules. Writes outside approved roots remain read-only by default. When a native sandbox blocks a command, Zero surfaces the denial reason and retries only after explicit user approval.

Verification

  • make fmt-check
  • go vet ./...
  • go test ./...
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • govulncheck ./... — no vulnerabilities found
  • Linux TUI smoke test: global Git ignores honored; home write denied by default; approval prompt shown; approved retry succeeded
  • Windows and macOS test binaries cross-compiled successfully
  • git diff HEAD --check

The pinned advisory linter reports only existing repository-wide findings outside the changed files.

Summary by CodeRabbit

  • Bug Fixes
    • Improved sandbox denial interpretation so retry/approval decisions correctly follow typed denial details, distinguishing recoverable vs non-recoverable outcomes.
    • Prevented tool stderr output from being misclassified as a native sandbox denial.
    • Streamlined sandbox runtime directory creation while preserving caller configuration (including Git global ignore behavior) and using managed writable cache/temp locations.
    • Expanded credential “read-all” workspace restrictions, honoring environment/config overrides.
  • Tests
    • Added/expanded coverage for typed denial retry logic, native denial non-inference, managed runtime environment (including Git ignore), and credential deny-path generation.

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.
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 9fc61fc43300
Changed files (7): internal/agent/loop.go, internal/agent/loop_test.go, internal/sandbox/manager_test.go, internal/sandbox/profile.go, internal/sandbox/runtime_state.go, internal/sandbox/runtime_state_test.go, internal/tools/exec_command_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 01ca5286-701b-4ae5-bb3c-acd6d77557eb

📥 Commits

Reviewing files that changed from the base of the PR and between bb103b2 and 9fc61fc.

📒 Files selected for processing (1)
  • internal/sandbox/manager_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/sandbox/manager_test.go

Walkthrough

The change reduces synthetic sandbox runtime directories, preserves caller configuration, expands credential deny-read paths, and refines structured sandbox-denial retry handling with additional tests.

Changes

Sandbox runtime environment

Layer / File(s) Summary
Runtime paths and environment wiring
internal/sandbox/runtime_state.go
SandboxRuntime now exposes only Root, Cache, Data, and Temp; preparation and environment setup no longer create or override synthetic home, config, or state paths.
Runtime environment validation
internal/sandbox/runtime_state_test.go
Tests cover managed directory creation, caller configuration preservation, writable cache mappings, Git global ignores, and command-plan HOME propagation.

Credential deny-read paths

Layer / File(s) Summary
Environment-driven credential deny paths
internal/sandbox/profile.go, internal/sandbox/manager_test.go
Credential deny-read computation derives candidates from multiple environment and tool configuration locations, filters them against allowed roots, and tests explicit overrides and additional credential files.

Structured sandbox denials

Layer / File(s) Summary
Approval-based sandbox retry handling
internal/agent/loop.go, internal/agent/loop_test.go, internal/tools/exec_command_test.go
Retry detection now requires a recoverable unrestricted platform denial requesting approval; tests cover structured denials, non-recoverable denials, and rejection of output-only native denial inference.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

  • Gitlawb/zero#77: Routes sandbox decisions into typed tools.Result.ExecutionOutcome, which this change interprets for retry handling.
  • Gitlawb/zero#681: Modifies the same environment-driven credential deny-read logic and related tests.
  • Gitlawb/zero#685: Extends the same credential deny-read path code in internal/sandbox/profile.go.

Suggested reviewers: gnanam1990, kevincodex1, vasanthdev2004

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title captures the main sandbox changes: preserving user config and handling denied writes with retry behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/preserve-user-environment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
internal/tools/sandbox_denial_test.go (1)

28-38: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add false-positive regression cases for the keyword heuristic.

The table covers obvious denial strings but not ordinary output containing "sandbox", "failed to write file", or unrelated permission/read-only errors. Add those cases and assert that both denial metadata and the returned denial remain unset.

As per coding guidelines, **/*_test.go files should add regression tests for behavior changes; this heuristic needs explicit false-positive coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/sandbox_denial_test.go` around lines 28 - 38, Expand the test
table in the markLikelySandboxDenial test to include sandboxed commands whose
output contains “sandbox”, “failed to write file”, and unrelated permission or
read-only errors. Set each case to expect no denial, and assert both the
SandboxLikelyDeniedMeta metadata and the returned denial remain unset.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/loop.go`:
- Around line 1622-1626: Update the denial predicate in the surrounding loop
logic to also require the typed platform denial’s Recoverable field to be true
before returning true. Preserve the existing source, capability, and next-action
checks so non-recoverable denials cannot enter the unrestricted retry or
host-escalation flow.

In `@internal/sandbox/runtime_state_test.go`:
- Line 27: Extend the runtime state filesystem assertions around the existing
Cache, Data, and Temp loop to verify the obsolete synthetic home, config, and
state paths are absent. Add regression coverage using the test’s established
assertion style, while preserving the existing existence checks for the active
directories.
- Around line 226-229: Update the environment setup in the runtime-state Git
test to remove inherited GIT_* override variables from os.Environ(), including
GIT_CONFIG_GLOBAL, GIT_DIR, and GIT_CONFIG_COUNT, and disable system Git
configuration before invoking Git. Add regression coverage verifying these
host-level settings cannot override the test’s HOME and XDG_CONFIG_HOME
configuration.

In `@internal/tools/sandbox_denial.go`:
- Around line 9-17: Narrow sandbox-denial detection to require platform-specific
evidence before treating generic keywords such as “sandbox” or “failed to write
file” as denials; update the heuristic in internal/tools/sandbox_denial.go lines
9-17 and only construct unrestricted host denial metadata in lines 35-54 after
stronger corroboration. Add regression cases in
internal/tools/sandbox_denial_test.go lines 28-38 covering generic application,
permission, and read-only errors, asserting neither denial metadata nor a
pointer is produced.

---

Nitpick comments:
In `@internal/tools/sandbox_denial_test.go`:
- Around line 28-38: Expand the test table in the markLikelySandboxDenial test
to include sandboxed commands whose output contains “sandbox”, “failed to write
file”, and unrelated permission or read-only errors. Set each case to expect no
denial, and assert both the SandboxLikelyDeniedMeta metadata and the returned
denial remain unset.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 289f6db2-c754-4b9c-91f7-df23c5876269

📥 Commits

Reviewing files that changed from the base of the PR and between 97ef90d and 99c74d9.

📒 Files selected for processing (9)
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/sandbox/runtime_state.go
  • internal/sandbox/runtime_state_test.go
  • internal/tools/bash.go
  • internal/tools/exec_command.go
  • internal/tools/exec_command_test.go
  • internal/tools/sandbox_denial.go
  • internal/tools/sandbox_denial_test.go

Comment thread internal/agent/loop.go
Comment thread internal/sandbox/runtime_state_test.go Outdated
Comment thread internal/sandbox/runtime_state_test.go Outdated
Comment thread internal/tools/sandbox_denial.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/tools/sandbox_denial_test.go`:
- Around line 39-42: Expand the assertions in the markLikelySandboxDenial test
to validate the complete denial structure, not just whether denial is non-nil.
Check Source, unrestricted Capability, Recoverable, NextAction, and the
reason/keyword metadata used by the retry prompt, using the expected values from
each test case and preserving the existing nil/non-nil assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b8019c76-b4ef-4dd4-84a2-3173d8acaf94

📥 Commits

Reviewing files that changed from the base of the PR and between 99c74d9 and c2429d0.

📒 Files selected for processing (5)
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/sandbox/runtime_state_test.go
  • internal/tools/sandbox_denial.go
  • internal/tools/sandbox_denial_test.go
💤 Files with no reviewable changes (1)
  • internal/tools/sandbox_denial.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/sandbox/runtime_state_test.go

Comment thread internal/tools/sandbox_denial_test.go Outdated
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 24, 2026
@anandh8x

Copy link
Copy Markdown
Collaborator Author

Windows Smoke is currently blocked by the existing TestLoadProviderCommandTimeout startup-timing flake in internal/config/command_test.go: the test tries to read sleep.pid before the Windows helper creates it. The sandbox, agent, and tools packages pass on Windows, and this PR does not change internal/config. The same job passed once on c2429d0, then reproduced the identical missing sleep.pid failure twice after the test-only 3b9be60 commit. The scoped fix is already in #800; that PR should land independently rather than mixing its unrelated test changes into this sandbox PR.

gnanam1990
gnanam1990 previously approved these changes Jul 24, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE

The fix does what it claims. A native-sandbox read-only / permission-denied shell failure now produces an ExecutionOutcome.Denial (Source=PlatformSandbox, Capability=Unrestricted, Recoverable=true, NextAction=RequestApproval) that matches the narrowed sandboxDeniedShellResult predicate in internal/agent/loop.go, so the command re-enters the unrestricted retry path. Wiring is coherent end-to-end: markLikelySandboxDenial (internal/tools/sandbox_denial.go) → execExecutionOutcome (exec_command.go:439/554) → StateDenied → loop.go retry prompt. PR-added tests pass on the PR HEAD (TestExecCommandInfersNativeSandboxDenialFromOutput, TestLikelySandboxDenialMetadataFromCommandOutput, TestExecCommandUsesStructuredAdapterDenial, TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics, and the sandbox runtime/env-preservation tests). gofmt/vet/build clean.

Two minor issues, neither blocking merge.

[Minor] Non-sandbox "permission denied" errors are misclassified as recoverable sandbox denials — PR-introduced

internal/tools/sandbox_denial.go:40

The heuristic matches the bare substring "permission denied" in stderr whenever the command ran sandboxed and exited non-zero (bash.go:184-191 / exec_command.go:440-447 gate only on sandboxed && exitCode != 0). Ordinary OS permission failures therefore get classified as recoverable sandbox denials:

  • git push git@github.com:foo/bar with no valid key → git@github.com: Permission denied (publickey)., exit 128
  • cat /etc/shadowcat: /etc/shadow: Permission denied, exit 1
  • any sudo ... invocation that is denied

Each yields Denial{Kind:CapabilityUnrestricted, Source:PlatformSandbox, Recoverable:true, NextAction:RequestApproval}, so maybeRetryUnsandboxedAfterSandboxRestriction (loop.go:1453) prompts the user to re-run unsandboxed with reason "sandbox blocked command execution". Because the sandbox never caused the failure, the unsandboxed retry hits the identical OS error (SSH auth / file mode / sudo) and the user is shown a factually wrong "sandbox blocked" reason. All three sample strings were confirmed to match the keyword heuristic.

Fix: require a corroborating signal before firing on "permission denied" — e.g. only when the target is a known-restricted write root or the denial monitor/adapter also flagged a block — or drop the broad "permission denied" keyword and rely on the sandbox-specific signals (EROFS "read-only file system", seccomp, landlock) plus structured adapter denials.

[Minor] bash.go sandbox-denial inference is untested on the real shell tool path — PR-introduced

internal/tools/bash.go:184

Deleting the entire if adapterReport.Denial == nil && reportErr == nil { adapterReport.Denial = markLikelySandboxDenial(...) } block (bash.go:184-192) produces zero new failures across go test ./internal/tools/... and go test ./internal/agent/... — only the pre-existing environment failures remain. So a future edit that breaks this wiring would silently disable sandbox-denied-write retry for real bash tool commands (e.g. touch "$HOME/probe" → "Read-only file system" under native enforcement no longer produces a Denial, and loop.go's retry never fires) with the suite staying green. The existing coverage doesn't reach this path: TestExecCommandInfersNativeSandboxDenialFromOutput drives exec_command.go, and the agent-level TestRunRetriesShellUnsandboxedAfterSandboxDeniedExit uses a fake tool that hand-builds the ExecutionOutcome, so neither exercises bash.go's plan.Wrapped && plan.EnforcementLevel == EnforcementNative guard or its stdout+stderr passing.

Fix: add a bash-tool-level test analogous to the exec_command one that runs the real bash tool with a native-wrapped plan and a stderr denial keyword, asserting result.ExecutionOutcome.Denial is populated and SandboxLikelyDeniedMeta is set.

Tests

All failures in the three touched packages (3 in internal/tools, 7 in internal/sandbox, 4 in internal/agent) are pre-existing environment failures — the known sandbox/path/symlink issues from the worktree living under /private/tmp. The --- FAIL name sets are byte-for-byte identical between the PR HEAD and the clean base worktree, so none is PR-attributable. -race not run (this is not a concurrency PR).

Merge is kevin's call per the program gate.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good change in the main line: pointing HOME and the XDG vars back at the caller's real paths so sandboxed tooling sees normal user settings is the right call, and I checked the thing I was most worried about before anything else. It does not widen the read boundary. profile.go is untouched, the posture there is still the deliberate read-all/write-jail one, and credentialDenyReadPaths resolves from os.UserHomeDir() in the Zero process rather than the child's $HOME, so the deny masks still land on the same real paths after the env change. Writes are actually tighter than before, since the old synthetic HOME sat inside the runtime root and was therefore writable, while the real home is in no write root. So no credential regression, and #675/#677 are neither helped nor hurt.

Two things I would like fixed before this lands.

1. [blocker] The sandbox denial is inferred from the command's own stdout/stderr again, and it now drives an unsandboxed re-run.

markLikelySandboxDenial (internal/tools/sandbox_denial.go) scans the command output for operation not permitted, permission denied, read-only file system, seccomp, landlock and mints a real execution.Denial{Source: DenialSourcePlatformSandbox, Capability: {Kind: CapabilityUnrestricted, Scope: "host"}, Recoverable: true, NextAction: DenialNextActionRequestApproval}. It sits directly under the comment // Classification is never inferred from stdout or stderr. that #781 added when it deleted this exact heuristic.

That matters more than it did before #781, because loop.go now keys the unsandboxed-retry predicate on precisely that denial shape, and this function is its only producer. So the retry path fires on, and only on, stdout-inferred denials. The strings are generic enough to be produced by ordinary commands: a failing test, a linter, or cat on a root-owned file all print permission denied without the sandbox being involved at all. Since tool output is model- and content-controllable, a command can effectively ask Zero to offer to re-run it without the sandbox.

To be fair on severity: in ask and auto modes the retry is fail-closed and requires a fresh human Allow, so this is not a silent bypass there. But under PermissionModeUnsafe it is taken with no prompt, and the retry resolves to a nil engine, which means ModeDisabled, NetworkAllow, FileSystemUnrestricted and no DenyRead at all, so the credential masks that are the whole safety story above are gone on that path.

The fix I would want is to keep the classification typed: derive the denial from the sandbox adapter's own facts (which is what markStructuredSandboxDenial already does) and let the keyword scan at most annotate a denial that the sandbox layer already reported, never create one. If a heuristic is genuinely needed for a backend that cannot report denials structurally, it should produce a non-recoverable, non-retry-eligible marker rather than one that unlocks the unsandboxed path.

2. [minor] CARGO_HOME looks like collateral damage.

The PR removes data/cargo and the CARGO_HOME override as unused, but it is the only place in the tree that sets it, and cargo does not honour XDG_CACHE_HOME. Every sibling redirect survives (npm, yarn, corepack, pip, GOCACHE, GOMODCACHE), so cargo is the one toolchain left pointing at $HOME/.cargo, which is in no write root. That should make cargo builds fail inside the sandbox rather than land in the managed cache. Worth restoring unless you found it genuinely dead.

Verification note: Smart App Control on my machine is currently blocking the unsigned go toolchain, so this review is source-reading only; I could not run builds or tests locally. CI is green on all three OSes.

Happy to re-review quickly once the denial classification is back to typed facts.

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.
@anandh8x
anandh8x dismissed stale reviews from gnanam1990 and coderabbitai[bot] via 7dd3d57 July 26, 2026 08:25
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 26, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Changes requested, on a single point noted below.

Thank you for this. Preserving the caller's configuration so sandboxed tooling behaves like the user's own shell is a real improvement, and the denial-retry path is carefully constructed. I am requesting changes on a single point, phrased as a question you may be able to close by stating intent rather than by writing code.

Verification. On macOS (darwin/arm64), make fmt-check, go vet ./... and git diff --check are clean, and the full suite passes at 82 packages with no failures. Both halves of the change are covered by tests that genuinely bind to them: reverting internal/agent/loop.go to base while retaining the new tests fails TestRunRetriesShellUnsandboxedAfterSandboxDeniedExit, and reverting internal/sandbox/runtime_state.go fails four tests, including TestSandboxRuntimeEnvironmentPreservesUserConfiguration and TestSandboxRuntimeEnvironmentPreservesGitGlobalIgnore.

I also traced the retry path rather than taking the description on trust. At internal/agent/loop.go:1449-1494 the unsandboxed retry reaches runToolForUnsandboxedRetry only after requestPermission returns an Allow variant, and sandboxRestrictedShellRetryCandidate additionally requires UnsandboxedExecutionAllowed() and refuses commands already marked require_escalated. PermissionModeUnsafe bypasses the prompt, which is that mode's documented contract. Narrowing the typed-denial branch to CapabilityUnrestricted, Recoverable and RequestApproval is a reasonable reading of the original constraint.

The point I would like resolved before this lands. Configuration resolution inside the sandbox now points at the user's real credential locations, while the credential deny-list has not been extended to match. The change removes HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME and NPM_CONFIG_USERCONFIG from the sandbox environment (internal/sandbox/runtime_state.go:198), while credentialDenyReadPathsIn (internal/sandbox/profile.go:174) still covers only ~/.aws, ~/.config/gcloud, ~/.azure and $GOOGLE_APPLICATION_CREDENTIALS.

Locations that become the resolution target and are not deny-listed include ~/.npmrc, which holds _authToken and is precisely what the removed NPM_CONFIG_USERCONFIG previously redirected away; ~/.config/gh/hosts.yml; ~/.netrc; ~/.docker/config.json; ~/.kube/config; and ~/.config/zero/config.json, which holds Zero's own provider API keys (internal/config/paths.go:41). The .zero and .agents entries in protectedMetadataNames cover workspace metadata and do not apply to the user configuration directory.

To be fair on severity, this is not new filesystem access on Linux: internal/sandbox/linux_helper.go:242 already binds / read-only, so those files were reachable by absolute path beforehand. What changes is the default, moving exposure from "requires knowing an absolute path" to "any tool that reads its own configuration". On macOS the seatbelt read rule is an allow-list (internal/sandbox/runner.go:670), so reads remain denied there and the effect is platform-dependent.

The tension worth resolving is with the design comment at internal/sandbox/profile.go:209-213, which records that only the global git config files are granted, deliberately excluding ~/.config/git, so that "credentials and the rest of HOME stay unreadable". This change makes the real ~/.config the resolution root while that comment still stands. Extending credentialDenyReadPaths to cover the newly-resolved surface, or documenting why the wider resolution is acceptable, would close the question either way.

$HOME is no longer writable inside the sandbox. The retained overrides cover caches, temporary directories, GOCACHE, GOMODCACHE and CARGO_HOME, but writes elsewhere under $HOMEgit config --global, ~/.gradle, ~/.m2, ~/.rustup — will now be denied rather than landing in a synthetic home. The final commit on this branch restores Cargo state, which suggests this was encountered once already; it would be worth confirming the enumeration is complete rather than incremental.

Stale comment at internal/agent/loop.go:1620-1621. The note that a structured narrow denial must never become the legacy unrestricted retry now sits directly above code that converts a typed denial into that retry. The new capability guard arguably honours the intent, but the wording reads as contradicting the code beneath it, and rewording it would help the next reader.

No linked issue. The description carries no Fixes # reference and the closing-issue reference is empty. This is not a ground for closure on a collaborator pull request, but it leaves the change without traceability.

Limitations of this review. I worked on macOS only. The Linux and Windows statements above rest on reading the code; I did not run the Linux TUI smoke test described in the description, and I did not observe the denial classification end to end against a native Linux sandbox. If the credential question turns on Linux behaviour I have not exercised, I would defer to a check on that platform.

Merge is kevin's call per the program gate.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/sandbox/profile.go`:
- Around line 202-236: Update credentialPathEnvironment and
credentialDenyReadPathsForEnvironment to add a CloudSDKConfig override,
defaulting it to ~/.config/gcloud only when unset, and use that value when
appending the gcloud deny-read path. Ensure credentialDenyReadPaths passes
os.Getenv("CLOUDSDK_CONFIG") into the environment so the configured credential
directory is protected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4398398c-0986-4b6a-a364-5bda62116f6e

📥 Commits

Reviewing files that changed from the base of the PR and between 7dd3d57 and 968d083.

📒 Files selected for processing (3)
  • internal/agent/loop.go
  • internal/sandbox/manager_test.go
  • internal/sandbox/profile.go

Comment thread internal/sandbox/profile.go
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.
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 26, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Changes requested, on one point that is narrower than my last one.

Reviewed at 538b31ed75e5, base 9d5c82d441b7, re-confirmed against the live head before posting.

My previous blocker is resolved, and thoroughly. I asked for the credential deny-list to be extended to cover the locations that the preserved caller environment makes the resolution target. Every location I named is now covered, and the two commits go further than I asked by honouring the environment overrides rather than hard-coding paths — CLOUDSDK_CONFIG, GH_CONFIG_DIR, NETRC, DOCKER_CONFIG, NPM_CONFIG_USERCONFIG and KUBECONFIG, the last correctly split on the list separator. I verified this by driving credentialDenyReadPathsForEnvironment against a synthetic home with every credential file materialised: ~/.aws, ~/.azure, ~/.npmrc, ~/.netrc, ~/.docker/config.json, ~/.config/zero/config.json, ~/.config/gcloud, ~/.config/gh/hosts.yml and ~/.kube/config all come back denied. Nine of nine.

The two stale comments are fixed as well — internal/agent/loop.go:1620 now describes what the code beneath it does, and the profile.go comment no longer claims a property the change had altered.

I also confirmed the new tests bind. Neutering the npmUserConfig/netrc append fails TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides, so the coverage is asserted rather than incidental.

The remaining point: Zero's own credential store is beside the file you denied, not in it.

internal/sandbox/profile.go denies ~/.config/zero/config.json. That is the one file in that directory which deliberately does not hold secrets. internal/credstore/credstore.go:1 opens by stating the package "persists provider API keys outside config.json so secrets are not written to disk in cleartext", and internal/config/credentials.go:32 resolves the store to filepath.Dir(configPath) — the same directory. So alongside the denied file sit:

  • credentials.enc and credentials.enc.secret (credstore.go:27, :108-109) — the encrypted provider keys and, in the same directory, the key material that decrypts them
  • credentials.json (credstore.go:28) — the plaintext backend
  • oauth-tokens.json (internal/oauth/store.go:158)

None of the four is denied. Denying the file rather than the directory protects the one location the codebase moved secrets out of, and leaves both halves of the encrypted store readable together, which defeats the encryption rather than merely bypassing one file.

Changing the entry from the file to the directory looks like a one-line change, and I would not hold the pull request for anything else.

Three further gaps, non-blocking, listed so the enumeration can be judged as a whole. ~/.ssh and ~/.gnupg are not covered. Neither is ~/.git-credentials or ~/.config/git/credentials — worth noting because the comment this change rewrote previously named the XDG credential store under ~/.config/git as the specific reason for granting only the git config files. That reasoning is still sound; it simply no longer appears anywhere, and the file it was protecting against is not on the list.

The broader shape, offered as a question rather than a request. This is a deny-list on a security boundary, and the entries are being added a few at a time — cloud CLIs first, then tool configs, then a custom gcloud location. This repository has been through that cycle before on command classification, where three correct incremental patches each left the class open. I do not think it blocks this change, but it is worth deciding whether the Linux read-all posture with credential deny rules is the shape you want long term, or whether the resolution root should be narrowed instead. #808 approaches the same boundary from the other side by making credentials unreachable by construction, and the two would benefit from being sequenced deliberately.

Verification. On macOS (darwin/arm64), go build ./..., go vet ./... and gofmt -l are clean, and ./internal/sandbox/... and ./internal/agent/... both pass.

Limitations. macOS only. The deny-list is skipped on Windows by design and is a no-op on macOS, where reads are allow-listed, so everything above concerns Linux behaviour that I reasoned about from the code rather than executed on a Linux host.

Merge is kevin's call per the program gate.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed on 538b31e. Both of my findings are resolved, so I am not holding this on my own account. I am not approving either, because I went and checked gnanam's remaining point and it is real.

My blocker is fixed properly. markLikelySandboxDenial is gone rather than softened, and there is no keyword scan of stdout or stderr left in internal/tools at all. The only producer now is markStructuredSandboxDenial, fed from the adapter's own report at three call sites. That is exactly the typed-facts shape I asked for, so the unsandboxed-retry predicate can no longer be reached by content a command prints.

CARGO_HOME is back, at internal/sandbox/runtime_state.go:211, so cargo lands in the managed cache with its siblings.

On gnanam's point about the credential store, which I would treat as blocking.

I drove credentialDenyReadPathsForEnvironment against a home with the whole store materialised, since the builder filters to paths that exist and a synthetic path returns nothing:

denied=true   ~/.config/zero/config.json
denied=false  ~/.config/zero/credentials.enc
denied=false  ~/.config/zero/credentials.enc.secret
denied=false  ~/.config/zero/credentials.json
denied=false  ~/.config/zero/oauth-tokens.json
total deny entries: 1

Confirmed, and I would put it slightly more strongly than "one file was missed". credentials.enc and credentials.enc.secret are both readable, in the same directory, so the ciphertext and the key that decrypts it are available together. That is not a bypass of one entry; it makes the encryption itself not worth anything on this path. config.json is the single file in that directory the codebase deliberately moved secrets out of, and it is the only one denied.

The same probe puts ~/.ssh/id_rsa and ~/.git-credentials at denied=false, so gnanam's non-blocking list holds too.

Changing that entry from the file to the directory is the one-line fix, and I would want it before this merges rather than as a follow-up, because the failure mode is silent: nothing about the current behaviour looks wrong from the outside.

On the enumeration question gnanam raised, since it is partly aimed at #808. I agree it is worth deciding deliberately rather than drifting. My read is that a deny-list on a read-all posture will keep needing entries as long as the resolution root is the real home, and this change is what makes that root the real home. So the incremental cycle is not an accident of this change, it is a consequence of it. That is an argument for sequencing, not for blocking: #808 removes the need for the list on Windows by giving the sandbox its own identity, and it is deliberately inert by default, so the two do not collide. Ordering is kevin's call.

Verification: on Windows, so the deny-list is a no-op for me at runtime and I exercised the builder directly rather than end to end. go build, go vet and gofmt clean; internal/sandbox passes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/sandbox/profile.go (1)

204-207: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Preserve non-blank path values instead of trimming them.

These values come from the caller’s environment and may legally contain spaces. Trimming changes the path before os.Stat, so the actual credential store can be omitted from DenyRead and remain readable in the sandbox. Only trim to detect an empty value; retain the original path for resolution and add a regression test covering a path with spaces.

Also applies to: 217-227, 238-243, 245-261

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/profile.go` around lines 204 - 207, Update
credentialDenyReadPathsForEnvironment to use trimmed Home and ConfigHome only
for blank-value checks, while preserving the original non-blank path strings for
resolution and os.Stat/DenyRead generation. Apply this consistently across the
related candidate and path-resolution logic, and add a regression test verifying
credential paths containing spaces are retained and denied.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/sandbox/profile.go`:
- Around line 204-207: Update credentialDenyReadPathsForEnvironment to use
trimmed Home and ConfigHome only for blank-value checks, while preserving the
original non-blank path strings for resolution and os.Stat/DenyRead generation.
Apply this consistently across the related candidate and path-resolution logic,
and add a regression test verifying credential paths containing spaces are
retained and denied.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f488b57e-d264-4dc6-9edc-66ac639aabca

📥 Commits

Reviewing files that changed from the base of the PR and between 538b31e and bb103b2.

📒 Files selected for processing (2)
  • internal/sandbox/manager_test.go
  • internal/sandbox/profile.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/sandbox/manager_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 27, 2026

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 9fc61fc. The blocking finding is fixed. Approving.

I re-ran the probe that found it rather than reading the one-line diff, with the whole store materialised, since the builder filters to paths that exist:

denied=true   ~/.config/zero/config.json
denied=true   ~/.config/zero/credentials.enc
denied=true   ~/.config/zero/credentials.enc.secret
denied=true   ~/.config/zero/credentials.json
denied=true   ~/.config/zero/oauth-tokens.json
denied=true   ~/.config/zero/providers/anthropic/token.json

The last row is one I added this time: the deny covers nested subtrees, not just the directory's immediate children, so a store that grows a subdirectory later is covered by construction rather than by remembering to add it. That is the property that makes this the right shape of fix rather than a bigger enumeration.

It also picks up files nobody listed. mcp-oauth-tokens.json and mcp-permissions.json live in that directory too and are now covered without anyone having had to notice them, which is precisely the argument for denying the directory.

I checked the fix binds rather than trusting the tests exist: reverting the entry to the file form fails TestCredentialDenyReadPathsIn and TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides.

I also checked the obvious risk of widening a deny to a whole directory, which is breaking something that legitimately reads from it. hooks.json, mcp-permissions.json and the dictation model root are all under there, but they are read by Zero's own process rather than by the sandboxed child, so denying the child is correct and I could not find a path that regresses.

Two things left open, both non-blocking and both gnanam's from the previous round rather than new:

~/.ssh, ~/.gnupg, ~/.git-credentials and ~/.config/git/credentials are still uncovered. I confirmed that at this head. Worth a follow-up rather than another round here.

The test proves the directory is in the deny list, but not the reason it matters. Asserting that credentials.enc specifically comes back denied would document the intent and survive a future refactor that changes how entries are built. Small, and I would not hold the change for it.

Verification: on Windows, so the deny-list is a no-op at runtime for me and I drove the builder directly rather than end to end. go vet clean, internal/sandbox passes.

Merge is kevin's call per the program gate. From my side this is done, and gnanam's outstanding request is the one on the previous commit rather than this one.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

@gnanam1990 when you get a chance, this one needs your eyes to move: your changes-requested is on 538b31e and the credential-store finding was fixed two commits later in bb103b2. You are the only thing holding it now, and I do not think that reflects your actual position on the current head.

I verified the fix independently before approving, with the store materialised, since the builder filters to paths that exist:

denied=true   ~/.config/zero/credentials.enc
denied=true   ~/.config/zero/credentials.enc.secret
denied=true   ~/.config/zero/credentials.json
denied=true   ~/.config/zero/oauth-tokens.json
denied=true   ~/.config/zero/providers/anthropic/token.json

The nested path is one I added: the deny covers subtrees, so the ciphertext and its key are no longer readable together and a store that grows a subdirectory later is covered without anyone having to notice. It also picks up mcp-oauth-tokens.json, which neither of us listed.

Your two non-blocking items are still open and I confirmed that at this head: ~/.ssh, ~/.gnupg, ~/.git-credentials and ~/.config/git/credentials are all denied=false. Worth a follow-up rather than another round on this PR, but that is your call, not mine.

Good catch on this one. Denying the file that deliberately holds no secrets while both halves of the encrypted store sat beside it readable is not something I would have spotted from the diff.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Approve.

Reviewed at 9fc61fc43300, base 5d1869e, re-confirmed against the live head before posting.

My blocker is resolved. Changing the entry from $XDG_CONFIG_HOME/zero/config.json to the directory is the one-line fix I asked for, and I verified it rather than reading the commit message. Driving credentialDenyReadPathsForEnvironment against a synthetic home with the whole credential set materialised:

deny entries: /.aws  /.npmrc  /.netrc  /.config/zero  /.kube/config

   denied  .config/zero/config.json
   denied  .config/zero/credentials.enc
   denied  .config/zero/credentials.enc.secret
   denied  .config/zero/credentials.json
   denied  .config/zero/oauth-tokens.json

All five, where previously only the first was covered. The pair that mattered most — credentials.enc and the .secret beside it — are no longer readable together.

Thank you to @Vasanthdev2004 for independently probing this and reaching the same result before the fix landed; his framing that it "makes the encryption itself not worth anything on this path" is the right way to describe the severity, and sharper than mine.

The tests bind. Reverting the entry from the directory back to the file fails both TestCredentialDenyReadPathsIn and TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides.

One consequence worth recording deliberately, because it is wider than what I asked for. $XDG_CONFIG_HOME/zero/ is not only the credential store. It also holds plugins/ (internal/plugins/plugins.go:194), commands/ (internal/usercommands/usercommands.go:51), hooks.json (internal/hooks/hooks.go:203), trust.json (internal/workspacetrust/trust.go:38), modelsdev.json (internal/modelregistry/modelsdev.go:151), background/ and the per-user context file (internal/agent/system_prompt.go:393). Denying the directory denies all of it to sandboxed commands, so a zero invoked from inside a sandboxed shell loses its user commands, plugins, hooks, cached model catalog and workspace-trust record.

I think that is the right trade and I am not asking you to narrow it. Enumerating the credential files instead would reintroduce exactly the defect this fixes: a credential file added later would default to readable, which is how credentials.enc came to be exposed in the first place. But it is a broader behaviour change than "deny the credential store", the failure mode is a nested zero behaving subtly differently rather than erroring, and it belongs in the description or a release note rather than being discovered later.

The non-blocking gaps from my previous review remain open, and @Vasanthdev2004's probe confirms them independently: ~/.ssh, ~/.gnupg, ~/.git-credentials and ~/.config/git/credentials are all still readable. The last two are worth a follow-up specifically because the comment this change rewrote previously named the XDG git credential store as the reason for granting only the git config files — that reasoning was sound and no longer appears anywhere.

On the deny-list question, I take his point and it is better than mine. I framed the incremental cycle as a risk to decide about. He is right that it is a consequence: a deny-list over a read-all posture will keep needing entries for as long as the resolution root is the real home, and this change is what makes it the real home. That reframes it from "will this drift?" to "this will require maintenance by construction, so decide who owns it" — which is a sequencing question against #808's identity model rather than a reason to hold this. Agreed that ordering is kevin's call.

Verification. On macOS (darwin/arm64): go build ./..., go vet ./..., gofmt -l and git diff --check clean; ./internal/sandbox/... and ./internal/agent/... passing. The branch is 11 commits behind main, so I merged origin/main and confirmed the merged tree builds and internal/sandbox still passes, rather than trusting the absence of a conflict — a clean textual merge in this repository has produced a broken tree before. A rebase before merge would still be worth doing so CI reflects the tree that lands.

Limitations. macOS only, and this is the part a reader should weigh: credentialDenyReadPaths returns early on Windows and is a no-op on macOS, where seatbelt reads are allow-listed. Everything above concerns Linux runtime behaviour that I exercised only through the profile builder, never against a live Linux sandbox. @Vasanthdev2004 reports the same constraint from the other direction, on Windows. Nobody on this thread has run the enforcement path this change is about.

Merge is kevin's call per the program gate.

@anandh8x
anandh8x merged commit 81a5e6c into main Jul 27, 2026
9 checks passed
@Vasanthdev2004

Vasanthdev2004 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

@gnanam1990 the ~/.ssh, ~/.gnupg, ~/.git-credentials and ~/.config/git/credentials gaps you listed as non-blocking are now #815, with the measured probe output.

One thing that came out of writing it up: it is not a one-line addition. Denying ~/.ssh wholesale stops a sandboxed git push over SSH from working, so it is a functional trade rather than a free win, and the issue lays out three options rather than assuming one.

The other thing worth recording there: userGitConfigReadPaths already grants only the two git config FILES, and its comment says why, to avoid exposing the surrounding directory. That directory is where the XDG credential store lives. So macOS already avoids it deliberately and only the Linux deny rule is missing; the intent is in the codebase already.

I also closed #675, since every file it named is covered by this PR, verified against merged main rather than the diff. I said explicitly there that these four are not covered, so closing it does not imply that boundary is done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants