fix(guard): the changed-path classifier cannot fail open (#384) - #399
Merged
Conversation
`Detect changed paths` decides whether 10 of the 18 required status checks run; the same logic cloned into proofs.yml gates an 11th. It could be made to answer "no code changed" two ways, and both had the error path produce the permissive verdict with the step exiting 0. `skipped` satisfies a required context, so a skipped required check and a passed one are the same green on the merge button. Mechanism A — the allow-list was scoped by prefix, not file type. `safety/.*` and `docs/.*` admitted anything under those trees including .rs and .sh. `docs/research/forge-replication/` already holds run_trial.sh, tasks.json, descriptor.sexp and descriptor.aadl, so this was one `git mv` from live rather than theoretical. Mechanism B — the error path failed open. A grep failure wrote nothing, `n_code` became empty, `[ "" -gt 0 ]` exited 2, and `set -e` is inert inside an `if`, so bash read that as false and took the `code=false` branch. The `|| true` was not removable: `grep -c` also exits 1 on a legitimate zero count, so success and failure arrived identically. Replaced by one script both workflows call. Every allow-list entry names its extensions; an unmatched path is code, because a classifier that cannot recognise a file has not shown it is inert. No error path emits a verdict at all — it raises, prints, and exits non-zero. The script appends to $GITHUB_OUTPUT itself. That is its only emission mode on purpose: `echo "code=$(script ...)"` would restore mechanism B verbatim in the glue — script dies, echo succeeds, `code=` lands empty, every `== 'true'` dependent skips, exit 0. Neither `changes` job now contains a subshell, `||`, `-gt` or `grep`. Exiting non-zero is safe only because both classifier jobs are themselves required contexts — read from the branch-protection API, not assumed. A failing `changes` job otherwise skips its dependents, and skipped satisfies a required context. Also verified rather than trusted: every consumer of `outputs.code` tests `== 'true'`, and python3 is present on [self-hosted, linux, x64, light] because ci.yml::fmt already runs check_fmt_workspaces.py there. One deliberate loosening: the two copies had already drifted — ci.yml gained `rivet.yaml` in #379 and proofs.yml never did. Single-sourcing gives the proofs side that entry, safe for the same reason #379 gave, and it is exactly the divergence single-sourcing exists to end. 25 self-test cases, run as the first step of both `changes` jobs. The detector is the property rather than the cases: for every directory prefix in the allow-list, no pattern may match <prefix>/evil.rs, .sh, .toml or Makefile — so a future re-broadening fails without anyone remembering to add a case. Mutation-tested with 5 mutants, all caught, each by the test that should catch it; the harness asserts each substitution changed the file first, since a mutation that fails to apply reports as "not caught". Measured on real history: 7db7f0e (two artifact YAMLs) still emits code=false, so the classifier was tightened without becoming always-true; c8c0450 (8 files, 6 code) emits code=true. Refs #384. REQ-GUARD-GATE-EVIDENCE-002 (e). The self-test's fixture repo pins core.hooksPath=/dev/null. It is step 2 of a required context gating 11 others and it shells out to git init/commit, so a machine with a global core.hooksPath would run foreign hooks inside the fixture and turn that context red for every PR in the repo. Verified load-bearing: under a simulated hostile global hooksPath the unpinned copy fails 3 of 25, the pinned one passes 25. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rivet verification gate✅ 20/20 passed
Filter: Failed artifacts(none) Updated automatically by |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #384. REQ-GUARD-GATE-EVIDENCE-002 obligation (e) — the fifth of six.
Detect changed pathsdecides whether 10 of the 18 required status checksrun, and the same logic cloned into
proofs.ymlgates an 11th(
Lean proof typecheck (lake build)). It could be made to answer "no codechanged" two different ways, and in both the error path produced the
permissive verdict with the step exiting 0.
skippedsatisfies a required context. A skipped required check and a passedone are the same green on the merge button.
The two mechanisms
A — the allow-list was scoped by prefix, not by file type.
artifacts/and the two*.mdentries name the extensions they admit.safety/.*anddocs/.*do not — they admit anything under those trees,including
.rs,.shand.toml.docs/research/forge-replication/alreadyholds
run_trial.sh,tasks.json,descriptor.sexpanddescriptor.aadl, sothis was one
git mvfrom live rather than theoretical.B — the error path failed open.
If
grepfailed for any reason other than a zero count it wrote nothing,n_codebecame the empty string, and[ "" -gt 0 ]exited 2. Inside anifcondition
set -eis inert, so bash read that non-zero as false and took thecode=falsebranch. Two error messages printed and the step still exited 0,having skipped the suite.
The
|| truewas not simply removable:grep -calso exits 1 on a legitimatezero count. Success and failure arrived identically, which is what made the
suppression both load-bearing and catastrophic.
What replaces it
One script,
tools/classify_changed_paths.py, called from both workflows.nothing is code — a classifier that cannot recognise a file has not shown it
is inert.
exits non-zero.
$GITHUB_OUTPUTand appends to it directly. This is itsonly emission mode on purpose:
echo "code=$(script ...)"would put mechanismB straight back into the glue — script dies,
echosucceeds,code=landsempty, every
== 'true'dependent skips, exit 0. Neitherchangesjob nowcontains a subshell,
||,-gtorgrep.classifier whose own tests fail must not decide which required checks run.
Why exiting non-zero is safe here — and why that is not obvious
A failing
changesjob means its dependents are skipped, and skippedsatisfies a required context. Exiting non-zero would normally be worse than
failing open.
It is safe only because
Detect changed pathsandDetect changed paths (proofs)are themselves required contexts, so the classifier going red blocksthe merge on its own. That was read from the branch-protection API, not assumed.
Two further facts were checked rather than trusted:
outputs.codetests== 'true', so no job runs on theinverse and an absent output cannot trigger anything;
python3is present on[self-hosted, linux, x64, light]—ci.yml::fmtalready runs
check_fmt_workspaces.pyon that exact label.If
Detect changed pathsis ever dropped from the required contexts thisdesign becomes unsafe.
check_required_contexts.py(REQ-GUARD-GATE-EVIDENCE-001)is what stops that happening quietly, and the module docstring says so.
One deliberate behaviour change
The two copies had already drifted:
ci.ymlgainedrivet.yamlin #379 andproofs.ymlnever did. Single-sourcing gives the proofs side that entry, so arivet.yaml-only PR now also skips the Lean job. Safe for the same reason #379
gave — nothing under
proofs/readsrivet.yaml— and it is exactly thedivergence single-sourcing exists to end. Called out rather than absorbed
silently, because it is a loosening.
Evidence
changesjobs.the allow-list, no pattern may match
<prefix>/evil.rs,.sh,.tomlorMakefile. Someone re-broadening an entry back to prefix scope fails withoutanyone having remembered to add a case for them.
it: unknown→non-code,
emit()ignoring its argument, restoring the oldexception handler, re-broadening
safety/, re-addingdocs/.*. The harnessasserts each substitution changed the file before drawing a conclusion — a
mutation that silently fails to apply reports as "not caught" and is worthless.
7db7f0e6(two artifact YAMLs) still emitscode=false, so the classifier was tightened without becoming uselesslyalways-true;
c8c0450e(8 files, 6 code) emitscode=true.core.hooksPath=/dev/null. The self-test shells outto
git init/commit, and it is step 2 of a required context gating 11others, so a machine with a global
core.hooksPathwould run foreign hooksand turn that context red for every PR in the repo. Verified load-bearing:
under a simulated hostile global hooksPath the unpinned copy fails 3 of 25,
the pinned one passes 25.
NOT claimed: that skipping is correct for any particular file. This only
ensures a skip is caused by a match against a declared, type-scoped pattern and
never by a crash.
🤖 Generated with Claude Code