Skip to content

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.md instruction so the policy is documented in the repo
  • Claude Code permissions.deny rules that catch the obvious patterns
  • A PreToolUse hook 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
  • git on the PATH
  • Node and pnpm (or npx) for the block-no-verify package, 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:

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:

.claude/settings.json
{
  "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:

  1. 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-verify appears immediately after commit or push.
  2. A real-world bypass like git commit -m "wip" --no-verify is not caught, because the deny pattern requires the flag right after commit.
  3. 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:

.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:

.claude/settings.json
"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:

~/bin/git
#!/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-verify requires temporarily prefixing the call with the real path (/usr/bin/git commit --no-verify ...) or removing the shim from PATH.
  • 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 git through a path that bypasses your PATH (for example /usr/bin/git directly). 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:

.github/workflows/ci.yml
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
- run: uvx pre-commit run --all-files

Run 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

Last updated on