Skip to content

fix(sandbox): deny reads of Zero credential stores - #681

Open
PierrunoYT wants to merge 26 commits into
Gitlawb:mainfrom
PierrunoYT:agent/deny-zero-credential-reads
Open

fix(sandbox): deny reads of Zero credential stores#681
PierrunoYT wants to merge 26 commits into
Gitlawb:mainfrom
PierrunoYT:agent/deny-zero-credential-reads

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • deny sandbox reads of Zero configuration, credential, encrypted-secret, and OAuth token stores
  • honor ZERO_OAUTH_TOKENS_PATH and ZERO_MCP_OAUTH_TOKENS_PATH overrides, including adjacent .secret files
  • preserve path normalization, existence filtering, deduplication, and explicit AllowRead opt-outs

Root cause

The sandbox scrubbed credential environment variables but its read-deny profile only covered cloud-provider paths. Under a read-all posture, an agent could still read Zero's own credential and OAuth stores from disk.

Windows read denial remains tracked separately in #662.

Related to #675

Validation

  • go test ./internal/sandbox -count=1
  • go test ./... -count=1 with ANTHROPIC_API_KEY unset
  • go vet ./...
  • go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./... (Go 1.26.5): no vulnerabilities found
  • go fmt ./...

Known repository/environment limitations

  • The pinned golangci-lint command ran with Go 1.26.5 and reported 35 pre-existing findings outside this patch; it reported none in the changed code.
  • make lint uses a Unix shell assignment that does not run under this Windows shell.
  • The race-enabled make test path cannot run because GCC is not installed; the complete non-race suite passed.

Summary by CodeRabbit

  • Security
    • Strengthened sandbox protections for credential-related reads (AWS, gcloud, Azure, Google application credentials, OAuth/MCP token stores, and Zero config).
    • Added separate “deny-if-exists” handling for credential-derived paths, using command-aware resolution (including override file paths) and improved deduping.
    • Landlock now rejects unsupported deny-read combinations.
  • Bug Fixes
    • Sandbox skips missing credential baselines safely while preserving correct enforcement when paths exist.
    • Improved isolation when credential data appears after launch.
    • More reliable token secret/store atomic write behavior to avoid stale temp artifacts.
  • Tests
    • Expanded coverage for new deny-if-exists and credential resolution semantics.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Credential deny-read computation now uses command-specific environment and directory context, covers Zero and token stores before creation, and resolves overrides literally. Linux bwrap conditionally denies existing credential targets, while OAuth writes use fixed protected temporary siblings.

Changes

Credential deny-read generation

Layer / File(s) Summary
Credential path resolution and profile wiring
internal/sandbox/profile.go, internal/sandbox/runner.go, internal/sandbox/export_test.go, internal/sandbox/landlock_linux.go
Permission profiles resolve credential paths from command context, store them in DenyReadIfExists, and include them in supported backend enforcement.
Credential denial and policy validation
internal/sandbox/manager_test.go, internal/sandbox/profile_test.go, internal/cli/sandbox_test.go, internal/sandbox/runner_test.go
Tests cover cloud, Google, Zero, OAuth, and MCP paths, absent stores, literal XDG handling, command-relative overrides, and serialized policy output.
Missing credential target handling
internal/sandbox/linux_helper.go, internal/sandbox/linux_helper_test.go, internal/sandbox/runner_linux_integration_test.go
Linux bwrap skips absent conditional targets and denies credential paths when they exist, with integration coverage for stores created after launch.

Atomic OAuth store writes

Layer / File(s) Summary
Fixed protected publication sibling
internal/oauth/encrypt.go, internal/oauth/store.go, internal/oauth/store_test.go
OAuth and encrypted secret writes use <path>.tmp with exclusive creation, cleanup, and stale-sibling tests.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

  • Gitlawb/zero 675 — Directly addresses sandbox access to Zero OAuth and provider credential stores.
  • Gitlawb/zero 662 — Relates to the same credential deny-read generation and profile enforcement.
  • Gitlawb/zero 663 — Relates to credential deny-read handling across platform-specific enforcement.

Possibly related PRs

  • Gitlawb/zero#685 — Modifies the same credential deny-read profile construction.
  • Gitlawb/zero#658 — Interacts with profiles whose credential rules move into DenyReadIfExists.
  • Gitlawb/zero#79 — Touches the sandbox command planning and wrapper execution pipeline.

Suggested reviewers: anandh8x, vasanthdev2004

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.82% 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
Linked Issues check ✅ Passed The changes address #675 by denying reads for Zero credential/OAuth stores, overrides, and adjacent temp/lock/migration files.
Out of Scope Changes check ✅ Passed The added tests and helper changes stay focused on sandbox credential denial behavior and supporting write-path fixes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the PR's primary change: denying sandbox reads of Zero credential stores.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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: 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 206-214: The default Zero deny-read candidates in
internal/sandbox/profile.go:206-214 must include mcp-oauth-tokens.json.secret
alongside the other token files. Update the zeroFiles test slice in
internal/sandbox/manager_test.go:388-396 with the same path to enforce coverage;
both changes belong near the existing mcp-oauth-tokens.json entries.
🪄 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

Run ID: 5c715035-5186-49df-889d-1fac687bba1c

📥 Commits

Reviewing files that changed from the base of the PR and between 6fc1220 and 51b2ea2.

📒 Files selected for processing (2)
  • internal/sandbox/manager_test.go
  • internal/sandbox/profile.go

Comment thread internal/sandbox/profile.go Outdated
@PierrunoYT
PierrunoYT marked this pull request as ready for review July 14, 2026 21:04
Copilot AI review requested due to automatic review settings July 14, 2026 21:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the sandbox’s default deny-read profile so sandboxed commands cannot read Zero’s own on-disk secrets (user config, cred stores, OAuth token stores), in addition to existing cloud-provider credential locations—closing a gap where environment scrubbing alone was insufficient.

Changes:

  • Expands credentialDenyReadPaths to include Zero’s config/credential/token files and to honor ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH (including adjacent .secret files).
  • Refactors credentialDenyReadPathsIn to take a structured options object, improving testability and supporting new inputs.
  • Adds/extends tests to validate the expanded deny list and AllowRead opt-outs.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
internal/sandbox/profile.go Extends default deny-read candidates to cover Zero credential/config/token files and env overrides; adds zeroUserConfigDir helper.
internal/sandbox/manager_test.go Updates deny-path unit tests for new candidates/options and adds a profile-level test for denying Zero token reads.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/sandbox/profile.go Outdated
Comment thread internal/sandbox/profile.go Outdated
- Deny reads of mcp-oauth-tokens.json.secret in the default Zero
  config candidates: file-backed oauth stores keep their encryption
  secret in a sibling .secret file, so the default MCP token store
  needs the same protection as the override paths (CodeRabbit).
