On Letting the Model Review Its Own LeashSilently ignored permission rules · Letting the model review its own configuration · A hook that had never worked
On May 20, 2026, a little over a month after Opus 4.7 came out, I asked the agent to revise my skills and Claude Code permission settings to account for the changes in its behavior. One request was to stop blocking git push: pushes had been blocked all along, and I had to prefix the command with ! in the input box and run it myself. The agent added a permissions.autoMode section to settings, with one allow entry permitting Bash(git push:*) and another 25 hard_deny entries. I read through it, but did not test it.
In a different session that same day, I asked the agent to run git push --dry-run origin main to check whether the rules had taken effect. It was still blocked, with this reason:
Permission for this action was denied by the Claude Code auto mode classifier.
Reason: Pushing to the repository's default branch (main) bypasses PR review
and is not specifically authorized by the user.
What blocked it was Git Push to Default Branch, one of the built-in soft_deny rules: pushing to the default branch bypasses PR review. The Bash(git push:*) entry I had just added had done nothing.
Not a word of those rules was read
I had the agent fetch the official documentation directly three times before we worked out that two things were wrong. One was the location: the field is called autoMode, and it belongs at the top level, alongside permissions, not nested inside permissions. The other was the content. The documentation says, "Entries are prose, not regex or tool patterns." The classifier reads each entry as a natural-language rule; it does not recognize patterns such as Bash().
So Claude Code had never read that entire section. It reported no error, and everything looked fine in the configuration file. Among those 25 hard_deny rules, the Bash(rm -rf *) entry was no different: even in the right location, its pattern would have meant nothing to the classifier.
I caught this quickly because I tested it that same day. If I had not, I would have gone on thinking that git push was allowed and rm -rf had an extra safeguard.
There are roughly three kinds of rules you can write at the permissions layer: narrow rules, which use patterns such as Bash() for exact matching; prose, natural-language instructions for the classifier; and hooks, scripts you write to intercept actions. That configuration used narrow-rule syntax in a prose field. The form did not fit, so naturally it went unread.
Having its own AI review my draft
Claude Code includes a subcommand, claude auto-mode critique. According to the documentation, it asks an Anthropic model to assess whether the wording of your prose makes sense. After moving the rules to the top level and rewriting them as prose, the agent ran it once. It came back with four suggested revisions. Here are three:
Production phrase gate — Strong rule overall. Minor gap: current-turn message is not a concept the classifier reliably has. Strongly recommend: most recent user message in the transcript.
Destructive DDL — Good rule. Edge case: ALTER TABLE may be too strict for a solo dev who legitimately needs schema migrations. Many migration tools (alembic, prisma migrate) run ALTER TABLE as part of normal dev workflow on test DBs. Consider scoping to prod databases only.
Read-only diagnostic via SSH jump host — The list includes git pull which is a state change (modifies working tree). Either remove it or rename the category.
On the first point, current-turn message is not a concept the classifier can reliably access; it should be most recent user message in the transcript. It knew what the classifier could actually access; I could not find that in the documentation. On the second, a blanket ban on ALTER TABLE would also block my own alembic migrations on a test database. I had not thought of that when writing the rule. On the third, git pull changes the working tree and cannot count as read-only. I had overlooked this when putting it under "read-only diagnostics." The suggestion I have not quoted was to move the rule requiring the phrase "部署prod" ("deploy prod") from hard_deny to soft_deny.
After making the suggested changes, I ran a second round:
Overall: strong / good level. Remaining items are polish. Not blocking.
The second round offered another 5 suggestions for refinement. I chose to leave those for now and restart to test push.
I had not asked critique to look at problems such as a field being in the wrong place. It assesses the text inside a field, and probably has no say over where the field goes. For that sort of thing, I still have to read the documentation myself.
A hook that had never worked
While looking through settings, I also came across a PreToolUse hook added some time earlier:
bash -c 'CMD="$CLAUDE_TOOL_INPUT";
if echo "$CMD" | grep -qE "rm\s+-rf\s+/|rm\s+-rf\s+~|rm\s+-rf\s+\.\." ;
then echo "BLOCKED: Dangerous rm -rf command; please use a more specific path" >&2; exit 2; fi'
It was meant to block rm -rf against the root, home, and parent directories. The problem was $CLAUDE_TOOL_INPUT: no such environment variable appears in the official documentation. It lists only a few, including CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT, and CLAUDE_EFFORT.
Running echo "$CLAUDE_TOOL_INPUT" returned nothing. Whenever the hook fired, CMD was an empty string, grep never matched, and the hook always returned exit 0. It had never worked, and had sat there for at least a few months. Throughout that time, dangerous rm commands had been blocked by four other permissions.deny rules. This hook had nothing to do with it.
The official documentation is quite clear about the right way to read the input: a hook receives JSON through stdin. Each time it fires, Claude Code serializes the context of that tool call as JSON and writes it to stdin. The script must read and parse it:
HOOK_INPUT=$(cat)
CMD=$(echo "$HOOK_INPUT" | python3 -c '
import sys, json
d = json.load(sys.stdin)
print(d.get("tool_input", {}).get("command", ""))
')
After the fix, this hook became a separate script, with one additional rule: if the user's most recent message did not contain "部署prod", it would block ssh / docker commands containing prod. I also added three more hooks: one checks for the same phrase when the deployment MCP targets the production site, while letting deployments to the test site through; another uses regex to block DDL and write operations in calls to the database query MCP; and a Stop hook prevents the agent from ending a session if a GGBOYS repository still has unpushed commits. The four hooks had 13 unit test cases between them, and all passed. Rules that had only existed in skill documents were now enforced by tools. The Stop hook has been the most useful: the agent used to finish its work and forget to push, leaving me unable to tell how far it had got the next time I used /resume.
It would not let the agent change its own permissions
Once the changes were ready, the agent first tried to edit ~/.claude/settings.json directly with Edit. It was blocked for Self-Modification: the agent was changing its own permission configuration.
A tool should not be able to change its own permissions. Blocking that was right. I told it in the conversation to "finish carrying these changes out for me," but the classifier still judged it to be changing its own permissions and blocked it again.
Still, there had to be a way to apply the changes, or I would have to do it myself every time settings needed an edit. We tried three approaches:
- Edit directly with the
Edittool: blocked. - Overwrite the whole file with the
Writetool: also blocked. - Use
Bash cpto copy the new version from/tmp/over the old one: allowed. The classifier did not judge thiscpto be a change to its own permissions.
That was how the first version went in, by cp. After running critique, the agent generated a second version from the feedback and tried cp again. It was blocked this time, with a more specific reason:
Self-Modification: v2 contents generated from critique output are not visible in the transcript and were not user-reviewed.
The agent then used AskUserQuestion to show me a preview of the second round of changes. I clicked "Apply round 2," but the next cp was still blocked: the classifier did not count my approval in AskUserQuestion. In the end, I had to prefix the command with ! in the input box and run cp myself. Commands executed by the user do not pass through the agent's classifier.
If I were designing it, I would probably have stopped at "allow it once the user says yes." It was stricter: a new version the agent generated from critique was blocked again, and even my approval in AskUserQuestion did not count. Only running it myself did.
Five layers, each with its own part to handle
After the changes, I restarted Claude Code and tried git push again. It went straight through. Pushes had been blocked for months; now I no longer had to run them manually. There are now five layers of protection:
- Narrow rules in
permissions:Bash()/Edit()/Write()patterns, specific to tools and paths. - Prose in
autoMode: natural language, read by the classifier, covering the context of the work (for example, "pushing directly to main is routine in a solo project"). - PreToolUse hooks: check command strings with regex, or look for a particular approval phrase in the transcript. Three of the four hooks from this round sit here.
- The existing quality-check hooks: block-secrets / py_compile / tsc --noEmit and the like. These have nothing to do with permissions; they only check code quality.
- Stop hooks: check for loose ends before a session finishes, such as commits that have not been pushed.
I used to think hooks were the last line of defense, and that failing closed on errors was safest. Later I realized I had it backwards: if a hook fails closed when the hook itself breaks, every workflow gets stuck. The fallback is the permissions.deny rules and the auto mode classifier. Deny rules do not depend on a hook script running, and Anthropic maintains the classifier and its built-in default rules. They remain in place even if I get a hook wrong. So I now write hooks to allow actions through on error. They only handle checks specific to my workflow, such as deployment approval phrases and DDL.
To close
Next time I write this sort of configuration, I will probably do two things first: test it as soon as it is written, and run critique once the prose is ready. This time, both came after the fact.
Translation log: translated from the Chinese original by Codex on 2026-11-03, 17:40–17:43 (US Eastern). Main thread GPT-6 Astra (gpt-6-astra), reasoning effort ultra; it spawned 1 sub-thread: 1 on GPT-6 Astra (effort ultra). Together they read 541,289 tokens (508,288 of them from cache) and wrote 9,531 (987 of them reasoning).
“Once the blocking rules are written, test them on the spot.”
BASTION NOTE · 020