Claude Code can run a command of yours before every tool call: a PreToolUse hook. It’s a great place for guardrails, but it’s easy to get subtly wrong. I built one that blocks secrets, and these are the lessons. (Check the current Claude Code hooks docs for exact field names, since they can change.)
How a hook works, in 30 seconds
Claude Code sends the tool call to your command as JSON on stdin, containing the tool name and its input. For a file write that includes the path and the content. Your command either lets it through or denies it.
Registering a hook looks like this in .claude/settings.json:
json
{ "hooks": { "PreToolUse": [ { "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash", "hooks": [{ "type": "command", "command": "your-hook" }] } ] } } The matcher decides which tools trigger it. Include Bash, or the agent can bypass your file checks with echo "KEY=..." > file.
Mistake 1: Emitting “allow” when everything looks fine
My first version printed an “allow” decision for clean calls. That sounds harmless, but an explicit allow can skip the user’s normal permission prompts. A guardrail should only ever remove permission, never grant it.
Fix: for a clean call, print nothing and exit 0. Only speak up to deny.
Mistake 2: Assuming any non-zero exit blocks
In Claude Code, exit code 2 blocks the call, but exit code 1 is treated as a non-blocking error. If your hook crashes on bad input and exits 1, the tool call goes ahead. Decide deliberately:
Can’t parse the input → exit 1 (non-blocking), with a message on stderr.
Found a problem → deny, either with a JSON permissionDecision: "deny" or exit 2.
Mistake 3: Letting the agent exempt itself
Many scanners support a comment like # allow to ignore a line. That’s fine for humans, but an agent can just write the comment next to its own secret. In agent mode I ignore inline allow comments and only honor the allowlist in the config file, which the human controls.
Mistake 4: Failing open on big input
If the input is too large to scan, the easy choice is to let it through. Then a huge file becomes a free bypass. I cap input at 16 MiB and block anything larger, telling the agent to split the content. A check that silently covers only part of the content reports a false “clean”.
And most critical what not to put in the deny message
When you block, the message goes back to the model. Don’t include the real secret. I show only a masked form like AKIA************WZVB, so the credential never enters the model’s context a second time.
The project
I turned all of this into a free, local, open-source tool called StagedSec, and the hook is one command: stagedsec agent-hook --print-config prints the settings snippet. It’s early (v0.1), and I list its limits in the README, including that split or encoded secrets aren’t detected yet.
If you’ve built Claude Code hooks, what surprised you? I’m especially curious about edge cases around multi-edit calls.




