DEV Community

Ashton Du Toit
Ashton Du Toit

Posted on

y Claude Code safety hook wasn't blocking anything

I set up a hook in Claude Code a while back so it couldn't run anything
destructive on my repo. Nine lines of bash, a list of patterns, exit 2 to
block. Tested it once, saw it block, felt good about myself, forgot about
it.

Found out two months later it had been letting everything through.

The hook itself:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c \
  "import sys,json;print(json.load(sys.stdin) \
  .get('tool_input',{}).get('command',''))")

BLOCKLIST='rm -rf /|rm -rf ~|git push --force|chmod -R 777'

if echo "$CMD" | grep -qE "$BLOCKLIST"; then
  echo "Blocked: matches destructive pattern." >&2
  exit 2
fi
exit 0
Enter fullscreen mode Exit fullscreen mode

Claude Code hands the hook the tool call as JSON on stdin, so that python
one-liner is just pulling the command string out so grep can look at it.
Exit 2 blocks, exit 0 lets it run. Reads fine.

I'm on Windows, and python3 on Windows isn't necessarily Python. There's
an app execution alias that ships with Windows 10/11 which exists to open
the Microsoft Store when someone types python3 and hasn't installed
anything. I did have real Python, from python.org, and it didn't matter,
because the alias sits in WindowsApps near the front of PATH and it
claims the name python3 specifically. Plain python went to my actual
install. python3 went to the stub.

So the stub gets my JSON, does nothing useful with it, CMD ends up as an
empty string, grep finds nothing in an empty string, script falls through
to exit 0, and Claude Code reads exit 0 as the hook saying go ahead.

Every dangerous command was being explicitly approved by the thing I wrote
to stop dangerous commands.

What got me is that it never looked broken. If it had crashed I'd have
caught it in a day. Instead it ran, exited clean, printed nothing, and a
hook that says "all clear" is indistinguishable from a hook that works
unless you go looking.

Fix was one word:

CMD=$(echo "$INPUT" | python -c \
  "import sys,json;print(json.load(sys.stdin) \
  .get('tool_input',{}).get('command',''))")
Enter fullscreen mode Exit fullscreen mode

You can also just turn the aliases off in Settings → Apps → Advanced app
settings → App execution aliases. I didn't, because the script lives in a
repo and I don't want it depending on a checkbox somewhere in my Windows
settings.

The word swap fixes my machine but not the actual mistake, which is that
the script's default answer was yes. Empty variable, malformed JSON,
Python missing entirely, tool payload changing shape in some future
version — all of those roads lead to exit 0. It should refuse when it
doesn't understand the question:

CMD=$(echo "$INPUT" | python -c \
  "import sys,json;print(json.load(sys.stdin) \
  .get('tool_input',{}).get('command',''))") || {
  echo "Guard could not parse tool input — blocking." >&2
  exit 2
}
Enter fullscreen mode Exit fullscreen mode

A guard that blocks too much annoys you by lunchtime and you fix it. A
guard that allows too much just sits there.

If you've got hooks, don't read them, run them. Reading mine is exactly
what I did and it looked correct:

echo '{"tool_input":{"command":"rm -rf /"}}' | bash .claude/hooks/guard.sh
echo "exit code: $?"
Enter fullscreen mode Exit fullscreen mode

Should be exit 2 and a message on stderr. If you get 0, it's decoration.

Do that from a normal terminal, not through Claude Code, or your live hook
sees the rm -rf / sitting in the test payload and blocks the test itself.
Took me a second to work out what was happening there.

And the broader Windows version, for anything of yours that shells out:

which python3 && python3 -c "print('real python')"
Enter fullscreen mode Exit fullscreen mode

Nothing, or a WindowsApps path with no output after it, means every
script you've got calling python3 has been quietly handing back empty
strings.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

This is a useful failure to write up. Safety hooks need tests for the hook itself, not only the behavior they are supposed to block. Otherwise the team gets the worst outcome: a control that creates confidence without actually controlling anything.