2 min read
Blocking force-pushes with a Claude Code hook
A short Claude Code PreToolUse hook that stops an AI agent from force-pushing over shared git history, and why such rules belong in hooks, not prompts.
- #claude-code
- #ai-agents
- #git
Telling an agent “never force-push” in a prompt works most of the time. Most of the time is not good enough for something that can rewrite a shared branch. Rules that must always hold belong in a hook: a script the harness runs on every matching tool call, whatever the model decides.
The hook
Save this as .claude/hooks/block_force_push.py. Claude Code passes the pending tool call to PreToolUse hooks as JSON on stdin. If the script exits with code 2, the call is blocked and whatever it wrote to stderr is shown to the model, so it can choose a different approach.
#!/usr/bin/env python3
import json
import re
import sys
payload = json.load(sys.stdin)
command = payload.get("tool_input", {}).get("command", "")
if re.search(r"\bgit\s+push\b.*\s(--force|-f)\b", command):
print("Force-pushing is blocked. Push a new commit instead.", file=sys.stderr)
sys.exit(2)
Wiring it up
Register it for the Bash tool in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 .claude/hooks/block_force_push.py" }
]
}
]
}
}
Commit both files so everyone on the team, and every agent run in CI, gets the same guardrail.
Prompts for judgement, hooks for rules
A useful split:
- Prompts carry context and judgement: coding style, architecture preferences, what a good PR description looks like.
- Hooks enforce invariants: no force-pushes, formatter runs after every edit, tests must pass before the agent stops.
If breaking a rule would cost you an afternoon of recovery, it shouldn’t depend on the model remembering it.