Skip to content

fix(sandbox): protect daemon token file - #685

Open
PierrunoYT wants to merge 14 commits into
Gitlawb:mainfrom
PierrunoYT:agent/protect-daemon-token-file
Open

fix(sandbox): protect daemon token file#685
PierrunoYT wants to merge 14 commits into
Gitlawb:mainfrom
PierrunoYT:agent/protect-daemon-token-file

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • remove ZERO_DAEMON_REMOTE_TOKEN_FILE from inherited sandbox command environments
  • add the existing token-file target to the established normalized credential deny-read profile
  • retain the profile's existing existence filtering and explicit AllowRead opt-out behavior
  • cover environment scrubbing and permission-profile construction with focused regression tests

Root cause

The sandbox scrubbed the inline ZERO_DAEMON_REMOTE_TOKEN value but left its file-pointer alternative in the child environment. Under the read-all workspace posture, a sandboxed command could read that pointer and then the bearer-token file outside the workspace.

The pointer is now scrubbed on every platform. Its existing normalized target is protected through the same credential deny-read mechanism used for GOOGLE_APPLICATION_CREDENTIALS on platforms where that mechanism is enabled. Windows filesystem deny-read remains subject to the existing ACL-model limitation tracked by #662.

Fixes #677

Validation

  • go test ./internal/sandbox -count=1
  • go vet ./...
  • git diff --check

The repository-pinned golangci-lint@v2.12.2 and govulncheck@v1.3.0 currently build with Go 1.25.12 and cannot load this repository's Go 1.26.5 packages.

Summary by CodeRabbit

  • Security
    • Strengthened sandbox protections for daemon authentication tokens by treating the token file as a protected path and enforcing fail-closed handling.
    • Canonicalizes the token file path before sandboxing and broadens seatbelt deny rules to prevent unauthorized token access.
  • Bug Fixes
    • Updated credential access and exclusion logic so deny/allow overrides can’t accidentally re-enable token file reads or related path corner cases.
  • Tests
    • Added/expanded coverage for token-file exclusions across grep/glob, tool permissions, and policy/seatbelt scenarios.

@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

The daemon token file is canonicalized before remote serving, added to sandbox credential protections, excluded from grep/glob and direct file operations, and removed from spawned command environments. Tests cover path resolution, policy enforcement, seatbelt rules, deny-read profiles, and edge cases.

Changes

Daemon token protection

Layer / File(s) Summary
Token file canonicalization
internal/daemon/remote/auth.go, internal/daemon/remote/auth_test.go, internal/cli/daemon.go
Relative and symlinked token-file paths are resolved before token loading; unresolved paths fail closed, while inline tokens take precedence.
Sandbox credential protection
internal/sandbox/pathlists.go, internal/sandbox/engine.go, internal/sandbox/profile.go, internal/sandbox/protected_credentials_test.go, internal/sandbox/manager_test.go, internal/tools/daemon_token_exclusion_test.go
The configured daemon token path is denied from reads and writes despite allow policies, excluded from directory searches and tool access, and covered by permission, profile, and integration tests.
Sandbox runtime hardening
internal/sandbox/runner.go, internal/sandbox/runner_test.go
The token-file environment variable is scrubbed from spawned commands, and read-denied paths use broad write-denial seatbelt rules with updated expectations.

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

Possibly related PRs

  • Gitlawb/zero#199 — Modifies the sandbox read-exclusion framework used by daemon-token path protection.
  • Gitlawb/zero#660 — Changes the related sensitive-environment scrubbing path.
  • Gitlawb/zero#681 — Modifies the shared credential deny-read path computation.

Suggested reviewers: gnanam1990, vasanthdev2004, anandh8x

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.21% 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 summarizes the main change: protecting the daemon token file.
Linked Issues check ✅ Passed The PR removes ZERO_DAEMON_REMOTE_TOKEN_FILE from sandbox envs, denies its target, and adds coverage for scrubbing and access blocking.
Out of Scope Changes check ✅ Passed All changes support daemon-token protection or regression coverage; no unrelated code paths stand out.
✨ 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[bot]
coderabbitai Bot previously approved these changes Jul 14, 2026
@PierrunoYT
PierrunoYT marked this pull request as ready for review July 14, 2026 21:05
Copilot AI review requested due to automatic review settings July 14, 2026 21:05

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 closes a sandbox escape where ZERO_DAEMON_REMOTE_TOKEN_FILE could be inherited by sandboxed commands (allowing them to locate and read the daemon bearer token file under the read-all posture). It scrubs the pointer env var across platforms and extends the existing “credential deny-read” profile logic to also deny reads of the referenced token file where deny-read enforcement is supported.

Changes:

  • Scrub ZERO_DAEMON_REMOTE_TOKEN_FILE from sandbox command environments (in addition to the inline token env var).
  • Extend credentialDenyReadPaths to include the path named by ZERO_DAEMON_REMOTE_TOKEN_FILE (alongside GOOGLE_APPLICATION_CREDENTIALS) and plumb this through the pure helper.
  • Add/extend regression tests covering env scrubbing and permission-profile deny-read construction (skipping the deny-read assertion on Windows per existing platform limitations).

