Skip to content

How to stop AI agents from bypassing pre-commit hooks

Keep an agent’s skipped local checks from reaching your default branch by running pre-commit in CI and requiring that check before merging. Local instructions and permissions reduce bypass attempts; the repository’s merge rules enforce the result.

Prerequisites

  • A Python repository with a working .pre-commit-config.yaml
  • A CI workflow that runs those hooks on pull requests
  • Permission to configure branch protection or repository rulesets

Document the checks in project instructions

Add the policy to AGENTS.md, CLAUDE.md, or the instruction file your agent loads:

## Validate changes

Run `uv run pre-commit run --all-files` before committing.
If a hook fails, fix the cause and rerun it.
Do not skip hooks with --no-verify, -n, SKIP, or core.hooksPath.
Report checks that could not run and the reason.

This example assumes pre-commit is a project development dependency. If the repository uses prek, substitute its installed command. Instructions describe the policy; they do not restrict what a process can execute.

Block common bypass commands in Claude Code

Merge these rules into .claude/settings.json, preserving existing settings:

.claude/settings.json
{
  "permissions": {
    "deny": [
      "Bash(git *--no-verify*)",
      "Bash(git commit -n *)",
      "Bash(git commit * -n *)"
    ]
  }
}

Claude Code supports * anywhere in a Bash permission pattern, including between arguments. These rules catch common command spellings, including git commit -m 'message' --no-verify. Matching text also matches quoted message text, so a message containing a blocked flag may be rejected.

These rules do not cover every equivalent operation: an absolute Git path, an alias, a script, or an MCP commit tool needs separate controls. They also do not govern commands run by Codex or Cursor. Check Claude Code’s permission matching documentation when expanding them.

A PreToolUse hook supports more detailed checks. Exit code 2 blocks a matching tool call; other nonzero exits do not provide the same guarantee unless failure handling is configured. A Bash matcher does not inspect a different tool’s actions. Test both an allowed command and a blocked command before relying on a hook.

Run the hooks independently in CI

Use the project’s existing GitHub Actions workflow. Add pre-commit to the project’s development dependencies if it is not already tracked:

uv add --dev pre-commit

The --dev flag marks pre-commit as a development dependencyA package needed during development (testing, linting, formatting) that is not shipped to users of your project. Installed with the --dev flag. , keeping it out of production installs. Expect pyproject.toml and uv.lock to change; commit both files.

After checkout and uv setup in the CI job, run:

- name: Run repository hooks
  run: uv run --locked pre-commit run --all-files

uv run executes the command inside the project’s virtual environment, ensuring all dependencies are installed first. --locked rejects a stale lockfile. Expect a nonzero job result when any hook fails or changes a file. Keep SKIP unset and do not add continue-on-error: true to this step.

Require the CI result before merging

Configure branch protection or an active ruleset for the default branch:

  1. Require a pull request before merging.
  2. Require the status check produced by the job that runs the hooks.
  3. Restrict direct pushes and review which accounts or apps can bypass the rules.

A CI run alone does not prevent a merge. Confirm the required check is selected and that the account used by your coding agent has no unintended bypass access. Review edits to workflows and hook configuration as carefully as source edits.

Verify a failing change cannot merge

On a disposable branch, introduce a violation that an existing hook detects, such as trailing whitespace. Open a pull request and confirm the hook job fails and merging is blocked. Fix the violation, rerun CI, and confirm the required check succeeds.

This verifies the outcome for Claude Code, Codex, Cursor, and human commits alike, even when local hooks never ran.

Last updated on