Skip to content

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

Open
anandh8x wants to merge 4 commits into
mainfrom
fix/preserve-user-environment
Open

fix(sandbox): preserve user config and retry denied writes#801
anandh8x wants to merge 4 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-denied detection by inferring structured denials from normal command output, not only from explicit error paths.
    • More consistently routes recoverable platform sandbox denials to the “request approval” next step.
    • Preserves user configuration behavior (including Git global ignore) while using managed writable cache/temp locations.
    • Reduced creation of unnecessary sandbox runtime directories and tightened exported environment overrides to required managed paths.
  • Tests
    • Expanded unit/integration coverage for inferred structured denials, non-recoverable denial handling, and sandbox runtime/environment behavior.

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: 0684cf4c4bdb
Changed files (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

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: 2fcc3dc1-e495-4ed7-8d26-80847c182005

📥 Commits

Reviewing files that changed from the base of the PR and between c2429d0 and 3b9be60.

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

Walkthrough

The change reduces synthetic sandbox runtime paths while preserving caller configuration, and adds structured inference and retry handling for recoverable platform sandbox denials detected from command output.

Changes

Sandbox runtime environment

Layer / File(s) Summary
Runtime paths and environment wiring
internal/sandbox/runtime_state.go, internal/sandbox/runtime_state_test.go
SandboxRuntime now manages only root, cache, data, and temp paths. User configuration is preserved while writable cache locations use managed paths, with updated Git and command-plan coverage.

Structured sandbox denials

Layer / File(s) Summary
Sandbox denial inference
internal/tools/sandbox_denial.go, internal/tools/sandbox_denial_test.go
Failed sandboxed command output is scanned for denial keywords and converted into structured denial metadata.
Tool result denial propagation
internal/tools/bash.go, internal/tools/exec_command.go, internal/tools/exec_command_test.go
Command result handling invokes denial inference when no explicit denial or report error exists, with native sandbox output coverage.
Approval-based sandbox retry
internal/agent/loop.go, internal/agent/loop_test.go
Retry detection recognizes recoverable typed platform denials requesting approval for unrestricted execution.

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

Sequence Diagram(s)

sequenceDiagram
  participant Command
  participant DenialInference
  participant ExecutionOutcome
  participant AgentRetry
  Command->>DenialInference: return failed sandboxed output
  DenialInference->>ExecutionOutcome: attach structured platform denial
  ExecutionOutcome->>AgentRetry: provide recoverable approval request
  AgentRetry->>Command: trigger approval-based retry
Loading

Possibly related PRs

  • Gitlawb/zero#79: Propagates tool-result metadata used by sandbox denial handling.
  • Gitlawb/zero#659: Modifies sandbox-denial inference in the same implementation area.

Suggested reviewers: 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 is concise and accurately captures the two main changes: preserving user config paths and retrying denied writes.
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
Comment thread internal/sandbox/runtime_state_test.go Outdated
Comment thread internal/tools/sandbox_denial.go

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

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