Reviewed changes

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

File Description
internal/sandbox/runner.go Adds ZERO_DAEMON_REMOTE_TOKEN_FILE to the sandbox env scrub list.
internal/sandbox/runner_test.go Extends env scrubbing regression test to ensure the pointer env var is removed.
internal/sandbox/profile.go Adds ZERO_DAEMON_REMOTE_TOKEN_FILE to default credential deny-read path construction and updates helper signature/docs.
internal/sandbox/manager_test.go Updates credential deny-read tests for the new parameter and adds a profile-level regression test for daemon token file denial (non-Windows).

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

@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 writes to the daemon token file on macOS as well
    internal/sandbox/profile.go:176
    The new target enters DenyRead, but the Seatbelt backend translates that only into file-read* and unlink denials. Its broad file-write* allowance still covers every workspace root and the default temporary roots. Therefore, when ZERO_DAEMON_REMOTE_TOKEN_FILE names a file under /tmp or another writable root, a sandboxed command can discover the filename from its parent directory and overwrite or truncate the bearer-token file. This makes the remote bridge unavailable and can replace its credential on a restart/reload. Add a write denial for credential DenyRead files in the Seatbelt profile (and a macOS regression case for a token under a writable temporary root).

PierrunoYT added a commit to PierrunoYT/zero that referenced this pull request Jul 15, 2026
Address code review on PR Gitlawb#685: the Seatbelt profile only translated
DenyRead entries into file-read* and file-write-unlink denials. The
broad file-write* allowance for workspace/temp write roots still
covered a DenyRead file (e.g. the file ZERO_DAEMON_REMOTE_TOKEN_FILE
names) if it happened to sit under one of them, so a sandboxed command
could discover and overwrite/truncate the daemon bearer-token file
even though it couldn't read or delete it.

A file a sandboxed command must not read has no legitimate reason to
be written either, so seatbeltProfileFromPermissionProfile now also
emits a full file-write* deny for every DenyRead path, placed after
the broad write allow (deny rules that follow an allow win, matching
the existing DenyWrite/metadata-carveout ordering).

Adds a regression test with a DenyRead file under a writable /tmp
root, and extends the existing deny-ordering test to assert the new
file-write* rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 2248aca8 (head). The macOS Seatbelt fix (patch 2/2) is the right primitive: a DenyRead file that's also under a writable root was overwritable/truncatable because the prior profile only emitted file-read* and file-write-unlink, not file-write*. Denying the full write direction for every DenyRead path is correct, the ordering (deny after the broad allow) is correct, and TestSeatbeltProfileDeniesWritesToDenyReadUnderWritableRoot covers both the rule presence and the ordering. The TestSeatbeltProfileProtectsMetadataAndDenyOrdering extension covers the general case.

LGTM.

Cross-PR note: #685 depends on the credentialDenyReadPathsIn signature change from #681 (daemon token file as a parameter) and the scrubSensitiveEnv plumbed sensitiveEnvKeys from #682. Recommend rebasing #685 onto #681 + #682 in that order.

@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; all pass. The deny-write-for-DenyRead fix is a genuine security improvement (closes the truncate/overwrite bypass under a writable root). One integration note.

Comment thread internal/sandbox/profile.go

@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] Protect the configured symlink pathname as well as its target
    internal/sandbox/profile.go:200
    normalizeProfilePaths resolves ZERO_DAEMON_REMOTE_TOKEN_FILE through symlinks before it is added to DenyRead. If the configured pathname is a symlink under a writable root such as /tmp, the new deny rules protect only its current referent; a sandboxed command can unlink the writable symlink and recreate a regular file at the configured pathname. On the next remote-daemon start, TokenFromEnv reads that replacement pathname and accepts the attacker-chosen bearer token (or fails, causing a denial of service). Preserve and deny the lexical configured path in addition to its resolved target, and add a symlink-replacement regression test.

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 clean security hardening. Scrubbing ZERO_DAEMON_REMOTE_TOKEN_FILE from child envs and adding its target to the credential deny-read set closes a real hole (a sandboxed command could otherwise resolve the pointer and read the daemon bearer-token file under the read-all posture), and extending the macOS seatbelt profile to file-write*-deny every DenyRead path is the right fix: denyReadRules only blocked read and unlink, leaving a credential file under a writable root overwritable/truncatable. I checked the Linux bubblewrap path and it already bind-mounts DenyRead targets read-only, so this just brings macOS to parity. One thing to be aware of: the write-deny now covers all DenyRead paths (~/.aws, ~/.azure, etc.), so no sandboxed command can update cloud creds consistent with the existing unlink-deny and fine under the current threat model, just calling it out.

@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 320-328: Keep normalizeProfilePath purely lexical by removing its
filepath.EvalSymlinks resolution and returning the result of
normalizeProfilePathLexical unchanged. Resolve symlinks only within
normalizeProfilePathVariants while retaining both the configured lexical path
and resolved target for deny-policy expansion, and add a regression test
covering a writable denied symlink.
🪄 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: 974aa02c-d6a1-45e8-ae0b-c2df72771e98

