Skip to content

A Commit Gate Needs a Sanctioned Path, Not Just a Refuser

Adityo Guni Waluyo

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.

Sources

  1. Claude Code Docs, Hooks reference [1]
  2. Git Docs, githooks [2]
  3. OWASP Cheat Sheet Series, Error Handling Cheat Sheet [3]

Related articles