I've been building jo4.io - a URL shortener with analytics - using Claude Code as my AI pair programmer. It's been fantastic for productivity, but I quickly discovered that Claude can be a bit... enthusiastic about running commands.
The Problem
While debugging a stats tracking bug in jo4, Claude was happily:
- Running
cd /some/pathcommands, messing up my terminal's working directory - Attempting
git commitandgit pushwithout my review - Sometimes running destructive git commands like
git reset --hard
These aren't bugs - Claude is just doing what it thinks is helpful. But in a production codebase, I need control over what gets committed and pushed.
The Solution: Claude Code Hooks
Claude Code has a powerful but underutilized feature: PreToolUse hooks. These let you intercept tool calls (like Bash commands) and block them before execution.
Here's my setup:
~/.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/block-git.sh",
"timeout": 10
},
{
"type": "command",
"command": "~/.claude/block-cd.sh",
"timeout": 10
}
]
}
]
}
}
~/.claude/block-cd.sh
This blocks standalone cd commands while allowing subshell patterns:
#!/bin/bash
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
trimmed_command=$(echo "$command" | sed 's/^[[:space:]]*//')
# Block standalone cd, allow (cd /path && command)
if [[ "$trimmed_command" =~ ^cd[[:space:]] ]]; then
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED: Use subshell pattern: (cd /path && command)"
}
}
EOF
exit 0
fi
echo "{}"
exit 0
~/.claude/block-git.sh
This blocks git commit, push, and destructive operations:
#!/bin/bash
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
if [[ $command == *"git commit"* ]] || \
[[ $command == *"git push"* ]] || \
[[ $command == *"git reset --hard"* ]] || \
[[ $command == *"git rebase"* ]] || \
[[ $command == *"--force"* && $command == *"git"* ]]; then
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED: git commit/push requires explicit user approval"
}
}
EOF
exit 0
fi
echo "{}"
exit 0
How It Works
When Claude tries to run a blocked command, it sees:
PreToolUse:Bash hook blocking error: BLOCKED: git commit/push requires explicit user approval
Claude then adapts - it'll prepare the commit message and tell me what it would commit, letting me run the actual command myself.
Real World Example
Today while fixing a bug in jo4's analytics service, Claude found that HttpServletRequest was being passed to an @Async method - causing stats to silently fail. It refactored the code, wrote tests, and then said:
"The fix is complete. When you're ready, you can commit with:
git commit -m 'Fix stats tracking by extracting request data before async'"
Instead of just committing and pushing (which some AI tools do), it handed control back to me. I reviewed the changes, ran the tests, and committed when I was satisfied.
Why This Matters
- Safety: No accidental force pushes or hard resets
- Review: I always see what's being committed
- Learning: Claude explains what it would do, teaching me in the process
- Trust: I can give Claude more autonomy on read operations while keeping write operations gated
Setup Instructions
- Create the hooks directory:
mkdir -p ~/.claude - Create
settings.jsonand the shell scripts above - Make scripts executable:
chmod +x ~/.claude/*.sh - Restart Claude Code
What's Next?
I'm considering adding hooks for:
- Blocking
rm -rfon certain directories - Requiring confirmation for database migrations
- Logging all commands to an audit file
The hook system is incredibly flexible. You can match specific tools, check command content, and return custom error messages.
Are you using Claude Code hooks? I'd love to hear about your setup in the comments!
Building jo4.io - a modern URL shortener with advanced analytics. Check it out at jo4.io
Top comments (0)