/tmp-block
Fail-CLOSED PreToolUse guard denying durable-content writes to temp paths (/tmp, /private/tmp, /var/folders, $TMPDIR) across Write/Edit and Bash writes, and enforcing the ratified <repo>/.worktrees/<name> worktree location. Triggers: tmp guard, TMP-BLOCK deny, WORKTREE-CONVENTION deny, bypass ledger. NOT for storage policy.
$ golems-cli skills install tmp-blockUpdated yesterday
Hook-carried skill. The enforcing artifact is hooks/tmp-block-pretooluse.py
(PreToolUse on Write|Edit|NotebookEdit|Bash); this page is its contract and
audit guide.
Why it exists (S04, weave 2026-06-07 Phase-2 Fix-3)
A worker ran Write(/tmp/orqi-tts-answer-msg.md); the era's /tmp guard
half-fired (validated then allowed) — a hook validation error let the
durable write through, and Etan caught it live ("Wait, why are we writing
those things in temp?"). Class evidence: ≥24 /tmp anti-pattern findings,
4 proven costs in 24h (reboot-wiped leak worksheet, a harness fix stranded in
a prunable /tmp worktree, Etan's own terminal burned by a /tmp-worktree-held
branch). Adversary verdict: KEEP, with the deny extended to the
canonicalized path-CLASS and to Bash writes, "else it's one directory
away from useless."
The contract — two-valued (Etan, by voice, 2026-08-17)
"none of y'all would be able to write to temp, but also not ask me so we don't get agent stuck."
Provably outside the temp class -> ALLOW. Everything else -> DENY, with a reason the agent can act on. This hook NEVER emits a PreToolUse prompt.
A prompt suspends the pane until a human answers it, and a headless Codex or
Cursor worker has no human in its pane at all — 2026-08-14/17 lost hours to
~/Documents probes stranding panes overnight. A deny comes back as a readable
error the agent reroutes around by itself, so the residual failure mode is the
recoverable one.
| Surface | Behavior |
|---|---|
Write/Edit/NotebookEdit into the class | DENY + redirect to the repo / docs.local/ |
Bash output redirect (>, >>, &>), incl. heredoc+redirect | DENY |
Bash tee / tee -a into the class | DENY |
git worktree add into the class | DENY + redirect to <repo>/.worktrees/ |
git worktree add outside any .worktrees/ parent | DENY + the exact fixed command (Rule 2) |
git worktree add whose target can't be resolved | DENY, naming the exact resolution failure (golems#676's requirement, minus the prompt) |
| Any target the hook cannot read statically | DENY — rewrite it with a literal path, or a variable whose value has a literal head |
Reads/deletes (ls, cat, grep, rm, worktree list) | NEVER denied |
| Any hook/validation error (incl. unwritable ledger) | DENY — fail CLOSED (the S04 half-fire class) |
CLAUDE_WORKER | does NOT exempt (S04's violator was a worker) |
| The harness session scratchpad | ALLOW — the one sanctioned temp location (below) |
Path-CLASS (canonicalized via realpath): /tmp, /private/tmp,
/var/folders, /private/var/folders, the live $TMPDIR value, and literal
$TMPDIR tokens in Bash commands. The macOS /tmp → /private/tmp symlink is
not a route-around.
The one exception — the harness session scratchpad (2026-08-17)
Claude Code's own system prompt hands every session a scratchpad and instructs
it, verbatim: "IMPORTANT: Always use this scratchpad directory for temporary
files instead of /tmp or other system temp directories". The path it supplies
is inside the class this guard denies:
/private/tmp/claude-<uid>/<repo-slug>/<session-uuid>/scratchpad/…
So the harness said "put temp files here" and the guard said no. Observed live 2026-08-17: brainlayerClaude took two consecutive denials arming a monitor, and skillcreatorClaude hit the same wall writing a test fixture the same day. Every agent walks into it, because they are following instructions correctly — the same failure shape as the "always notify on commits" flood.
Etan's ruling, 2026-08-17: allowlist the harness session scratchpad, keep denying every other temp path. It is session-scoped and nothing durable belongs there, so allowing it costs nothing Rule 1 was protecting.
The exception is structural and narrow — never a hardcoded uid or session id,
and not a widening of the temp path-class. The whole
claude-<uid>/<slug>/<session-uuid>/scratchpad chain must sit directly under a
temp root, and every component is matched exactly: claude-<digits> (not
any directory), a slug, a session UUID, and scratchpad (not scratchpad-evil
or myscratchpad). The #727 review found both of those relaxations surviving
the suite, so all three are now pinned by deny-side tests that redden the
mutation.
It applies to every Rule 1 write surface — Write/Edit, redirects, appends,
tee, heredocs — not to redirects only. Still denied:
/private/tmp/scratchpad/x.txt (no session chain),
/private/tmp/claude-501/x.txt (no scratchpad component), every $(mktemp)
form (a bare temp path with no chain at all), /tmp/foo.txt.
.. traversal is defended by the callers, not by the shape match. The
path is canonicalized before the shape is consulted: in_temp_class
normalizes each candidate (normpath and realpath) and only then asks
whether it is scratchpad-shaped, so …/scratchpad/../../../../leak.txt
arrives as /private/tmp/leak.txt and denies; _literal_prefix_class rejects
a literal .. component outright before building a probe. The normalization
inside the shape check itself is defense-in-depth for a direct caller — the
#727 review's mutation M5 removed it and opened no hole.
scratchpad is its own prefix-class verdict, decided independently of the
repo/outside/temp proofs below.
Rule 2 — the worktree location convention (2026-08-09)
Ratified by Etan by voice, 2026-08-09: the fleet worktree location is the
in-repo <repo>/.worktrees/<name>.
The gap this closes is Etan's own catch, verbatim: "I thought the guard's job
was to guard it, so it actually goes the right way." Rule 1's deny message
advised <repo>/.worktrees/ — but nothing enforced it, so
git worktree add ~/Gits/foo.wt/bar (durable, not temp) sailed straight
through and 18 sibling *.wt directories accumulated under ~/Gits/.
| Resolved target | Decision |
|---|---|
has a .worktrees ancestor component | allow |
| resolves anywhere else | DENY, naming the convention + git -C <repo> worktree add <repo>/.worktrees/<name> |
cannot be resolved ($UNSET, $(…), relative after cd/git -C) | DENY, naming the resolution failure |
Rule 2 silently allows a target whose literal or already-resolvable, repo-anchored
prefix proves .worktrees/ containment before an unresolved variable suffix, such
as git worktree add .worktrees/$wt or $HOME/Gits/golems/.worktrees/$wt. An
unresolvable variable prefix still asks; a literal .. escape still disqualifies
the proof; and lexical plus realpath containment checks still reject a
.worktrees symlink that leaves the repo.
golems#676 applied. git-guardian judged the unexpanded literal — it
blocked a legitimate cleanup of a mktemp -d variable, then blocked the
gh issue create that filed the bug because the title carried the pattern as
prose. So Rule 2: expands ~/$NAME from the live environment and judges the
resolved path (lexical and realpath, so a .worktrees symlink pointing
at a sibling is not a route-around); fires only in command position (quoted
text and heredoc bodies run nothing); and names the exact resolution failure
instead of blocking blind when the target is unknowable. #676's requirement
was the actionable reason, not the prompt — the prompt was dropped on
2026-08-17 and the reason kept.
Migration-window hatch, mirroring Rule 1: WEAVE_ALLOW_WT_MIGRATION=1 —
allowed AND logged. It is location-scoped only; it never unlocks the temp class
(that still needs WEAVE_ALLOW_TMP).
This is the backstop, not the only fix. It sees git worktree add issued
as a Bash tool call. Generators that create worktrees in-process never reach
it — cmuxlayer/src/worktree.ts:240 builds join(homeGitsDir, ${repo}.wt
, spec.name), which is where the 18 dirs actually came from, and is fixed in
the cmuxlayer/golems lanes. Likewise a worktree add nested in a quoted payload
(bash -c "git worktree add …", an agent-spawn prompt string) is one token to
this parser and is not caught.
Full SKILL.md source — includes LLM directives, anti-patterns, and technical instructions stripped from the Overview tab.
Hook-carried skill. The enforcing artifact is hooks/tmp-block-pretooluse.py
(PreToolUse on Write|Edit|NotebookEdit|Bash); this page is its contract and
audit guide.
Why it exists (S04, weave 2026-06-07 Phase-2 Fix-3)
A worker ran Write(/tmp/orqi-tts-answer-msg.md); the era's /tmp guard
half-fired (validated then allowed) — a hook validation error let the
durable write through, and Etan caught it live ("Wait, why are we writing
those things in temp?"). Class evidence: ≥24 /tmp anti-pattern findings,
4 proven costs in 24h (reboot-wiped leak worksheet, a harness fix stranded in
a prunable /tmp worktree, Etan's own terminal burned by a /tmp-worktree-held
branch). Adversary verdict: KEEP, with the deny extended to the
canonicalized path-CLASS and to Bash writes, "else it's one directory
away from useless."
The contract — two-valued (Etan, by voice, 2026-08-17)
"none of y'all would be able to write to temp, but also not ask me so we don't get agent stuck."
Provably outside the temp class -> ALLOW. Everything else -> DENY, with a reason the agent can act on. This hook NEVER emits a PreToolUse prompt.
A prompt suspends the pane until a human answers it, and a headless Codex or
Cursor worker has no human in its pane at all — 2026-08-14/17 lost hours to
~/Documents probes stranding panes overnight. A deny comes back as a readable
error the agent reroutes around by itself, so the residual failure mode is the
recoverable one.
| Surface | Behavior |
|---|---|
Write/Edit/NotebookEdit into the class | DENY + redirect to the repo / docs.local/ |
Bash output redirect (>, >>, &>), incl. heredoc+redirect | DENY |
Bash tee / tee -a into the class | DENY |
git worktree add into the class | DENY + redirect to <repo>/.worktrees/ |
git worktree add outside any .worktrees/ parent | DENY + the exact fixed command (Rule 2) |
git worktree add whose target can't be resolved | DENY, naming the exact resolution failure (golems#676's requirement, minus the prompt) |
| Any target the hook cannot read statically | DENY — rewrite it with a literal path, or a variable whose value has a literal head |
Reads/deletes (ls, cat, grep, rm, worktree list) | NEVER denied |
| Any hook/validation error (incl. unwritable ledger) | DENY — fail CLOSED (the S04 half-fire class) |
CLAUDE_WORKER | does NOT exempt (S04's violator was a worker) |
| The harness session scratchpad | ALLOW — the one sanctioned temp location (below) |
Path-CLASS (canonicalized via realpath): /tmp, /private/tmp,
/var/folders, /private/var/folders, the live $TMPDIR value, and literal
$TMPDIR tokens in Bash commands. The macOS /tmp → /private/tmp symlink is
not a route-around.
The one exception — the harness session scratchpad (2026-08-17)
Claude Code's own system prompt hands every session a scratchpad and instructs
it, verbatim: "IMPORTANT: Always use this scratchpad directory for temporary
files instead of /tmp or other system temp directories". The path it supplies
is inside the class this guard denies:
/private/tmp/claude-<uid>/<repo-slug>/<session-uuid>/scratchpad/…
So the harness said "put temp files here" and the guard said no. Observed live 2026-08-17: brainlayerClaude took two consecutive denials arming a monitor, and skillcreatorClaude hit the same wall writing a test fixture the same day. Every agent walks into it, because they are following instructions correctly — the same failure shape as the "always notify on commits" flood.
Etan's ruling, 2026-08-17: allowlist the harness session scratchpad, keep denying every other temp path. It is session-scoped and nothing durable belongs there, so allowing it costs nothing Rule 1 was protecting.
The exception is structural and narrow — never a hardcoded uid or session id,
and not a widening of the temp path-class. The whole
claude-<uid>/<slug>/<session-uuid>/scratchpad chain must sit directly under a
temp root, and every component is matched exactly: claude-<digits> (not
any directory), a slug, a session UUID, and scratchpad (not scratchpad-evil
or myscratchpad). The #727 review found both of those relaxations surviving
the suite, so all three are now pinned by deny-side tests that redden the
mutation.
It applies to every Rule 1 write surface — Write/Edit, redirects, appends,
tee, heredocs — not to redirects only. Still denied:
/private/tmp/scratchpad/x.txt (no session chain),
/private/tmp/claude-501/x.txt (no scratchpad component), every $(mktemp)
form (a bare temp path with no chain at all), /tmp/foo.txt.
.. traversal is defended by the callers, not by the shape match. The
path is canonicalized before the shape is consulted: in_temp_class
normalizes each candidate (normpath and realpath) and only then asks
whether it is scratchpad-shaped, so …/scratchpad/../../../../leak.txt
arrives as /private/tmp/leak.txt and denies; _literal_prefix_class rejects
a literal .. component outright before building a probe. The normalization
inside the shape check itself is defense-in-depth for a direct caller — the
#727 review's mutation M5 removed it and opened no hole.
scratchpad is its own prefix-class verdict, decided independently of the
repo/outside/temp proofs below.
Rule 2 — the worktree location convention (2026-08-09)
Ratified by Etan by voice, 2026-08-09: the fleet worktree location is the
in-repo <repo>/.worktrees/<name>.
The gap this closes is Etan's own catch, verbatim: "I thought the guard's job
was to guard it, so it actually goes the right way." Rule 1's deny message
advised <repo>/.worktrees/ — but nothing enforced it, so
git worktree add ~/Gits/foo.wt/bar (durable, not temp) sailed straight
through and 18 sibling *.wt directories accumulated under ~/Gits/.
| Resolved target | Decision |
|---|---|
has a .worktrees ancestor component | allow |
| resolves anywhere else | DENY, naming the convention + git -C <repo> worktree add <repo>/.worktrees/<name> |
cannot be resolved ($UNSET, $(…), relative after cd/git -C) | DENY, naming the resolution failure |
Rule 2 silently allows a target whose literal or already-resolvable, repo-anchored
prefix proves .worktrees/ containment before an unresolved variable suffix, such
as git worktree add .worktrees/$wt or $HOME/Gits/golems/.worktrees/$wt. An
unresolvable variable prefix still asks; a literal .. escape still disqualifies
the proof; and lexical plus realpath containment checks still reject a
.worktrees symlink that leaves the repo.
golems#676 applied. git-guardian judged the unexpanded literal — it
blocked a legitimate cleanup of a mktemp -d variable, then blocked the
gh issue create that filed the bug because the title carried the pattern as
prose. So Rule 2: expands ~/$NAME from the live environment and judges the
resolved path (lexical and realpath, so a .worktrees symlink pointing
at a sibling is not a route-around); fires only in command position (quoted
text and heredoc bodies run nothing); and names the exact resolution failure
instead of blocking blind when the target is unknowable. #676's requirement
was the actionable reason, not the prompt — the prompt was dropped on
2026-08-17 and the reason kept.
Migration-window hatch, mirroring Rule 1: WEAVE_ALLOW_WT_MIGRATION=1 —
allowed AND logged. It is location-scoped only; it never unlocks the temp class
(that still needs WEAVE_ALLOW_TMP).
This is the backstop, not the only fix. It sees git worktree add issued
as a Bash tool call. Generators that create worktrees in-process never reach
it — cmuxlayer/src/worktree.ts:240 builds join(homeGitsDir, ${repo}.wt
, spec.name), which is where the 18 dirs actually came from, and is fixed in
the cmuxlayer/golems lanes. Likewise a worktree add nested in a quoted payload
(bash -c "git worktree add …", an agent-spawn prompt string) is one token to
this parser and is not caught.
Escape hatch = bypass-detector seed
Over-broad guards INDUCE route-arounds (adversary Attack 5.4), so genuinely-ephemeral writes have a sanctioned path:
WEAVE_ALLOW_TMP=1 <command> # temp path-class (Rule 1)
WEAVE_ALLOW_WT_MIGRATION=1 <command> # worktree location (Rule 2)Both are inline-prefix or session-exported, and both are scoped to their own Bash simple command when used inline.
Every use is logged to the durable ledger
~/.claude/logs/tmp-block-ledger.jsonl (override: TMP_BLOCK_LEDGER). If the
ledger cannot be written, the bypass is DENIED — an unlogged bypass must not
proceed. Audit the ledger during weaves: entries that look durable
(.md notes, worksheets, worktrees) are route-arounds to flag.
The three class proofs (temp / repo / outside)
A target with an unreadable suffix is still judged by its literal head,
because every value it can take starts with that head. The head proves one of
three classes: temp (deny), repo (allow), or — since 2026-08-17 —
outside, an absolute head that is provably not temp and belongs to no
repository. outside is what makes > ~/Documents/probe_$$.txt allow instead
of stranding a pane; without it the guard could prove only two classes and had
to ask about the entire home directory.
The head is read through variables too: P=~/Documents/x_$$.txt; … > "$P" is
judged exactly like the same path spelled inline, and P=/private/tmp/x_$$.txt
still reaches the hard temp-class deny. A variable with no literal head
(P=$UNSET/x, P=$(mktemp)) proves nothing and is refused. Rule 2 does not
consume outside: a worktree target must still prove repo membership.
Known limits (the detection frontier)
Statically uncaught: inline-interpreter writes (python3 -c "open('/tmp/…')"),
cp/mv/rsync into the class, relative paths from a temp cwd. The guard is
also Claude-only — neither ~/.codex/config.toml nor Cursor has a hook
configured (checked 2026-08-17), so Codex and Cursor workers reach /tmp
freely. Each of these has its own lane.
The literal-head residual (#703, widened 2026-08-17). The suffix a head
proof cannot read could itself contain ../.. and land back in the temp class
(> /durable/head$(printf /../../tmp/leak)). Measured at both refs, the two
halves differ and only one of them predates this change:
| head | 95964ba4 | now |
|---|---|---|
repo-contained — <repo>/docs.local$(…) and <repo>/docs.local/$(…) | allow | allow |
belongs to no repository — ~/Documents$(…), /workspace/golems$(…) | ask | allow |
The repo-contained half is #703's already-accepted residual, unchanged. The
non-repo half is new: outside is a proof that class did not previously
have, and refusing it is precisely the home-directory prompt storm this
contract exists to end. The discriminator is repo-containment of the head,
not whether a / separates it from the substitution — both separator forms
behave identically at both refs. Each half is pinned by name in
tests/test_worktree_anchor.py
(…_predates_this_change, …_is_newly_allowed) so neither becomes a surprise.
The ledger + a periodic temp-dir scan for agent-shaped artifacts are the detectors for this whole frontier — detectors trail the evasion frontier by construction.
Install & evals
- Install:
hooks/INSTALL.md(symlink + settings.json PreToolUse entry). - Evals:
hooks/tests/test_tmp_block.py(280 cases, incl. the verbatim S04 replay) andtests/test_worktree_anchor.py(78 cases, static anchor and literal-head unit coverage) — 358 together. Rule 1: RED against the prior guard stack (15/17 pass-through), GREEN. Rule 2: RED 12 failing / 51 passing against the advise-only guard (every enforcement case exited 0 with{}; the.worktrees/and prose cases were already silent), GREEN. Run both:python3 -m pytest hooks/tests/ tests/ -q.
Changelog entries are derived from eval runs and skill version updates. Full cascading changelog (Phase 4D) coming soon.
- +Initial release to Golems skill library