Skip to content

The Read-Only Audit: The Missing Half of a Commit Gate

Adityo Guni Waluyo

The commit gate refuses a trailer-less message in seconds, but the configuration behind it drifts silently. A read-only sweep closes that gap.

TL;DR

The commit gate enforces a Security-review trailer but can silently vanish through a lost executable bit or a moved hooksPath. A read-only audit subcommand in review_gate.sh sweeps commits, warnings, slice-gate results, and registration, always exiting 0. The gate prevents, the audit detects; together they keep the invariant honest.

The commit gate in the KotaPortal repository refuses a code commit without a Security-review: trailer within seconds of the attempt. The message bounces, the author fixes it, work continues. Then a question appears that has no ready answer: is the gate itself still installed on every execution path? A hook that loses its executable bit is silently ignored by Git, and the hooks directory can move through core.hooksPath [2]. The assumption that a one-time setup holds forever turned out to be wrong.

The answer is not another gate but a read-only audit subcommand in review_gate.sh: a sweep that only reads and always exits 0. Control theory names this pairing. A preventive control activity is designed to avoid an unintended event before it occurs; a detective control activity is designed to discover and timely correct an unintended event after it occurs [4]. The commit gate is the preventive side. The audit sweep is the detective half that most setups forget to build.

What the sweep reads

The sweep covers four trails: code commits since the adoption date, warning files that remain open, per-task slice-gate results, and the bypass log. For the commit trail, the sweep restricts itself to the directories where production code lives: api, frontend, deploy, migrations, and scripts. Documentation commits outside those paths are not treated as violations, because the commit gate itself never demanded a trailer from them.

The TRAILER section deliberately avoids Git's built-in --trailers option. Duplicate trailer values inject new lines into a single record, and column parsing turns fragile. The per-commit loop takes the stabler route:

git log --no-merges --since="$ADOPTION_DATE 00:00" --format=%h -- \
  api frontend deploy migrations scripts | while read -r sha; do
  n=$(git log -1 --format=%B "$sha" | grep -c '^Security-review:')
  [ "$n" = "1" ] || printf '  %s  %s  trailer=%s\n' "$sha" "$(git log -1 --format=%s "$sha")" "$n"
done

%B fetches the raw body: subject and body unwrapped [3]. The trailer definition itself is plain: a key-value pair with a colon separator, and the trailer block must be preceded by a blank line [1]. Any count other than exactly one means the invariant broke, and that commit lands on the list.

Registration that falls off quietly

The registration section answers the opening question. The check covers three conditions: the hook shim is executable, its name is listed in settings.json, and every agent file that carries the Bash tool mentions the hook. Any single miss gets flagged. The failure mode is not hypothetical: the script's selftest includes a dedicated case that plants a trailer-less code commit into a throwaway repository, runs the sweep, and asserts the commit appears on the list. Without that case, a small refactor of the loop could switch detection off with nobody noticing.

To verify the same things on your own repository:

test -x .git/hooks/commit-msg || echo 'hook hilang atau tidak executable'
git config core.hooksPath || echo '(default .git/hooks)'
grep -l 'review-gate' .claude/agents/*.md 2>/dev/null

The first line catches a missing hook or a lost executable bit. The second reveals a relocated hooks directory. The third searches for agents that forgot to mention the hook. The output pattern is direct: a file name means that path is registered; empty output means that path deserves a look.

Why exit 0 on purpose

An audit that returns a non-zero status gets wrapped in retry logic by the orchestration layer, or gets switched off as a noisy pipeline failure source. That is why this subcommand always exits 0 and only prints findings to standard output. The process stays boring enough to run every day. It stops nothing; it leaves a trail that can be reviewed and repaired on a schedule. The gate prevents, the audit records. They work as a pair, and the second one only feels necessary after the first question appears: who watches the gate itself?

Sources

Related articles