Fix sandbox allowWrite so /tmp and ~/worktrees actually work - #23
Merged
Conversation
~/worktrees was the single biggest source of sandbox denials across every project: 296 "Operation not permitted" Bash results in Jul-Aug spanning 10 repos (a2a-experiments 126, llamafit 67, brineworks 56, ollama-scope 34). Nothing in claude/roles/ or claude/stacks/ allowlisted it. That made claude/rules/worktrees.md self-defeating -- it tells agents to prefer `wt` over EnterWorktree, then tells them the commands fail and to re-run unsandboxed. Sessions took the hint: 21% of all Bash calls (3874 of 18511) ran with dangerouslyDisableSandbox, and the top offenders are `cat`, `echo`, `grep -n` and `git status`, i.e. agents flip the escape on after one real denial and leave it on for everything after. It failed at every layer: `wt switch --create` couldn't create the leading .git dirs, pytest's cacheprovider EPERM'd inside the worktree .venv, tsc couldn't write dist/*.js, and relative-path writes failed once an agent cd'd in (the sandbox's implicit cwd allowance only covers the session's original cwd, not a directory the agent navigated to). Verified sandboxed after the change: wt switch --create, mkdir/write under ~/worktrees, relative writes after cd, git add (the .git/index.lock write), node_modules/.tmp/tsbuildinfo, and wt remove cleanup. Also pre-creates ~/worktrees in setup_sandbox_dirs, since allowWrite permits writes under a path but not creating the path itself. Beans: dotfiles-lbe4 (this), dotfiles-uxr8 and dotfiles-b6gd (follow-ups from the same review, dotfiles-85ee). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/tmp has been in sandbox.filesystem.allowWrite since cabbbea and never worked. The entry was present and correct in the generated settings.json; it just had no effect. The recorded hypothesis (dotfiles-uxr8) was that Claude Code strips non-~-rooted absolute paths on rewrite. That's refuted: allowlisting the bare absolute /Users/technicalpickles/.config/fish works immediately. The real cause is macOS path canonicalization. /tmp, /var and /etc are symlinks into /private, and Seatbelt matches the resolved path, so a rule built from "/tmp" never fires — the write lands on /private/tmp. Claude Code already works around this for its own session dirs, registering both "/tmp/claude" and "/private/tmp/claude"; user entries get no such help. Live A/B via project settings.local.json: allowWrite = ["/tmp"] -> /tmp DENIED, /private/tmp DENIED allowWrite = ["/private/tmp"] -> /tmp OK, /private/tmp OK claudeconfig.sh now canonicalizes at generate time (Darwin-gated): - every ^/(tmp|var|etc)(/|$) entry gets a /private twin, keeping both so base.jsonc stays portable (plain /tmp is the one that works on Linux) - injects /private$(getconf DARWIN_USER_TEMP_DIR), since macOS mktemp with no template uses confstr(_CS_DARWIN_USER_TEMP_DIR) and ignores $TMPDIR That last one is why claudeconfig.sh itself couldn't run sandboxed. It now runs sandboxed up to its final step, failing only on the mv onto ~/.claude/settings.json — a protected path the docs say no allowWrite entry can exempt. By design; documented in CLAUDE.md so it stops being chased. Revives dotfiles-b6gd's /var/folders line of attack, previously believed dead. Splits the allowedHosts survival watch out to dotfiles-1iso. See ADR 0050. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The /tmp finding is only useful if it's reachable from the next repo, so it goes in claude/rules/ (symlinked to ~/.claude/rules/, loaded everywhere) rather than staying buried in this repo's ADR. claude/rules/sandbox-paths.md covers: - the symlink canonicalization rule (/tmp, /var, /etc into /private) and why a correct-looking allowWrite entry silently never fires - how to tell a real EPERM from an ENOENT, which is half of all "the sandbox is blocking me" reports - how to A/B an allowlist change live via project settings.local.json, since settings edits apply to the running session with no restart - getconf DARWIN_USER_TEMP_DIR / DARWIN_USER_CACHE_DIR, which tools bypass $TMPDIR to reach them, and why the path must never be hardcoded - the "denied within allowed" set that no allowWrite entry can exempt ADR 0050 gains a section decoding the /var/folders/<b>/<hash> path: it encodes the account UUID (dsmemberutil getuuid -u $(id -u)), which is why system daemons share a long prefix and a login account doesn't. Per-user and per-machine, hence getconf over a literal. The exact encoding is documented as unsolved rather than guessed at. Also files dotfiles-3vm4: claude/rules/ sits in the sandbox deny list, so prettier --write and some git ops on it EPERM and need the escape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First draft was shaped like a wiki page: 79 lines, ### nesting, reference- manual prose. The sibling rules are terse directives -- worktrees.md is 12 lines, taskwarrior.md is 32, both built as heading, rule, **Why:**, **How to apply:**. Restructured to match: flattened ### to ##, led each section with the rule itself instead of the background, moved the mechanism into **Why:**, and cut the throat-clearing. 79 -> 46 lines with the reference material intact (the getconf lookups, the live A/B technique, the EPERM vs ENOENT tell). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The style lesson from the sandbox-paths.md draft belongs here, not in the global set: "how to write a rules file in this repo" only matters in this repo. Scoped with paths: ["claude/**"] so it loads on demand rather than costing context in every session. Covers the things claude/README.md doesn't: - claude/rules/ (global, every repo) vs .claude/rules/ (this repo only), and how to decide which one a piece of knowledge belongs in - the house style for a rules file: heading, rule, **Why:**, **How to apply:**, only ## headings, worktrees.md's 12 lines as the target - rules go live through a directory symlink, settings.json needs claudeconfig.sh and the sandbox escape for its final write - claude/rules/ sits in the sandbox deny list, so prettier --write EPERMs Also files dotfiles-emgv: finicky.md's `paths: a, b` parses as a YAML string, not a list, so its globs likely match nothing and the rule never loads. Left alone pending runtime confirmation rather than edited blind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.scratch/ is already ignored through ~/.gitignore, so git never sees it and CI stays green. Prettier only reads repo-local ignore files, though, so `bin/prettier --check .` walks it and npm run lint fails on PR body drafts and other agent scratch output that will never be committed. Sits next to .parkinglot/ and .claude/settings.local.json, which are the same class of local-tooling ignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/tmphas been insandbox.filesystem.allowWritesincecabbbeaand never actually worked. On macOS/tmp,/var, and/etcare symlinks into/private, and Seatbelt matches the resolved path, so a rule built from/tmpnever fires. Claude Code already works around this for its own session dirs (it registers both/tmp/claudeand/private/tmp/claude), but user entries get no such help.claudeconfig.shnow adds the/privatetwin at generate time for any/tmp,/var, or/etcentry. Both spellings are kept sobase.jsoncstays portable: on Linux the plain one is the one that fires./private$(getconf DARWIN_USER_TEMP_DIR), because macOSmktempwith no template usesconfstr(_CS_DARWIN_USER_TEMP_DIR)and ignores$TMPDIR. That was whyclaudeconfig.shitself could not run sandboxed. It now runs sandboxed right up to themvonto~/.claude/settings.json, which is a protected path noallowWriteentry can exempt. That one is by design, and CLAUDE.md now says so, so it stops getting chased.claude/rules/sandbox-paths.md, which symlinks to~/.claude/rules/and so loads in every repo, not just this one. It covers the symlink rule, telling a real EPERM from an ENOENT, A/B-ing an allowlist change live through a projectsettings.local.json, thegetconf DARWIN_USER_TEMP_DIRlookup, and the "denied within allowed" set that nothing can exempt./var/folders/<b>/<hash>path, since the injected entry otherwise reads as an opaque machine-specific string that someone will eventually hardcode. It encodes the account UUID (dsmemberutil getuuid -u $(id -u)), which is why every system daemon shares a long common prefix and a login account does not. Per-user and per-machine, hencegetconfover a literal. The exact encoding is recorded as unsolved rather than guessed at.~/worktreesallowlist commit, which makes the documentedwtworktree workflow work without reaching fordangerouslyDisableSandbox.The previous theory was that Claude Code strips non-
~-rooted absolute paths on rewrite. That is refuted: allowlisting a bare absolute/Users/technicalpickles/.config/fishworks immediately.Test plan
Re-run
./claudeconfig.sh(needs the sandbox off for its final write), then, in a sandboxed shell:Settings edits apply to a running session, so no Claude Code restart is needed.
The A/B that identified the cause, run through a project-scoped
settings.local.json:allowWriteentry/tmp/private/tmp/tmponly/private/tmponlyFollow-up work
dotfiles-b6gd(Xcode/Swift toolchain) gets its/var/foldersline of attack back, previously believed dead. The clang ModuleCache blocker lives inDARWIN_USER_CACHE_DIR, which needs the same treatment as/T.dotfiles-1isotracks whethersandbox.network.allowedHostssurvives future rewrites. That array was genuinely dropped at some point, unlike theallowWriteentries.dotfiles-3vm4notes thatclaude/rules/sits in the sandbox deny list, soprettier --writeand some git operations on it need the sandbox escape.