Claude Code permissions: parse the command, don't match the string
Anthropic made auto mode the default in Claude Code, and the Hacker News thread about it is full of a complaint I recognise:
These models generate the most unreadable bash commands I’ve ever seen. Utilising every single option available and piping the result through multiple layers of regex and junk.
The mental load of having to actually review these commands beyond the most surface level glance is too much. — SchemaLoad
And its companion:
Not just that, the commands also have often slight variations in each new session. They still do the same, but the variations are enough so it isn’t matched by the allowlist any more. — xg15
Anthropic’s answer is an LLM classifier that vets commands before they run.
Their announcement says users approve 97% of permission prompts, and that in
a paid study human testers caught a planted dangerous command 13.6% of the
time while the classifier blocked 89% of them. I believe all of that. A
prompt that is approved 97% of the time carries almost no information, and
nobody carefully reads the fifteenth find-with-eight-flags of the
afternoon.
But before reaching for a second model to judge the first model’s shell commands, it’s worth asking why the prompts are so noisy in the first place. I think the built-in allowlist has the wrong shape: it treats a shell command as a string, matched by prefix. And a shell command isn’t a string, it’s a program.
String matching fails in both directions at once. It’s too strict: git diff
approved once doesn’t cover nice git diff, and any pipe or && glues two
commands into a fresh unique string that no allowlist entry will ever match
again — that’s xg15’s complaint. And it’s too loose: the day you get tired
and approve git * wholesale, you’ve approved git push --force along with
git log.
First attempt: ban the shell tricks
My first fix worked on the model’s side of the problem. I put a rule in my
global CLAUDE.md: no &&, no ;, no pipes — one command per Bash tool
call, temp files instead of pipelines, no command substitution. The reason
was exactly the allowlist mechanics above: separate plain calls reuse
existing approvals; anything chained never matches.
It half worked: the commands got shorter and more readable, and the
allowlist matched more often. But it never worked completely, because a
CLAUDE.md rule is advisory: the model weighs it against its own judgement
and writes a pipeline anyway when a pipeline seems obviously right. And it
was backwards — I was constraining perfectly good shell to fit a matcher
that couldn’t read it, and paying for that with clunkier commands and more
tool-call round trips. The instruction was a prose patch for a structural
problem. I’ve since deleted it, because the real fix makes it unnecessary.
The fix: a hook that parses
Claude Code has a PreToolUse hook: a script that sees every tool call
before the permission system does, and can answer “allow”, “deny”, or stay
silent and let the normal prompt happen. It gets the tool call as JSON on
stdin and answers with JSON on stdout — the whole mechanism is documented
in the hooks reference, and it’s
one of Claude Code’s least-advertised features. Mine is a single Python
file, about 900 lines, standard library only, no second LLM.
The core decision is to parse the command instead of matching it:
-
Tokenise with
shlexin punctuation mode, sogit diff; echo hisplits into tokens rather than producing an unsplittablediff;. If shlex can’t parse it, fall through to the human. -
Split at shell operators (
&&,||,;,|,&) and classify every sub-command independently. The whole call is auto-allowed only if every part is. A pipeline of three safe commands is safe; a pipeline with one unknown command in the middle goes to the prompt. Chaining stops defeating the allowlist, because nothing is matched on the combined string. -
Peel wrappers.
nice,ionice,env VAR=x,timeout 30— these wrap the real command, so the classifier skips over them (consuming their flags) and judges what’s underneath.nice makeismake. -
Judge programs, not prefixes. Tools I’ve decided to trust regardless of arguments (
ls,grep,wc,stat, …) are always allowed. Programs whose safety depends on their arguments get their own small checker. The git one knows thatgit commitis fine butgit commit --amendrewrites history, thatgit add file.cis fine butgit add .violates a house rule, thatpush,resetandrebasealways go to the human:if subcmd in GIT_DANGEROUS_SUBCOMMANDS: # push, reset, rebase return "ask" if subcmd == "commit": return "ask" if "--amend" in args else "allow" if subcmd == "add": if "--all" in args or "-A" in args: return "ask"No string-prefix allowlist can express “commit yes, amend no”. A parser expresses it in two lines.
-
Recurse into substitutions.
echo $(rm -rf /)runsrm -rf /, whateverechothen does with the result. So the inner command of every$(...), backtick and<(...)outside single quotes is classified like any other command, recursively. Heredoc bodies with quoted delimiters (<<'EOF') are literal, so they’re stripped first — which keeps the everydaygit commit -m "$(cat <<'EOF' ...idiom prompt-free. -
Fail towards the human.
sudoalways asks.killalways asks. Anything unparseable or that the scanner can’t follow — subshells, unquoted heredocs, arithmetic expansion, an unknown program anywhere — falls through to the ordinary permission prompt. The hook never auto-allows a command it can’t classify under its rules; the worst case is the status quo: you get asked.
Here is a stripped-down but working version. The real one adds many more programs and checkers, consumes each wrapper’s own flags, parses git’s global flags, recurses into substitutions, logs every decision with its reason, and handles other tools (Read, Edit, MCP calls) — but nothing structurally different:
#!/usr/bin/env python3
"""PreToolUse hook, proof of concept: auto-allow Bash commands that
parse as safe.
Claude Code sends {"tool_name": ..., "tool_input": ...} as JSON on
stdin. Print a permissionDecision to skip the prompt; print nothing to
fall through to the normal permission flow.
"""
import json
import os
import shlex
import sys
ALWAYS_SAFE = {"ls", "cat", "grep", "rg", "head", "tail", "wc", "sort",
"uniq", "find", "diff", "stat", "file", "echo", "which"}
WRAPPERS = {"nice", "ionice", "env", "time", "nohup"}
OPERATORS = {"&&", "||", ";", "|", "&"}
GIT_SAFE = {"status", "diff", "log", "show", "branch", "add", "commit",
"stash", "fetch"}
def classify_git(tokens):
# The real version also parses git's global flags (-C, -c, ...).
sub = next((t for t in tokens[1:] if not t.startswith("-")), None)
if sub == "commit" and "--amend" in tokens:
return "ask"
if sub == "add" and any(t in ("-A", "--all", ".") for t in tokens[2:]):
return "ask"
return "allow" if sub in GIT_SAFE else "ask"
def classify_one(tokens):
# Peel wrappers and VAR=val prefixes to find the real program.
# (The real version also consumes each wrapper's own flags.)
while tokens and (os.path.basename(tokens[0]) in WRAPPERS
or ("=" in tokens[0]
and tokens[0].split("=")[0].isidentifier())):
tokens = tokens[1:]
if not tokens:
return "ask"
prog = os.path.basename(tokens[0])
if prog == "git":
return classify_git(tokens)
return "allow" if prog in ALWAYS_SAFE else "ask"
def classify(command):
lex = shlex.shlex(command, posix=True, punctuation_chars=True)
lex.whitespace_split = True
lex.commenters = ""
try:
tokens = list(lex)
except ValueError: # unparseable: let the human decide
return "ask"
if not tokens:
return "ask"
group = []
for tok in tokens + ["&&"]: # sentinel flushes the last group
if tok in OPERATORS:
if group and classify_one(group) != "allow":
return "ask" # every part must pass
group = []
else:
group.append(tok)
return "allow"
def main():
data = json.load(sys.stdin)
if data.get("tool_name") != "Bash":
return
if classify(data["tool_input"].get("command", "")) == "allow":
json.dump({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"}}, sys.stdout)
if __name__ == "__main__":
main()
To run it yourself: save the script somewhere stable (mine lives at
~/.claude/hooks/pre-tool-use.py) and register it in
~/.claude/settings.json — or in a project’s .claude/settings.json for
per-repo scope:
"hooks": {
"PreToolUse": [{"hooks": [
{"type": "command", "command": "python3 ~/.claude/hooks/pre-tool-use.py"}
]}]
}
Claude Code snapshots hook configuration at session start, so an
already-running session won’t pick it up until restarted; the /hooks
command shows what’s active.
Even this toy version gets the interesting cases right: git diff; echo done is allowed, git push; echo done asks, git add file.c is allowed,
git add . asks, nice grep -r foo src is allowed, and an unterminated
quote or an unknown program falls through to the prompt. It omits the
substitution recursion, though — echo $(rm -rf /) fools it; the real
version classifies the inner command.
What four months of the log says
Every decision is appended to a log file with its reason. That log is how the allowlist grows — when the same command keeps falling through, I add a checker for it, from observed use rather than guesswork — and it’s where the following numbers come from.
Since mid-April the hook has classified 32,381 Bash calls. It auto-allowed 23,172 of them (72%) and denied 658 (2%); the remaining 26% fell through to the normal permission flow, where Claude Code’s own allowlist catches a further chunk before a prompt ever reaches me. The two mechanisms compose: the hook handles the structural cases, the built-in allowlist remembers the specific one-off approvals.
Those 23,172 auto-allows are prompts I didn’t see and didn’t rubber-stamp.
That’s the point. The prompts I do see now are sudo, git push, rm -rf, package installs, and commands weird enough that a parser
refused to guess. Those I actually read.
A hook can teach, not just gate
The deny verdict turned out to be the most interesting one, because the
model reads the denial reason and reacts to it. Those 658 denials are almost
all one rule that has nothing to do with safety:
# Reject piping to head/tail without tee — it silently discards data.
return _block("Use '| tee <file> | head/tail' instead of bare "
"'| head/tail' to avoid discarding data. "
"Append '# no-tee' to override.")
build 2>&1 | tail -20 throws away everything but the last twenty lines,
and half an hour later the model re-runs an expensive command to see output
it already had. The hook rejects the call with that message; the model reads
it, rewrites the command with tee, and the full output lands in a file.
There’s an escape hatch (# no-tee) for the cases where discarding really
is fine. It is a style rule enforced mechanically: a CLAUDE.md
instruction saying the same thing gets weighed against the model’s
judgement; a deny doesn’t.
What this is not
The hook is not a security boundary, and I want to be precise about the threat model, because the HN thread mixes two different problems.
My hook defends against a well-meaning model’s accidents: the reflexive
git push, the confident rm -rf, the history rewrite that seemed like a
good idea. It does nothing against a malicious or prompt-injected model.
python, perl, curl and ssh are on my always-allow list because I use
them constantly — and any one of them is arbitrary code execution or network
egress. Writing this post also found two real holes. cat .env sailed
straight through even though the Read tool is denied .env; the hook now
asks when any argument looks like a secret path (a .env, .pem or
.key file, or anything under .ssh or .aws) — a net for accidents,
not a boundary; the principled fix for secrets is a sandbox that never
mounts them. Worse, echo $(rm -rf /) was auto-allowed: the substitution
hid inside an argument token of a safe program. How that surfaced is the
addendum at the end of this post. Redirections are still not gated: echo x > important-file truncates
whatever it likes. Another HN commenter (kingstnap) put the general
version well: you can’t regex your way around python <<PY; whether the
Python is safe is not a question about the command line.
If your threat model includes the model actively working against you, the people in that thread running bubblewrap, podman, devcontainers or throwaway VMs have the real answer: constrain the process, not the prompt stream. I do that too where it matters — reproducers never run against the host, agents that install packages get a container. The hook and the sandbox solve different problems, and you can run both.
An open problem: judgement calls
Between the two clean cases — commands a parser can classify, and malice only a sandbox can contain — sits a third class I don’t have a good answer for: actions whose safety is a judgement call about content and context, not about command shape.
Outbound messages are the canonical example. Back when I was on Slack, I
wanted Claude to be able to send some messages — posting a build result to
my own channel is fine — but not others, and “which others” is not a
predicate I can write in Python. A parser can see that a call sends a
message; it cannot see that this particular message is a bad idea. And
unlike nearly everything else in this post, a sent message is unrecoverable:
I have backups against rm and the reflog against git reset, but there
is no reflog for a dumb message to your boss. The actions where a structural check helps
least are exactly the ones you can’t undo.
I’m off Slack now, but the same class is live in my setup today: gh is on
my always-allow list because I read PRs and issues with it constantly — and
the same binary posts comments. As far as the hook can see, gh pr diff
and gh pr comment have the same shape; only one of them publishes.
My actual solution is instructions to Claude — show me anything destined for GitHub before posting it — which is the same advisory patch I spent the first half of this post retiring, and I know it. The musing I keep returning to: the hook is just a program, so nothing stops it shelling out to another model for exactly this residue. Not the model that proposed the action — asking the proposer to judge its own proposal buys you correlated failures — but a different family, codex or DeepSeek, with a narrow question: here is the message and its context; should a human see this first?
This shape isn’t hypothetical. One commenter in the thread (prtmnth) ran it before auto mode existed: a script that ran before every permission request, asked Haiku for a safe/unsafe verdict, and logged the answers. Auto mode is the built-in version of the same idea, and its classifier is now free. What I’d want is the layered combination: the parser keeps answering the structural majority deterministically, and a classifier only ever sees the fall-through band — the judgement calls and the genuinely weird — with a human prompt when the classifier is negative or unsure. The cross-family version appeals to me over a same-family one because two families are less likely to fail the same way, and because the verdicts would land in the same log as every other decision, where I can audit them. I haven’t built it. For now the honest description of my outbound-message policy is: instructions, plus paying attention.
What’s left
What the hook solves is the problem the 97% number describes: a prompt stream so dense with obviously-fine commands that approval becomes a reflex, at which point the prompts protect nothing. Anthropic’s fix is a classifier model watching the command stream. Mine is a parser — deterministic, auditable via the log, as fast as any small local script (milliseconds, against a server round trip for a classifier), and wrong in ways I can read in the source rather than ways I have to discover empirically. For the residue that neither structure nor allowlist covers, I’m still in the loop — and with so much less noise, I’m actually reading.
Addendum: reviewing the post fixed the hook
The substitution recursion in point 5 exists because this post was
reviewed, not because I knew to write it. Before publishing, I sent the
draft, the hook’s source, and the HN thread to two other model families —
codex and DeepSeek — with instructions to check the post’s claims against
the code. Codex refuted one directly: the draft claimed anything
syntactically odd falls through to the human, and codex pointed out that
process substitution doesn’t — cat <(id) tokenises with cat in front,
and the hook judged the leading program only. Measuring that claim against
the live hook confirmed it, and turned up the wider hole: echo $(rm -rf /) and backticks were auto-allowed too, and had been for the
whole four months the log covers.
Nothing went wrong in those four months, and the log shows why: the ~300
substitution commands actually allowed were all benign — $(date ...)
timestamps and heredoc commit messages — because the model writing them
was well-meaning. Which is exactly the threat model, and exactly why the
hole stayed invisible: an accident-catching net with a gap in it looks
identical to one without, right up until the accident.
So the fix went in before the post did. Substitutions now classify their inner command recursively, and replaying the four months of allowed commands through the new code prices the change at roughly one extra prompt every three days. The draft claimed the hook fails towards the human; reviewing the draft is what made that claim true.