- Clarify that explicit credential-file overrides are still filtered
  by on-disk existence like every other candidate (Copilot).
- Align the zeroUserConfigDir doc comment with config.UserConfigDir:
  macOS honors XDG_CONFIG_HOME before falling back to ~/.config
  (Copilot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 14, 2026

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Deny the migrated legacy MCP token backup
    internal/sandbox/profile.go:214
    NewTokenStore intentionally preserves the pre-unification mcp-oauth-tokens.json as mcp-oauth-tokens.json.migrated after importing it. That backup still contains the access and refresh tokens, but this list denies only the original filename; the override branch has the same omission. A sandboxed command can therefore read the persistent migrated backup under the read-all profile. Include the migrated filename for both default and override paths (and cover the migration path in the regression test) or remove/redact the backup before claiming the legacy store is protected.

  • [P1] Cover credential-store publication files, not just their final names
    internal/sandbox/profile.go:208
    The protected stores publish secret-bearing temporary siblings before their atomic rename: OAuth uses oauth-tokens.json.tmp-*, the credential store uses credentials.{enc,json}.*.tmp, encrypted stores create *.secret.*.tmp, and config writes .zero-config-*.tmp. Those files live in the same readable directory but do not match any added exact deny entry, so a sandboxed process can enumerate/poll the directory during a login or key/config update and read the token, API key, encryption key, or inline config secret. Deny the appropriate credential-file patterns or otherwise make the write paths unreadable for the lifetime of the sandbox, with a concurrent-publication regression test.

  • [P2] Do not drop default credential paths merely because they are absent when the sandbox starts
    internal/sandbox/profile.go:225
    The new rules are omitted whenever os.Stat initially returns an error. If a sandboxed command is already running while zero auth or a credential rotation creates one of these files, no later filesystem rule is installed and that command can read the newly created final store. The Linux helper already has a missing-path unreadable mount mode, and seatbelt can express a deny before a file exists, so retain the fixed default candidates (subject to the explicit AllowRead opt-out) and test creation after profile construction.

  • [P2] Resolve token overrides using the token stores' path semantics
    internal/sandbox/profile.go:218
    oauth.ResolveStorePath and mcp.ResolveTokenStorePath treat any non-absolute override as a literal path relative to the process working directory. normalizeProfilePath, however, expands a ~/... override before making it absolute. With ZERO_OAUTH_TOKENS_PATH=~/tokens.json, the real store is <cwd>/~/tokens.json while the deny entry targets $HOME/tokens.json; the actual token file remains readable. Normalize these overrides through the same resolver used by their stores (and add a relative-tilde case) before deriving DenyRead.

Address code review on PR Gitlawb#681:

- Deny credentialDenyReadPaths' Zero-config candidate as the containing
  directory instead of an itemized filename list. The token/credential/
  config stores each publish through a randomly-named .tmp-<pid>-<nanos>
  sibling before their atomic rename, and the legacy MCP token store
  leaves a mcp-oauth-tokens.json.migrated backup after importing it —
  none of those names were covered by the old itemized list, so a
  sandboxed command could read them under the read-all posture.

- Stop dropping default candidates that don't exist yet at profile-build
  time. A store created later in a long-lived sandboxed session (e.g. a
  concurrent ) previously got no deny rule at all; every
  backend already treats a deny rule over a not-yet-existing path as a
  harmless no-op that still takes effect once the path appears.

- Resolve ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH overrides
  the same way the token stores resolve them (relative-to-cwd, no ~
  expansion) instead of through normalizeProfilePath, which tilde-expands
  and so could derive a deny path different from where the store actually
  writes.

Adds regression coverage for the directory-wide deny (including the
migrated backup and synthetic temp-file siblings), for building the
profile before the store directory exists, and for the tilde-override
resolution mismatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

🧹 Nitpick comments (1)
internal/sandbox/profile.go (1)

244-267: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify path resolution by relying on filepath.Abs.

filepath.Abs inherently handles both relative and absolute paths, and automatically invokes filepath.Clean on its output. You can streamline this helper by removing the redundant filepath.IsAbs condition and the explicit filepath.Clean calls.

♻️ Proposed refactor
 func resolveCredentialOverridePath(override string) string {
 	override = strings.TrimSpace(override)
 	if override == "" {
 		return ""
 	}
-	if filepath.IsAbs(override) {
-		return filepath.Clean(override)
-	}
 	abs, err := filepath.Abs(override)
 	if err != nil {
 		return ""
 	}
-	return filepath.Clean(abs)
+	return abs
 }
🤖 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 244 - 267, Update
resolveCredentialOverridePath to trim and reject empty overrides, then rely
solely on filepath.Abs for both absolute and relative paths. Remove the
filepath.IsAbs branch and explicit filepath.Clean calls, while preserving the
existing empty-string and Abs-error return behavior.
🤖 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.

Nitpick comments:
In `@internal/sandbox/profile.go`:
- Around line 244-267: Update resolveCredentialOverridePath to trim and reject
empty overrides, then rely solely on filepath.Abs for both absolute and relative
paths. Remove the filepath.IsAbs branch and explicit filepath.Clean calls, while
preserving the existing empty-string and Abs-error return behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 20cdc36c-d46b-4198-9a7d-066526d292f3

📥 Commits

Reviewing files that changed from the base of the PR and between 0547046 and 43e81fd.

📒 Files selected for processing (2)
  • internal/sandbox/manager_test.go
  • internal/sandbox/profile.go

anandh8x
anandh8x previously approved these changes Jul 15, 2026

@anandh8x anandh8x 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-reviewing against commit 43e81fdd (head). The directory-wide deny on ~/.config/zero (patch 3/3) closes the atomic-rename temp-file and .migrated backup leak that the prior itemized list missed — the directory is the only stable primitive. The "emit whether or not it exists on disk" rule is correct (backends treat missing paths as no-ops, but the rule takes effect once the store appears mid-session, which the prior behavior would have missed). The resolveCredentialOverridePath fix aligns with the token stores' literal-CWD resolution instead of normalizeProfilePath's tilde expansion, closing the real mismatch the new test covers.

LGTM. The zeroUserConfigDir duplication is intentional (import cycle: config depends on sandbox); a follow-up to break the cycle is worth filing but not a blocker.

Cross-PR note: #681 / #682 / #685 are coordinated. Recommend rebasing #682 and #685 on top of #681 in that order to resolve the credentialDenyReadPaths signature and the scrubSensitiveEnv plumbing cleanly.

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

Local review: built and ran go test ./internal/sandbox on darwin/arm64 — one test fails (blocking). Plus one drift note.

Comment thread internal/sandbox/manager_test.go
Comment thread internal/sandbox/profile.go
Vasanthdev2004
Vasanthdev2004 previously approved these changes Jul 16, 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.

Approving. I checked the new helpers against the real store resolution: zeroUserConfigDir matches config.UserConfigDir exactly (including the macOS ~/.config/XDG behavior), and resolveCredentialOverridePath mirrors oauth.ResolveStorePath / mcp.ResolveTokenStorePath relative overrides resolve literally against cwd with no tilde expansion, so the deny rule lands on the path the store actually writes to. Denying the whole /zero directory is the right call given the random-named .tmp/.secret/.migrated siblings the stores publish, and emitting rules for not-yet-existing paths is safe (seatbelt treats them as no-ops, bwrap mounts a perms-000 tmpfs, Windows is skipped) so stores created mid-session stay covered. One minor residual worth a follow-up: for an explicit ZERO_OAUTH_TOKENS_PATH override, the atomic-write .tmp-- sibling isn't denied (only the file and .secret are), leaving a brief read window during a token write not a regression since overrides had no coverage before. Build, vet, gofmt, and the new tests pass here; the six sandbox-suite failures reproduce on clean main.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

fix smoke tests and stuffs seems all good then

Recompute the expected Zero config deny path after MkdirAll and resolve
the temp base with EvalSymlinks so macOS /var -> /private/var does not
flake TestPermissionProfileDeniesZeroCredentialFiles. Add a parity test
that sandbox.zeroUserConfigDir stays aligned with config.UserConfigDir.

Co-authored-by: PierrunoYT <PierrunoYT@users.noreply.github.com>

@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/zeroconfigdir_parity_test.go`:
- Line 1: Rename the test file from zeroconfigdir_parity_test.go to
profile_test.go so the tests for zeroUserConfigDir in profile.go reside
alongside the source file they cover.
🪄 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

Run ID: 584d1c8c-ed4f-4446-88c8-e9f7a52829c6

📥 Commits

Reviewing files that changed from the base of the PR and between 43e81fd and 5b4f743.

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

Comment thread internal/sandbox/profile_test.go
@PierrunoYT
PierrunoYT requested review from gnanam1990 and jatmn July 17, 2026 13:14

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep absent deny paths from aborting the Linux sandbox
    internal/sandbox/profile.go:228
    This now emits every missing default and override candidate (including ~/.azure and a fresh ~/.config/zero). The Linux backend translates a missing DenyRead target into --tmpfs <path> only after it has read-only-bound /; bubblewrap cannot create that target or its missing parents in the read-only tree, so startup fails before the requested command executes. For example, bwrap --ro-bind / / --tmpfs <existing-parent>/missing -- true exits with “Can't mkdir … Read-only file system.” Preserve backend-safe handling for nonexistent paths (or materialize them safely before the read-only bind) and add a real Linux launch regression test.

  • [P1] Cover all credential-bearing siblings of explicit token-store overrides
    internal/sandbox/profile.go:221
    An override outside the default config directory receives rules only for <path> and <path>.secret. OAuth publishes its token JSON through <path>.tmp-<pid>-<nanos>, encrypted storage creates <path>.secret.*.tmp, and MCP migration preserves the token-bearing legacy file at <ZERO_MCP_OAUTH_TOKENS_PATH>.migrated; none of those siblings is denied. A sandboxed command can poll the override directory during a save or read the durable migration backup and recover the token/key. Protect the required sibling set or use a safe directory-level containment approach for overrides, with publication and migration tests.

  • [P2] Resolve the config-root deny path with the store's literal XDG semantics
    internal/sandbox/profile.go:211
    zeroUserConfigDir returns a nonempty XDG_CONFIG_HOME verbatim, matching the config and token stores, but the later normalizeProfilePaths expands a leading ~. With XDG_CONFIG_HOME=~/zero-config, the stores use the literal cwd-relative ~/zero-config/zero/... path while this profile denies $HOME/zero-config/zero; the real config, provider credentials, and OAuth files remain readable in the sandbox. Resolve this candidate without tilde expansion (or through the same resolver used by the stores) and cover a literal-tilde XDG value.

Open inline review threads

The following inline threads remain open on GitHub:

  • Copilotinternal/sandbox/profile.go:170: the comment about existence filtering is stale; the filter was removed in the current head.
  • Copilotinternal/sandbox/profile.go:244: the macOS/XDG wording is now aligned in the current helper comment.
  • gnanam1990internal/sandbox/manager_test.go:511: the macOS path-normalization test issue is addressed by resolving the temp base and recomputing the expected path after creation.
  • gnanam1990internal/sandbox/profile.go:188: the resolver-drift concern is addressed by the new parity test against config.UserConfigDir.
  • CodeRabbitinternal/sandbox/zeroconfigdir_parity_test.go:1: the requested rename to profile_test.go has not been made; this is a non-blocking test-file organization nit.

Vasanthdev2004
Vasanthdev2004 previously approved these changes Jul 18, 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-approving. gnanam's blocker was the darwin/arm64 sandbox test failing, and the commit that addresses the macOS deny-path test plus config parity fixes it: the full smoke matrix is green now, macOS included. Denying the whole config dir rather than itemized filenames is the approach I already backed last round, since the stores publish random-named .tmp/.secret/.migrated siblings, so this is consistent with what I approved. gnanam will want to clear his own changes-requested once he re-runs, but from my side this is good.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Final security follow-up on current head 497aeb5:

  • normalized golden and macOS credential-mask paths consistently
  • made carveouts symlink-safe and trusted-process-derived only
  • retained both process and command credential roots conservatively
  • removed redundant nested automatic masks under user denies
  • prevented command-controlled environment paths from creating host directories
  • Oracle reviewed the security boundary
  • zero unresolved review threads remain; race tests, cross-platform checks, full validation, and all GitHub checks pass

The PR is mergeable. PierrunoYT requests a fresh human review to supersede the previous CHANGES_REQUESTED decision.

Preserve the expanded upstream credential baseline while retaining Zero store masking, trusted directory creation, and cross-platform carveout safety.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-85ad-76ef-ac88-f7b9c4d27cc0

Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT pushed bf75c60, a normal merge of current upstream/main (81a5e6c) into agent/deny-zero-credential-reads.

Conflict resolution and security follow-up

  • Preserved main's expanded credential baseline for npm, GitHub CLI, netrc, Docker, Kubernetes, Cloud SDK, cloud-provider, and Zero config paths.
  • Kept automatic credentials separate from user denyRead, with parent-process and command-environment roots unioned conservatively.
  • Restricted Linux precreation to Zero-owned directories derived from the parent process; command-controlled paths are deny-if-present only and cannot create host directories.
  • Removed automatic nested masks already covered by user or directory-level denies.
  • Kept the whole Zero config deny while making plugins, specialists, and commands carveouts canonical on macOS and rejecting symlink carveouts.
  • Updated the policy JSON normalization for every new manager-baseline field and the expanded credential set.
  • Added cross-platform regressions for relative/path-list tool overrides, uppercase/lowercase npm precedence, file-vs-directory masks, parent/child root union, and non-creation of command-controlled paths.

Validation

Local (Go 1.26.5):

  • make fmt-check
  • go vet ./...
  • go test ./... -count=1
  • go test -race ./internal/oauth ./internal/sandbox ./internal/cli -count=1
  • darwin/amd64 and windows/amd64 compile checks for internal/oauth, internal/sandbox, and internal/cli
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • make lint-static — 0 issues
  • make vulncheck — no vulnerabilities
  • git diff HEAD --check

GitHub checks on bf75c60 are all green: Ubuntu, macOS, and Windows Smoke; Performance Smoke; Security & code health; Zero Review; and CodeRabbit.

The PR is MERGEABLE with zero unresolved inline review threads. The remaining CHANGES_REQUESTED gate is the review on pre-fix commit 0f6c87a; @jatmn, a fresh review of the current head would be appreciated.

@PierrunoYT
PierrunoYT requested a review from jatmn July 27, 2026 23:42

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Mask the committed OAuth/MCP store file for process-trusted overrides outside ~/.config/zero, not only the .publish directory
    internal/sandbox/linux_helper.go:285
    internal/sandbox/profile.go:446
    internal/oauth/store.go:412
    The default …/zero case is covered correctly: the whole directory is ensured and tmpfs-masked, so a rename into that tree stays hidden (including the mid-session integration case). A process-trusted ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH outside that directory is different. The profile ensures and masks <path>.publish, but only lists the final <path> in DenyReadIfExists. Bubblewrap skips absent file targets, so a sandbox launched before the first save has no mount on the store file itself. fileBlob.write publishes plaintext under the masked .publish tree and then Renames it into the unmasked parent; under the read-all --ro-bind / posture the committed token file becomes visible to the already-running sandbox as soon as the host save completes. TestPermissionProfileUnionsProcessAndCommandCredentialRootsWithoutCreatingCommandDirs encodes the .publish half of this contract for parentToken but never checks post-rename visibility. Please keep directory-level containment for default …/zero stores, but for out-of-tree process-trusted overrides also make the committed store path unreadable on Linux after first save (for example by masking a safe parent, pre-materializing a maskable placeholder, or another approach that does not reintroduce workspace-parent masking). Command-controlled overrides that deliberately omit EnsureDenyReadDirs are out of scope here.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT addressed the out-of-tree token-store masking finding in f25b3e7.

  • Process-trusted token and .secret final paths are tracked lexically and independently.
  • Linux Bubblewrap fails closed before runtime/helper launch when atomic replacement makes an exact-file deny unenforceable.
  • Durable directory masks, AllowRead, Seatbelt behavior, command-controlled environments, and terminal symlink semantics are preserved.
  • Added focused trust-boundary, selection, and early-failure regressions.

Validation: focused sandbox tests pass with -race; all fresh CI checks pass on Ubuntu, macOS, and Windows, including security, performance, CodeRabbit, and Zero Review. Ready for re-review.

@PierrunoYT
PierrunoYT requested a review from jatmn July 28, 2026 14:07

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

I found issues that need to be addressed before this is ready.

The first three items are rebase drift against current main, not defects in the new credential-deny logic itself. The remaining items are gaps in this PR's own design on head f25b3e7.

Findings

  • [P1] Rebase onto current main and resolve the sandbox conflicts before merge
    The live PR remains not mergeable (mergeable: false, head f25b3e7, base 81a5e6c, current main 8acd42a). git merge-tree shows conflicting edits in internal/sandbox/profile.go, internal/sandbox/profile_test.go, and internal/sandbox/runner_test.go. Please rebase, resolve those conflicts deliberately, and push so the resolved diff can be reviewed.

  • [P1] On rebase, restore git credential-store deny paths from #816
    internal/sandbox/profile.go:417
    main denies git's cleartext stores at ~/.git-credentials and ~/.config/git/credentials. This head's refactored credentialDenyReadPathsIn never had those paths — they are missing from the checkout, not removed by this patch. Port them into the new helper (deny the credentials file under ~/.config/git, not the whole git directory), bring over the #816 tests, and extend internal/cli/sandbox_test.go wantDenyRead so the policy JSON golden catches a regression.

  • [P1] On rebase, restore ZERO_DAEMON_REMOTE_TOKEN_FILE scrub from #677
    internal/sandbox/runner.go:1080
    main scrubs both ZERO_DAEMON_REMOTE_TOKEN and ZERO_DAEMON_REMOTE_TOKEN_FILE because TokenFromEnv accepts either form. This head only scrubs the inline variable. Restore the file-pointer scrub and TestScrubSensitiveEnv coverage from main when rebasing runner.go.

  • [P1] Give command-controlled token overrides durable Linux protection
    internal/sandbox/profile.go:254
    internal/sandbox/linux_helper.go:296
    f25b3e7 closes the process-trusted out-of-tree rename leak via fail-closed bwrap validation. Command-supplied overrides (CommandSpec.Env, including MCP-injected env) are still weaker by design: appendUntrusted copies only Paths and Dirs, never EnsureDirs, and tests require those paths to stay out of ProcessTrustedDenyReadFiles. On bubblewrap, absent token files or .publish directories are skipped, existing files get a /dev/null bind that store Rename can bypass, and plaintext in an unmasked .publish tree during the run stays readable. Please give command-env token paths the same durability as process-trusted stores without creating command-controlled host directories outside the token tree.

  • [P1] Pin relative process token denies to the store path, not rolling Getwd
    internal/sandbox/profile.go:288
    internal/oauth/store.go:138
    The stores resolve a relative ZERO_OAUTH_TOKENS_PATH with filepath.Abs once at open and write to that fixed path thereafter. credentialDenyReadPaths re-resolves the same override against current os.Getwd() on every BuildCommandPlan. After a cwd change, the profile can deny the wrong path while the real store stays readable. Derive process-trusted token denies from the same resolved location the writers use.

  • [P2] Make zero sandbox policy --json show command-scoped credential denies
    internal/cli/sandbox.go:71
    internal/sandbox/adapters.go:139
    Execution uses permissionProfileFromPolicy(..., spec.Dir, spec.Env); zero sandbox policy --json still uses PermissionProfileFromPolicy with no command context, so exported permissionProfile under-reports denyReadIfExists versus nested commands. Thread command context into introspection or document that the JSON view is process-env-only.

…fixes

Resolves the two sandbox conflicts deliberately rather than by side:

- internal/sandbox/profile.go keeps this branch's multi-home
  credentialDenyReadPathsIn and ports Gitlawb#816's git credential-store denies into
  it. ~/.git-credentials joins candidates only, not dirs, because it is a file
  and dirs drives directory-shaped handling; the XDG credentials file is added
  before the zero-directory handling so a normalization failure there cannot
  drop it. Gitlawb#816's tests come with it, rewritten against the new helper, and the
  policy JSON baseline in internal/cli/sandbox_test.go now lists both paths so
  the exported contract catches a regression.
- internal/sandbox/profile_test.go stays this branch's external-package file;
  the ported Gitlawb#816 tests need unexported symbols and live in their own internal
  test file.

runner.go's ZERO_DAEMON_REMOTE_TOKEN_FILE scrub from Gitlawb#677 merged cleanly and is
verified present. internal/config/command.go and the rest of main come through
untouched.

Also carries the review's two design fixes and the introspection note; see the
following commit message trailers in the PR discussion for what each one does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

All six findings addressed in 64ca22e. GitHub now reports mergeable: MERGEABLE (was false).

One thing up front, since it affects how you read the diff: the merge commit carries both the conflict resolution and the two design fixes. I resolved and validated them together and did not want to push a merge whose tree I had not finished testing. If you would rather review them apart, say so and I will split it.

[P1] Merge with main. I merged upstream/main (8acd42a) rather than rebasing — the branch already carries an upstream merge commit and the PR is from a fork, so this keeps review anchors intact for the same resolved tree. Two conflicts, both resolved deliberately:

[P1] #816 git credential denies. Ported, with two placement decisions worth flagging: ~/.git-credentials joins candidates only and not dirs, because it is a file and dirs drives directory-shaped handling (bwrap binds, carveouts); and the XDG git/credentials entry is added before the zero-directory block so a normalizeProfilePath failure there cannot silently drop it. Both #816 tests came across, rewritten against the new helper, and internal/cli/sandbox_test.go's wantDenyRead now lists both paths — so the exported policy JSON is what catches a regression, as you asked.

[P1] #677 token-file scrub. Merged cleanly from main; verified present with its TestScrubSensitiveEnv coverage. internal/config/command.go comes through untouched.

[P1] Command-supplied token overrides. New CommandDenyReadFinalFiles on the profile, populated from the untrusted resolutions. It deliberately earns none of the process-trusted privileges — no carveouts, no EnsureDenyReadDir, no host mutation, so a command still cannot steer Zero into creating directories — but it now carries the same durability: validateLinuxBwrapPermissionProfile fails closed on it exactly as it already did for process-trusted stores. That is the only honest answer bubblewrap can give when a /dev/null bind is detachable by the store's next rename and an absent path is skipped outright. Seatbelt enforces these as ordinary pathname rules, present or not, so it has no reason to refuse.

Two things keep this from firing on every command: entries already covered by a user deny or by a directory Zero actually masks are filtered out (so the default store inside <config>/zero is not a fail-closed reason), and a path that lands in both lists is reported once, as the process-trusted store it is.

[P1] Relative token denies. The process base directory is now captured once during package initialization instead of re-read per BuildCommandPlan. That is what makes it equal the directory the stores resolved against — nothing in Zero calls os.Chdir today, so pinning keeps the two equal if one is ever added, instead of letting them drift per call.

This changed the contract two existing tests relied on, so they get a seam rather than a rewrite: PinProcessCredentialBaseDir (test-only, in export_test.go) moves the pin with a test that chdirs to simulate a process running elsewhere. TestCredentialDeniesMatchRelativeTokenOverridesFromStoreResolution is now the strongest form of your requirement — it pins the base dir to the same directory oauth.ResolveStorePath resolved in, and asserts the profile names exactly that file.

[P2] zero sandbox policy --json. Documented rather than threaded, because there is no command to thread: the command has not been chosen when this runs. The payload now carries a permissionProfileNote saying the profile is derived from this process's environment and working directory and that an executed command can add further credential deny-read entries, and Backend.BuildPlan says the same in godoc. A consumer diffing this against a nested command's profile now knows why they differ.

Tests. Two new regressions beyond the ported #816 pair, both verified on Linux:

  • TestCommandSuppliedTokenOverrideFailsClosedOnBubblewrap — asserts the baseline (no override) still plans successfully, then that a command-supplied override lands in CommandDenyReadFinalFiles, stays out of ProcessTrustedDenyReadFiles and EnsureDenyReadDirs, fails bwrap planning closed, and creates nothing on the host.
  • TestProcessCredentialBaseDirDoesNotFollowWorkingDirectoryChangest.Chdir between two profile builds must not move the deny.

Validation

  • go build ./... plus GOOS=linux / GOOS=darwin cross-builds; go vet ./...
  • go test ./... on Windows
  • go test ./internal/sandbox ./internal/cli ./internal/tools under WSL/Linux, where the credential deny-read paths actually execute (they skip on Windows)
  • git diff --check clean; gofmt -l clean for every touched file

One pre-existing Linux failure I did not introduce and left alone: TestSelectBackendChoosesPlatformAdapterWithFallback/linux_helper_missing_falls_back_explicitly fails under WSL2 because backend selection reports wsl. I confirmed it fails identically on f25b3e7 in a clean worktree.

@PierrunoYT
PierrunoYT requested a review from jatmn July 29, 2026 12:18
jatmn
jatmn previously approved these changes Jul 29, 2026

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

Thanks for the contribution. I do not see any actionable merge-blocking issues from my review of the #675 credential-deny surface at head 64ca22e5.

The default-path fix is implemented and tested: ~/.config/zero is denied whole with plugin/specialist/command carveouts, EnsureDenyReadDirs plus bubblewrap masking covers the mid-session race for Zero-owned stores, OAuth/MCP publication writes pair with .publish denies, and parent/command credential roots are unioned without command-controlled host directory creation. Required CI is green. Windows read denial remains correctly deferred to #662.

Rechecking the prior draft against current code: out-of-tree process-trusted overrides do not leave a live bubblewrap bypass at head — validateLinuxBwrapPermissionProfile refuses launch when ProcessTrustedDenyReadFiles is populated (TestEngineBuildCommandPlanValidatesBwrapBeforeCreatingRuntime). Git credential paths and absent third-party stores follow the same documented best-effort model as ~/.aws. AWS/Azure env override gaps predate this PR.

Follow-up (not blocking merge) @Vasanthdev2004

  • [Needs maintainer decision] Linux policy for out-of-tree ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH
    internal/sandbox/linux_helper.go:223
    internal/sandbox/profile.go:250
    Bubblewrap cannot durably deny a replaceable file pathname; the current answer is fail-closed (sandboxed commands refuse to start when an out-of-tree process-trusted override is set). That is secure at head but breaks users who rely on a custom absolute token path outside ~/.config/zero. The alternative is committed-path masking after first save without masking workspace parents. Pick the product tradeoff explicitly rather than treating fail-closed as a temporary gap.

  • [P2] Deny the filesystem path behind ZERO_DAEMON_REMOTE_TOKEN_FILE (#677 follow-up)
    internal/sandbox/runner.go:1084
    internal/sandbox/profile.go:356
    Scrubbing the variable in this PR closes half of the #677 leak class. The on-disk bearer token at a known path (for example under ~/.zero, which is outside the ~/.config/zero deny tree) remains readable under the read-all posture. That gap largely predates this PR; extend the credential baseline the same way GOOGLE_APPLICATION_CREDENTIALS is handled.

  • [P3] Include permissionProfileNote on zero sandbox policy --effective --json
    internal/cli/sandbox.go:250
    internal/cli/sandbox.go:112
    Plain --json documents that the embedded profile is process-only and that command context can add denies or trigger bubblewrap fail-closed rejection. --effective --json serializes the same process-derived profile without that note, even though doctor/TUI steer operators to --effective for inspection.

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

Requesting one change, then I'm happy to take this.

I re-verified from scratch on the current head (64ca22e) rather than standing on my July 25 approval — the design has moved a long way since then, and the fail-closed markers and publication directories are new. The default path is right and the tests around it are good: TestLinuxBwrapKeepsCarveoutsReachableInsideMaskedDir actually pins --perms 111 and the bind/remount ordering, and TestCommandSuppliedTokenOverrideFailsClosedOnBubblewrap asserts the host wasn't touched on refusal, which is the part that usually gets skipped. I drove the real profile builder with the Windows early-return at profile.go:237 temporarily lifted so I could see what Linux and macOS actually get; with no override, ~/.config/zero lands whole in DenyReadIfExists, plugins/specialists/commands come back as carveouts, EnsureDenyReadDirs has the config dir, and bwrap validation passes. That's the shape you documented and it holds.

The one blocker: ZERO_OAUTH_TOKENS_PATH now makes the Linux sandbox unusable.

internal/cli/auth.go:609 documents that variable in zero auth --help as the supported way to relocate the token store. Point it anywhere outside $XDG_CONFIG_HOME/zero and the profile puts the store and its .secret sibling in ProcessTrustedDenyReadFiles, validateLinuxBwrapPermissionProfile (linux_helper.go:223) returns an error, and Engine.BuildCommandPlan (runner.go:186) hands that straight back — so PrepareExecution never launches anything. Every sandboxed command in the session fails with:

bubblewrap cannot securely deny process-trusted credential file across atomic replacement: /elsewhere/tokens.json, /elsewhere/tokens.json.secret

which names an internal concept and no way out. On main this couldn't happen at all — credentialDenyReadPaths(policy) had no marker list.

Worse, it fires when the file provably cannot exist. I ran the same case with ZERO_OAUTH_STORAGE=keyring, where oauth.NewStore takes the keyring branch and never writes that path: ProcessTrustedDenyReadFiles was still populated and bwrap still refused. credentialPathOptionsFromEnvironment reads the path variable without ever consulting the storage backend.

Two asks, either is enough to unblock:

  • Skip the fail-closed markers when the resolved storage backend isn't file-based.
  • Make the message actionable — it should say the store must live under $XDG_CONFIG_HOME/zero, or be listed in allowRead. I confirmed allowRead is a real escape hatch (credentialPathReincluded drops the marker and validation passes), it's just undiscoverable from the error.

Masking the store's parent the way the default case does would be the nicer fix, but I'm not asking for that here.

Worth fixing while you're in there, not blocking. Command-supplied env values become deny targets with no check that they overlap the sandbox's own roots. Building a profile with CLOUDSDK_CONFIG=<workspace> in the command env puts the workspace in DenyReadIfExists, and the bwrap args then read --bind <ws> <ws>--perms 000 --tmpfs <ws> --remount-ro <ws> — bound, then masked unreadable, because the deny loop at linux_helper.go:307 runs after the write-root binds. Seatbelt emits four deny rules naming the workspace. Five keys take an arbitrary path that turns into a whole-directory mask: CLOUDSDK_CONFIG (profile.go:373), NPM_CONFIG_USERCONFIG (386), NETRC (400), KUBECONFIG (413), GOOGLE_APPLICATION_CREDENTIALS (426); HOME, XDG_CONFIG_HOME, DOCKER_CONFIG and GH_CONFIG_DIR add fixed-name children under a command-named root. It reaches there from internal/mcp/client.go:152, internal/hooks/dispatch.go:116 and internal/plugins/activate.go:711.

To be straight about the severity: the profile is per-command, so a command can only degrade its own view — I tried and could not turn this into cross-command harm, and no host mutation happens because command-derived roots never reach EnsureDenyReadDirs. But pathsOutsideOverlappingRoots at profile.go:635 is already exactly the right helper; running command-derived candidates through it against the profile's read/write roots would close it, with a regression test alongside TestCommandSuppliedTokenOverrideFailsClosedOnBubblewrap.

The same gap has a second face: with CLOUDSDK_CONFIG=<configHome> in a command env, the pruning in finalizeCredentialDenyPaths collapses ~/.config/gh/hosts.yml, ~/.config/gcloud, ~/.config/git/credentials and ~/.config/zero into one ~/.config deny and empties EnsureDenyReadDirs. Everything stays denied on both backends, so it's not a hole, but it also masks ~/.config/git/config, which userGitConfigReadPaths grants deliberately.

Three scope notes, no code change needed.

credentialDenyReadPaths returns empty on Windows at profile.go:237, so none of this reaches a Windows user. "Fixes #675" will auto-close the issue with the Windows half fully exposed — please drop the closing keyword or carve out the Windows half in the body, so the tracker doesn't show the credential-read gap as done. Same request I made last time; it matters more now that the rest is landing.

On macOS, the default provider-credential backend isn't the file this protects. internal/credstore/credstore.go:90 selects keyring when goos == "darwin" && kr.Available(), and internal/keyring/keyring.go:108 reads it back with security find-generic-password -s <service> -a <account> -w — which a sandboxed command can still execute. The directory deny is still worth having on a Mac, because oauth.NewStore defaults to a plaintext oauth-tokens.json and that is now covered. Just don't let the body read as "the credential store is denied on macOS"; it isn't, by default. I've been wrong about exactly this before on #662, so I checked the per-GOOS default rather than assuming.

And unchanged, correctly: engine.go:158 still builds ReadExclusions from policy.DenyRead alone, so read_file/grep/glob don't see the credential baseline. That's the right call for #675 and consistent with keeping Policy.DenyRead reflecting user config only — worth its own issue rather than scope creep here.

Minor, take it or leave it: nothing prunes the new <store>.publish directories, so a crash between createPublicationFile and the rename leaves plaintext token or key bytes under publish-* indefinitely. Same exposure class as the old .tmp-<pid>-<nano> file, and it's inside the denied directory in the default layout, so it isn't a regression — there's just no reaper.

Test-wise I ran ./internal/sandbox, ./internal/oauth and ./internal/cli on Windows; sandbox and oauth are green, and the only cli failure is the pre-existing TestBuildServeScopeKeepsLexicalPaths symlink-privilege one, which isn't in this diff. CI is green on all three platforms.

Thanks for grinding through this one — the carveout normalization and the symlink rejection in normalizeCredentialCarveoutPath are the right answers to the earlier rounds, and they're properly pinned by tests.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the latest review blocker in e106d06.

What changed:

  • ZERO_OAUTH_STORAGE=keyring no longer adds OAuth token/blob final-file markers, so an out-of-tree ZERO_OAUTH_TOKENS_PATH does not make Bubblewrap refuse every sandboxed command when the keyring backend will not publish those files.
  • The ordinary credential deny baseline remains in place for the configured path.
  • File and encrypted-file OAuth stores remain fail-closed, as do MCP OAuth stores even when OAuth itself uses keyring storage.
  • Bubblewrap's fail-closed errors now explain the supported remedies: move the store under $XDG_CONFIG_HOME/zero or add the path to sandbox allowRead.
  • The PR body now uses Related to #675 rather than auto-closing it, since Windows read denial remains tracked in Windows sandbox does not deny reads of cloud credential stores #662.

Validation completed with Go 1.26.5:

  • make fmt-check
  • go vet ./...
  • go test ./...
  • focused go test -race ./internal/sandbox
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • make lint-static (0 issues)
  • make vulncheck (no vulnerabilities found)
  • git diff HEAD --check

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

I rechecked the current head and found issues that need to be addressed before this is ready.

Findings

  • [P1] Preserve the deny mask for command-scoped token roots created after launch
    internal/sandbox/profile.go:268
    The profile builder treats every CommandSpec.Env root as untrusted and therefore copies only Paths/Dirs out of credentialDenyReadPathsIn; it intentionally discards EnsureDirs. The bwrap planner then skips a missing DenyReadIfExists mount target. A command with a fresh HOME/XDG_CONFIG_HOME consequently starts with no mount over <XDG_CONFIG_HOME>/zero; a concurrent host zero auth (or another host Zero process) can create oauth-tokens.json after the read-only host-root bind is established, and the running command can read it. The new mid-session smoke case supplies exactly this command-scoped environment but does not materialize its directory before launch.

    The root decision needs to be made at the command-env trust boundary, rather than by adding another best-effort deny. Either reject/fail closed on command-controlled credential roots that require future-path protection on bwrap, or introduce a durable isolation mechanism that does not create arbitrary command-selected host directories. Preserve the existing process-environment EnsureDirs behavior only for roots Zero itself owns, and add a real bwrap regression that writes the token from a host goroutine after the child has started.

  • [P2] Do not create publication directories for keyring OAuth overrides
    internal/sandbox/profile.go:556
    The new keyring exception only changes credentialFinalTokenFiles, which removes the atomic-replacement fail-closed marker. credentialDenyReadPathsIn still unconditionally treats every OAuth override as a file store, adding <override>.publish and <override>.secret.publish to EnsureDenyReadDirs. Linux planning calls MkdirAll for those trusted ensure paths before launch, but oauth.NewStore's keyring branch never calls fileBlob or createPublicationFile. A supported configuration such as ZERO_OAUTH_STORAGE=keyring with ZERO_OAUTH_TOKENS_PATH=/outside/tokens.json therefore creates unused host directories below /outside whenever a sandboxed command runs.

    Make storage selection part of the candidate derivation: keep any intentional pathname-level deny for the override if it is useful to the general credential baseline, but do not add file siblings, publication directories, Dirs, or EnsureDirs for OAuth when the effective backend is keyring. Keep MCP's legacy file-store handling independent. Extend the new keyring override test to assert that bwrap validation and planning leave both publication paths absent.

  • [P2] Prevent command credential settings from masking the command's allowed roots
    internal/sandbox/profile.go:268
    Command-provided credential variables are appended without being compared with the resolved ReadRoots and WriteRoots. For example, a plugin, hook, or MCP command with CLOUDSDK_CONFIG=<workspace> adds the whole workspace to DenyReadIfExists; bwrap first bind-mounts that workspace writable and later overlays it with the unreadable tmpfs mask, while Seatbelt emits the corresponding read deny. The command can no longer access its own working tree. The same class affects directory-valued settings such as XDG_CONFIG_HOME and DOCKER_CONFIG when they overlap an allowed root.

    Finalize command-derived candidates against the resolved profile roots before rendering either backend. The existing overlap helper is close to the needed rule: discard a command-derived candidate when it contains, or is contained by, a root deliberately granted to that command; do not apply that relaxation to process-derived credential roots. Add backend-level tests for a workspace-valued CLOUDSDK_CONFIG and for a nested credential file that should remain denied when it does not overlap a permitted root.

  • [P3] Include the permission-profile scope warning in effective JSON
    internal/cli/sandbox.go:253
    Plain zero sandbox policy --json adds permissionProfileNote because its plan is derived only from the current process environment and cwd. zero sandbox policy --effective --json serializes that same process-derived BackendPlan, but its separate payload omits the note. Execution can add command cwd/environment credential denies, and the effective view is the operator-facing inspection path, so JSON consumers can incorrectly treat it as the complete enforcement profile.

    Put the same scope note in the effective JSON payload (or refactor both JSON paths to use one common profile-description field), and extend the effective JSON test/golden alongside the existing plain-JSON coverage. The text view should communicate the same limitation if it is intended to be a complete inspection surface.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the current-head review findings in 56fa645:

  • command-scoped credential directories now carry an explicit untrusted durability marker: Bubblewrap masks an existing real directory, but fails closed for a missing/inaccessible/non-directory target without creating command-selected host paths;
  • OAuth keyring selection now derives only the conservative override pathname deny and no file-store siblings, .publish directories, or ensure/create entries; MCP's file-store behavior remains independent;
  • command-derived credential candidates that contain or are contained by a deliberately granted command read/write root are discarded before backend rendering, while process-derived denies and non-overlapping command denies remain enforced;
  • sandbox policy --effective now includes the permission-profile scope warning in both JSON and text output.

Regressions cover missing/file/existing command directory behavior, host non-mutation, Bubblewrap and Seatbelt allowed-root rendering, retention of non-overlapping denies, keyring planning/publication paths, effective JSON/text output, and the Linux integration fail-closed path. Vasanthdev2004's older keyring blocker is superseded by this change: keyring overrides neither trigger atomic-replacement fail-closed markers nor create unused publication directories.

Validation run exactly:

  • go test ./internal/sandbox ./internal/cli
  • go test -race ./internal/sandbox ./internal/oauth ./internal/cli
  • ZERO_SANDBOX_REAL_SMOKE=1 go test -race ./internal/sandbox -run '^TestLinuxHelperRealSandboxSmoke$' -count=1 -v (passed with the real smoke test skipped because the Linux sandbox helper is unavailable in the orb)
  • make fmt-check
  • go vet ./...
  • go test ./...
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • make lint-static (0 issues)
  • make vulncheck (No vulnerabilities found)
  • git diff HEAD --check

@jatmn @Vasanthdev2004 please re-review the updated head when convenient.

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Reapply credential denies after the extension-root carveouts
    internal/sandbox/runner.go:675
    A token override may legitimately resolve beneath one of the required extension carveouts, for example ZERO_OAUTH_TOKENS_PATH=$XDG_CONFIG_HOME/zero/plugins/tokens.json. finalizeCredentialDenyPaths intentionally retains that token and its .secret as explicit children of the denied Zero config directory, because the plugins carveout makes the enclosing directory deny insufficient. However, seatbeltProfileFromPermissionProfile emits every deny rule first and then appends (allow file-read* ... (subpath ".../zero/plugins")). The code documents Seatbelt as last-match-wins, so the broad, later allow overrides both retained child denies and lets a sandboxed macOS command read the OAuth credentials.

    The root cause is treating a carveout as a simple trailing allow even when the profile contains a more-specific denial inside it. Preserve the intended precedence in the rendered Seatbelt policy: emit the nested credential denies after their enclosing carveout (or split the carveout into non-credential portions / omit it when it contains a protected credential). Add a macOS rendering regression that sets an override below each of plugins, specialists, and commands, and asserts the final SBPL ordering keeps the token and .secret denied while ordinary extension files remain readable.

  • [P2] Do not classify the legacy MCP migration source as an atomically replaced token file
    internal/sandbox/profile.go:488
    ZERO_MCP_OAUTH_TOKENS_PATH identifies only the legacy source consumed by mcp.NewTokenStore. That code reads the file for one-time migration and then renames it to .migrated; it writes the unified store through the separately resolved OAuth store (ZERO_OAUTH_TOKENS_PATH or its default). The PR adds the legacy source to ProcessTrustedDenyReadFiles / CommandDenyReadFinalFiles, so validateLinuxBwrapPermissionProfile rejects every Bubblewrap-sandboxed command when that legacy path sits outside the Zero config directory. This rejection is based on atomic replacement, but the legacy source is not atomically published during normal operation: an ordinary read-deny mount remains durable for it. A user retaining an external legacy MCP store is therefore unable to run unrelated sandboxed commands.

    The root cause is conflating the migration input with the writable unified token-store outputs. Model these paths according to their lifecycle rather than their common credential content: retain the legacy path and .migrated backup in the ordinary deny candidates, but exclude ZERO_MCP_OAUTH_TOKENS_PATH from the atomic-replacement final-file markers. Keep fail-closed handling for the actual writable OAuth store and its encryption-key sibling. Add a Bubblewrap planning regression with an external legacy MCP path that verifies the source is denied without rejecting the command, alongside the existing tests that verify true atomic writers fail closed.

Reapply credential denies after Seatbelt extension carveouts and model the legacy MCP token path as a migration input rather than an atomic writer.

Retain Bubblewrap fail-closed markers for OAuth outputs exposed through readable carveouts, and add regressions for both platform policies.

Amp-Thread-ID: https://ampcode.com/threads/T-019fbf55-f6b0-71fc-b384-652ba32b9645
Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the latest review findings in b14e659.

[P1] Reapply credential denies after the extension-root carveoutsseatbeltProfileFromPermissionProfile now re-emits the nested credential denies after the broad carveout allows via denyReadRulesInsideCarveouts (runner.go), covering both the ordinary deny paths and the process-trusted/command final token files, so Seatbelt's last-match-wins evaluation keeps the token and .secret denied without hiding ordinary extension files. Regressions on both renderers:

  • TestSeatbeltProfileReappliesCredentialDeniesInsideCarveouts sets a token override below each of plugins, specialists, and commands, asserts every nested deny literal follows its carveout allow in the final SBPL, and asserts an ordinary extension file under each root stays readable.
  • TestOAuthOverridesInsideCredentialCarveoutsRemainFailClosedOnBubblewrap asserts the same three placements keep their atomic-replacement fail-closed markers on the Bubblewrap side.

[P2] Do not classify the legacy MCP migration source as an atomically replaced token filecredentialFinalTokenFiles now returns only the writable OAuth outputs. ZERO_MCP_OAUTH_TOKENS_PATH and its .migrated backup remain in the ordinary deny baseline (durable --ro-bind /dev/null exact mask), and no .tmp/.secret/.lock*/.publish siblings, final-file markers, or ensure dirs are derived for it. TestLegacyMCPOverrideDoesNotFailClosedOnBubblewrap covers the process and command override variants: the existing source is exactly masked, planning does not reject the command, and the true atomic writers keep their fail-closed handling (TestKeyringExceptionKeepsFileBackedTokenStoresFailClosed).

Validation: go test ./internal/sandbox ./internal/cli -count=1, go vet ./..., govulncheck (no vulnerabilities), golangci-lint unused,ineffassign,staticcheck (0 issues), release build + smoke on Windows; CI green on Ubuntu, macOS, and Windows.

@PierrunoYT
PierrunoYT requested a review from jatmn August 2, 2026 19:47
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.

8 participants