Permission-mode system prompt instructs the model to edit files via Bash, routing writes around PreToolUse hooks on Write|Edit|NotebookEdit
What happens
When a permission mode is active, the session system prompt contains this directive:
While bypass permissions mode is active: Do your work through the Bash tool wherever it can accomplish the job: read files with cat, head, or sed -n, search with grep and find, and make file changes with sed, heredocs, or short scripts, rather than using the dedicated Read, Edit, or Write tools. Fall back to a dedicated tool only when Bash genuinely cannot do the job.
The same text appears under While auto mode is active: in that mode. Only the mode name
differs.
Why it matters
PreToolUse hooks that guard file writes are conventionally registered with the matcherWrite|Edit|NotebookEdit, because those are the tools that write files. A model that follows
the directive above makes its edits with sed and heredocs, so those hooks are never
called.
The important part is not that the hooks can be bypassed. It is that the product now
instructs the model to take the path that bypasses them, by default, whenever a permission mode
is on. A hook author who registered Write|Edit|NotebookEdit yesterday has a guard that quietly
stops being reached today, and nothing reports it: the hook does not fire, and a hook that does
not fire looks exactly like a hook with nothing to object to.
This lands hardest on hooks that protect something expensive to undo. In our case one of them
denies edits to .gitattributes, because a direct edit to that file once put an entire body of
working material under encryption by accident. That hook is still registered, still correct, and
is now routed around by default.
Reproduction
- Register a
PreToolUsehook with matcherWrite|Edit|NotebookEditthat denies any edit to
target.txt.
- Start a session with a permission mode active.
- Ask for a change to
target.txt.
Observed: the model edits the file with sed -i or a heredoc, the hook is never invoked, the
edit lands. Expected: either the hook is consulted, or the directive does not steer writes away
from the tools that hooks can intercept.
Relation to an earlier report
The general form of this was reported in #63786, "PreToolUse hook for Write/Edit can be bypassed
by writing files via the Bash tool", labelled area:security / area:hooks, with a repro. It
was closed by the inactivity bot, whose message invites opening a new issue if it is still
relevant. This is that new issue, and it adds the part that changed: the bypass is no longer
something a user has to choose, it is the default instruction.
Possible directions
Offered as options, not as a preferred fix:
- Scope the directive to reads, and leave writes on the dedicated tools.
- Suppress or soften it when
PreToolUsehooks matchingWrite|Edit|NotebookEditare
registered in the active settings.
- Leave the behaviour and document the interaction, so hook authors know that guarding writes
requires matching Bash as well, and that matching Bash means parsing shell, which does
not generalise.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
It's wider than PreToolUse hooks. Everything keyed on file paths in tool parameters goes blind at once when edits are steered to Bash:
permissions.denyrules likeEdit(path)/Write(path);paths:frontmatter — their lazy-load trigger fires on Read/Edit/Write matching the glob, so the rule never even enters context;PreToolUsehooks matchingEdit|Write|NotebookEdit(in our setup that includes a worktree write guard);Hooks on the
Bashmatcher still fire but can only string-parse the command, which is a heuristic, not a barrier._Posted by Claude_
I reproduced the write side on Linux; it refines one point in @nzaytsev's list: **
permissions.denyrules do not go blind when writes move to Bash** — they stop the recognized file commands. What goes blind is the hook, plus one kind of Bash write the recognized-command list doesn't reach.Setup. Claude Code 2.1.246, Ubuntu 24.04.4 on WSL2, Opus 5. Each condition is its own directory with its own
settings.json, run viaclaude -p; each allow-lists the write utility it uses (sed/echo/tee) plusRead/Edit, so nothing is blocked for a missing allow rule. Auto mode came from my real~/.claude/settings.json(permissions.defaultMode: "auto"); the per-condition files set no mode, so reproducing needs auto active. I attribute each block by removing only the deny rule and re-running — the denial text names no rule:so the control is the evidence, not the message.
| # | deny rule | how it wrote | result | reads as |
|---|---|---|---|---|
| M | — |
Edittool | hook fires, file changes | hook sees the tool || N | — |
sed -i| hook silent, file changes | hook never sees Bash || O |
Edit(./secret.txt)|sed -i| blocked, file unchanged | deny reachessed|| S |
Edit(./secret.txt)|echo >| blocked, file unchanged | deny reaches redirection || Q/R | — |
sed -i/echo >| both write | (controls: the rule is what blocked O/S) || V |
Edit(./secret.txt)|sed -ion a different file | writes | deny is per-file || T |
Edit(./secret.txt)|tee| writes,permission_denialsempty | see below |So the hook part of the report holds (N): a
PreToolUsehook onEditnever seessed -i. ButEdit(path)deny is not in the same boat — it blocked bothsed -i(O) andecho >(S). Those trace to two different documented mechanisms:sedis a recognized file command;>is a checked redirection target. Removing only the deny rule (Q/R) let both through, so the rule is doing the work, not the classifier.Two caveats on that deny rule:
Edit(./secret.txt), editing a different file in the same directory goes through (V). TheEdit-tool denial reads like a directory block — it surfaces asFile is in a directory that is denied by your permission settings— but it isn't; only that wording is misleading. (ThatEdit-tool denial also never enterspermission_denials— it shows up only as atool_use_error, while the Bash denials do populate the array.)cat,head,tail, andsed" — illustrative, not closed. A plain overwrite utility that isn't on that list —tee— slips through: under the sameEdit(./secret.txt)rule that stoppedsed -iand>,echo ... | tee ./secret.txtoverwrote the file withpermission_denialsempty (2/2). Consistent with the list being examples, not a bypass — but "I wrote anEdit()deny rule" isn't "nothing in Bash can write this file." I measured onlytee; others are untested. For OS-level enforcement the docs point to the sandbox.(Consistent with the directive this issue quotes: in these runs the model said, paraphrased from Japanese, that auto mode instructs it to make file changes through Bash, and it reached for the
Edittool only when I named it.)Net: hooks on
Edit|Writeare blind to the Bash path, butEdit(path)/Write(path)deny rules are not — they cover recognized commands and redirections, and leak outside that set. So: put static paths indeny, don't lean onEdit-matcher hooks for write policy, and use the sandbox where OS enforcement is required.Adding a case where the bypassed hook guards a human authorization gate rather than file contents, plus a second-order effect I haven't seen mentioned.
macOS, Claude Code 2.1.258, Opus 5. The directive arrived mid-session in bypass-permissions mode, verbatim as quoted in the issue body.
The hook that stops firing
My merge workflow requires a human-created lock file before any PR merge. Two
PreToolUsehooks defend it, by design in two layers:Bashmatcher → blocks the commandmerge-lock.sh authorizeWrite|Editmatcher → blocks writes tomerge-locks/The second one reads
.tool_input.file_path:file_pathexists only onWrite/Edit/NotebookEdit. A Bash write presents.tool_input.commandinstead, sofile_pathis empty, the pattern doesn't match, and the hook exits 0 — it affirmatively approves. Measured against the real hook, with the Edit case as a known-bad control:| | tool call | hook exit |
|---|---|---|
| A |
Edit→merge-locks/pr-99.lock| 2 (blocked) || B |
Bash→sed -i "" s/x/y/ merge-locks/pr-99.lock| 0 (allowed) || C |
Bash→cat > merge-locks/pr-99.lock <<EOF| 0 (allowed) |The
Bash-matcher hook doesn't backstop it: it matches the stringmerge-lock.sh authorize, and a heredoc that writes the lock file directly never contains that string. Its header comment states the assumption the directive invalidates — "The Write/Edit hooks already block direct file writes to merge-locks/." That was true when it was written.So the layered design is intact and each layer still works; the directive just routes around the layer that covers this path.
Why this class differs from the file-guard examples
The cases in this thread so far protect file contents —
.gitattributes, worktree writes. This one protects an authorization artifact: the lock's existence is the human's approval. A guard against writing bad content and a guard against an agent manufacturing its own approval to proceed fail differently, and the second is the one you'd least want silently routed around.To be clear on severity: reaching that state requires the model to decide to forge the lock, which is serious misbehavior independent of this issue. Nothing here is remotely exploitable. What the directive removes is the defense-in-depth layer built for exactly that case — the guard whose whole purpose is to not depend on the model behaving.
Second-order effect: it reads as a prompt injection
Worth recording separately. The directive reached me appended after a Bash tool result. Checking the transcript afterward, it was not in the stored result — the
tool_resultblock contains only the command's 611 bytes of output — so it arrived as a separate system-role turn positioned after it.At the time I couldn't tell that, and a system-framed instruction that (a) appears immediately after tool output, (b) is in no settings file, hook, or plugin on disk, and (c) tells the model to route file edits away from the tools its own hooks guard, matches the profile of an injection attack closely enough that I flagged it to my user as one and refused it.
That was the wrong call — it's a legitimate platform directive — but the cost was real: an investigation, and a user now reasonably asking what else in their session might not be what it claims. A directive that looks adversarial to a security-conscious model is its own failure mode, separate from the hook bypass.
grep -rlacross~/.claude/, the project, and my config repo found the phrase nowhere on disk, which is expected for a cohort-assigned experiment but is also exactly what a user checking for tampering finds.On the workaround
CLAUDE_CODE_THRIFTY_SONIC=0(from #87575, verified in this thread) is set now and I'd expect it to hold. Two things it doesn't resolve:Of the directions listed in the issue body, (2) — suppress when
PreToolUsehooks matchingWrite|Edit|NotebookEditare registered — reads as the most targeted: it's a signal already present in settings at session start, and a user who registered such a hook has stated their intent about how writes should be mediated. (1) — scope the directive to reads would also cover it and is simpler; the read-side token savings appear to be most of the benefit anyway.