Claude Code safety hooks can be bypassed with chained commands
I use Claude Code’s PreToolUse hooks to enforce git hygiene — blocking git add ., git checkout ., dangerous rm, unreviewed git commit, and state-changing JIRA commands. They’ve been working great. Or so I thought.
During a routine infrastructure task, Claude committed a directory containing secrets to a shared repo. My git add hook should have caught it. It didn’t.
The root cause
Every hook validated commands assuming they start at position 0:
def check_command(command):
if command.startswith("git add ."):
return True, "Use specific file paths"
# ...
But Claude Code chains commands:
cd /some/dir && git add .
The hook saw cd /some/dir && git add ., checked if it starts with git add — it doesn’t — and approved it. The dangerous command sailed right through.
This isn’t a Claude Code platform bug. It’s a hook authoring bug, and it affects anyone writing PreToolUse hooks that parse Bash commands with startswith() or ^-anchored regex.
The fix
Split on shell operators and check each subcommand independently:
import re
def check_command(command):
subcommands = re.split(r'\s*(?:&&|\|\||;)\s*', command)
for subcmd in subcommands:
should_block, reason = _check_single(subcmd.strip())
if should_block:
return True, reason
return False, None
def _check_single(cmd):
if cmd.startswith("git add .") or cmd.startswith("git add -A"):
return True, "Stage specific files, not everything"
if cmd.startswith("git checkout .") or cmd.startswith("git restore ."):
return True, "Don't discard all changes"
# ... other checks
return False, None
5 of my 8 hooks were vulnerable. All fixed, all tested with chained inputs.
Testing hooks properly
The mistake was only testing with direct commands:
# What I tested
assert blocks("git add .") # ✓ caught
assert blocks("git add -A") # ✓ caught
# What I didn't test
assert blocks("cd foo && git add .") # ✗ MISSED
assert blocks("ls; git add .") # ✗ MISSED
assert blocks("echo hi || git add .") # ✗ MISSED
Every hook test suite needs chained command variants. If your tests only cover single commands, your hooks have the same vulnerability mine did.
Takeaways
- Never assume a shell command is a single command. LLM agents chain with
&&,||,;, and sometimes even subshells. - Split on shell operators before pattern matching. This is the minimal fix.
- Test your hooks with chained inputs, not just direct ones.
- Audit all your hooks when you find a pattern bug — if one has it, others probably do too.
- Don’t put sensitive patterns in
.gitignore— that’s a public file. Use.git/info/exclude.
The agent didn’t act maliciously. It optimized for efficiency by chaining cd with git add. The hooks just weren’t written to handle that. Guardrails need to be at least as creative as the thing they’re guarding.