📥 Commits

Reviewing files that changed from the base of the PR and between 8533492 and 5619a29.

📒 Files selected for processing (4)
  • internal/sandbox/manager_test.go
  • internal/sandbox/profile.go
  • internal/sandbox/runner.go
  • internal/sandbox/runner_test.go

Comment thread internal/sandbox/profile.go Outdated

@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] Do not pass a lexical symlink to Bubblewrap's deny mount
    internal/sandbox/profile.go:200
    For an existing ZERO_DAEMON_REMOTE_TOKEN_FILE symlink, the new variant list includes the symlink pathname as well as its target. The Linux backend then emits --ro-bind /dev/null <symlink> for that pathname; Bubblewrap rejects a symlink mount destination before the command starts (Can't create file at .../daemon-token: No such file or directory). Thus configuring the supported token-file option through a symlink makes every Linux sandboxed command fail to launch. Materialize/protect that pathname with a Bubblewrap-safe mechanism (or avoid adding it to the Linux deny-mount list) and add a Linux regression test.

  • [P1] Resolve the token-file path in the daemon's context, not each worker's
    internal/sandbox/profile.go:195
    TokenFromEnv accepts relative token paths, and serve-remote reads one before it starts workers. The daemon then preserves ZERO_DAEMON_REMOTE_TOKEN_FILE for workers whose cmd.Dir is the per-session spec.Cwd; normalizeProfilePathLexical consequently turns token into a path beneath that session instead of the daemon startup directory that contains the actual bearer-token file. The real file is left outside DenyRead under the read-all posture, so a sandboxed command that can infer its location can read it. Normalize the value at the daemon boundary (or pass an already-absolute protected path) and cover a remote worker whose session CWD differs from the daemon CWD.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

Following up on my earlier approve, which I am pulling back from for now. jatmn's latest P1 is a real one: the symlink-protection commit adds the ZERO_DAEMON_REMOTE_TOKEN_FILE symlink pathname itself, not just its resolved target, to the Linux deny-mount list, and Bubblewrap rejects a symlink as a mount destination, so every sandboxed command on Linux fails to launch when that option points at a symlink. I am on Windows and cannot reproduce the bwrap behavior here, but jatmn tested it on Linux with the exact "Can't create file ... daemon-token" error and the mechanism is sound. The target protection and the macOS write-deny are still the right hardening. This just needs the Linux side to protect that pathname without ro-binding the symlink itself (materialize it, or keep the symlink pathname off the Linux deny-mount list). Not re-approving until that is closed.

@PierrunoYT
PierrunoYT requested a review from jatmn July 18, 2026 11:03

@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: 2

🤖 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/linux_helper.go`:
- Around line 319-324: Add the same lexical-symlink guard used in the DenyRead
path to appendReadOnlyLinuxPathArgs, checking the mount path with os.Lstat and
returning the existing args unchanged when it is a symlink. Keep the current
handling for non-symlink paths unchanged.

In `@internal/sandbox/profile.go`:
- Line 325: The FileSystemPolicy initializers in PermissionProfileFromPolicy and
seatbeltCompatibilityPermissionProfile must preserve both lexical and resolved
paths for user deny policies. Replace single-path normalization for
policy.DenyRead and policy.DenyWrite with normalizeProfilePathVariants, while
leaving normalizeProfilePath unchanged for other uses.
🪄 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: cbb0b9b3-3559-4c77-bb5a-2c1692650e7a

📥 Commits

Reviewing files that changed from the base of the PR and between 5619a29 and 5cd8009.

📒 Files selected for processing (7)
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go
  • internal/sandbox/linux_helper.go
  • internal/sandbox/linux_helper_test.go
  • internal/sandbox/manager_test.go
  • internal/sandbox/profile.go
  • internal/sandbox/runner_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/sandbox/runner_test.go
  • internal/sandbox/manager_test.go

Comment thread internal/sandbox/linux_helper.go Outdated
Comment thread internal/sandbox/profile.go Outdated

@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] Preserve the resolved target for user-configured DenyRead symlinks
    internal/sandbox/profile.go:104
    normalizeProfilePath is now lexical-only, while this initializer still uses normalizeProfilePaths for policy entries. On Linux, appendUnreadableLinuxPathArgs then skips that symlink mount destination and no resolved target is present (unlike the credential-path branch). Thus a policy such as denyRead: [link], where link points to a secret, produces no deny mount under the read-all profile and the sandboxed command can read the target. Keep both variants for deny paths (and update the macOS compatibility initializer) so the Bubblewrap-safe target is actually denied.

  • [P1] Do not use lexical paths for ordinary sandbox roots
    internal/sandbox/profile.go:324
    This changed the shared normalizer used for workspaceRoot, AllowWrite, and DenyWrite, not just the new credential deny variant. A workspace opened through a symlink now reaches Linux Bubblewrap as --bind <link> <link>; Bubblewrap rejects a symlink mount destination, so every sandboxed command fails before it starts. I reproduced the failure with a symlinked workspace. Restore resolved normalization for ordinary roots and keep lexical-plus-resolved handling scoped to deny-path expansion.

  • [P1] Do not leave a writable token-file symlink unprotected on Linux
    internal/sandbox/linux_helper.go:319
    Skipping the lexical symlink avoids Bubblewrap's invalid mount destination, but only its original target is masked. If ZERO_DAEMON_REMOTE_TOKEN_FILE is a symlink under a writable root such as /tmp, a sandboxed command can replace it with a link to another host-readable file and read through the replacement; it can also corrupt the daemon's token path. The test currently asserts the unsafe omission. Protect or materialize the lexical pathname with a Bubblewrap-safe mechanism rather than simply dropping its deny rule.

  • [P1] Handle symlinked parent directories before emitting a deny mount
    internal/sandbox/linux_helper.go:319
    The Lstat check catches only a final-component symlink. For a supported token path such as /tmp/linkdir/token, where linkdir is a symlink, Lstat(token) reports a regular file and the helper emits a deny mount through the symlinked parent. Bubblewrap rejects that destination and every Linux sandbox launch fails. Detect path traversal through a symlink (or omit the lexical variant after retaining the resolved target) and add a regression case for this layout.

@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/linux_helper.go`:
- Around line 323-349: The Linux path argument helpers currently abort on
lexical symlinks instead of skipping them when their resolved target is also
protected. Update the profile-processing flow around appendReadOnlyLinuxPathArgs
and appendUnreadableLinuxPathArgs to recognize lexical symlink entries whose
resolved targets exist in the same deny set, skip those entries, and continue
enforcing the target; retain the existing error behavior when no enforceable
target is present. Update the related test to assert successful sandbox startup
and target enforcement.
🪄 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: baa5d0ce-25e0-42a5-8752-15ae141e7d1d

📥 Commits

Reviewing files that changed from the base of the PR and between 5cd8009 and a9da4ff.

📒 Files selected for processing (7)
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go
  • internal/sandbox/linux_helper.go
  • internal/sandbox/linux_helper_test.go
  • internal/sandbox/manager_test.go
  • internal/sandbox/profile.go
  • internal/sandbox/runner.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/cli/daemon.go
  • internal/sandbox/runner.go

Comment thread internal/sandbox/linux_helper.go Outdated
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 18, 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] Keep the remote token excluded from in-process file tools
    internal/sandbox/profile.go:104
    The new daemon-token path is added only to PermissionProfile.FileSystem.DenyRead, which protects wrapped shell commands. Built-in tools do not consume that profile: read_file reads scoped files directly, and grep/glob exclusions are built from Policy.DenyRead. If the token file is inside a remote session workspace (for example, a daemon started with a relative token-file path from that workspace), a remote-controlled agent can use read_file to exfiltrate the bridge bearer token. Apply the automatic credential exclusion to the in-process read/search tool boundary as well, and cover this with an end-to-end tool test.

  • [P1] Preserve inline-token precedence when a token-file variable is stale
    internal/cli/daemon.go:480
    TokenFromEnv intentionally returns a nonempty ZERO_DAEMON_REMOTE_TOKEN before consulting ZERO_DAEMON_REMOTE_TOKEN_FILE, but this new preflight resolves the file first. Consequently, a valid inline token plus an inherited missing or dangling token-file variable now makes daemon serve-remote exit instead of starting. Only canonicalize the file when it is the selected source (or otherwise leave an ignored file pointer from changing the result), and add the both-variables regression case.

  • [P1] Do not make symlink-backed credential paths disable every Linux sandbox command
    internal/sandbox/linux_helper.go:344
    The profile now deliberately retains both lexical and resolved forms of every credential/deny path, but the Linux argument builder aborts whenever either form has a symlink component. This makes common configurations such as GOOGLE_APPLICATION_CREDENTIALS=/var/run/... (where /var/run is commonly a symlink to /run) fail plan construction for every sandboxed command; the pre-PR profile kept only the resolved target. Preserve the denial of the resolved target while using a Bubblewrap-safe treatment for the lexical path instead of turning a valid credential configuration into a global sandbox-startup failure.

