A Commit Gate Needs a Sanctioned Path, Not Just a Refuser
A hook that refuses direct commits is half a gate; a wrapper promised since Task 1 but never implemented turns security into a total lock.
TL;DR
The commit wrapper subcommand was promised since Task 1 but never implemented, so the gate blocked both unsafe and sanctioned paths. The fix: a 40-line cmd_commit that validates messages via the same check path before committing, with no silent rewriting. Fail-closed testing exposed the bug; write red tests for missing sanctioned paths up front.
Task 6, step 5: the commit wrapper subcommand was called for the first time, and the dispatcher answered exit 2. The hook had worked correctly all along; a bare git commit from an agent session was indeed refused. What failed was the sanctioned path: the subcommand promised since Task 1 had never existed.
That moment closed a simulation with an unexpected conclusion. The emergency door was shut tight and the main door was missing. Anyone caught between the two had no way through, and for a gate that was declared ready, that is dangerous.
The Illusion of a Feature
The gate script's usage string had listed the commit subcommand since Task 1. That text quietly created the belief that the implementation was complete. In reality the cmd_commit function had never been written; what existed was a list of capabilities on a help line.
Gaps like this are typical of homemade tooling. Documentation moves faster than code because documentation is written as intent, not as a tested contract. For a security gate, partially tested intent is more dangerous than a feature honestly acknowledged as missing: users press a path that appears to exist, and the confusion becomes the first reason people look for a bypass.
A Gate Without a Sanctioned Path Is a Total Lock
The principle that fixes this is simple: a gate that blocks the unsafe path must ship the safe path in the same change. If the only way to get a commit out is disabling the gate, the design failed not in enforcement but in ergonomics. A logged bypass does exist for emergencies, but making it a daily routine means the gate has already stopped working.
The fix was 40 lines in review_gate.sh. The cmd_commit function rewrites no validation at all; it writes the message to a temporary file, calls the same cmd_check used by the ordinary check path, and only runs git commit -F if validation passes. A rejected message stops before any commit is created, with an explicit instruction: fix the message, not bypass the gate.
The message the user supplies, via an option or a file, is written verbatim to the temporary file, validated in that form, and used verbatim when the commit is created. There is no silent rewriting in the middle; a gate that edits the user's message unannounced is as dangerous as a gate that never checks.
OWASP frames the principle: error handling is part of an application's security, and unhandled errors hand extra information to the wrong party [3]. The process-control version: a refusal with no sanctioned path trains users to hunt for gaps. Git's githooks documentation reminds us of the other half of the same contract: a hook without the executable bit is silently ignored [2], so every component of a gate, including the one that is "only" a wrapper, must genuinely exist and be installed, not merely mentioned.
Selftest Case 19: Refusing Without a Trace
Test case 19 locks three guarantees. First, a message without the review trailer is rejected and leaves no commit at all. Second, a valid message is accepted. Third, the result is exactly one commit with the correct subject, no more, no duplicates. The third guarantee is the one manual testing most often misses: a wrapper that validates twice or commits twice is a defect that only shows up once the repository history is dirty.
The dual code-and-security review approved with no critical and no major findings. The probes from the previous task showed the rejection path executed for real; case 19 completed the other side, the forwarding path, so both directions of the gate now carry equally strong evidence [1].
For a small team copying this pattern, the ordering matters: if today only the refuser gets written, also write the test for the sanctioned path, even if the implementation lands next week. A red test up front is far more honest than a green usage string with nothing behind it.
The commit that opens this story was refused by the gate, and that refusal is what found the bug. Fail-closed never makes anyone comfortable in the moment, but it is the only design choice that forces the sanctioned path to actually get built.