A PreToolUse hook is the guardrail that survives bypass mode
Permission prompts depend on what mode you're in. A PreToolUse deny hook doesn't. Here's a small one that blocks force pushes, and where it falls short.
Permission prompts are only as strong as the mode you’re running in. Switch to bypass mode, or launch with --dangerously-skip-permissions, and the prompts are gone. A PreToolUse hook is different: according to the Claude Code hooks guide
, it fires before any permission-mode check, and a deny from it blocks the tool even in bypassPermissions mode.
That makes it the right place for the two or three things an agent must never do in your repo, however it’s configured. Force pushes are a good first one.
The script
The hook gets the pending tool call as JSON on stdin. For a Bash call, the command is at tool_input.command. Save this as .claude/hooks/no-force-push.sh:
#!/bin/bash
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eq 'git( .*)? push( .*)? (-f|--force)( |$)'; then
echo "Blocked: force pushes aren't allowed here. Use --force-with-lease, or ask the user." >&2
exit 2
fi
exit 0
Exit 2 blocks the call, and whatever you write to stderr goes back to Claude as feedback. That second part matters. A bare refusal sends the agent hunting for a workaround. A message that names the allowed alternative usually gets you the allowed alternative.
The regex deliberately lets --force-with-lease through, since the pattern requires --force to be followed by a space or the end of the line. Tighten it if your team bans that too.
Make the script executable, or Claude Code can’t run it:
chmod +x .claude/hooks/no-force-push.sh
Register it
Add this to .claude/settings.json to share it with the team, or .claude/settings.local.json to keep it to yourself:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-force-push.sh"
}
]
}
]
}
}
The if field takes the same patterns as permission rules, so the script only spawns for git commands instead of every shell call. Ask Claude to force-push a scratch branch and you should see the block message instead of a push. Run /hooks to confirm the hook is registered.
If you’d rather return structured output, exit 0 and print a JSON object with permissionDecision set to "deny" and a permissionDecisionReason. The docs say to pick one style per hook, not both.
Where it falls short
A regex over a shell string is crude. The pattern above misses git push origin +main, which force-pushes through refspec syntax, and anything that reaches git through an alias or a wrapper script. The hooks guide itself says the if filter is best-effort and recommends the permission system for a hard allow or deny. For the blunt cases, a deny rule in your permission settings is the simpler tool.
Use the hook when the rule needs logic a pattern can’t express: checking the current branch, reading a protected-paths list, or looking at what a command touches. The same mechanism covers file edits (match Edit|Write and read tool_input.file_path), which is how you keep an agent out of .env or a lockfile.
One more limit. Hooks in settings files can tighten restrictions but can’t loosen what your permission rules already forbid. Treat a hook as an extra lock, not a replacement for a sensible allow list.
Start with one rule that has bitten you before, and add the next one when it bites again.