Skip to content

Fix sandbox allowWrite so /tmp and ~/worktrees actually work - #23

Merged
technicalpickles merged 7 commits into
mainfrom
sandbox-allow-worktrees
Aug 24, 2026
Merged

Fix sandbox allowWrite so /tmp and ~/worktrees actually work#23
technicalpickles merged 7 commits into
mainfrom
sandbox-allow-worktrees

Conversation

@technicalpickles

@technicalpickles technicalpickles commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • /tmp has been in sandbox.filesystem.allowWrite since cabbbea and never actually worked. On macOS /tmp, /var, and /etc are symlinks into /private, and Seatbelt matches the resolved path, so a rule built from /tmp never fires. Claude Code already works around this for its own session dirs (it registers both /tmp/claude and /private/tmp/claude), but user entries get no such help.
  • claudeconfig.sh now adds the /private twin at generate time for any /tmp, /var, or /etc entry. Both spellings are kept so base.jsonc stays portable: on Linux the plain one is the one that fires.
  • It also injects /private$(getconf DARWIN_USER_TEMP_DIR), because macOS mktemp with no template uses confstr(_CS_DARWIN_USER_TEMP_DIR) and ignores $TMPDIR. That was why claudeconfig.sh itself could not run sandboxed. It now runs sandboxed right up to the mv onto ~/.claude/settings.json, which is a protected path no allowWrite entry can exempt. That one is by design, and CLAUDE.md now says so, so it stops getting chased.
  • The finding is written up as a global rule in 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 project settings.local.json, the getconf DARWIN_USER_TEMP_DIR lookup, and the "denied within allowed" set that nothing can exempt.
  • ADR 0050 additionally decodes the /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, hence getconf over a literal. The exact encoding is recorded as unsolved rather than guessed at.
  • Also carries the earlier ~/worktrees allowlist commit, which makes the documented wt worktree workflow work without reaching for dangerouslyDisableSandbox.

The previous theory was that Claude Code strips non-~-rooted absolute paths on rewrite. That is refuted: allowlisting a bare absolute /Users/technicalpickles/.config/fish works immediately.

Test plan

Re-run ./claudeconfig.sh (needs the sandbox off for its final write), then, in a sandboxed shell:

echo hi > /tmp/probe && rm /tmp/probe   # was EPERM, now works
mktemp                                   # was EPERM, now works
mkdir ~/worktrees/_probe && rmdir ~/worktrees/_probe

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:

allowWrite entry write /tmp write /private/tmp
/tmp only denied denied
/private/tmp only ok ok

Follow-up work

  • dotfiles-b6gd (Xcode/Swift toolchain) gets its /var/folders line of attack back, previously believed dead. The clang ModuleCache blocker lives in DARWIN_USER_CACHE_DIR, which needs the same treatment as /T.
  • dotfiles-1iso tracks whether sandbox.network.allowedHosts survives future rewrites. That array was genuinely dropped at some point, unlike the allowWrite entries.
  • dotfiles-3vm4 notes that claude/rules/ sits in the sandbox deny list, so prettier --write and some git operations on it need the sandbox escape.

technicalpickles and others added 7 commits August 24, 2026 09:36
~/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>
@technicalpickles
technicalpickles merged commit ac0d4b4 into main Aug 24, 2026
1 check passed
@technicalpickles
technicalpickles deleted the sandbox-allow-worktrees branch August 24, 2026 14:23
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