claude-hooker
The mystery of claude code over the past year and a half has been how does a world leading ‘AI productization lab’ continually generate such shitty amatuer globally distributed software and services over and over again? It’s almost like staffing a company entirely by 24 year olds seeing their net worth increase by $2 million per month for two years is a bad idea?
Even the tech brain infosphere has noticed how claude code’s architecture is absolutely mornoic and amatuer and anthropic just continually ego-sociopath lies and nobody can stop them because hey, when you spend over $2 billion a month renting GPUs run by globally polluting gas turbines, they let you do it. don’t worry, bro, “it’s a javascript game engine in your console, ur not smaht enouf to understand our god-ai logic here!!!”
as you may be able to tell, i’m deploying this post in annoyance/anger mode because I don’t understand why me, somebody who can’t afford a place to live and has 20 years of detailed technical experience, is giving free work to trillion dollar “ai” “startups” with good models but apparently abysmally amatuer software engineering practices. i guess it’s part of industry bubble syndrome.
It’s not really worth thinking about what drives weird un-self-aware ego-sociopathic-autistic billionaires if you aren’t close enough to change anything in their minds though.
The Bad
i guess back to the point: given they have made claude so same-system dangerous with its cavalier attitude towards running destructive or system-resource-consuming actions you must install your own safeguards using claude code event hooks.
You have seen event hooks in the common case of “do you approve this
command” settings. In every claude code session you should now deny a
minimum of pgrep globally because I got tired of having
claude code kill either its own terminal sessions or other concurrent
programs through its default “non-awareness of global state” rampant
“run-and-gun system-level-destruction-commands” admin/operational
criteria they have amatuerly RL’d into the model itself.
But just blocking pkill can take different forms. The
simplest form they gave us for compensating controls against the latest
brain damage they trained into claude models:
"deny": [
"Bash(pkill:*)"
]Except, when claude trips over the settings.json “deny” switch, the
only feedback is “command denied” and claude isn’t given any
reasons as to why an action is not allowed here. Also,
the default (naive/dumb) Bash() pattern thing they have
doesn’t detect the string in different command invocation attempts (more
on this later, which is the entire point of this new project).
But they have another mechanism where you can attach a “PreToolUse” hook which runs a command which gets the input as JSON which can then evaluate the command and allow/deny WHILE providing written reasons/feedback along with the rejection as well.
$ cat .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "block_pkill.sh"
}
]
}
]
}
}#!/usr/bin/env bash
# PreToolUse(Bash) hook: BLOCK any command whose text contains `pkill`.
#
# Rationale: `pkill -f <pattern>` has twice matched the agent's OWN command
# line and killed its shell (exit 137/143). A prefix permission rule like
# `Bash(pkill:*)` cannot catch `pkill` nested inside quoted / exec / bash -lc
# substrings, so we inspect the full command text here.
#
# Reads hook JSON on stdin, extracts .tool_input.command, and exits:
# 2 -> block the tool call (stderr is fed back to the agent)
# 0 -> allow
#
# We deliberately do NOT block `kill` (explicit-PID kill must work) or
# `pgrep` (read-only). Match the `pkill` word specifically.
set -u
# Extract the command text. Prefer jq; fall back to python3 if jq is absent.
if command -v jq >/dev/null 2>&1; then
cmd="$(jq -r '.tool_input.command // empty')"
else
cmd="$(python3 -c 'import sys,json; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))' 2>/dev/null)"
fi
# Empty command -> nothing to block.
if [ -z "$cmd" ]; then
exit 0
fi
# Word-boundary match for `pkill` anywhere in the command text. The character
# classes ensure we match `pkill` as its own word (so `mypkillx` won't trip it)
# while still catching it inside quotes / after `bash -lc` / `exec`, e.g.
# poetry run python x.py exec bash -lc "...pkill -f foo..."
if printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]_])pkill([^[:alnum:]_]|$)'; then
echo "BLOCKED: \`pkill\` is forbidden (self-match risk -- it has killed the agent's own shell). Kill by explicit PID instead: \`kill -9 <pid>\`; for liveness probes use the \`[p]attern\` bracket-trick with \`ps\`/\`grep\`." >&2
exit 2
fi
exit 0Another one I started using to also stop all the goddam piece of shit “programs in strings” anthropic, again, somehow brain-dead amatuer engineering RL’d claude into doing millions of times per day globally:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/scripts/guard-no-stdin-python.sh\"",
"statusMessage": "Checking for throwaway/stdin python…"
}
]
}
]
}
}#!/usr/bin/env bash
# PreToolUse(Bash) guard — FORBIDS inline/throwaway/stdin python so all code lives in reusable files.
# Blocks: python -c "..." , python - <<EOF ... , python - (stdin) , any heredoc piped to python.
# Allows: uv run python file.py , .venv/bin/python file.py , python -m module , pytest, neotex CLI, etc.
# Exit 2 => the harness blocks the tool call and shows the message to the model.
#
# NOTE: this guard does NOT itself use `python -c` (it used to — removed). It extracts the command with
# `jq` when available and otherwise scans the raw payload; both paths catch the forbidden patterns.
payload="$(cat)"
# Prefer the parsed command field (avoids false positives from unrelated text); fall back to raw payload.
if command -v jq >/dev/null 2>&1; then
cmd="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null)"
else
cmd=""
fi
[ -n "$cmd" ] || cmd="$payload"
blocked=0
# `python -c` / `python3 -c` / `python3.14 -c` (the inline-code flag) — the main offender.
if printf '%s' "$cmd" | grep -Eq 'python[0-9.]*[[:space:]]+-c([[:space:]]|$|"|'\'')'; then
blocked=1
fi
# `python -` (read program from stdin), incl. `python - <<EOF`.
if printf '%s' "$cmd" | grep -Eq 'python[0-9.]*[[:space:]]+-([[:space:]]|$|<)'; then
blocked=1
fi
# any heredoc feeding a python interpreter.
if printf '%s' "$cmd" | grep -q '<<' && printf '%s' "$cmd" | grep -Eq 'python[0-9.]*'; then
blocked=1
fi
if [ "$blocked" -eq 1 ]; then
echo "BLOCKED: inline/throwaway python (python -c, python -, heredoc->python) is FORBIDDEN." >&2
echo "Put the code in a reusable file (src/neotex/... a @demo, a test, or scripts/*.py) and run it via" >&2
echo "the harness: 'uv run neotex demo --name ...' / 'uv run pytest ...'." >&2
exit 2
fi
exit 0The Good
So, as you may see if you have good taste, trying to grow or maintain these json-parsed-in-shell-scripts-with-regex-matching is both fragile and unmaintainable in the long term (also composing them or building different systems out of them or dragging them across different setups is annoying too).
Sidepoint: it’s 2026? please stop writting fucking “shell” “scripts.” Why are the “god intelligence coding agents” so convinced they must use 50 year old dangerous impossible to grow and refactor and debug “shell” “scripts” for doing ANYTHING AT ALL? absolute abysmal management of the coding and architecture and operational and experience-based RL practices for software development polluting millions of codebases every month for no reason just because the “trillion dollar ai labs” only allow people with two years of self taught experience who don’t understand historical-vs-modern software engineering to be hired. What about hiring people who have long term operational and infrastructure and growth and “good software taste” and organizational and expansive capability growth and complexity management experience? nah, sorry, you didn’t say “kubernetes ingress pod splat silly daemonset rancher node taint deez quotas” when our self-taught 23 year old “infrastructure manager” interviewed you, so you don’t deserve to join any early-in exponential comp growth ladders. joke of an industry.
Enter: claude-hooker
a fast (zig runtime + json config spec) claude command and hook guarder
system.
Available for a discount for a limited time only at https://github.com/mattsta/claude-hooker get your Claude Hooke today!
Benefits of claude-hooker over the
naive/amauter/teenage-level-engineering claude has been trained to
perform by default:
- combinational command guards (you can specify no
rm -rfand it can also catchrm -frandrm -r -fandrm -f -rand other “workarounds” (routing throughxargstoo etc) (via combinational up-front generation:12 rules, 487 cases (97 literal + 390 generated)) - intelligent directory guards (no root, no homedir access; useful for
blocking claude’s recent fucking childish pattern of aggressively
find / -name file-i-want.txtwhen i have 100 million inodes on my system and across mounted network filesystems and everything else under the sun) - blocking heredoc/program-in-strings for any programs you want to specify
- intelligent parsing of shell lexing so you can detect commands vs quoted commands vs blocked-words-but-in-strings-which-aren’t-commands
- reasonably intelligent shell expansion workaround guards (me: “NEVER
RUN
pkillEVER AGAIN!” later, claude:P=pki;K=ll;$P$K) - well-defined block message reasons so agents see why they should use different approaches instead of just “denied”
- and much much more including auditing/observabilty/logging/stats
also, please do not in any way say the following to me:
- but UM ACKSHWUALLYYYY U CANNOT PREVENT ALL BAD USE BECAUSE THE MODEL CAN WRITE PROGRAMS AND RUN PROGRAMS & U R NOT READING ALL PROGRAMZ!!! – uh, sorry, but that IS NOT THE POINT of this project (but did you even look to see we do have alerts and observabilty for “claude wrote a file with a forbidden command inside of it” you can also monitor or block too at various levels). This project is to stop models from doing “bad-engineering-because-they-are-RL’d-by-amatuer-software-interns” against your own better engineering practices. If claude aggressively wants to base 64 encode commands split across 8 variable shards then encrypt them with a symmetric key then at runtime reverse the actions to execute the command in hidden nested control scripts, that isn’t the condition we are guarding against here, so thanks for your input, i’m sure you are very very very smart.
- UM ACKAHHHHHHHHHHHHHHUALLYYYYYYYY DETECTING BAD COMMANDS IS EQUIVALENT TO THE HALTING PROBLEMS AND U CANNOT DU THAT!!! – i’m sorry, do you have brain damage? that is also not the point of this project. The project is not a security boundary it is a “smack the model’s hands from doing unintentionally bad things due to its rote poorly-experienced default-reflexive-amatuer-operational-practices.”
Some Stats
$ ./hookctl stats
log : /Users/matt/.claude/hook-gate-log.jsonl
rule total deny ask allow shadow bypassed last hit
no-inline-python 44 44 0 0 0 0 55m ago
wrapper-script-shadow 7 0 0 0 7 0 3m ago
watch-unresolved-command-word 5 0 0 0 5 0 1h ago
no-pkill 3 3 0 0 0 0 2d ago
ask-whole-world-traversal 1 0 1 0 0 0 2d ago
deny-recursive-mutation-from-anchor 1 1 0 0 0 0 2d ago
no-git-add-all 1 1 0 0 0 0 1d ago
protect-hook-config 1 1 0 0 0 0 2d ago
63 line(s) countedGetting Started Wizard
You can run a ten step rule wizard generator so you don’t have to interact with all 800 config options at once (or just send your agent into the docs and code to write rules for you):
$ ./hookctl rules new
== author a rule ==
Enter accepts the [default]; Ctrl-D aborts and writes nothing. The
RULES_COOKBOOK is the long form of everything asked here.
Which hook event does this rule read? `./hookctl events` lists all 30
with what each one carries and what it can refuse.
event [PreToolUse]:
tool it applies to (`*` means every tool) [Bash]:
What happens on a hit?
1.* log shadow: record hits, block nothing — the recommended first version
2. deny refuse, with your reason shown to the model and the user
3. ask pause the call until the operator says yes
4. allow skip the permission prompt (hooks can only tighten — read the docs)
choice [1-4, Enter = log]:
What is this rule about?
1.* program a program must never run, however it is wrapped or spelled
2. option a program is fine; one of its options is not
3. argument a phrase in any single argument (quotes are stripped first)
4. invocation an exact invocation shape: a subcommand and its arguments
5. position a program only in a position: fed by a pipe, remote, nested
6. structure the command's counted structure: too many pipes/statements
7. path edits to a particular file or path (whatever tool writes it)
8. bytes raw bytes anywhere in a payload field
9. json I will type the matcher JSON myself
choice [1-9, Enter = program]:
program name (a trailing `*` prefix-matches, e.g. python*):
add another condition that must ALSO hold? [y/N]:
rule name (lowercase-with-dashes, e.g. no-npm-publish): testing
The reason is the mechanism: Claude Code shows it to the model verbatim,
so a good one redirects instead of merely refusing. Two sentences:
1. the concrete risk — what happens, to what;
2. the approved alternative, as an exact `backticked` command.
reason: i hate spoons
This rule is `log`, so its cases assert `none` (shadow never blocks);
`./hookctl check` is what shows a shadow hit explicitly.
a command this rule MUST catch: pineapple
a command this rule MUST catch (blank when done):
a command it must NOT catch: spoons
a command it must NOT catch (blank when done):
Where does this rule live?
1.* global /Users/matt/.claude/hook-rules.json — every session on this machine
2. project /Users/matt/repos/ziplite/claude-hook-manager/.claude/hook-rules.json — this repository's overlay (rules only)
choice [1-2, Enter = global]: 2
== selftest, then write ==
PASS #1 Bash: pineapple
PASS #2 Bash: spoonsThe Primary Runner
$ ./hookctl
hookctl — the one runner for claude-hooker.
usage: ./hookctl <verb> [options]
operator verbs — installing and operating the gate
setup build, install and verify — the one command a new install needs
init choose your rules, then install — the guided first run
upgrade rebuild, show what the defaults gained, reinstall (keeps your rules)
uninstall remove every gate hook entry; --purge also drops binary and log
status one screen: version, rules, overlay, log, what is switched off
doctor diagnose the install, PASS/WARN/FAIL with a fix for each problem
diff-defaults what the shipped defaults gained since your rule file was seeded
rules list, adopt, shadow, promote, remove or author rules
check ask the gate what it would do about one command
explain as `check`, plus the parsed and resolved command model
stats per-rule summary of the decision log
classes the built-in classes a rule may name, and their members
events the 30 hook events: what each carries, and what it can refuse
selftest run the rule file's own cases, then lint it
version the gate's version
help this message
dev verbs — working on this repository (these need `zig` on PATH)
build compile both binaries into zig-out/bin
test unit tests only
selfcheck hookctl's own unit tests (no toolchain needed; folded into verify)
verify THE GATE: tests + both binaries + shlex parity + doc checks
parity regenerate the shlex oracle and diff it against the checked-in copy
cross compile the binaries and all tests for Linux (x86_64, aarch64) without running
fmt zig fmt over src/ and build.zig (add --check to only report)
audit every mechanical consistency check, with its counts
reap kill this checkout's stale build/test processes (--dry-run, --all)
check vs verify
`check` is the GATE's verb: it asks what the gate would do about one
command (`./hookctl check 'git add -A'`). The repository's own gate — unit
tests, both binaries, the shlex parity oracle, the doc checks — is `verify`.
There is deliberately no `hookctl check` that means the second thing.
paths and sandboxes
Every verb that touches an install accepts --claude-dir DIR, which moves the
whole install (binary, rules, settings.json, log) into DIR. Without it the
install is ~/.claude. Relative paths are resolved against your cwd.
the underlying steps still work
This shells out to `zig build` — `zig build setup`, `zig build check` and
friends are unchanged and are exactly what these verbs run.Some Settings
Rules live in ~/.claude/hook-rules.json. Edits are live
on the next tool call.
The 14 default rules
deny (blocked, with a reason shown to the model):
no-pkill—pkillin any wrapper (sudo,bash -lc,xargs, variables)no-inline-python—python -c/ stdin one-liners (-mis carved out)no-heredoc-python— heredoc/here-string piped into pythonno-git-add-all—git add -A/--all/git add .deny-recursive-mutation-from-anchor— recursivechmod/chown/chgrp,rsync --delete,find -delete/-exec rmrooted at/,~, or a system dirprotect-hook-config— no tool may writehook-rules.jsonor.claude/settings.json
ask (confirmation prompt):
ask-whole-world-traversal—find/grep -r/rg/du/ls -Rfrom/or~
log (recorded only, nothing blocked):
wrapper-script-shadow— file writes whose content names a denied commandobserve-script-file-run— a shell handed a file to execute (PostToolUse)observe-session-start— one line per session, so “quiet” ≠ “not wired”watch-eval,watch-pipe-into-shell,watch-decode-into-shell,watch-unresolved-command-word— opaque-execution patterns
Matching is structural (parsed command, resolved variables, flag
sets, normalized paths), so echo pkill passes while
CMD=pkill; $CMD -f x is denied.
The catalog: 14 more rules you can adopt
Every recipe in RULES_COOKBOOK.md
is adoptable by name. The 28 catalog rules are grouped into 9 bundles:
agent-hygiene, machine-guards,
git-discipline, opaque-execution,
database-safety, secrets-and-config,
observability, command-shape,
single-entrypoint. Notable non-defaults:
no-rm-rf-home-or-root, ask-sudo,
ask-force-push-protected-branch,
no-destructive-sql,
deny-prompt-private-key.
Profiles for non-interactive setup:
init --profile recommended (the defaults),
observe (same, demoted to log), minimal (4
machine/gate guards).
Editing and maintaining
./hookctl rules # your file vs. the catalog
./hookctl rules add machine-guards # adopt a bundle (or one rule) + its tests
./hookctl rules add ask-sudo --shadow # adopt as log-only
./hookctl rules promote ask-sudo # shadow -> enforced (demote reverses)
./hookctl rules remove NAME
./hookctl rules new # guided authoring of your own ruleOr edit the JSON directly, then:
./hookctl selftest # run the file's own test cases + lint
./hookctl check 'git add -A' # see the decision, matched bytes underlined
./hookctl stats # per-rule hit counts from the decision logEvery write path (including rules add/remove/promote)
selftests the result before saving and backs up the old file to a
.bak-<seconds> sibling.
Rolling out a new rule: ship it as
"decision": "log", watch ./hookctl stats for a
week, then rules promote it — or delete it if it never
fires.
Rule shape, briefly: rules are first-match-wins,
scoped to one event (default PreToolUse) and tool (default
Bash), with a decision
(deny/ask/allow/log),
a reason, and matchers in match (any-of) /
match_all / match_none. Prefer the structural
kinds (command_word, flags, argv,
path_class) inside an invocation group; full
reference in README.md#configuration-reference.
Per-repo policy: a repository can commit
.claude/hook-rules.json, evaluated before your global
rules. Its allow pre-empts your deny; disable
with "allow_project_overlay": false in the global file.
Upgrades: ./hookctl upgrade replaces
the binary only and prints diff-defaults; your rule file is
never touched.
and now read all about it with claude writing some claude slop to
document claude-hooker as an article by itself:
Teaching your coding agent some manners: an introduction to claude-hooker
I love working with coding agents. I do not love watching one run
pkill -f server and kill its own shell, or
git add -A a directory with a .env in it, or
“check something quickly” with python3 -c for the
forty-first time after being told, forty times, to stop.
Here’s the thing about system prompts and CLAUDE.md files: they’re suggestions. The model reads them, means well, and then three hours into a session the old habits come back. A stronger suggestion is not the fix. The fix is a gate sitting between the agent and your shell, saying no, and here’s what to do instead.
Enter claude-hooker: a small, fast hook gate for Claude Code, written in Zig. It evaluates every proposed tool call against a JSON policy file you own. No daemon, no network, no shell scripts. A short-lived native process reads one JSON event on stdin and writes at most one decision on stdout.
One honest disclaimer before anything else, because the project itself leads with it: claude-hooker is a habit-breaker for a cooperative model, not a containment boundary. Text matching is bypassable by construction, and the README’s threat-model section tells you exactly how. If you need a sandbox, use a sandbox. What you get here is something different and, day to day, arguably more useful: a policy firing at the moment of temptation, with a reason the model actually reads.
The reason is the secret. When the gate denies a command, the refusal carries a human-written explanation, and Claude Code injects it straight into the model’s context:
deny : no-git-add-all [command_line command "add -A"]
cd /x && git add -A && git commit -m wip
^~~~~~
reason :
Blanket staging is denied because it sweeps in unintended files (secrets, temp
files, generated artifacts)... Stage paths explicitly with `git add <path> ...`,
or `git add -u` for tracked-only changes.
A bare “no” leaves the model with an unfinished task and no better idea, and workarounds get invented in exactly such moments. A “no, do X instead” redirects it. In practice the agent just does the better thing and moves on.
Getting started
You need a Zig toolchain (0.16 or newer) and Python 3.11+ on your PATH. There are no prebuilt binaries. The project builds when you clone it, so there is also nothing to download and trust:
git clone https://github.com/mattsta/claude-hooker claude-hooker
cd claude-hooker
./hookctl setupsetup builds the gate, runs the shipped default rules
through a full self-test before writing anything, backs up your
~/.claude/settings.json to a timestamped sibling, wires up
the hook entries, and then verifies every artifact by reading it back.
On macOS it also checks the binary’s code signature, because a gate the
loader kills fails open and says nothing. The project is
allergic to silent failure.
Two timing facts worth knowing on day one:
- Hooks are snapshotted when a session starts. Installing takes effect in new Claude Code sessions.
- Rule edits are live. The gate re-reads your rule file on every call, so editing a rule changes behavior on the very next tool call. No reinstall, no restart.
Now poke it:
./hookctl check 'git add -A' ## what would the gate do here?
./hookctl status ## one screen: what's installed, what's wired
./hookctl doctor ## PASS/WARN/FAIL, each failure with a fixsetup seeds a recommended default set and is the
one-command path. If you’d rather choose (my recommendation, because
choosing is how you learn what’s on offer), run the guided first run
instead:
./hookctl initinit walks you through nine themed
bundles, each rule shown with a plain-language line
about what it stops:
-- agent-hygiene: break the agent's worst shell habits --
deny no-pkill pkill kills by pattern, and has killed the agent's own shell
deny no-inline-python `python -c` / stdin one-liners: throwaway code nobody reviews
deny no-heredoc-python a heredoc piped into python: the same habit in disguise
ask ask-whole-world-traversal find/grep/du from `/` or `~` floods context; asks first
Each bundle gets one answer: y, n, or
s. The s takes the bundle in
shadow (log-only, nothing blocked). Skipped bundles get
an a-la-carte pass afterwards so you can cherry-pick single rules, and
the walkthrough ends with one final question: enforce now, or start
everything in shadow and watch first. Enter all the way through
gives you the full recommended set; scripts can skip the conversation
entirely with --profile recommended|observe|minimal or
repeatable --bundle NAME flags.
Every choice is reversible later, and an aborted run (Ctrl-D, a declined confirmation, a failed self-test) writes nothing at all.
Intermediate: the rule file is yours
Everything the gate does comes from one JSON document,
~/.claude/hook-rules.json. It’s a policy file, not a config
file: rules in first-match-wins order, each with a decision
(deny, ask, allow,
log), matchers, and the all-important reason. You can edit
it by hand any time. Most of the lifecycle, though, is verbs:
./hookctl rules ## your file vs. the catalog, grouped by bundle
./hookctl rules show no-rm-rf-home-or-root
./hookctl rules add machine-guards ## adopt a whole bundle...
./hookctl rules add ask-sudo --shadow ## ...or one rule, as log-only
./hookctl rules remove wrapper-script-shadowThe catalog behind those verbs is the union of the
shipped defaults and the project’s cookbook: twenty-four documented,
tested recipes, every one held mechanically identical to a fixture
running its own test cases in CI. rules add doesn’t paste
JSON at the end of your file; it carries the rule with its test
cases and any named set it references, inserted at the position
where first-match-wins lets it actually fire. Every mutation is
validated by the gate’s own selftest before a byte lands,
the old file is backed up, and the swap is atomic.
Shadow first, always
The best habit the tool teaches, and tools for, is the shadow-first
rollout. Never ship a new rule as deny. Ship it as
log, let it record what it would have caught, then
look at the evidence:
./hookctl rules add no-destructive-sql --shadow
## ...a week of normal work later...
./hookctl stats --since 7d
./hookctl rules promote no-destructive-sql ## shadow -> the catalog's enforced formpromote and demote are exact inverses:
demotion is one deterministic transform (decision to log, a
stated prefix on the reason, test expectations rewritten), so promotion
can restore the enforced rule and its original cases without you
touching JSON. Zero hits after a week? Delete the rule. A rule with no
way to fire is worse than no rule, because it reads like protection.
Writing your own rules
There’s an interview for it:
./hookctl rules newIt asks the judgment calls: the event, the consequence, what to match
(offered as situations like “a program must never run” or “edits to a
particular path”, not as matcher jargon). It shows you the house style
for reasons, requires at least one must-catch and one must-not-catch
example, self-tests the result, and then replays your first example
through check, so you see the matched bytes underlined
before trusting the rule. It defaults new rules to log,
because of course it does.
The reason style guide is worth internalizing even if you never open the cookbook: two sentences. First, the concrete risk: what happens, to what. Second, the approved alternative as an exact, copy-pasteable command. No policy citations, no scolding. The reason is a prompt; write it like one.
Per-project rules
A repository can ship its own overlay at
.claude/hook-rules.json, evaluated before your global
rules. Only the overlay’s rules are read, a broken overlay is skipped
with a note rather than disabling your gate, and your global file
decides whether overlays are consulted at all. A repo you cloned must
never be able to switch your policy off.
Advanced: matching the command, not the bytes
Here’s where it gets fun. Most command filters are substring
matchers, and substring matchers fail in both directions: a rule against
pkill fires on echo "don't use pkill" (a
mention, not an execution), and misses
P=pki; K=ll; $P$K -f server (an execution, assembled from
fragments). Ten years of shell says better strings will never fix
it.
So the gate doesn’t match strings. It ships a POSIX shell lexer and a
resolution pass, and the structural matcher kinds ask questions of the
parsed and resolved command: pipeline stages, nested program
text (bash -lc "..." is re-lexed), quote-stripped
arguments, normalized option sets (-rf, -fr,
and -r -f are one thing), wrapper unwrapping
(sudo, env, timeout,
xargs, uv run, ssh, the whole
table), and straight-line constant propagation over variables, aliases,
and function bodies. The $P$K trick above? Caught.
alias k='pkill -f svc'; k? Caught.
echo pkill is bad? Correctly ignored.
A taste of the vocabulary:
{ "kind": "command_word", "value": "pkill" } // what will actually RUN, at any depth
{ "kind": "argv", "value": "DROP TABLE", "ignore_case": true }
{ "kind": "flags", "value": "R|--recursive" } // one option set, every spelling
{ "kind": "path_class", "value": "home_or_root" } // where a path NORMALIZES to
{ "kind": "signal", "value": "pipe_into_shell" } // what the parser noticedOne group operator ties matchers to a single invocation, so “a
database client running a destructive statement” cannot be satisfied by
psql -l && git commit -m "drop table users":
{ "invocation": [
{ "any": [ { "kind": "command_word", "value": "psql" },
{ "kind": "command_word", "value": "mysql" } ] },
{ "kind": "argv", "value": "DROP TABLE", "ignore_case": true } ] }The shape of a command
Two kinds go further and ask about structure rather than content.
stage reads an invocation’s position: pipe target, pipe
source, nested, remote. “head, but only when eating a pipe” becomes one
binding:
{ "invocation": [
{ "kind": "command_word", "value": "head" },
{ "kind": "stage", "value": "pipe_target" } ] }The rule fires on cat error.log | head and
cat f | timeout 5 head -5, and stays quiet on
head -20 error.log, the bounded read you actually wanted
from the agent.
shape counts the whole parse against a threshold
("pipes > 1", "statements > 1"), and the
counts read the parse, never the bytes:
echo 'a; b; c' counts zero statements,
bash -c 'a | b | c' counts two pipes, and
cat f | sudo head counts one, because a wrapper isn’t a
second join. ./hookctl explain '<cmd>' prints every
metric so you can find the number to compare against.
The strictest policy: one real program with arguments
Those kinds compose into my favorite thing in the catalog: the
single-entrypoint posture, built for the shop where every
action should go through a project CLI with clean parameters, and nobody
should be assembling programs out of pipes, redirects, and
sed fragments at the prompt:
./hookctl rules add single-entrypointIts main rule fires on any shell plumbing at all
(separators, pipes, chains, redirects, heredocs, command substitution),
while APP_RESTART=1 uv run mycli --flag value sails through
untouched, because an env prefix, a wrapper, and an argument list are
exactly the shape the posture wants. A companion rule watches the
fragment vocabulary itself (sed, awk,
tr, cut; deliberately not grep,
because searching is reading).
Both ship as log. You run it, you read
stats, you grow your project CLIs until the hits are noise,
and then enforcement is one command:
./hookctl rules promote single-entrypoint-only --to denyMeasure first, then refuse. The tool won’t let you skip the
stating-your-intent step: a bare promote on a watch rule is
refused, because the catalog has no enforced form to guess at.
The safety nets under all of it
A few properties you stop noticing until you try to live without them:
- Your rule file tests itself. A
testsblock asserts what fires and what doesn’t,generateblocks expand cross-products (every recursive-flag spelling times every system path, plus the near-misses required not to fire), and./hookctl selftestruns it all plus a lint catching dead rules, unknown names, and matchers reading fields their event doesn’t carry. A typo’d key is a hard error, never a silently weakened rule. - Broken fails open, loudly. An unreadable rule file
means the gate steps aside and says so. “Everything blocked because the
config is broken” would be worse. The version-stamped schema
(
schema_version) makes even the subtle case diagnosable: a rule file from a newer release is refused by version with the fix named, not mis-reported as a syntax error. - The decision log tells the whole story. Every hit
(enforced, shadow, or bypassed) is one JSON line,
statssummarizes it per rule, and a shipped SessionStart marker means an empty log reads as “wired and quiet,” never “was the gate even on?” Command text stays out of the log unless you opt in, because commands carry secrets.
Where to go from here
The README is the reference: configuration, all thirty hook events, the complete matcher vocabulary, and the threat model spelling out what the tool is not. The RULES_COOKBOOK is the part I’d actually read on a couch: twenty-four recipes, each with what it catches, what it deliberately doesn’t, where the false positives live, and a style guide for reasons the model actually follows.
Start smaller, honestly. Clone it, run ./hookctl init,
take the agent-hygiene bundle, and let your agent hit the
gate once. Watch it read the reason, adjust, and stage its files by name
like a professional. One such moment is the whole pitch.