Skip to content

About

Seven Claude Code hooks, and the case study of finding that four of them had never worked once. Published with two defects still live, stated before the install instructions.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Agent hooks — a case study in a gate that never fired

Seven Claude Code hooks, and the story of finding out that four of them had never worked once. Not misconfigured, not disabled — installed, enabled, correctly written, and structurally incapable of reading their own input.

The hooks are here to use and refactor. The case study is here because the interesting part was never the hooks. It was how long they looked fine.

This repository shows the mess on purpose. Not the tidy version with the bugs quietly removed — the wrong hypotheses, the four hours spent on plausible theories, the claims that turned out false, and the two defects still live today. If you want the transferable part, read docs/findings-log.md: every defect in the order it was actually found, each one answering what I thought it was, what it actually was, and what would have found it sooner.

The scripts show what was decided. The log shows what it cost to decide it.

An instruction asks. A hook enforces. But a hook you have never watched fire is not a guarantee. It's a belief.


⚠️ Known defects — read before installing

These are documented rather than fixed, deliberately. Two of them are live right now. If you install a gate believing it gates something, and it doesn't, that is the exact failure this repository is about — so it is stated before the install instructions, not after.

# Defect Effect if you install it anyway
1 test-hooks.sh exits 0 on CANNOT-TELL It prints "That is not a pass" and then returns success. Do not gate CI on its exit code. Read its output, not its status.
2 7 of 8 hooks resolve state via ${CLAUDE_PROJECT_DIR:-.} If that variable is unset — and on the machine these were written on, it is empty — state files land in whatever the hook's cwd happens to be. On a folder containing several repos, one stamp can unlock commits in all of them.

Defect 2 in particular: before trusting verify-gate.sh, find out where your stamp file actually lands. Run a recognised test command, then look for .claude/.verified — and check whether it appeared in the repository you expected or one directory up.

hooks/README.md lists which hooks are affected. docs/limitations.md covers what these hooks cannot do at all.


Install

git clone https://github.com/serina-mcfall/agent-hooks-case-study
cd agent-hooks-case-study
cp hooks/*.sh ~/.claude/hooks/

Then register the ones you want in ~/.claude/settings.json under hooks. hooks/README.md gives the matcher and event for each.

Verify before trusting anything:

~/.claude/hooks/test-hooks.sh

That script runs every configured hook over a real socket, which is the only way to find out whether a hook can see its payload — see the case study for why a pipe gives the wrong answer. Read its output. Remember defect 1: its exit code is not the verdict.

Then run the negative control. Do the dangerous thing and check it is stopped:

git branch -D a-branch-that-does-not-exist

If git-safety.sh is installed and working, that is refused. If it isn't refused, the hook is not protecting you, however correct the config looks. That single command is the whole lesson of this repository.


What's here

Path What it is
docs/findings-log.md Start here. Every defect in the order found, with the wrong hypotheses and what would have found each one sooner
docs/before-and-after.md The broken line and the fix, why one word mattered, and how to reproduce it
hooks/ The eight scripts, plus per-hook notes and their individual defects
docs/case-study.md The narrative: a two-character grep, a socket, and a category
docs/five-instances.md The same defect five times in unrelated code, and which two are still live
docs/limitations.md What hooks cannot do, and where to reach for something else
docs/talk-script.md The full 10-minute talk script, with every measurement and its evidence
presentation/ The Slidev deck built from that script

A gap worth knowing about: these hooks were not in version control until 2026-08-03, and the commit that added them is the commit that fixed them — an add, 387 insertions, zero deletions. So there is no pre-fix state to git diff. before-and-after.md reconstructs it faithfully from the comment the fix left behind, and says so. Untracked code cannot tell you when it broke; the bug that hid longest was in the only code not being tracked.

The short version

A recursive grep across some config files used the pattern 5h. Two characters. It matched base64 inside a credentials file, and printed a live access token into a stored transcript.

The rule against that already existed. It wasn't ignored — it was followed, by someone whose two-character search happened to match a JWT.

A regex does not inherit the intent of the person who wrote it.

So a PreToolUse hook was written to block dangerous commands. It read its payload with:

INPUT=$(cat /dev/stdin)

Claude Code delivers hook payloads on a socket, and a socket cannot be opened by path. The open fails with ENXIO, the error goes to stderr while $(...) captures only stdout, there is no set -e, and an empty payload took each script's "nothing to do" branch — which exits 0, which means allow.

Total failure was indistinguishable from a quiet success. Four hooks were affected, including the only security hook on the machine.

And every manual test passed — because piping a payload by hand gives /dev/stdin a pipe, and a pipe can be opened by path. The test environment differed from production in exactly the one way that mattered, so the test wasn't merely useless. It was actively reassuring.

A passing test that runs in the wrong environment is worse than no test.

Where hooks sit

Three layers can enforce a control, weakest to strongest:

Layer Is like Enforces
1. Prompt — CLAUDE.md, your wording the sign on the door nothing
2. Harness — tool dispatch, permission rules, hooks the bouncer a tool call, before it runs
3. Sandbox — filesystem, network, kernel the walls what is possible

Everything here is layer 2. Hooks are policy at the boundary, not isolation.

That frame is from Jeff's Security Primer for Agentic Coding — his vocabulary, my case study. Two of the three limits in docs/limitations.md are his as well, including the sharpest criticism of this entire repository: check whether your harness has a native control before you write a hook. permissions.deny is also layer 2, and the harness maintains it. A native rule could not have been dead for months. Mine was.

His primer named blockers I already had and had no words for. It is where this work goes next.

Contributing

Refactor it, fork it, take the bits you want — MIT, and no attribution needed beyond the licence.

If you fix one of the known defects, a PR is welcome. If you find a sixth instance of the fail-open shape, that is more interesting than a fix, and I would genuinely like to hear about it.

One request. If you add a check, prove it can fail before you trust it. Every script in here that survived scrutiny had a negative control; every one that didn't, didn't.

Licence

MIT — see LICENSE.

About

Seven Claude Code hooks, and the case study of finding that four of them had never worked once. Published with two defects still live, stated before the install instructions.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages