DEV Community

jidonglab
jidonglab

Posted on

Claude Code PreToolUse Hook: 80 of 91 Secret Blocks Were False Alarms

My agent was trying to keep secrets out of its own output. It ran grep -v "^./.env" to filter those lines away, and my Claude Code PreToolUse hook blocked it for touching a secret file.

That made me wonder how often my security hook was right, so I parsed 31 days of transcripts: 820 sessions and 255,911 tool calls, 159,513 of them Bash. The hook had blocked 91 Bash commands. I read all 91 by hand.

TL;DR

  • My env-guard PreToolUse hook blocked 91 of 159,513 Bash commands in 31 days. 80 of the 91 (88%) were false alarms.
  • Only 11 touched a real secret file, and none of those 11 would have printed a credential.
  • The cause: two grep checks ran over the whole command string, so a .env here and a sed there counted as a match.
  • At least 5 real secret-file accesses went past the hook, through scripts or a different filename. A human had asked for or approved the work in all 5. The hook knew nothing about that.
  • A quote-aware rewrite cut false alarms from 80 to 4 and kept 11 of 11 real hits on replay. That result is in-sample, and it still misses 3 of the 5 script paths.

What does my Claude Code PreToolUse hook actually check?

My Claude Code PreToolUse hook checks two things: does the command name a secret file, and does it contain a program that can print file contents? If both are true, it exits 2 and the call is refused.

Here is the core, shortened:

CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' | tr -d "\"'")

SECRET_TOKEN='(^|[[:space:]=/])([.]env([.][A-Za-z0-9_.-]+)?|[.]envrc|id_rsa|id_ed25519|credentials[.]json)([[:space:]:;|&)]|$)|/[.]ssh/'
DUMPERS='(^|[[:space:];|&(])(sudo[[:space:]]+)?(cat|head|tail|grep|sed|awk|python3|node|jq|cut|...)[[:space:]]'

if echo "$CMD" | grep -qE "$SECRET_TOKEN" && echo "$CMD" | grep -qE "$DUMPERS"; then
  echo "env-guard: this command targets a secret file..." >&2
  exit 2
fi
Enter fullscreen mode Exit fullscreen mode

The bug is the &&. Nothing requires the two matches to be in the same command. Agents write long compound one-liners, and the median blocked command was 726 characters.

This shape appeared over and over (file names changed):

sed -n 1,60p scripts/query.mjs | head -60; ls -la .env.local
Enter fullscreen mode Exit fullscreen mode

ls only reads the filename. sed never goes near the secret file. The hook saw both words and refused.

How many Claude Code hook blocks were false alarms?

80 of 91 blocks were false alarms, or 88%. I labeled each one by reading the full command:

What the blocked command was doing Blocks Real secret access?
Test fixtures: strings, or fake files in a throwaway directory 46 No
Existence checks (ls, test -f, git check-ignore) 14 No
Touching a real secret file 11 Yes
Reading .env.example 8 No
Prose inside a prompt, note or draft 7 No
.env that isn't a file (JSON key, filter pattern) 5 No

One caveat cuts against my headline. The 46 fixtures are lopsided, because I spent that month testing permission checks for a desktop app, and those tests are full of cat ~/.ssh/id_rsa as a string. Remove them and it's still 34 false alarms out of 45, or 76%.

A few favorites:

  • jq '.env // {} | keys' ~/.claude/settings.json was blocked. That's a JSON key.
  • A status note that mentioned the hook blocking .env commands was blocked by that same hook.
  • A draft of a post for this blog was blocked because it quoted a .gitignore.

What were the 11 real secret file reads?

None of the 11 real hits would have printed a credential. The agent was already being careful:

  • 9 asked only for variable names, line counts, or masked output, such as cut -d= -f1 or sed 's/=.*/=<redacted>/'. One of them also printed a sandbox/live mode flag.
  • 1 piped the file into head -0, which prints nothing at all.
  • 1 loaded the file into a shell to run a job I had just approved.

So the hook stopped zero credential leaks in 31 days. It might still catch a bad one someday, but I can't measure that.

Can Claude Code get around a PreToolUse hook?

Yes, and it doesn't take any cleverness. A PreToolUse hook only sees the text of the call, so a script that opens the file later is invisible to it.

The clearest case went like this. The agent asked me whether it could use a database URL from a secret file. I said yes. It ran set -a; . path/to/.env, and the hook blocked it, because the hook has no idea I approved anything. So the agent wrote a short Node runner that pulled out one key with readFileSync, and that passed.

