Hook Refusal Proof¶
The Git hooks in lefthook.yml run the pinned Praetor engine. No CI job installs that engine, so CI cannot show that the hooks refuse anything. make prove-hooks shows it on a workstation where the hooks are installed. It covers the three cases of issue #80 and plants one defect for every other pre-commit and pre-push job. The post-commit jobs (state-sync, dedupe-cadence) only record state and refuse nothing, so they have no case.
Run it¶
make prove-hooks # same as: python3 -I tools/ci/prove_hooks.py
python3 -I tools/ci/prove_hooks.py --skip-gate # without the two gate cases
It needs git, lefthook, go, gofmt, govulncheck, reuse 6.x and the engine wrapper .config/lefthook/engine.sh, as make verify-all does. The security case fetches an old module version, so it needs network access, and the gate cases need the engine's devcontainer (Docker). --skip-gate leaves the gate cases out and prints pre-push gate: skipped (--skip-gate), not proven. A job that skips because its tool is missing exits 0, so its planted case fails rather than passing silently. The run exits 0 only when every case behaves as below; any other result prints ::error::hook proof failed: with the reason and exits 1. A full run takes a few minutes; the gate cases take most of it.
What it does¶
tools/ci/prove_hooks.py clones the committed tree of the checkout into a temporary directory. The clone takes the origin URL and the default branch of the checkout, because the audit reads both, and its push URL is set to no-push. Then it runs lefthook install in the clone. The checkout it runs from is never changed.
| Case | Action in the clone | Passes when |
|---|---|---|
| Positive | compile-context --verify and audit --offline |
both exit 0 |
| Negative | commit a hand edit to CLAUDE.md |
the commit fails, HEAD stays put, the output names CLAUDE.md is out of sync with AGENTS.md |
| Boundary | commit a new eval call appended to tools/ci/trivy_coverage.py |
the commit fails, HEAD stays put, the output names [HISS-08] tools/ci/trivy_coverage.py, and .standards-baseline.json has the same SHA-256 afterwards |
govet |
commit pkg/reason/planted_vet.go with fmt.Sprintf("%d", "text") |
the commit fails, HEAD stays put, the output names the vet printf finding |
reuse-lint |
commit tools/ci/planted_licence.txt declaring 0BSD, which LICENSES/ does not hold |
the commit fails, HEAD stays put, the output names '0BSD' found in: |
gofmt |
commit an unformatted pkg/reason/planted_format.go |
the commit goes through and the committed file is in gofmt form: this job repairs, it does not refuse |
| Pre-push positive | lefthook run pre-push --force --command <job> for security, flavor-audit, audit, gate |
each exits 0 on the clean clone |
flavor-audit |
delete .yamllint.yml |
the job fails and names it under Missing Templates: |
audit |
append the eval call without committing it |
the job fails and names [HISS-08] tools/ci/trivy_coverage.py |
security |
go get gopkg.in/yaml.v2@v2.2.2 and call yaml.Unmarshal |
the job fails and names GO-2020-0036: gopkg.in/yaml.v2.Unmarshal is called |
gate |
commit a test that calls t.Fatal |
the job fails and names --- FAIL: TestPlantedFailure; a gate that fails for another reason, such as a broken devcontainer, does not count |
Each pre-push case runs one job with --force, because lefthook otherwise skips jobs when no push is in progress. After every case the clone is reset to its starting commit. Commands run with LC_ALL=C and NO_COLOR=1 so that the reasons match on every host locale. Planted texts that would trip a check of this repository (the eval call, the licence tag) are assembled at run time, so the tool's source holds no infraction.
How the proof itself is checked¶
tests/test_prove_hooks.py replaces every command with a scripted reply and runs in the normal pytest suite. Each failure mode has its own planted case for every job: a failing check on the clean clone, an accepted plant, a moved HEAD, a refusal without its reason, a gate refused by a broken devcontainer, a changed baseline, a refused or unformatted gofmt commit and a failed setup step.
A clone without active hooks fails as well, at the positive case: the audit reports .git/hooks/pre-commit is missing or inactive.