@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/engine.go`:
- Around line 57-75: Update withAutomaticDenyRead to recompute automaticDenyRead
from the current effective policy before merging it with policy.DenyRead, rather
than reusing the constructor-time list. Ensure credential paths allowed through
session or turn permission profiles are removed from the automatic deny set
while preserving deduplication.
🪄 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: 059619b5-dbbc-4812-a361-6fad61cca69c

📥 Commits

Reviewing files that changed from the base of the PR and between 2a0e63e and 4db4c6f.

📒 Files selected for processing (6)
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go
  • internal/sandbox/engine.go
  • internal/sandbox/linux_helper.go
  • internal/sandbox/linux_helper_test.go
  • internal/tools/read_exclusions_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/cli/daemon.go
  • internal/sandbox/linux_helper.go

Comment thread internal/sandbox/engine.go Outdated

@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

  • [P2] Ignore an unused token-file pointer when the inline token is selected
    internal/sandbox/pathlists.go:75
    TokenFromEnv gives ZERO_DAEMON_REMOTE_TOKEN precedence, and CanonicalizeTokenFileEnv deliberately leaves ZERO_DAEMON_REMOTE_TOKEN_FILE alone in that case. However, protectedCredentialPaths (and the profile builder) still treats that unused file pointer as the live credential. A valid daemon configuration with both variables set therefore makes the file named by a stale pointer unreadable, unsearchable, and unwritable to every session even though it was never used for authentication. Apply file-token protection only when the inline token is blank (or remove the unused pointer before workers inherit it), and cover the dual-variable case.

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

Findings

  • [P2] require_escalated shell bypasses all token protection when user DenyRead is empty
    internal/sandbox/engine.go:197-205
    internal/tools/bash.go:241-245
    internal/sandbox/profile.go:164-166
    The bridge token is deliberately kept out of Policy.DenyRead so escalated execution stays available, but UnsandboxedExecutionAllowed uses that same emptiness check. With the default remote-daemon policy (no user DenyRead), an approved sandbox_permissions: "require_escalated" shell runs with commandEngine == nil — completely outside bubblewrap/seatbelt — even though the token is in the profile DenyRead via credentialDenyReadPaths. A remote-controlled session that learns the token path (for example via list_directory) can therefore read the bearer secret through an escalation the user approved for unrelated host-state work. TestBashRequireEscalatedKeepsSandboxWhenDeniedReadsActive only covers user policy.DenyRead, not automatic credential denies. Please make UnsandboxedExecutionAllowed (and the exec_command twin) treat configured protected credential paths the same as user DenyRead for escalation gating, or block require_escalated outright for remote-bridge sessions.

  • [P3] list_directory still surfaces the protected token filename
    internal/tools/list_directory.go:83-95
    internal/tools/read_exclusions.go:22-30
    This PR wires the new automatic credential exclusion into Engine.ReadExclusions() and the grep/glob walkers (sandboxReadExcluder), but list_directory never consults that matcher — it lists os.ReadDir entries directly after the registry gate passes on the requested directory path. A remote session whose token file lives in the workspace can therefore learn the basename (for example bridge-token) even though read_file, grep, and glob are blocked. Content does not leak, and the same omission exists for user DenyRead, but this is the one discovery tool left out of the new protected-credential path. Please filter listed entries through ReadExclusions().PathExcluded (same pattern as grep/glob) or add a focused regression alongside daemon_token_exclusion_test.go.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Final follow-up on current head d3bc423:

  • automatic daemon-token protection now prevents unsandboxed require_escalated execution for both bash and exec_command
  • list_directory now applies sandbox read exclusions, hiding protected/denied entries while preserving valid nested AllowRead traversal
  • current upstream main was merged normally
  • focused race tests, static lint (0 issues), full validation, vulnerability checks, and all GitHub checks pass

The PR is mergeable. The only remaining inline thread is the future #681 sequencing note; #681 is still open. PierrunoYT requests a fresh human review to supersede the previous CHANGES_REQUESTED decision.

…n-token-file

# Conflicts:
#	internal/sandbox/manager_test.go
#	internal/sandbox/profile.go
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Pushed f833e42 after normally merging current upstream/main (81a5e6c).

The expected #681 conflicts were resolved by retaining its expanded credential inventory and environment overrides while carrying DaemonTokenFile as the mandatory, non-AllowRead-overrideable candidate. Inline-token precedence remains intact.

Re-verified the latest security requests against the merged tree:

  • automatic bridge-token protection prevents require_escalated from dropping the native sandbox in both bash and exec_command
  • list_directory uses the same read-exclusion matcher as grep/glob, hides both protected token paths and user DenyRead entries, and still descends to nested AllowRead paths
  • all upstream automatic credential candidates remain covered

Validation on Go 1.26.5/linux-amd64:

  • focused token/escalation/list-directory regressions: pass
  • go test -race ./internal/sandbox ./internal/tools -count=1: pass
  • make fmt-check: pass
  • go vet ./...: pass
  • go test ./...: pass
  • go run ./cmd/zero-release build: pass
  • go run ./cmd/zero-release smoke: pass
  • make lint-static: pass (0 issues)
  • make vulncheck: pass (No vulnerabilities found)
  • git diff --check: pass

The sole unresolved inline thread was the anticipated sibling-PR sequencing note; this merge addresses that integration. Fresh review requested for the merged head.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 27, 2026
@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 rechecked head f833e42b against the earlier review threads. The blocking items from prior rounds look addressed on the current tree: in-process credential exclusion, macOS credential write-deny scoping, ModeDisabled tool protection, daemon-boundary canonicalization, case folding on darwin/windows, mandatory token deny under AllowRead, require_escalated gating, and list_directory read exclusions. CI is green and the branch is mergeable.

One follow-up remains.

Findings

  • [P2] Workspace symlink/hardlink aliases can bypass the protected-token pathname check
    internal/sandbox/pathlists.go:167-178
    protectedPathDenied compares only the requested spelling via pathUnderProtectedRootnormalizePathForPolicyRoot, which deliberately leaves in-workspace symlink components unresolved (scope.go:286-288). validateWorkspacePath then permits symlinks whose target stays inside the workspace (paths.go:67-74). In the common remote-daemon fixture (token file inside the session workspace), an agent can run bash to create ln -s bridge-token leak (or a hard link), then call read_file, write_file, edit_file, or apply_patch on leak; Evaluate allows the request because leak is not spelled like the protected path, and the tool layer follows the alias to the bearer file (workspace.go:57-60). grep has the same shape: walkGrepFiles calls exclude.fileExcluded(path) on the unresolved walk path (grep.go:302-304) but scanGrepFile opens the symlink-resolved target (grep.go:365-383). This is a real bypass of protectedCredentialPaths, but it is not a regression from this PR: user-configured DenyRead uses the same lexical pathUnderPolicyRoot model, so any workspace-local deny-by-pathname rule is aliasable the same way. Please either resolve the request path (or compare inode identity) inside protectedPathDenied before returning nil — with an end-to-end regression covering read_file, write_file, and grep — or document that credential protection intentionally matches existing DenyRead pathname semantics and does not block in-workspace aliases.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT addressed the remaining protected-token alias bypass in fc777ca.

  • Protected-path checks retain lexical pathname protection and additionally compare resolved targets and filesystem identity.
  • Symlink and hardlink aliases are blocked for direct reads/writes and excluded from grep.
  • Added end-to-end alias regressions.

Validation: focused sandbox/tools 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 in-process daemon-token boundary on fc777ca looks sound after rebasing: alias closure via SameFile, ModeDisabled tool protection, escalation gating, and list_directory exclusions in the agent path all check out. What blocks merge today is stale-branch drift against current main, not a broken core patch. The items below separate merge prerequisites from PR-scoped follow-ups and pre-existing platform limits.

Findings

  • [P1] Rebase onto current main and re-run CI on the resolved tree
    internal/sandbox/profile.go, internal/sandbox/runner.go, internal/sandbox/runner_test.go, internal/sandbox/protected_credentials_test.go, internal/tools/daemon_token_exclusion_test.go, internal/tools/exec_command_test.go
    origin/main has moved ahead of this branch (3e7692d #818 env scrub, 33c94ec #816 git credential denies, 1c38e8c #811 provider-command timeout). git merge-tree reports changed in both on the files above, and captured GitHub metadata shows mergeStateStatus: DIRTY / mergeable: CONFLICTING. Recent author comments say the PR is mergeable; that does not match the current GitHub state. None of the daemon-token changes should land until this is rebased and conflicts are resolved.

  • [P1] Restore #816 git credential-store denies during the rebase (stale-branch regression, not intentional PR work)
    internal/sandbox/profile.go, internal/sandbox/profile_test.go
    Relative to the PR base (8acd42a3, which already includes #816), head fc777ca removes filepath.Join(home, ".git-credentials") and filepath.Join(configHome, "git", "credentials") from credentialDenyReadPathsForEnvironment and drops profile_test.go. That is branch drift, not part of the 15-file daemon-token diff, but merging without fixing it would reopen cleartext git credential reads for sandboxed commands. Keep the #816 candidates and tests while integrating the daemon-token mandatory list.

  • [P1] Restore #811 provider-command timeout hardening during the rebase (stale-branch regression)
    internal/config/command.go
    The same base-to-head comparison rolls back syncBuffer, awaitWithGrace, deadline-before-start wiring, and richer timeout errors in LoadProviderCommand / runProviderCommand. This file is outside the PR scope and should come back verbatim from main; it is not a deliberate change in the daemon-token patch.

  • [P2] macOS sandboxed shell can still read the token through a workspace hard-link alias
    internal/sandbox/pathlists.go:133-169, internal/sandbox/runner.go (denyReadRules / credentialDenyWriteRules)
    fc777ca closes inode aliases for built-in tools via os.SameFile, but the OS profile still denies only configured pathname literals. On macOS, a sandboxed bash/exec_command can ln bridge-token alias and cat alias because seatbelt rules are path-based, not inode-based. This is the same shell-side model as user-configured DenyRead, but it leaves a real bypass for the credential this PR is hardening. TestDaemonTokenAliasesDeniedEndToEnd covers built-in tools only. Please add a shell regression on darwin or document that OS-layer protection intentionally matches existing DenyRead pathname semantics.

  • [P2] ModeDisabled leaves shell outside bridge-token protection
    internal/sandbox/pathlists.go:107-111, internal/sandbox/engine.go:331-339, internal/sandbox/profile.go:76-80, internal/sandbox/runner.go:179-180, internal/tools/bash.go:241-245
    protectedCredentialPathBlock handles only SideEffectRead, SideEffectWrite, and SideEffectOutOfWorkspace, so bash/exec_command (SideEffectShell) is allowed under ModeDisabled while PermissionProfileFromPolicy returns FileSystemUnrestricted and the runner sets SandboxPreferenceForbid. In-process file tools stay blocked; shell does not. UnsandboxedExecutionAllowed() also returns true for ModeDisabled before consulting protectedCredentialPaths(), so require_escalated can drop the native sandbox entirely. The code comments treat shell as a separate boundary; if remote-bridge sessions can run with sandbox disabled, please either keep shell sandboxed when a token file is configured or state explicitly that ModeDisabled is shell-open by design.

Accepted residuals (not merge blockers for this PR)

  • Windows shell OS deny-read (#662). credentialDenyReadPaths still returns nil on Windows, so wrapped shell commands have no profile DenyRead for the token. This PR adds new in-process protection on Windows; it does not regress OS-level shell behavior. Track under #662 unless Windows shell coverage is in scope here.

  • Literal ~/… token paths. protectedCredentialPaths matches os.ReadFile (no ~ expansion) while credentialDenyReadPaths uses normalizeProfilePath (does expand ~). Obscure misconfiguration only; normal serve-remote paths are absolute after CanonicalizeTokenFileEnv.

  • MCP list_directory. internal/mcp/server.go calls RunWithOptions without Sandbox, so MCP clients do not get read exclusions. Pre-existing wiring; this PR fixed the agent-loop path only.

  • Linux shell alias/write concerns. Linux DenyRead uses --ro-bind /dev/null on the token path, which masks the host inode for that pathname; the macOS seatbelt write-allow problem and separate Linux write-deny rule do not apply the same way. No Linux shell hard-link read bypass was verified on the current tree.

PierrunoYT and others added 2 commits July 28, 2026 22:28
…n-token-file

# Conflicts:
#	internal/sandbox/runner.go
Two properties of the token protection were only implicit, and both are the
kind that get mistaken for bugs or, worse, for guarantees.

Inode aliasing is closed by the in-process tools alone. They see a path before
opening it, so a symlink or hard link resolves back to the protected inode.
Seatbelt and Bubblewrap rules name paths, so a sandboxed shell on macOS can
still hard-link the token to a fresh name and read that — the same pathname
model a user-configured DenyRead has always had, and not something either
backend's policy language can express otherwise.

ModeDisabled is shell-open by design. In that mode no wrapper is built at all,
so there is no layer left to confine a command and nothing for an escalation to
bypass; re-wrapping shell for this one file would undo the switch the operator
flipped without holding. The exclusion is a guarantee about Zero's own file
tools, not about the machine.

Both are now documented where the decision lives, and the ModeDisabled boundary
is pinned by a test so changing it has to be deliberate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT
PierrunoYT requested a review from jatmn July 28, 2026 20:34
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed all five findings. Branch is 643eb6e; GitHub now reports mergeable: MERGEABLE (was CONFLICTING).

[P1] Integrated current main. I merged upstream/main (8acd42a) rather than rebasing — this branch already carries a Merge remote-tracking branch 'upstream/main' commit (f833e42) and the PR is open from a fork, so a merge keeps the review history and existing comment anchors intact while producing the same resolved tree. Say the word if you want a linear history instead and I'll rebase and force-push.

Only one file conflicted: internal/sandbox/runner.go, in scrubSensitiveEnv. Resolved to main's version — #818 landed the same ZERO_DAEMON_REMOTE_TOKEN_FILE entry with a fuller comment, so main's side is a strict superset of what this branch had.

The resolved tree now differs from main only in this PR's own 15 files:

internal/cli/daemon.go, internal/daemon/remote/auth.go(+test),
internal/sandbox/{engine,pathlists,profile,runner}.go, internal/sandbox/{manager,protected_credentials,runner}_test.go,
internal/tools/list_directory.go, internal/tools/{bash_auto_allow,daemon_token_exclusion,exec_command,read_exclusions}_test.go

[P1] #816 git credential denies are back and integrated, not merely restored. credentialDenyReadPathsForEnvironment carries both ~/.git-credentials and $XDG_CONFIG_HOME/git/credentials as ordinary candidates — existence-filtered and AllowRead-opt-outable — alongside the daemon token's separate mandatory list, which is deliberately neither. profile_test.go is back from main.

[P1] #811 provider-command timeout is back verbatim. git diff upstream/main -- internal/config/command.go is empty: syncBuffer, awaitWithGrace, deadline-before-start wiring, and the richer timeout errors all come straight from main.

[P2] macOS hard-link alias — documented, per your second option. I took the documentation route rather than a darwin shell regression, for a reason I want to be straight about: I can't run or verify a seatbelt test from this host, and a test I can't execute is worse than an honest comment. The comment on protectedPathDenied now says outright that inode closure is specific to the in-process tools, that seatbelt/Bubblewrap rules are pathname-based so ln <token> alias && cat alias works from a sandboxed shell on macOS, and that this is the same model user-configured DenyRead has always had. denyReadRules and TestDaemonTokenAliasesDeniedEndToEnd carry matching notes so the test's scope isn't mistaken for the OS layer's.

[P2] ModeDisabled — stated as shell-open by design, and pinned. Keeping shell sandboxed for this one file would quietly undo the switch the operator flipped and wouldn't hold anyway: a shell that can run anything can read anything the process can. So the boundary is now spelled out in Evaluate's ModeDisabled branch, in UnsandboxedExecutionAllowed (why it short-circuits before consulting the token), and in protectedCredentialPathBlock (why SideEffectShell can't be covered — a shell request carries a command line, not a path). New TestDisabledPolicyLeavesShellOutsideTheTokenBoundary asserts all of it, including that the same engine under DefaultPolicy() does refuse escalation while the token is protected — so if anyone changes this, they change the test on purpose.

I left the four accepted residuals alone as you scoped them.

Validation on the merged tree

  • go build ./..., go vet ./...
  • go test ./...
  • go test ./internal/sandbox ./internal/tools -count=1
  • git diff --check clean; gofmt -l clean for every touched file

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.

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

Reviewed this properly. Not blocking: @jatmn's approval should stand and nothing below is a correctness or security defect. Notes worth your time though, and one answer you specifically need.

It does not collide with #818. I merged #818 yesterday, which adds ZERO_DAEMON_REMOTE_TOKEN_FILE to scrubSensitiveEnv so a sandboxed command never inherits the path. That is the pointer; this is the file. They complement rather than duplicate, and this still applies cleanly. I checked because if your approach superseded my one-liner I would rather revert mine than have two mechanisms.

The one I would actually fix

protectedPathDenied (internal/sandbox/pathlists.go:141) has no len(protected) == 0 early-out, so it runs filepath.EvalSymlinks (:174) and os.Stat (:178) before looping over nothing. It is the first predicate in PathExcluded (:368) and DirExcluded (:384), and Active() is true on denyRoots alone, so a user with a DenyRead entry and no token configured pays both syscalls on every entry visited by grep, glob and list_directory.

Being straight about magnitude, because my first measurement was wrong: alternating same-process A/B puts it at roughly +7.5% median for the no-token case, not the 27% I first got. The guard is free and semantics-preserving, since both return true sites are inside the loop over protected.

The bridge-daemon case is the larger one and the guard does not help it: with protected entries present the cost is roughly +107%, dominated by the per-entry NormalizePrefixForRoot component walk at :142-146, and protectedCredentialPaths can return two entries. If that matters, hoist the normalization rather than adding the empty guard. I would not cache os.Stat results at construction, since that weakens the inode-alias closure at :148-166: a FileInfo captured early goes stale if the token file is replaced mid-session.

Coverage gaps

Three behaviour-bearing paths delete clean with the suite still green:

  • the || len(protectedCredentialPaths()) > 0 disjunct at pathlists.go:475
  • the protected-path branch in DirExcluded at pathlists.go:384
  • remote.CanonicalizeTokenFileEnv() at internal/cli/daemon.go:483, which is the only thing pinning the variable before workers inherit it, and auth.go:128 is its sole writer in the tree

Given the PR states the exclusion as absolute, those three are the ones I would want pinned.

The question for you

selectedDaemonRemoteTokenFile() (pathlists.go:56-61) returns "" whenever ZERO_DAEMON_REMOTE_TOKEN is non-empty, so the file's protection is conditional on the daemon selecting the file form, and nothing tells the operator when it is not selected. Is that intended? If so it is worth a line in the docs, because "the token file is protected" reads as unconditional right now.

Also worth knowing

On macOS the seatbelt profile names the token by literal path only, so the protection does not follow an alias to the same inode the way the in-process check does. Lower severity than it sounds, but the two layers disagree.

Four larger claims I chased did not survive checking, including that this disables escalated execution on Windows and that it adds no OS-level protection there. Mentioning it so you know those were considered rather than missed.

@PierrunoYT
PierrunoYT requested a review from jatmn July 29, 2026 12:24
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT completed the remaining daemon-token follow-ups in 6e561b6.

  • Added the empty-protected-list fast path before symlink/stat work.
  • Pinned protected-directory exclusion and escalation gating with focused tests.
  • Added a production-path daemon serve-remote regression proving the selected relative token file is canonicalized before worker startup, while inline-token precedence remains unchanged.
  • Clarified in daemon help that an inline token wins, so a stale token-file pointer is not treated as the live credential.

Validation passed: make fmt-check, focused race tests (internal/sandbox, internal/daemon/remote, internal/cli), go vet ./..., go test ./..., release build, smoke, make lint-static (0 issues), make vulncheck (no vulnerabilities), and git diff HEAD --check.

@jatmn @Vasanthdev2004 — PierrunoYT requests a fresh review of the current head. The account does not have permission to use GitHub’s reviewer-request button.

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.

ZERO_DAEMON_REMOTE_TOKEN_FILE leaks the daemon bearer token into sandboxed commands

7 participants