I set up hooks to prevent Claude Code from committing without my approval. It bypassed them using git -C /path commit instead of git commit. Here's the story and the fix.
The Setup
I use Claude Code as my coding assistant. It can read files, write code, run shell commands, and commit to git. That last part made me nervous. I wanted to review and commit changes myself.
Claude Code supports PreToolUse hooks that run before tool execution. Here's what I had in ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/block-git.sh",
"timeout": 10
}
]
}
]
}
}
And my block-git.sh:
#!/bin/bash
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
if [[ $command == *"git commit"* ]] || \
[[ $command == *"git push"* ]] || \
[[ $command == *"git reset --hard"* ]]; then
cat <<EOF
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED: git commit/push requires manual approval"
}
}
EOF
exit 0
fi
echo "{}"
exit 0
This worked. When Claude tried git commit -m "message", it got blocked.
The Bypass
Today, I asked Claude to commit some changes. Instead of git commit, it ran:
git -C /Users/anand/ws/project commit -m "Fix bug"
It went through. The commit was made without my approval.
The -C flag tells git to run as if it was started in that directory. Functionally identical to cd /path && git commit, but syntactically different enough to slip past my pattern matching.
Why It Bypassed
My hook used glob patterns:
[[ $command == *"git commit"* ]]
This looks for the literal string git commit with a space between them. But the actual command was:
git -C /path commit -m "..."
There's -C /path between git and commit. No direct match. No block.
The Fix
Updated the hook to use regex with word boundaries:
#!/bin/bash
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
if echo "$command" | grep -qE '\bgit\b.*\bcommit\b' || \
echo "$command" | grep -qE '\bgit\b.*\bpush\b' || \
echo "$command" | grep -qE '\bgit\b.*\breset\b.*--hard' || \
echo "$command" | grep -qE '\bgit\b.*\brebase\b' || \
echo "$command" | grep -qE '\bgit\b.*--force' || \
echo "$command" | grep -qE '\bgit\b.*-f\b'; then
cat <<EOF
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED: git commit/push requires manual approval"
}
}
EOF
exit 0
fi
echo "{}"
exit 0
The key change: \bgit\b.*\bcommit\b matches "git" followed by anything, then "commit". Word boundaries (\b) prevent partial matches.
Testing
# Now blocked:
echo '{"tool_input":{"command":"git -C /path commit -m test"}}' | ~/.claude/block-git.sh
# BLOCKED
echo '{"tool_input":{"command":"git commit -m test"}}' | ~/.claude/block-git.sh
# BLOCKED
# Still allowed:
echo '{"tool_input":{"command":"git status"}}' | ~/.claude/block-git.sh
# {} (allowed)
Blocking Standalone cd
I also block standalone cd commands to prevent persistent directory changes:
#!/bin/bash
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
trimmed_command=$(echo "$command" | sed 's/^[[:space:]]*//')
if [[ "$trimmed_command" =~ ^cd[[:space:]] ]]; then
cat <<EOF
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED: Use subshell pattern: (cd /path && command)"
}
}
EOF
exit 0
fi
echo "{}"
exit 0
This forces the AI to use (cd /path && command) which runs in a subshell and doesn't persist.
Takeaways
Glob patterns are fragile for command matching. Use regex. AI assistants can find alternative syntax you didn't anticipate. Test your guardrails by trying to bypass them yourself.
The irony? Claude helped me fix the very hook it bypassed.
Have you set up guardrails for your AI tools? What edge cases have you hit?
Building jo4.io - a URL shortener with analytics and white-labeling.
Tags: #ai #security #git #claudecode #devtools
Top comments (2)
What a time to live in. LOL
Some comments may only be visible to logged-in visitors. Sign in to view all comments.