Blocking Direct Git Commits with a PreToolUse Hook
A PreToolUse hook makes bare git commit from coding-agent sessions technically impossible, not merely forbidden in a document.
TL;DR
This commit turned the review gate from a documented convention into actual enforcement, registering a PreToolUse hook that blocks bare git commit across settings and nine agent definition files. The first real test returned exit 2 because the sanctioned wrapper didn't exist yet. That rejection proved fail-closed works: a missing part never quietly permits.
The first attempt to use a new path in the KotaPortal repository ended with a rejection: the dispatcher returned exit 2 for a plain git commit. The hook had done its job correctly, while the sanctioned wrapper command was simply not recognized yet. The emergency door was shut tight; the front door had not been installed.
That experience came from the commit that installed the review gate's hook shim. Before this change, the rule "every commit goes through the review gate" lived only as a convention in workflow documents. A convention has no way to refuse: a coding agent that typed a bare git commit -m "..." without a review trailer simply succeeded, and the violation only surfaced later in an audit.
The Limitation of Repository-Level Hooks
The first guess placed enforcement in core.hooksPath, Git's built-in mechanism. The githooks documentation explains that hooks are programs in a hooks directory triggered by Git at specific points, and hooks without the executable bit are ignored [2]. Git even changes the working directory to the worktree root before invoking a hook [2]. The repository layer can certainly refuse commits, but the intended actor here is not a human at a terminal; it is a coding-agent session carrying its own Bash tool.
So the enforcement point moved up one layer, into the agent harness. The official hooks reference explains that hooks run automatically at specific points in the Claude Code lifecycle, and that the PreToolUse event happens "before a tool call executes" and "can block it" [1]. The matcher targets the Bash tool, and the hook returns exit 2 for a direct git commit. The command never reaches a shell.
Enforcing at the Agent Harness Layer
A hook that is written means nothing without registration. The commit registered it in two places: user settings and nine project agent definition files, from backend-engineer, code-reviewer, debugger, frontend-engineer, infra-engineer, release-manager, researcher, security-reviewer, to test-engineer. Every agent carrying a Bash tool received the same hook line. The composition between settings-level and agent-level configuration is not fully documented, so registration happened in both, without relying on undocumented precedence assumptions.
Registration coverage also determines the strength of a gate like this. A hook registered only in global settings leaks the moment an agent has its own definition file with its own hook configuration. That is why the list of nine agents was written out one by one rather than left to implicit inheritance. The cost is eleven repeated lines of configuration; the payoff is that every agent session, whichever definition it runs under, carries the same enforcement.
The single exception is a bypass environment variable whose use is recorded in a bypass log. The shortcut exists for genuine emergencies, but it is never silent.
Auditable Evidence, Not Assumptions
The commit message records the empirical probes: a direct commit rejected twice, zero bypass attempts, zero wrapper uses. The negative paths were exercised for real, while the success path was left to the gate script's own selftest. The dual code-and-security review approved the change with no critical and no major findings, with a few riders parked for later tasks.
The numbers "bare 2, bypass 0, wrapper 0" look trivial, but this pattern is what makes a decision auditable: the rejection path was actually executed, not assumed to work from reading the code. For a gate whose job is to refuse, evidence of refusing is its primary feature.
The OWASP Error Handling guidance fits here: error handling is part of an application's overall security, and unhandled errors feed attacker reconnaissance [3]. The process-control equivalent: a policy violation that ends in silent success is an error path that never woke up. This hook makes every violation visible in the moment, not weeks later when an audit walks the commit history.
Knowingly Unfinished
The door that refuses is installed and tested. The door that forwards, the gate's commit subcommand, only landed in the next commit after real use ran into its absence. The exit 2 that opens this story was not a system failure; it was proof that fail-closed works: a missing part must never quietly permit.