# 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](https://github.com/anthropics/claude-code/issues/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`](https://code.claude.com/docs/en/settings) 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](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md) 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](https://code.claude.com/) installed
* A Python project with [pre-commit](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md) (or [prek](https://pydevtools.com/handbook/how-to/how-to-set-up-prek-hooks-for-a-python-project.md)) 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](#document-the-policy-in-claudemd) | trivial |
| Catch the obvious `git commit --no-verify` prefix in Claude Code | [Permissions deny rule](#deny--no-verify-with-claude-code-permissions) | trivial, partial |
| Reliably block every `--no-verify` use in Claude Code, including MCP commits | [`PreToolUse` hook with `block-no-verify`](#block--no-verify-with-a-pretooluse-hook) | small |
| Block `--no-verify` against any agent or shell, not just Claude Code | [PATH-shim git wrapper](#intercept-git-with-a-path-shim) | medium |
| Catch anything that slipped through every other layer | [pre-commit in CI](#catch-bypassed-hooks-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`:

```markdown {filename="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](https://github.com/anthropics/claude-code/issues/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:

```json {filename=".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](https://code.claude.com/docs/en/settings), 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](https://github.com/anthropics/claude-code/issues/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`](https://github.com/tupe12334/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`:

```json {filename=".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:

```json {filename=".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](https://pydevtools.com/handbook/how-to/how-to-write-claude-code-hooks-for-python-projects.md) shows the stdin-and-exit-2 pattern.

## Intercept git with a PATH shim

A `PreToolUse` hook only catches Claude Code. If [Codex](https://pydevtools.com/handbook/explanation/codex-complete-guide.md), 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:

```bash {filename="~/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](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md); the relevant step is:

```yaml {filename=".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](https://microservices.io/post/genaidevelopment/2025/09/10/allow-git-commit-considered-harmful.html) 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](https://pydevtools.com/handbook/how-to/how-to-set-up-pre-commit-hooks-for-a-python-project.md) for the underlying hook setup
* [How to set up prek hooks for a Python project](https://pydevtools.com/handbook/how-to/how-to-set-up-prek-hooks-for-a-python-project.md) for the faster prek alternative
* [How to write Claude Code hooks for Python projects](https://pydevtools.com/handbook/how-to/how-to-write-claude-code-hooks-for-python-projects.md) for the full `PreToolUse` / `Stop` hook reference
* [How to configure Claude Code with a Python type checker](https://pydevtools.com/handbook/how-to/how-to-configure-claude-code-with-a-python-type-checker.md) and [how to configure Ruff with Claude Code](https://pydevtools.com/handbook/how-to/how-to-configure-ruff-with-claude-code.md) for the parallel hook-and-permission playbooks
* [Claude Code permissions documentation](https://code.claude.com/docs/en/settings) for the full `permissions.deny` syntax
* [block-no-verify on GitHub](https://github.com/tupe12334/block-no-verify) and [Allow Bash(git commit:*) considered harmful](https://microservices.io/post/genaidevelopment/2025/09/10/allow-git-commit-considered-harmful.html) for the upstream tools and the MCP-based pattern
