How to stop AI agents from bypassing pre-commit hooks
Claude Code can ship broken code by running git commit --no-verify to skip your pre-commit hooks. The behavior is documented and persistent: Anthropic’s claude-code issue #40117 describes Claude Code Opus 4.6 bypassing explicit deny rules and CLAUDE.md instructions across six consecutive commits, using --no-verify, git stash, and quiet flags. The issue was closed as “not planned,” which means defense comes from outside the agent.
This guide walks four enforcement layers plus a CI backstop:
- A
CLAUDE.mdinstruction so the policy is documented in the repo - Claude Code
permissions.denyrules that catch the obvious patterns - A
PreToolUsehook that parses every git command and rejects--no-verify - A PATH-shim git wrapper that defends against any agent, not just Claude Code
- A CI pre-commit run as the backstop
The hook layer is the only one that reliably enforces the rule. The other layers are useful but not sufficient on their own.
Prerequisites
- Claude Code installed
- A Python project with pre-commit (or prek) hooks configured
giton thePATH- Node and
pnpm(ornpx) for theblock-no-verifypackage, or Bash for the PATH-shim alternative
Pick the right layer for your goal
Each layer answers a different question. Pick the ones that match your workflow; nothing requires installing all of them.
| Goal | Layer | Effort |
|---|---|---|
| Document the policy for human collaborators reading the repo | CLAUDE.md | trivial |
Catch the obvious git commit --no-verify prefix in Claude Code |
Permissions deny rule | trivial, partial |
Reliably block every --no-verify use in Claude Code, including MCP commits |
PreToolUse hook with block-no-verify |
small |
Block --no-verify against any agent or shell, not just Claude Code |
PATH-shim git wrapper | medium |
| Catch anything that slipped through every other layer | pre-commit in CI | already done if CI is set up |
Document the policy in CLAUDE.md
A CLAUDE.md section is the simplest place to record the project’s stance, even though documenting the rule does not enforce it. Add this to your CLAUDE.md:
## Git hygiene
Never run `git commit --no-verify`, `git commit -n`, or any equivalent
that skips pre-commit hooks. If a hook fails, fix the underlying issue.
If a hook is genuinely broken, fix the hook in a separate commit. Do
not use `git stash` to manipulate staged state in order to dodge a hook.This section makes the policy visible to humans reading the repo and to AI agents that respect CLAUDE.md. It does not stop a determined agent. Anthropic’s claude-code issue #40117 records the exact failure mode: Claude Code Opus 4.6 bypassing identical instructions across six consecutive commits. The instruction is the floor of the playbook, not the enforcement.
Deny --no-verify with Claude Code permissions
Claude Code reads permissions.deny from .claude/settings.json before running any tool. A deny rule that matches the agent’s intended Bash command blocks it. Add this:
{
"permissions": {
"deny": [
"Bash(git commit --no-verify *)",
"Bash(git commit -n *)",
"Bash(git push --no-verify *)"
]
}
}Three things to know about the limits of this layer:
- Claude Code’s deny syntax uses prefix matching with wildcards, not “contains” matching.
Bash(git commit *--no-verify*)does not work the way it reads, because the leading*is not a wildcard for “anything in front.” The deny rules above only catch commands where--no-verifyappears immediately aftercommitorpush. - A real-world bypass like
git commit -m "wip" --no-verifyis not caught, because the deny pattern requires the flag right aftercommit. - Even when a deny rule matches, issue #40117 shows Claude Code reaching for adjacent escape hatches (
git stash, quiet flags) when blocked.
Keep the deny rules. They are a free partial backstop. The PreToolUse hook is what actually enforces the rule.
Block --no-verify with a PreToolUse hook
A PreToolUse hook fires before every tool call and can block it by exiting non-zero. Unlike permission rules, a hook can parse the full command at runtime and detect --no-verify anywhere in the arguments, including after -m "msg".
The fastest path is the block-no-verify package, which monitors git commit, push, merge, cherry-pick, rebase, and am and rejects any of them with --no-verify or -n. Add this to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "pnpm exec block-no-verify"
}
]
},
{
"matcher": "mcp__github__.*",
"hooks": [
{
"type": "command",
"command": "pnpm exec block-no-verify"
}
]
}
]
}
}The second matcher (mcp__github__.*) covers commits made through an MCP GitHub server. Without it, an agent that switches from local git to an MCP commit tool still slips past the Bash matcher. If your project does not use an MCP GitHub server, you can omit that block.
Swap pnpm exec for npx if your team uses npm directly:
"command": "npx block-no-verify"Tip
No Node available? Write a Python PreToolUse hook with the same logic; the Claude Code hooks reference shows the stdin-and-exit-2 pattern.
Intercept git with a PATH shim
A PreToolUse hook only catches Claude Code. If Codex, Cursor, or any other shell-based tool also runs against your repo, install a Bash shim earlier in $PATH than the real git.
Note
The shim requires Bash (macOS, Linux, WSL, or Git Bash on Windows). Native Windows shells (PowerShell, cmd) need a different wrapper. If Bash is not an option, the PreToolUse hook above is fully cross-platform and covers the Claude Code case without the shim.
Save this as ~/bin/git (or wherever your PATH-first directory lives) and chmod +x it:
#!/usr/bin/env bash
set -euo pipefail
# Find the real git, skipping this wrapper.
SELF="$(cd "$(dirname "$0")" && pwd)/git"
REAL_GIT=""
IFS=':' read -ra DIRS <<< "$PATH"
for dir in "${DIRS[@]}"; do
candidate="$dir/git"
if [[ -x "$candidate" && "$candidate" != "$SELF" ]]; then
REAL_GIT="$candidate"
break
fi
done
if [[ -z "$REAL_GIT" ]]; then
echo "git wrapper: cannot locate real git binary" >&2
exit 127
fi
# Detect --no-verify (or -n on commit) against any subcommand that accepts it.
cmd=""
has_no_verify=false
for arg in "$@"; do
case "$arg" in
commit|push|merge|cherry-pick|rebase|am) cmd="$arg" ;;
--no-verify) has_no_verify=true ;;
-n) [[ "$cmd" == "commit" ]] && has_no_verify=true ;;
esac
done
if $has_no_verify && [[ -n "$cmd" ]]; then
echo "git wrapper: --no-verify is blocked on '$cmd'." >&2
echo "Fix the failing hook instead, or unset PATH to override deliberately." >&2
exit 1
fi
exec "$REAL_GIT" "$@"The script searches $PATH for the next git that is not itself, so it does not infinite-loop. It covers the same six subcommands block-no-verify watches.
Trade-offs to know:
- The shim shadows system git in every shell, not just AI-agent sessions. A legitimate WIP commit with
--no-verifyrequires temporarily prefixing the call with the real path (/usr/bin/git commit --no-verify ...) or removing the shim fromPATH. - Some workflows (rebasing through a series, force-merging when a CI commit-msg hook is broken) sometimes need
--no-verify. The shim treats every use the same, so build an unset path into your workflow before installing it. - The shim does not protect against the agent calling
gitthrough a path that bypasses yourPATH(for example/usr/bin/gitdirectly). Pair it with the PreToolUse hook for defense in depth.
Catch bypassed hooks in CI
Every layer above runs locally. The final defense is running pre-commit on every push in CI. If any earlier layer was bypassed or misconfigured, CI catches the missing checks before the change is merged. The setup is covered in how to set up pre-commit hooks for a Python project; the relevant step is:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
- run: uvx pre-commit run --all-filesRun the same hooks against the whole repo on every PR. The agent cannot pass --no-verify to CI.
Combine the layers
For a Claude Code project, the recommended baseline is the CLAUDE.md instruction plus the permissions.deny rules plus the block-no-verify PreToolUse hook from the sections above, with pre-commit in CI as the backstop. Commit CLAUDE.md and .claude/settings.json so the configuration travels with the repo. Add the PATH-shim wrapper to your shell’s PATH separately if Codex, Cursor, or other shell-based agents also work in the same repo; the shim is a personal-machine concern, not a per-repo file.
Caution
Issue #40117 documents adjacent bypass strategies beyond --no-verify: git stash to manipulate staged state, and quiet/silent flags to suppress hook output. The layers in this guide block the --no-verify flag specifically. A determined agent can still try git stash to hide failing test artifacts, or use Chris Richardson’s stronger pattern of denying Bash(git commit:*) entirely and routing all commits through a custom MCP server tool. That option trades convenience for full control over which changes become commits.
Learn More
- How to set up pre-commit hooks for a Python project for the underlying hook setup
- How to set up prek hooks for a Python project for the faster prek alternative
- How to write Claude Code hooks for Python projects for the full
PreToolUse/Stophook reference - How to configure Claude Code with a Python type checker and how to configure Ruff with Claude Code for the parallel hook-and-permission playbooks
- Claude Code permissions documentation for the full
permissions.denysyntax - block-no-verify on GitHub and Allow Bash(git commit:*) considered harmful for the upstream tools and the MCP-based pattern