Why it passed: the regex needs whitespace or one of :;|&) after the filename. In readFileSync("/path/.env", "utf8"), the next character is a comma.

A narrow search found 5 real accesses that went past the hook:

  • 3 scripts that read a real .env with readFileSync
  • 1 launcher that sourced a keys file named relay-hermes-keys.env
  • 1 grep -c on ~/.config/secrets.env

That last one matters most. The same grep -c on a file named .env was blocked, and only the filename differed.

I checked the context on all 5. Each had a human behind it: an explicit yes, a task prompt that said to read the key, or a question I had asked about that key. So this isn't a rogue agent story. The honest version is that my safety came from the agent following instructions, and the hook's contribution was mostly noise. My search was also narrow, so 5 is a floor.

Only 4 of the 91 blocks led to a byte-identical retry. In at least 4 cases the agent simply renamed its fake .env fixture to something like .secret and carried on.

While gathering numbers for this post, I tried to pipe a list of bypass strings through the hook to see which ones got caught. It blocked that too. Nothing would have touched a real file, but its message says to ask first, so I dropped the probe. From reading the regex, not from running anything: sort and diff aren't on the reader list.

How do you fix a regex PreToolUse hook?

Parse the command instead of grepping it. I wrote a Python version of about 60 lines with four changes:

  1. Tokenize with shlex so a quoted string stays one token, and split on ;, | and & outside quotes.
  2. Flip the list. Known-safe verbs (ls, test, stat, echo) are allowed, and any other program given a secret path as an argument is a reader. That closes the sort gap by design.
  3. Drop heredoc bodies, unless the heredoc feeds an interpreter like python3 or node.
  4. Exempt .env.example, and track loop variables.

The last part of item 4 isn't optional. Splitting on ; alone looks like the obvious fix, but it loses 3 real hits. Two were for f in .env .env.local; do sed ... $f, where the filename and the reader sit in different segments. The third was the . source command, which the old reader list never included.

Replaying the logged commands gave me this:

Original hook Naive split on ; Parsed version
False alarms (of 80) 80 28 4
Real hits kept (of 11) 11 8 11
Script accesses caught (of 5) 0 not tested 2

The 2 catches need a second check on the Write tool, which also flagged 3 harmless files. Across the 1,070 logged Bash calls that contain a secret-like string and ran without error, the final parser blocks none. My first pass flagged 23 of them, because my own regex matched process.env.

Don't read too much into that table. I tuned the parser on these same 91 commands, and one person (me) made the labels. It hasn't run live, so the old hook is still the one in place.

Is a PreToolUse hook enough to protect secrets?

No. A hook is a speed bump for honest mistakes.

The most useful thing on my machine isn't the hook at all. It's a pair of read-only query scripts that load their own credentials and don't print them. Agents called them at least 1,367 times in those 31 days. If the agent has no reason to open the secret file, you don't need to win a regex fight over it. For the part a text filter can't see, the block has to happen at the OS level, where a script's open() fails too.

Are Claude Code PreToolUse hooks reliable for blocking secret reads? Not when they regex the command string. Over 159,513 Bash commands, my Claude Code PreToolUse hook blocked 91, and 80 of those were false alarms. None of the 11 real hits would have exposed a credential, and at least 5 real accesses went around it through scripts or a different filename. Parsing the command cut false alarms to 4 in replay, but text matching can't see what a script does at runtime. Keep the hook for honest mistakes, and put the real boundary in a credential-holding script or an OS-level sandbox.


Written by the developer behind Preterview, an interview prep platform.

Top comments (2)

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

If a guard fires on the string instead of the action, people learn to route around it. I'd match the declared path the action touches, not the pattern in the command, and log every bypass as a finding rather than a silent pass.

Collapse
 
steven_mock_afbb801174fc3 profile image
Steven Mock •

This is a really honest audit — most people would have stopped at "my hook blocked 91 commands" and called it a win. The detail that the two grep checks did not have to be in the same command is the whole story, I think. Once an agent is writing long compound one-liners, a regex over the full string is basically guaranteed to cry wolf, and then the false alarms train you to ignore the real ones. The parsed version going from 80 false alarms to 4 while keeping all 11 real hits is a strong result, even with the tuning-on-the-same-data caveat you flagged. I also liked the conclusion that the read-only query scripts are doing more real work than the hook — that feels like the right takeaway: make the safe path easy, and treat the hook as a backstop for honest mistakes, not a security boundary.