Skip to content

(sandbox): refuse extra binds at or under .claude and .git (#385) - #406

Merged
devsuitup merged 3 commits into
mainfrom
fix/385-sandbox-binds
Oct 2, 2026
Merged

devsuitup merged 3 commits into
mainfrom
fix/385-sandbox-binds

Conversation

@devsuitup

@devsuitup devsuitup commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Refs #385 (items 1, 4 and 5; items 2 and 3 are not changed, see below).

What changed

  • scripts/claude-sandbox.sh refuses (status 125, names the path) an extra bind (SWITCHBOARD_SANDBOX_BINDS: Additional Directories, a worktree's project root) that is at or inside a .claude or .git directory. The path is judged both normalised as spelled (realpath -m -s: .., trailing slash) and by real path (readlink -m: links), so a link to a .claude, or a .claude that is itself a link, does not get through. The working directory gets the same rule, except below .claude/worktrees. The $HOME check compares the same two forms, so $HOME/, $HOME/. and a link to it are refused. Relative add-dirs of a schedule resolve against the schedule's directory, and the skip message names the reason. A missing path under a .claude or .git is refused too; a path that cannot be resolved at all is refused.
  • refusedScheduleBinds in schedule-runner.js applies the same rule before the project check, anywhere (inside a registered project, outside $HOME), and now compares real paths for the $HOME / known-project check (new canonicalPath: realpath of the nearest existing ancestor plus the rest).
  • docs/sandbox.md: new "Additional directories" section; the -xdev / node_modules gap of the worktree search is documented under "Git" (item 4). .ai/contexts/schedule-runner.md updated. CHANGELOG entry under Unreleased.

Tests

  • test/sandbox-wrapper.test.js: "refuses an extra bind at or under a .claude or .git, however the path is spelled" (11 refused spellings, 6 allowed controls such as .claude-notes, x.git, .claude/..).
  • test/schedule-project-provenance.test.js: add-dir under .claude/.git refused inside a project and outside $HOME; links judged by target and a .claude link judged as spelled (skipped where links cannot be created); a relative cwd for resolveScheduleSandbox (item 5).
  • The wrapper test was seen red before the fix (status was not 125), green after. The wrapper tests are Linux-only and skip on Windows; I ran them in a Linux container (node 20, bash, git, bubblewrap installed, userns unavailable so the real-bwrap tests skipped): 37 pass, 0 fail, 5 skipped.
  • Mutations, each killed: wrapper without the readlink -m form, without the realpath -m -s form, without the .git pattern, without the .claude pattern; refusedScheduleBinds without the lexical check, without the real-path check, without canonicalPath, without the .git name; resolveScheduleSandbox without path.resolve(cwd) (item 5). One mutation initially survived (lexical check); the .claude-is-a-link case was added and kills it.

Not done

  • Item 2 (seed from transcript folders): tightening needs a product decision. Options: seed nothing and let projects enter by use (a schedule of a never-opened project then stops running after upgrade), or seed only projects with a project: setting. Recommendation: the second.
  • Item 3 (open-terminal registers the renderer's projectPath): read, not changed. The path comes from the sidebar's folder metadata (derived from transcripts) and registration needs the user to start a session in that project; a planted folder is only registered by that act. Worth re-checking when (triggers): let a session open sister sessions and follow their state #380 shares the helper.

Not verified

No real bwrap run of the new refusal (it exits before bwrap). task check on Windows: lint 0 errors; 3 failures, none in touched files: two measureUntrackedLocal tests in git-changes-runner.test.js (fail identically on the untouched main checkout at 4608df0) and viewer-file-watch (the known libuv _wcsnicmp assertion).

An extra bind inside a .claude or .git was mounted read-write after, and
over, the read-only protection of the same path. The wrapper now refuses
such a bind (as spelled and by real path), refusedScheduleBinds applies
the same rule and compares real paths, the worktree search's limits are
documented, and resolveScheduleSandbox's path.resolve is pinned by a test.

Refs #385
@devsuitup

Copy link
Copy Markdown
Owner Author

Reviewing 40aa802 (adversarial review in progress).

@devsuitup

Copy link
Copy Markdown
Owner Author

Adversarial review at 40aa802: the new refusal holds — .claude, .claude/, src/../.git, relative .claude/worktrees, a link to .claude and a .git file are all refused with 125 before bwrap, path echoed without eval. Being folded in (neighbouring holes giving the same rw result): (1) the cwd exemption covers any cwd, so cwd x/.claude + bind x ends with --bind x/.claude read-write; (2) the $HOME check is textual, so $HOME/ binds the whole home rw; (3) relative schedule add-dirs resolve against the app's cwd, not the schedule's; plus the skip message reason in main.js and failing closed on an empty realpath. Items 2 and 3 of #385 are stated as not done and absent from the diff.

The working directory was exempt from the .claude/.git refusal, so a
session in <proj>/.claude with <proj> bound got a read-write bind over the
read-only one. It is now refused except below .claude/worktrees. The
$HOME/parent check compared text, so $HOME/ or a link to $HOME passed; it
now compares the normalised and real forms, and a path that cannot be
resolved is refused. A schedule's relative add-dirs are resolved against
the schedule's directory, and the skip message names the reason.

Refs #385
@devsuitup

Copy link
Copy Markdown
Owner Author

Reviewing 8b9b3f6 (adversarial review in progress).

@devsuitup

Copy link
Copy Markdown
Owner Author

Re-review at 8b9b3f6: no blocker, no major. Refused as intended: cwd .claude/worktrees/.., .claude/worktrees/wt/../.., .claude/worktreesX, .claude/worktrees itself; binds /, a parent of $HOME, $HOME/../u, $HOME/. No regression: project root, plain cwd, and a real git worktree add cwd under .claude/worktrees/wt with its .git file pass the new checks. Accepted minors (safe side / low exposure): a nested worktree …/.claude/worktrees/wt/.claude/worktrees/inner is refused; a user-level $HOME/.claude/worktrees/<x> cwd is accepted (read-only from inside the sandbox); the JS side does not pre-refuse / (the wrapper does, exit 125). Symlink cases verified by reading only (Git Bash copies on ln -s); the Linux CI job runs the wrapper tests. Ready to merge once CI is green on 8b9b3f6.

@devsuitup
devsuitup merged commit d4a1bc4 into main Oct 2, 2026
10 checks passed
@devsuitup
devsuitup deleted the fix/385-sandbox-binds branch October 2, 2026 10:11
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.

1 participant