Claude Hooker: do you come here often?

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 0

Another 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 0

The 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 -rf and it can also catch rm -fr and rm -r -f and rm -f -r and other “workarounds” (routing through xargs too 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.txt when 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 pkill EVER 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) counted

Getting 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: spoons

The 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-pkillpkill in any wrapper (sudo, bash -lc, xargs, variables)
  • no-inline-pythonpython -c / stdin one-liners (-m is carved out)
  • no-heredoc-python — heredoc/here-string piped into python
  • no-git-add-allgit add -A / --all / git add .
  • deny-recursive-mutation-from-anchor — recursive chmod/chown/chgrp, rsync --delete, find -delete/-exec rm rooted at /, ~, or a system dir
  • protect-hook-config — no tool may write hook-rules.json or .claude/settings.json

ask (confirmation prompt):

  • ask-whole-world-traversalfind/grep -r/rg/du/ls -R from / or ~

log (recorded only, nothing blocked):

  • wrapper-script-shadow — file writes whose content names a denied command
  • observe-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 rule

Or 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 log

Every 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 setup

setup 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 fix

setup 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 init

init 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-shadow

The 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 form

promote 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 new

It 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 noticed

One 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-entrypoint

Its 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 deny

Measure 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 tests block asserts what fires and what doesn’t, generate blocks expand cross-products (every recursive-flag spelling times every system path, plus the near-misses required not to fire), and ./hookctl selftest runs 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, stats summarizes 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.