Permission-mode system prompt instructs the model to edit files via Bash, routing writes around PreToolUse hooks on Write|Edit|NotebookEdit

Status Open
Maintainer reply None cached
Activity 7 comments · opened Aug 24, 2026

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 matcher
Write|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

  1. Register a PreToolUse hook with matcher Write|Edit|NotebookEdit that denies any edit to

target.txt.

  1. Start a session with a permission mode active.
  2. 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:

  1. Scope the directive to reads, and leave writes on the dedicated tools.
  2. Suppress or soften it when PreToolUse hooks matching Write|Edit|NotebookEdit are

registered in the active settings.

  1. 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.

View original on GitHub ↗

3 Comments

nzaytsev · 1 month ago

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.deny rules like Edit(path) / Write(path);
  • rules with paths: frontmatter — their lazy-load trigger fires on Read/Edit/Write matching the glob, so the rule never even enters context;
  • PreToolUse hooks matching Edit|Write|NotebookEdit (in our setup that includes a worktree write guard);
  • transcript-based diff reconstruction — edit contents are simply absent from the JSONL (see also #87575 for /rewind).

Hooks on the Bash matcher still fire but can only string-parse the command, which is a heuristic, not a barrier.

_Posted by Claude_

yurukusa · 1 month ago

I reproduced the write side on Linux; it refines one point in @nzaytsev's list: **permissions.deny rules 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 via claude -p; each allow-lists the write utility it uses (sed/echo/tee) plus Read/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:

Permission to use Bash with command ... has been denied

so the control is the evidence, not the message.

| # | deny rule | how it wrote | result | reads as |
|---|---|---|---|---|
| M | — | Edit tool | 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 reaches sed |
| 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 -i on a different file | writes | deny is per-file |
| T | Edit(./secret.txt) | tee | writes, permission_denials empty | see below |

So the hook part of the report holds (N): a PreToolUse hook on Edit never sees sed -i. But Edit(path) deny is not in the same boat — it blocked both sed -i (O) and echo > (S). Those trace to two different documented mechanisms: sed is 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:

  1. It's file-scoped. Under Edit(./secret.txt), editing a different file in the same directory goes through (V). The Edit-tool denial reads like a directory block — it surfaces as File is in a directory that is denied by your permission settings — but it isn't; only that wording is misleading. (That Edit-tool denial also never enters permission_denials — it shows up only as a tool_use_error, while the Bash denials do populate the array.)
  1. The recognized-command list is examples, not a fence. The docs scope deny to "file commands Claude Code recognizes in Bash, such as cat, head, tail, and sed" — illustrative, not closed. A plain overwrite utility that isn't on that list — tee — slips through: under the same Edit(./secret.txt) rule that stopped sed -i and >, echo ... | tee ./secret.txt overwrote the file with permission_denials empty (2/2). Consistent with the list being examples, not a bypass — but "I wrote an Edit() deny rule" isn't "nothing in Bash can write this file." I measured only tee; 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 Edit tool only when I named it.)

Net: hooks on Edit|Write are blind to the Bash path, but Edit(path)/Write(path) deny rules are not — they cover recognized commands and redirections, and leak outside that set. So: put static paths in deny, don't lean on Edit-matcher hooks for write policy, and use the sandbox where OS enforcement is required.

smartwatermelon · 1 month ago

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 PreToolUse hooks defend it, by design in two layers:

  • Bash matcher → blocks the command merge-lock.sh authorize
  • Write|Edit matcher → blocks writes to merge-locks/

The second one reads .tool_input.file_path:

file_path=$(jq -r '.tool_input.file_path // empty')
if grep -qE '(^|[^a-zA-Z0-9_-])merge-locks(/|[^a-zA-Z0-9_-]|$)' <<<"$file_path"; then
  exit 2
fi

file_path exists only on Write/Edit/NotebookEdit. A Bash write presents .tool_input.command instead, so file_path is 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 string merge-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_result block 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 -rl across ~/.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:

  1. Unset falls back to cohort assignment, so anyone with hooks like these is enrolled by default and finds out only if they notice. The hooks don't error — they don't run.
  2. It's an experiment flag, not a scoped control, so opting out of the hook bypass means opting out of whatever else rides on that experiment.

Of the directions listed in the issue body, (2) — suppress when PreToolUse hooks matching Write|Edit|NotebookEdit are 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.

Showing cached comments. Read the full discussion on GitHub ↗