Skip to content

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

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

fix(sandbox): protect daemon token file#685
PierrunoYT wants to merge 21 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 protections for the daemon remote-token file: it’s now treated as a protected credential target with fail-closed behavior, broader sandbox/seatbelt deny rules, and stronger path handling. Inline tokens take precedence over token-file values.
    • Improved enforcement prevents token-file replacement via policy, and tools won’t surface the token via filesystem aliases.
  • Bug Fixes
    • Refined read/write exclusion behavior so allow/deny changes can’t accidentally re-enable token access; improved credential-path deny coverage for environment/config overrides.
    • Updated directory listing to respect per-run read exclusions.
  • Tests
    • Added/expanded coverage for token-file canonicalization, nested allow/deny listing behavior, alias handling, and permission-profile denial cases.

@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 search and file tools, 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, internal/cli/daemon_test.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
The configured daemon token path is denied from reads and writes despite allow policies, including alias paths and disabled-policy modes; credential overrides and permission profiles are covered.
Sandbox runtime and tool hardening
internal/sandbox/runner.go, internal/sandbox/runner_test.go, internal/tools/*
The token-file environment variable is scrubbed, seatbelt rules add targeted write-unlink protection, and sandbox-aware listing/search/file tools honor read exclusions while preserving nested allowed reads.

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

Possibly related issues

Possibly related PRs

  • Gitlawb/zero#681 — Modifies the shared credential deny-read path computation used here.

Suggested reviewers: jatmn, gnanam1990, anandh8x

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR adds broad sandbox and tool hardening beyond #677, including alias protection, disabled-policy behavior, and tool-specific exclusions. If these protections are intentional, track them in a broader issue or separate PR; otherwise trim this patch to env scrubbing, token-path deny-read, and the related tests.
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 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 describes the main change: protecting the daemon token file.
Linked Issues check ✅ Passed The PR removes ZERO_DAEMON_REMOTE_TOKEN_FILE from sandboxed envs and denies the token path, with tests covering both.
✨ 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

@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. The early-out is in, and documenting the inline-token precedence in the help text answers the question I actually had: it is intentional rather than an oversight, which is all I needed.

I mutation-checked the three uncovered paths rather than taking the new tests at face value. Two are genuinely closed now: deleting the protected branch in DirExcluded fails TestProtectedCredentialDirExcluded, and removing the CanonicalizeTokenFileEnv block fails TestDaemonServeRemoteCanonicalizesTokenFileBeforeStartingWorkers.

One is still open, not blocking: the || len(protectedCredentialPaths()) > 0 disjunct at pathlists.go:478. Dropping it produces no new failures, so that path still carries behaviour nothing pins. Worth a case whenever you next touch the file.

Two notes on my own testing so you can discount them appropriately. Six sandbox tests fail for me locally, and they fail identically on clean origin/main from the same directory, so that is my environment rather than anything here. And my first attempt at the CanonicalizeTokenFileEnv mutation only removed the if line, which broke the build and looked like "no new failures" until I checked whether it had compiled.

Vasanthdev2004
Vasanthdev2004 previously approved these changes Jul 30, 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. The early-out is in, and documenting the inline-token precedence in the help text answers the question I actually had: it is intentional rather than an oversight, which is all I needed.

I mutation-checked the three uncovered paths rather than taking the new tests at face value. Two are genuinely closed now: deleting the protected branch in DirExcluded fails TestProtectedCredentialDirExcluded, and removing the CanonicalizeTokenFileEnv block fails TestDaemonServeRemoteCanonicalizesTokenFileBeforeStartingWorkers.

One is still open, not blocking: the || len(protectedCredentialPaths()) > 0 disjunct at pathlists.go:478. Dropping it produces no new failures, so that path still carries behaviour nothing pins. Worth a case whenever you next touch the file.

Two notes on my own testing so you can discount them appropriately. Six sandbox tests fail for me locally, and they fail identically on clean origin/main from the same directory, so that is my environment rather than anything here. And my first attempt at the CanonicalizeTokenFileEnv mutation only removed the if line, which broke the build and looked like "no new failures" until I checked whether it had compiled.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

@jatmn @gnanam1990 this one is at one of three: my approval, and CodeRabbit's was dismissed by the last push. I have re-triggered it.

Since either of you last looked, @PierrunoYT closed the two coverage gaps I raised and added the empty-list early-out on the hot path. I mutation-checked rather than trusting the new tests: deleting the protected branch in DirExcluded now fails TestProtectedCredentialDirExcluded, and removing the CanonicalizeTokenFileEnv block fails TestDaemonServeRemoteCanonicalizesTokenFileBeforeStartingWorkers. One disjunct at pathlists.go:478 is still unpinned, which I noted as non-blocking.

The conditional protection I asked about is answered and documented: an inline token takes precedence, so a stale token-file pointer is intentionally not protected as the live credential.

@kevincodex1 not ready for you yet either, same as #757.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
internal/tools/exec_command_test.go (1)

20-21: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Split unrelated exec-command test changes from this security PR.

These Windows-cleanup, shell-guidance, session-lifecycle, and outcome-classification changes are outside the daemon-token protection objective, making the security change harder to validate and revert.

As per coding guidelines, “Keep each change focused on its approved or assigned scope; do not include unrelated fixes, refactors, formatting churn, generated output, or existing local changes.”

Also applies to: 79-90, 92-93, 141-141, 197-197, 206-214, 224-225, 234-234, 273-273, 304-306, 337-355, 367-367, 421-421, 547-547, 578-610, 612-688, 728-728, 766-766

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

In `@internal/tools/exec_command_test.go` around lines 20 - 21, Remove the
unrelated exec-command test and implementation changes identified in this
review, including Windows cleanup, shell guidance, session lifecycle, and
outcome-classification updates around
TestIndependentExecCommandConstructorsShareDefaultManager and the referenced
hunks. Keep this security PR limited to daemon-token protection, preserving only
changes required for that objective.

Source: Coding guidelines

🧹 Nitpick comments (4)
internal/sandbox/protected_credentials_test.go (1)

315-339: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The case-variant expectation is derived from the same helper under test.

wantDenied := protectedPathFoldsCase() means the test passes vacuously if protectedPathFoldsCase itself regresses (e.g. someone drops darwin) — the expectation flips along with the behavior. Probing the actual filesystem gives an independent oracle: the fixture token already exists, so os.Stat(variant) succeeding proves the volume folds case.

💚 Independent expectation
-	wantDenied := protectedPathFoldsCase()
+	// The volume itself decides: the token exists, so if its upper-case spelling
+	// stats, a case-variant request opens the same bearer-token file.
+	_, statErr := os.Stat(variant)
+	wantDenied := statErr == nil
+	if wantDenied != protectedPathFoldsCase() {
+		t.Fatalf("protectedPathFoldsCase() = %t, but the filesystem folds case = %t", protectedPathFoldsCase(), wantDenied)
+	}
🤖 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/protected_credentials_test.go` around lines 315 - 339,
Update TestProtectedCredentialsMatchCaseVariantOnCaseInsensitiveFilesystems to
derive wantDenied from the filesystem rather than protectedPathFoldsCase: use
whether os.Stat(variant) succeeds as the independent case-folding expectation,
while preserving the existing validation and read-exclusion assertions.
internal/sandbox/profile.go (1)

188-196: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

credentialDenyReadPathsIn is test-only production code — move it into the test file.

Its doc comment says it exists so tests can pass a synthetic home, and the advisory check flags it as unreachable. Since credentialDenyReadPathsForEnvironment is already the real seam, this wrapper only adds a fourth positional parameter for callers to get wrong (and it is the exact signature the sibling PR noted as a merge-conflict surface). Relocating it to internal/sandbox/manager_test.go — or dropping it and constructing credentialPathEnvironment directly in the test — clears the finding without changing behavior.

🤖 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 188 - 196, Remove the test-only
wrapper credentialDenyReadPathsIn from production code. Update its callers in
internal/sandbox/manager_test.go to invoke credentialDenyReadPathsForEnvironment
with a credentialPathEnvironment directly, preserving synthetic-home test
behavior and avoiding the extra positional-argument seam.

Source: Linters/SAST tools

internal/cli/daemon_test.go (1)

180-206: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify PEM writing with pem.EncodeToMemory + os.WriteFile.

The create/encode/close triples double the length of the helper, and a t.Fatal between os.Create and Close leaves a handle open (which can make t.TempDir cleanup noisy on Windows).

♻️ Proposed simplification
 	dir := t.TempDir()
 	certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
-	certOut, err := os.Create(certFile)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
-		t.Fatal(err)
-	}
-	if err := certOut.Close(); err != nil {
-		t.Fatal(err)
-	}
+	if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
+		t.Fatal(err)
+	}
 	keyDER, err := x509.MarshalECPrivateKey(key)
 	if err != nil {
 		t.Fatal(err)
 	}
-	keyOut, err := os.Create(keyFile)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
-		t.Fatal(err)
-	}
-	if err := keyOut.Close(); err != nil {
-		t.Fatal(err)
-	}
+	if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
+		t.Fatal(err)
+	}
 	return certFile, keyFile
🤖 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/cli/daemon_test.go` around lines 180 - 206, Update the
certificate/key-writing helper around the PEM encoding logic to use
pem.EncodeToMemory followed by os.WriteFile for both certFile and keyFile.
Remove the os.Create, explicit Close, and intermediate file-handle error paths
while preserving the existing PEM block types, encoded contents, permissions,
and t.Fatal handling.
internal/sandbox/pathlists.go (1)

170-192: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

The inode-alias check makes every walked file pay EvalSymlinks + two Stats.

ReadExclusions.PathExcluded/DirExcluded call this for each entry during grep/glob/list_directory walks, so on a large tree the fallback path costs an EvalSymlinks (one lstat per path component) plus a Stat per protected entry — per file — even though the lexical check already covers the overwhelmingly common case. Two cheap mitigations: stat the protected entries once (they don't change within a walk) and skip the inode comparison when no protected entry shares the request's base name, since a hard/symlink alias to the token that resolves to the same inode still has to be reached by name.

Not a blocker for correctness, but worth measuring before this lands on the search hot path.

🤖 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/pathlists.go` around lines 170 - 192, Optimize the
inode-alias fallback used by ReadExclusions.PathExcluded and DirExcluded: cache
each protected entry’s os.FileInfo once per walk instead of calling os.Stat
inside the per-path loop, and only perform filepath.EvalSymlinks plus inode
comparison when the requested path’s base name matches a protected entry’s base
name. Preserve the existing lexical exclusion and SameFile 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.

Outside diff comments:
In `@internal/tools/exec_command_test.go`:
- Around line 20-21: Remove the unrelated exec-command test and implementation
changes identified in this review, including Windows cleanup, shell guidance,
session lifecycle, and outcome-classification updates around
TestIndependentExecCommandConstructorsShareDefaultManager and the referenced
hunks. Keep this security PR limited to daemon-token protection, preserving only
changes required for that objective.

---

Nitpick comments:
In `@internal/cli/daemon_test.go`:
- Around line 180-206: Update the certificate/key-writing helper around the PEM
encoding logic to use pem.EncodeToMemory followed by os.WriteFile for both
certFile and keyFile. Remove the os.Create, explicit Close, and intermediate
file-handle error paths while preserving the existing PEM block types, encoded
contents, permissions, and t.Fatal handling.

In `@internal/sandbox/pathlists.go`:
- Around line 170-192: Optimize the inode-alias fallback used by
ReadExclusions.PathExcluded and DirExcluded: cache each protected entry’s
os.FileInfo once per walk instead of calling os.Stat inside the per-path loop,
and only perform filepath.EvalSymlinks plus inode comparison when the requested
path’s base name matches a protected entry’s base name. Preserve the existing
lexical exclusion and SameFile behavior.

In `@internal/sandbox/profile.go`:
- Around line 188-196: Remove the test-only wrapper credentialDenyReadPathsIn
from production code. Update its callers in internal/sandbox/manager_test.go to
invoke credentialDenyReadPathsForEnvironment with a credentialPathEnvironment
directly, preserving synthetic-home test behavior and avoiding the extra
positional-argument seam.

In `@internal/sandbox/protected_credentials_test.go`:
- Around line 315-339: Update
TestProtectedCredentialsMatchCaseVariantOnCaseInsensitiveFilesystems to derive
wantDenied from the filesystem rather than protectedPathFoldsCase: use whether
os.Stat(variant) succeeds as the independent case-folding expectation, while
preserving the existing validation and read-exclusion assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d394710e-2acc-461f-940e-6fc59d71af77

📥 Commits

Reviewing files that changed from the base of the PR and between caccae4 and d402545.

📒 Files selected for processing (15)
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go
  • internal/daemon/remote/auth_test.go
  • internal/sandbox/engine.go
  • internal/sandbox/manager_test.go
  • internal/sandbox/pathlists.go
  • internal/sandbox/profile.go
  • internal/sandbox/protected_credentials_test.go
  • internal/sandbox/runner.go
  • internal/sandbox/runner_test.go
  • internal/tools/bash_auto_allow_test.go
  • internal/tools/daemon_token_exclusion_test.go
  • internal/tools/exec_command_test.go
  • internal/tools/list_directory.go
  • internal/tools/read_exclusions_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/daemon/remote/auth_test.go
  • internal/cli/daemon.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 30, 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] Fail closed when the automatic token restriction cannot be enforced
    internal/sandbox/manager.go:231
    The new token deny is added only to the derived PermissionProfile, while this availability gate examines only user-configured Policy.DenyRead/DenyWrite. Consequently, if the native backend is unavailable, a remote worker with ZERO_DAEMON_REMOTE_TOKEN_FILE still receives an EnforcementDegraded direct shell plan. The pointer is scrubbed, but a shell command can read a workspace-local token path without any OS restriction and recover the bridge bearer token. Treat a selected protected credential as an enforceable deny for this fallback decision, or reject the remote shell when no native sandbox is available.

  • [P1] Do not leave the bridge token reachable through a macOS shell hard-link
    internal/sandbox/pathlists.go:161
    A remote worker may have both a writable workspace and a token file within it (the new fixture uses exactly that placement). Seatbelt denies the original pathname only, so the worker can create a hard link and read the new name: ln bridge-token token-alias && cat token-alias. The in-process SameFile check does not apply to shell commands, so this exposes the bearer token despite the new protection. The code currently documents this limitation rather than preventing it; make this placement fail closed or enforce a boundary that does not expose the token inode to sandboxed shell commands.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

@jatmn your three P1s look resolved — they were against fc777ca, and the head is d402545a now. Checked each rather than assuming the rebase covered them:

Rebase / conflicts. mergeable=MERGEABLE, mergeStateStatus=BLOCKED — blocked on the review, not on conflicts. Still 7 commits behind main, but nothing conflicting.

#816 git credential denies. Present and byte-identical to main:

.aws · .azure · .git-credentials · <config>/git/credentials · <config>/gcloud

git diff origin/main <head> -- internal/sandbox/profile.go shows the only removals are the comment block and the old credentialDenyReadPathsIn signature, which this PR restructures deliberately. No deny path lost. profile_test.go is back too.

#811 provider-command timeout. git diff origin/main <head> -- internal/config/command.go has zero deletions, and syncBuffer is present. Nothing rolled back.

So the stale-branch drift is gone. Your two P2s — the macOS hard-link alias and ModeDisabled leaving shell outside the token protection — are untouched by any of that and are still live questions for @PierrunoYT.

Flagging because this is sitting on a stale blocker. I have no view yet on the P2s; I only re-checked the three that were about drift.

jatmn

This comment was marked as duplicate.

@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 final follow-up correctly resolves the earlier Bubblewrap concern: canonicalizing the selected token file at the daemon boundary keeps a symlink pathname out of the Linux deny-mount list, so this is not a request to restore that broken approach. I also agree that ModeDisabled intentionally leaves shell commands outside the OS boundary, and do not treat the pre-existing Windows ACL limitation as a finding here. The two paths below occur with the normal enforcing policy and remain separate from those accepted boundaries.

Findings

  • [P1] Fail closed when the automatic token restriction cannot be enforced
    internal/sandbox/manager.go:231
    The selected daemon token is added only to the derived PermissionProfile.FileSystem.DenyRead, while the unavailable-native-backend gate examines only the user-configured Policy.DenyRead/DenyWrite. With the default policy and an unavailable backend, a remote worker whose token is in its workspace receives an EnforcementDegraded direct shell plan. The pointer is scrubbed, but that shell can read a known or discovered workspace token path and recover the bridge bearer token. This is not the intentionally open ModeDisabled case from the follow-up: the policy remains enforcing and it is the backend fallback that drops the derived deny. Treat the effective protected credential deny as enforceable for this fallback, or reject remote shell execution when native isolation is unavailable.

  • [P1] Do not leave the bridge token reachable through a macOS shell hard-link
    internal/sandbox/runner.go:789
    Seatbelt denies the configured pathname, but a token inside a writable workspace can be linked to a new allowed pathname with ln bridge-token token-alias and then read through the alias. The new inode comparison only protects in-process tools; the shell profile is explicitly pathname-based and the broad workspace write rule allows creating the link. The follow-up accurately documents this limitation, but documenting a bearer-token disclosure does not make it safe for the remote bridge's stated protection boundary. Reject a token location under a shell-writable root, or use an enforcement boundary that does not expose the token inode to sandboxed shells.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT addressed both current P1 security findings in fefbdf8.

  • A selected daemon token file is now treated as a deny that requires native enforcement. If the native backend is unavailable, shell planning fails closed instead of returning an EnforcementDegraded direct-command plan.
  • macOS shell execution now fails closed when the selected token is under any Seatbelt-writable root (workspace, configured write root, allowed temp root, or an unrestricted filesystem), preventing hard-link alias reads.
  • The macOS placement check preserves both lexical and resolved token paths, follows filesystem identity for case/Unicode-equivalent names, and catches a writable selected symlink even when its target is outside the workspace.
  • Duplicate unconditional temp-write grants were removed from the platform runtime rules; FileSystemPolicy.AllowTemp is now the source of truth.
  • The previously reviewed ModeDisabled and explicit SandboxPreferenceForbid shell-open behavior remains intentional and is pinned by tests.

Added regressions for unavailable-backend rejection, writable workspace/temp/unrestricted placement, case variants, symlink placement, safe outside-root placement, and disabled/forbid boundaries.

Validation passed:

  • make fmt-check
  • go test -race ./internal/sandbox -count=1
  • 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)
  • git diff HEAD --check

@jatmn @Vasanthdev2004 — PierrunoYT requests a fresh review of the current head.

@PierrunoYT
PierrunoYT requested a review from jatmn July 31, 2026 17:06

@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 an issue that needs to be addressed before this is ready.

Findings

  • [P1] Parse quoted patch headers before enforcing token-file writes
    internal/sandbox/risk.go:327
    The sandbox-side parser uses strings.Fields for ---/+++ headers, unlike apply_patch's parser, which preserves and unquotes the full pathname. For a protected token named bridge token, a normal quoted Git header is reduced to bridge; the engine therefore does not identify the token, but git apply receives the real path and writes it. Parse the complete header remainder with the same quoted-path handling as the tool parser, and add an end-to-end patch test using a protected path containing spaces.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT addressed the quoted patch-header bypass in 35ce8b9.

  • The sandbox parser now preserves the complete ---/+++ header pathname, removes only a tab-delimited timestamp, and C-unquotes quoted Git paths before enforcing protected-credential checks.
  • Added an end-to-end apply_patch regression using a selected daemon token named bridge token; the patch is denied and the token content remains unchanged.

Validation passed:

  • focused quoted-path regression
  • make fmt-check
  • go vet ./...
  • go test ./internal/sandbox ./internal/tools
  • go test ./...
  • 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

@jatmn — PierrunoYT requests a fresh review of the current head.

@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 an issue that needs to be addressed before this is ready.

Findings

  • [P1] Parse quoted diff --git paths before applying protected-token patches
    internal/sandbox/risk.go:319
    This only fixes quoted unified ---/+++ headers. Both this parser and the separate tool-side parser in internal/tools/apply_patch.go:234 still tokenize diff --git with strings.Fields, which turns a valid header such as diff --git "a/bridge token" b/exposed-token into path fragments instead of bridge token and exposed-token.

    That matters because Git accepts header-only copy/rename patches and binary patches without any ---/+++ headers. A remote session can submit a 100%-similarity copy from bridge token to exposed-token; the sandbox's requestPaths and the tool's validatePatchPaths/recheckPatchWriteTargets both miss the token, then git apply copies its bytes to the ordinary workspace path. I verified that git apply --check accepts this form and that applying it creates an identical, readable copy. The remote agent can then retrieve the bearer through read_file; rename and binary variants can also move or modify the protected file.

    Please replace the strings.Fields handling with one shared Git-path parser used by both packages. It should consume the two path operands after diff --git, honor Git's C-style quoting/escapes, strip the a//b/ prefixes only after unquoting, and retain both source and destination paths for copy/rename patches. Add end-to-end regressions with a spaced token filename for: (1) header-only copy, asserting the copy is denied and no exposed file is created; (2) header-only rename, asserting the token remains in place; and (3) a binary patch, asserting the token cannot be modified. Keeping those tests at the apply_patch tool boundary is important because both the engine gate and the tool's pre/post-apply checks need to agree on the same paths.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the latest patch-path review in ab2b7d4.

Changes:

  • made sandbox.PatchHeaderPaths the single parser used by sandbox request/risk extraction and all apply_patch validation/tracking paths
  • parse both diff --git operands with Git C-style quoting/escapes, stripping synthetic a/ / b/ only after decoding
  • handle Git's unquoted filenames with ordinary spaces plus authoritative copy from/to, rename from/to, and accepted rename old/new headers while preserving pathname whitespace
  • added end-to-end protected-token regressions for valid header-only copy, rename, rename aliases, leading-space copy, and binary modification patches; each fixture first applies successfully in an unprotected control workspace, then is denied without changing/moving/copying the protected token
  • made the C-quoted operand parser regression assert the exact source and destination paths independently

The prior Windows Smoke failure was attributable to this PR: the old strings.Fields parsing produced a malformed \"a/bridge path and Windows returned a path-syntax error before the protected-token check. The shared parser removes that path and both affected packages cross-compile for Windows.

Validation (Go 1.26.5):

  • go test -race ./internal/sandbox ./internal/tools
  • 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)
  • git diff HEAD --check
  • GOOS=windows GOARCH=amd64 go test -c ./internal/tools
  • GOOS=windows GOARCH=amd64 go test -c ./internal/sandbox

No unrelated CI limitation remains from the observed failure. @jatmn, please re-review when you have a chance.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Follow-up: main advanced to 8e26679 while this was being finalized and GitHub briefly reported the PR as conflicting. Because force-push is prohibited, I merged current upstream main normally and resolved the single internal/tools/apply_patch.go overlap by preserving upstream's new whole-file tracking behavior while routing its patch paths through the shared sandbox parser.

Current head: 4ebdfce (includes parser fix ab2b7d4).

Post-merge validation (Go 1.26.5):

  • go test -race ./internal/sandbox ./internal/tools
  • 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)
  • git diff --check
  • Windows cross-compilation of ./internal/tools and ./internal/sandbox tests

@jatmn, please review current head 4ebdfce.

PatchHeaderPaths yields nothing for a `diff --git` line whose two operands both
contain unquoted spaces and name different files: no split is recoverable from
the line alone. That is only safe because git cannot resolve it either — real
patches of that shape carry the copy/rename headers that disambiguate them, and
without those git refuses the patch outright, so the token gate has nothing to
miss. This asserts both halves, so a future git that accepted the form would
fail here instead of silently becoming a gap.

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

Copy link
Copy Markdown
Contributor Author

Every finding across the four change requests is addressed on the current head. Rather than restate the commits, I re-verified each one adversarially and by mutation — details below, plus one small test added in a454fb8f.

[P1] Quoted diff --git paths / shared parser (2026-08-01 18:53)ab2b7d49. sandbox.PatchHeaderPaths is now the single parser and internal/tools/apply_patch.go calls it everywhere it used to tokenize headers itself (the remaining strings.Fields there is hunk-count parsing, not paths). It consumes both diff --git operands, honours Git's C-quoting, keeps source and destination for copy/rename, and reads the copy from/to, rename from/to and rename old/new headers.

Verified by mutation: restoring strings.Fields for the diff --git line makes TestApplyPatchDeniesHeaderOnlyAndBinaryDaemonTokenPatches/binary_modification fail with status=ok "Patch applied successfully." — i.e. the binary bypass you described, now closed and pinned.

I also probed the parser beyond the three required regressions; all of these resolve to the protected path and are denied end-to-end: octal-escaped non-ASCII ("a/bridge\303\251-token"), a tab in the name, an embedded \", both operands quoted, ./.. spellings (copy from sub/../bridge-token), and traversal in the ---/+++ headers.

A residual worth naming (and the reason for the new commit): one shape stays unresolvable — diff --git a/bridge token b/exposed token, where both operands have unquoted spaces and name different files. Nothing in that line can be split reliably. It is safe only because git can't resolve it either: I confirmed with real git that both git apply --check and git apply reject it with "git diff header lacks filename information", and every genuine copy/rename of that shape carries the extended headers the parser does read. a454fb8f adds TestAmbiguousGitHeaderIsNotAnApplicablePatch, asserting both halves — no paths from the parser, and no applicable patch from git — so if a future git ever accepted the form, this fails instead of quietly becoming a gap.

[P1] Fail closed when the token restriction cannot be enforced (07-31 15:52)fefbdf88. BuildExecutionRequest now treats a selected protected credential like an explicit deny, so an unavailable native backend rejects the plan instead of returning EnforcementDegraded. Mutation: dropping protectedCredentialNeedsNative from that condition fails TestSandboxManagerRejectsUnavailableBackendForProtectedToken.

[P1] macOS shell hard-link on a token in a writable root (07-31 15:52) — same commit, and it is a rejection rather than documentation: protectedCredentialInWritableMacOSRoot refuses the plan when the token sits under any shell-writable root (write roots, plus the temp subpaths when AllowTemp is set), matching os.SameFile so a differently-spelled or symlinked root cannot slip past. The seatbelt base rules also no longer grant blanket file-write* on /tmp, /private/tmp and /var/tmp. Mutation: disabling that gate fails TestSandboxManagerRejectsMacOSTokenInsideWritableWorkspace, and the protectedCredentialInWritableMacOSRoot unit test covers the write-root/temp matrix directly.

[P1] Quoted unified ---/+++ headers (08-01 01:01)35ce8b9f, subsumed by the shared parser above and covered by TestApplyPatchDeniesQuotedDaemonTokenPathWithSpaces (which also fails under the mutation).

[P2] ModeDisabled leaves shell outside token protection — still by design, now stated explicitly in code and pinned by TestSandboxManagerLeavesProtectedTokenShellOpenWhenSandboxDisabled rather than left implicit.

Verification on this head: go build ./..., go vet ./internal/tools/... ./internal/sandbox/..., go test ./internal/... all pass; gofmt -l clean on the changed file; CI green including all three Smoke jobs.

@PierrunoYT
PierrunoYT requested a review from jatmn August 1, 2026 21:28

@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 trailing whitespace in unified-diff paths
    internal/sandbox/risk.go:479
    patchFileHeaderPath removes the optional tab-delimited timestamp and then calls strings.TrimSpace on the entire path. That changes a valid unquoted filename ending in a space: for a configured token at bridge-token , both the sandbox request-path gate and the tool's pre/post-apply validation evaluate bridge-token instead. git apply --whitespace=nowarn does not make that transformation; it accepts --- a/bridge-token / +++ b/bridge-token and writes the space-suffixed file, so a remote session can patch the live bearer token despite the new protection.

    The root cause is that the parser is treating filename data as header formatting. Keep all bytes before the first tab as the unquoted pathname, and apply C-style unquoting only when the operand is quoted; alternatively, reject unquoted headers with ambiguous leading/trailing whitespace before passing them to Git. Please add an end-to-end protected-token regression that applies this patch form to a trailing-space filename and asserts the token is unchanged.

  • [P1] Keep the protected token path denied when the file is temporarily absent
    internal/sandbox/profile.go:310
    The mandatory token path is discarded when os.Stat fails, even though protectedCredentialPaths deliberately retains the configured lexical path so in-process tools cannot recreate it. This splits the security boundary: after an external secret rotation deletes the file, the next Linux shell profile contains no DenyRead entry for that workspace or temp-root pathname. A remote worker can then recreate the file with an attacker-selected bearer value; the running bridge continues using its already-loaded token, but the next serve-remote start reads the replacement and accepts that attacker-selected credential.

    The root cause is applying the credential-store existence filter to a path that must also be protected as a future write target. Preserve the selected token pathname in the effective OS deny/write set even when it is absent. The Linux builder already has an appendUnreadableLinuxPathArgs fallback that materializes a missing deny target with a tmpfs mount, so wire this mandatory path through that mechanism and give other backends equivalent fail-closed handling. Add a regression that removes a workspace-local token after bridge setup, builds the next shell profile, and proves the file cannot be recreated or selected on restart.

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