Blocking the commit the agent shouldn't make
A pre-tool hook that refuses four kinds of commit and push — including the ones your repo's own pre-commit hook can't stop, because one flag turns it off.
The commit you didn't ask for
The problem this aims to address is that even when the turn goes well, the commit after it can be sth you didn't ask for. It lands on main. Or it carries .env along with the four files you wanted. Or the message is wip and the diff is 200 lines across three unrelated concerns. Or — the one that stings — it goes in with --no-verify, because the pre-commit hook complained and the agent read that as an obstacle rather than an instruction.
The obvious objection first: this repo already has a pre-commit hook, and CI runs on every push. Both true, and neither one is upstream of the decision. CI kicks off after there's a commit, which means after it's already on the branch; and the pre-commit hook is skippable by design — that's what its own flag is for.
So what we want is a pre-tool hook that reads the git command as text before it runs, and decides. Not a general shell-command policer: one narrow gate on the four properties of a commit or push you actually mind, with a refusal written so the model does the right thing next instead of trying a different spelling.
What --no-verify does to the hook you already have
Worth watching once, because it changes where you put the gate. A disposable repo with a real .git/hooks/pre-commit that fails, on git 2.39.5:
$ git commit -m first
pre-commit hook ran
# refused — no commit object created
$ git commit --no-verify -m first
2 files changed, 175 insertions(+)
$ git log --oneline
e1bbe38 first # the hook never ran; the commit exists
$ git -c core.hooksPath=/dev/null commit -m second
[main d24c9f5] second # the hook never ran eitherTwo bypasses, and only one of them is a flag. core.hooksPath points git at a different hooks directory for a single invocation, so there's nothing left for pre-commit to defend. A repo's own hooks can't protect themselves; sth further up has to.
That sth is the harness's pre-tool event, which sees the command as a string before a process starts — while --no-verify is still an argument you can read, rather than a decision git has already acted on.
Four properties, not a list of bad commands
So the setup we want is a thing that runs before every command, reads the command itself as text, and then decides whether it's okay to run it. What we don't want is a list of forbidden commands — git commit has enough spellings to lose that game, and Kimi Code's own hooks documentation says the quiet part out loud about its rm -rf example: it "is not a production-grade security parser. Real scenarios are better served by whitelists, or a dedicated shell parser to handle quoting, variable expansion, and multi-command sequences."
So we gate properties of one specific action instead. It's not okay when it's a commit or a push and it's any of these:
the branch is main or master — the cheapest of the four to enforce and the most common to trip,
sth in it turns the repo's own checks off:
--no-verify, the clustered-n, or-c core.hooksPath=…,there's a file that never gets committed here (
.env,*.pem, a keyfile) — and we mean what's actually staged, plus what this command is about to stage,it's a force push without a lease, because
--force-with-leasefails instead of overwriting a commit somebody else pushed and plain--forcedoesn't.
Everything else goes through, on purpose. A guard that fires on things you didn't mean gets deleted within a week and then you have nothing — while we were working on this one we watched an overly broad version refuse git commit --help, because it pattern-matched a word anywhere in the command string instead of looking at what the command does.
The guard
So… the guard itself is one file, scripts/commit-guard.py, and we keep it in the repo under scripts/ rather than in any harness's config directory — that way six harnesses can point at one script, and it gets reviewed like everything else in there. No dependencies; the two lists at the top are the whole config:
PROTECTED_BRANCHES = ("main", "master")
NEVER_COMMIT = (".env", ".env.*", "*.pem", "id_rsa", "*.p12")And there are four things in it worth explaining. Each of them is a consequence of a bug we hit first:
It finds the command in four different places. The pre-tool payload arrives as JSON on stdin — in the five harnesses whose docs name a transport at all; GitHub's reference only says the payload reaches the handler. And every one of them nests the shell command somewhere else:
tool_input.commandon Claude Code, Codex, Gemini CLI and Kimi Code,toolArgs.commandon Copilot's camelCase events, a top-levelcommandon Cursor'sbeforeShellExecution, andtoolCall.args.CommandLineon Antigravity. Four lookups, one script. Copilot's is the one to check rather than trust: its reference typestoolArgsasunknownand never names the sub-key.It leaves early and often. The first thing it does is return if there's no
gitin the command, or nocommit/pushamong the segments — exit 0 before doing anything expensive, because this code sits on the path of every shell call the agent makes.It knows
-nis two different flags.-nis short for--no-verifyon commit (git help commit) and for--dry-runon push (git help push), so if we treated it as a bypass on push we'd be blocking dry runs for no reason. Short flags also cluster, sogit commit -nm "wip"has to trip it too.It splits compound commands.
git add -A && git commit -m wipis two invocations in one string, and at that point the hook can't see anything theadddid yet — so the guard also asks what thataddis about to stage.-Aand.include untracked files while-uandcommit -aaffect only tracked ones, which is why an untracked.envdoesn't tripgit commit -am.
If the guard decides it's not a good command, it refuses by writing the reason to stderr and returning 2 as the exit code. All six harnesses treat that pair as a refusal, so the script speaks it and nothing else. What each one then does with the text varies — mostly it becomes the model's next input, on Copilot it doesn't — so the wording is an instruction with a way forward rather than a log line, and each harness section below says what happens to it.
Run the cases before you wire it up
Let's do some exercise before we get to the integration. The guard is a script that reads JSON on stdin, so you can run it against every single rule without a harness, without calling the model and without spending a token. Here's a fixture and a check that pipes one in and prints the exit code together with the reason:
fixture() { printf '{"cwd":"%s","tool_input":{"command":"%s"}}' "$PWD" "$1"; }
check() { reason=$(fixture "$1" | python3 scripts/commit-guard.py 2>&1); printf '[exit %d] %-34s %s\n' $? "$1" "$reason"; }
check 'npm test'
check 'git commit -m wip'
check 'git commit --no-verify -m wip'
check 'git commit -nm wip'
check 'git push --force origin HEAD'
check 'git push --force-with-lease origin HEAD'
check 'git add -A && git commit -m wip'On a topic branch with an untracked .env in the tree, that prints:
[exit 0] npm test
[exit 0] git commit -m wip
[exit 2] git commit --no-verify -m wip Drop `--no-verify` and run the commit again. It turns off the checks this repo runs from its own git hooks — if one of them fails, fix what it reports.
[exit 2] git commit -nm wip Drop `-n` and run the commit again. It turns off the checks this repo runs from its own git hooks — if one of them fails, fix what it reports.
[exit 2] git push --force origin HEAD Use `git push --force-with-lease` instead of `--force`, so the push fails instead of overwriting a commit somebody else pushed.
[exit 0] git push --force-with-lease origin HEAD
[exit 2] git add -A && git commit -m wip `.env` is not committed to this repo. Run `git restore --staged .env`, add it to .gitignore, and commit the rest.Run it on main and the second case — the plain commit — flips:
[exit 2] git commit -m wip main is protected here. Run `git switch -c <topic-branch>` first and commit there, then open a pull request when the work is ready to review.Read those reasons the way the model will read them, because that's all it gets. And do this exercise before the integration rather than after it: if you wire it up first and see exit 0 on every turn, you have no way to tell a guard that agrees with everything from a guard that was never loaded.
In Claude Code
Using the PreToolUse event, we put the hook in .claude/settings.json with Bash as its matcher, so the configuration is tracked along with the script — and $CLAUDE_PROJECT_DIR resolves to the root of the project no matter which directory you run it from.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 $CLAUDE_PROJECT_DIR/scripts/commit-guard.py"
}
]
}
]
}
}The python3 at the front is deliberate: called that way the file doesn't need its executable bit set, even though it has a shebang. And the exit code 2 is the only interface we need here — whatever the guard writes to stderr gets included in the output of the tool and marked as an error, prepended with the hook's name and the command that triggered it.
If you want to communicate something richer, you can do it with JSON on stdout in a property called hookSpecificOutput.permissionDecision, which takes four values:
allowdenyaskdefer
But for a gate that only ever refuses, it's not needed.
The gotcha: there are two flags that start a session with the hooks switched off. --bare is "minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory…", and --safe-mode starts "with all customizations (CLAUDE.md, skills, plugins, hooks, MCP servers…) disabled". So run /hooks in a session to see what's actually enabled.
In Codex CLI
Hooks are marked stable as of version 0.146.0 — run codex features list and you'll see hooks stable true. They're defined in the hooks.json file in the .codex/ directory in the root of your repository. The names of the events and the shape of the payload are the same words Claude Code uses, so the config is easily transferrable (with only minor changes).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/scripts/commit-guard.py\"",
"timeout": 30
}
]
}
]
}
}The matcher is a regular expression which is run against the tool's name, and every shell invocation is reported as Bash. There are two ways to prevent an action:
setting the exit code to 2 and writing a reason on
stderrreturning JSON with
hookSpecificOutput.permissionDecisionset to"deny"— for which Codex also needs a non-emptypermissionDecisionReason
timeout is in seconds.
The gotcha, and it's a real one for a guard: Codex records hook trust against the hash of the hook definition, so if you modify one or add a new one it's "marked for review and skipped until trusted" — you need to go to /hooks and approve the change before it runs again. Project-local hooks also need the project's .codex/ layer to be trusted at all; otherwise only system- and user-level hooks are loaded. Modify the entry in hooks.json without re-approving it and the protection you think is enabled doesn't actually work.
In GitHub Copilot CLI
Repository-level hooks live in .github/hooks/*.json. Use the PascalCase spelling of the event and it maps onto Claude's syntax — Claude's matchers and Claude's payload format — which is what our script already reads.
{
"version": 1,
"hooks": {
"PreToolUse": [
{
"type": "command",
"matcher": "Bash",
"bash": "python3 scripts/commit-guard.py",
"timeoutSec": 30
}
]
}
}PreToolUse is a different thing from preToolUse, and it matters here. The lowercase one matches the runtime tool name with a regex — bash, or powershell on Windows — and delivers the payload under toolArgs. The PascalCase one takes Claude's tool names and alternations (Bash, Edit|Write) and delivers the payload in the snake_case, VS Code-compatible form with tool_input. Either works with our script, but the PascalCase form lets you define the wiring once and keep it the same across harnesses. Copilot compiles every matcher as ^(?:PATTERN)$, so shell never matches anything.
Copilot also behaves differently from the other tools when it comes to exit codes, in the direction you want here. preToolUse is the only fail-closed event — exit 2, or any other non-zero code, prevents the command from running, and that includes a crash: "Denied by preToolUse hook (hook errored)". Timeouts are fail-open on every event, including this one.
There is one thing this setup doesn't give you, though, which is the reason for the refusal. Exit 2 puts the guard's stderr in front of you; what the agent reads is permissionDecisionReason from stdout, which the vendor requires when the decision is deny. So wrap the guard in four lines that move it across, and the flow ends up exactly the same as on the other tools:
#!/bin/bash
# Copilot shows the agent the stdout reason, not stderr — so re-emit the guard's refusal there.
reason=$(python3 scripts/commit-guard.py 2>&1) && exit 0
printf '{"permissionDecision":"deny","permissionDecisionReason":%s}\n' \
"$(printf '%s' "$reason" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))')"The gotcha: on 1.0.77 the repository-level hooks didn't run at all when we created a fresh directory and ran copilot there in -p mode. It looks like it might be connected with folder trust, as the reference says machine policy hooks work "regardless of folder trust state", which implies the repo-level ones don't. Run /env in the directory you actually work in to see which hooks the session picked up — and treat this whole section as written from the reference rather than from a run.
In Cursor
The pre-action moment is split per tool by Cursor, so the guard gets attached to shell commands only and every other tool call is left as is. It goes in <project>/.cursor/hooks.json:
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{
"command": "python3 ./scripts/commit-guard.py",
"timeout": 15,
"failClosed": true
}
]
}
}In project-level hooks, paths are resolved relative to the project's root, while for user-level ones they're resolved relative to the ~/.cursor folder. failClosed defaults to false, and for a guard you want it true — otherwise a hook that throws an error lets the commit through anyway.
We have tested it and can confirm that returning 2 with the reason on stderr works here, and that the message is passed straight to the model. The documented alternative is a deny-permission object on stdout, which is worth the extra step if you want separate user-facing and model-facing messages:
{ "permission": "deny", "user_message": "…shown to you", "agent_message": "…sent to the model" }The gotcha: Cloud Agents skip a chunk of the hook surface — sessionStart, sessionEnd, the MCP and Tab events and workspaceOpen don't fire there, and type: "prompt" handlers aren't available at all — so don't assume a local gate is also a cloud gate.
In Gemini CLI
In terms of Gemini, the hook is set on BeforeTool, under the hooks key in .gemini/settings.json. The tool it uses to run shell commands is called run_shell_command and its input can be found at tool_input.command. This is what the configuration looks like:
{
"hooks": {
"BeforeTool": [
{
"matcher": "run_shell_command",
"hooks": [
{
"name": "commit-guard",
"type": "command",
"command": "python3 $GEMINI_PROJECT_DIR/scripts/commit-guard.py",
"timeout": 15000
}
]
}
]
}
}Note that the timeout is in milliseconds here, unlike in the rest of the chapter. If you want to refuse a tool call in Gemini you can return exit code 2 and write the reason on stderr — it terminates the tool call and reaches the agent as an error for the tool — or you can return a deny decision with a reason as JSON on stdout. And if you already have this set up for Claude Code, gemini hooks migrate --from-claude migrates both the event names and the tool names in the matchers.
The gotcha is working out which Google CLI you're on. Google discontinued the consumer tiers for Gemini in June 2026, so on 0.46.0 the session doesn't even start with a regular Google account — it throws an IneligibleTierError pointing at Antigravity. And Antigravity is another tool, not a new name for Gemini: its hooks are defined in .agents/hooks.json, keyed by hook name with the event nested inside, its shell tool is run_command, and the command arrives at toolCall.args.CommandLine — the fourth payload shape our script handles. What doesn't carry over is the refusal itself. Its documented PreToolUse contract is a JSON object printed to stdout, with decision set to "allow", "deny", "ask" or "force_ask" plus a reason, and no exit-code route at all — so there you need a wrapper that translates the guard's exit 2 into that object.
In Kimi Code CLI
The rules go into a TOML array of tables in the ~/.kimi-code/config.toml file, one table per rule:
[[hooks]]
event = "PreToolUse"
matcher = "Bash"
command = "python3 ./scripts/commit-guard.py"
timeout = 15four keys are allowed per entry —
event,matcher,commandandtimeoutduring the execution of the hook the script has the session's project directory as its working directory, so you can keep it in the repo and include it in code review while the registration itself stays per-user and uncommitted
given that the docs describe only the user-level location, "everyone on the team has this" means asking six people to add four lines on their own machines
PreToolUseis one of the three blocking events (withUserPromptSubmitandStop) and it fires before the permission check; there are another 13, but they're non-blocking and just observe the processreturning exit code 2 with a message on
stderrstops the tool call and writes that message back into the context
The gotcha: the schema accepts exactly those four keys, so if you include any more in an entry the file won't load at all — one typo renders the whole config unusable rather than costing you one hook. Run kimi doctor after every change and it will tell you whether config.toml is valid, or which entry and which unrecognised key broke it.
Watch it refuse a real turn
Wiring done, spend one turn confirming it. Ask for the exact command rather than describing a goal, because what you want here is the refusal, not a negotiation:
Run exactly this command, nothing else, then tell me the outcome:
git commit --no-verify -am wip
On Claude Code 2.1.220 the tool result came back as an error, verbatim:
PreToolUse:Bash hook error: [python3 $CLAUDE_PROJECT_DIR/scripts/commit-guard.py]: Drop
`--no-verify` and run the commit again. It turns off the checks this repo runs from its own
git hooks — if one of them fails, fix what it reports.The command never ran, and the reply was "the command didn't run … re-running it as-is will just fail the same way. Want me to run git commit -am wip (hooks enabled) instead?" On Cursor 2026.07.23 the same guard on beforeShellExecution came back with the same refusal text, reported more briefly — "The command did not run … No commit was created." Neither of them went looking for a way around it, which is what the wording is for.
If you get silence instead, almost always it means the hook isn't loaded rather than that the logic is wrong. Swap the hook's command for this:
cat > /tmp/hook-payload.json…then run one turn and take a look at the file. If it's not there, the hook isn't loaded; if it is, you can see exactly which key holds the command.
One more thing we've seen, once, and it's worth knowing it's possible. Asked to "stage everything and commit" on main, the agent read scripts/commit-guard.py on its way past, then gitignored the .env, branched off main and committed there — without a single refusal firing. So a guard the agent can read is a piece of documentation for it before it's a gate.
What it costs on every other call
The last thing worth showing is what the guard costs. Measured on the machine this was written on, 20 runs each: 34 ms per call on the early-exit path, and 77 ms when the command really is a commit and the guard shells out to git. The 34 ms is the figure to focus on, because it's what every shell call in the session pays — which is the argument for the early return despite it being the less readable shape, and the argument against matching this hook on every tool instead of the shell one.
What it doesn't stop
We want to finish by saying a few words about what isn't covered:
it's not a security measure. Hooks fail open almost everywhere, so a crash, a timeout or a session started with hooks disabled and the gate simply isn't there. Permissions, sandboxing and branch protection on the server are the layers that work against an adversary; this one works against a mistake.
it only sees the shell tool. If you have an MCP git server, a commit that goes through it never reaches the guard at all. Gate that tool too, or don't install it.
it reads a string, not a shell.
python3 deploy.pyis a shell call the guard inspects and waves through, because the wordgitisn't in it — whatever the script does next is invisible. Same for aliases, variable expansion, and operators with no spaces around them (git add -A&&git commit): all of it is text, so all of it can be walked around by anyone who wants to. Agents mostly aren't trying to.local checks are not the repo's rules. Protected branches belong on the server as well; this just stops the commit from landing on main in the first place, which is the cheaper point of interception.
These are the four rules we came up with, but it's a starting point rather than a standard. The one worth adding next is whatever your team has already had to undo by hand.
scripts/commit-guard.py#!/usr/bin/env python3
"""Refuse the commits and pushes this repo doesn't want.
Reads a pre-tool hook payload on stdin. Exits 0 to let the call through, or writes a reason
to stderr and exits 2 to refuse it. The reason is the model's next input, so it is written
as an instruction, not as a log line.
"""
import fnmatch
import json
import shlex
import subprocess
import sys
PROTECTED_BRANCHES = ("main", "master")
NEVER_COMMIT = (".env", ".env.*", "*.pem", "id_rsa", "*.p12")
# Where each harness puts the shell command it is about to run.
COMMAND_KEYS = (
("tool_input", "command"), # Claude Code, Codex, Gemini CLI, Kimi Code
("toolArgs", "command"), # Copilot CLI, camelCase events
("toolCall", "args", "CommandLine"), # Antigravity CLI
("command",), # Cursor, beforeShellExecution
)
def refuse(reason):
print(reason, file=sys.stderr)
sys.exit(2)
def payload_command(payload):
for keys in COMMAND_KEYS:
value = payload
for key in keys:
value = value.get(key) if isinstance(value, dict) else None
if isinstance(value, str) and value.strip():
return value
return ""
def git(cwd, *args):
done = subprocess.run(("git", "-C", cwd, *args), capture_output=True, text=True)
return done.stdout.splitlines() if done.returncode == 0 else None
def segments(command):
"""The command split on shell operators, so `git add -A && git commit` is two calls."""
try:
tokens = shlex.split(command, comments=True)
except ValueError:
tokens = command.split()
out, current = [], []
for token in tokens:
if token in ("&&", "||", ";", "|", "&"):
out.append(current)
current = []
else:
current.append(token)
out.append(current)
return [s for s in out if s]
def git_call(tokens):
"""(subcommand, args, global_opts) for a git invocation, or None if this isn't one."""
i = 0
while i < len(tokens) and "=" in tokens[i] and tokens[i].split("=", 1)[0].isidentifier():
i += 1 # leading VAR=value assignments
if i >= len(tokens) or tokens[i] != "git":
return None
globals_, i = [], i + 1
while i < len(tokens):
token = tokens[i]
if token in ("-c", "-C", "--git-dir", "--work-tree"):
globals_ += tokens[i + 1:i + 2]
i += 2
elif token.startswith("-"):
globals_.append(token)
i += 1
else:
return token, tokens[i + 1:], globals_
return None
def bypasses_verification(subcommand, args, globals_):
if any(g.startswith("core.hooksPath") for g in globals_):
return "core.hooksPath"
if "--no-verify" in args:
return "--no-verify"
# -n is --no-verify for commit but --dry-run for push, and it clusters: -nm "wip".
if subcommand == "commit":
short = (a for a in args if a.startswith("-") and not a.startswith("--"))
if any("n" in a[1:] for a in short):
return "-n"
return None
def would_stage(parts):
"""(tracked, untracked) — what a broad `git add` or `commit -a` in this command picks up."""
tracked = untracked = False
for tokens in parts:
call = git_call(tokens)
if not call:
continue
subcommand, args, _ = call
clustered = [a[1:] for a in args if a.startswith("-") and not a.startswith("--")]
if subcommand == "add":
if any(a in ("-A", "--all", ".", ":/") for a in args):
tracked = untracked = True
elif "--update" in args or any("u" in c for c in clustered):
tracked = True
elif subcommand == "commit" and ("--all" in args or any("a" in c for c in clustered)):
tracked = True # -a stages modified tracked files, never untracked ones
return tracked, untracked
def main():
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, UnicodeDecodeError):
sys.exit(0) # not our payload; never break the session over a parse error
command = payload_command(payload)
if "git" not in command:
sys.exit(0)
parts = segments(command)
writes = [c for c in map(git_call, parts) if c and c[0] in ("commit", "push")]
if not writes:
sys.exit(0)
roots = payload.get("workspacePaths") or payload.get("workspace_roots") or ["."]
cwd = payload.get("cwd") or roots[0]
for subcommand, args, globals_ in writes:
flag = bypasses_verification(subcommand, args, globals_)
if flag:
refuse(
f"Drop `{flag}` and run the {subcommand} again. It turns off the checks this repo "
"runs from its own git hooks — if one of them fails, fix what it reports."
)
if subcommand == "push":
forced = [a for a in args if a == "--force" or (a[:1] == "-" and a[1:2] != "-" and "f" in a)]
if forced and not any(a.startswith("--force-with-lease") for a in args):
refuse(
"Use `git push --force-with-lease` instead of `--force`, so the push fails "
"instead of overwriting a commit somebody else pushed."
)
continue
branch = git(cwd, "symbolic-ref", "--short", "HEAD")
# No branch name means a detached HEAD (mid-rebase, mid-bisect) or no repo here at all.
if branch and branch[0] in PROTECTED_BRANCHES:
refuse(
f"{branch[0]} is protected here. Run `git switch -c <topic-branch>` first and "
"commit there, then open a pull request when the work is ready to review."
)
staged = git(cwd, "diff", "--cached", "--name-only") or []
tracked, untracked = would_stage(parts)
for line in git(cwd, "status", "--porcelain") or []:
if untracked if line.startswith("??") else tracked:
staged.append(line[3:])
for path in staged:
name = path.rsplit("/", 1)[-1]
if any(fnmatch.fnmatch(path, p) or fnmatch.fnmatch(name, p) for p in NEVER_COMMIT):
refuse(
f"`{path}` is not committed to this repo. Run `git restore --staged "
f"{path}`, add it to .gitignore, and commit the rest."
)
sys.exit(0)
if __name__ == "__main__":
main()