AUD-S09 + AUD-S14: pin task, lockfile the ajv validator, scrub checkout credentials, unstrip the release header - #25
Merged
Conversation
…flow env (AUD-S09 / SEC-04)
Both `verify:` and `release-exitgate:` installed Task with a floating upstream
tag, so a mutable release could change gate behaviour — or supply-chain posture
— under CI with no diff in this repo. The version is now defined ONCE, at
workflow scope (`env: TASK_VERSION: v3.52.0`), and both install sites
interpolate it, so the two jobs cannot skew either.
Spec reconciliation (recorded so review does not read it as a silent
deviation): AUD-S09's acceptance criterion says "every hit carries the pinned
version", while its Goal mandates single-sourcing through a workflow-level
`env`. Those conflict — with `task@"${TASK_VERSION}"` the install line carries
no literal semver. The Goal wins, and the new gate asserts the AC's intent
instead: zero floating tags anywhere under `.github/workflows/**`, every
`cmd/task@` site interpolating `${TASK_VERSION}`, and exactly ONE
workflow-level definition of it, as a literal vX.Y.Z.
The gate (`hack/lint/workflow_pins_test.sh`, REQ-AUD-S09-01) is almost entirely
negative assertions, the shape that most often ships green while testing
nothing. It is therefore built as check FUNCTIONS run twice — against the real
tree (must be green) and against temp copies carrying the very violation each
one exists to catch (must be red) — with the mutation asserted to have landed
before its red is believed. Five such controls run on every invocation. Only
portable ERE is used (no \t, \s, \b, no -P: GNU grep ignores \t in an ERE where
BSD honours it, and as a negative assertion that divergence passes OPEN on
Linux while looking green on macOS); no `grep -q` on the read end of a pipe
(SIGPIPE 141 under pipefail); no `git` and no `sed -i`, so it runs in a bare
container and inside a worktree.
Wiring, which is the deliverable and not an afterthought: `task check` runs
`lint-workflow-pins-test`, and the `verify:` job runs the script directly as its
first toolchain-free step. The wiring itself is proven by
`hack/release/changelog_gate_test.sh` — reached through a different `check:`
entry, so it stays red when the wiring line is deleted, which a self-assertion
inside the gate could never be.
…dentials (AUD-S14 / SEC-01 + SEC-03)
SEC-01 — the schemas job installed its stock Draft 2020-12 validator with
`npm install -g --ignore-scripts ajv-cli@5.0.0 ajv-formats@3.0.1`. Only the two
ROOTS were pinned: every transitive dependency was resolved from a semver range
at run time, so what CI validated the frozen schemas with could change without a
diff in this repo. Now `hack/schemas-validator/{package.json,package-lock.json}`
are committed and CI runs `npm ci --ignore-scripts` — 27 packages, every one
hash-pinned.
Two consequences worth spelling out:
* `hack/validate-schemas-stock.sh` now PREFERS the lockfile-pinned binary in
hack/schemas-validator/node_modules over any globally installed ajv, warning
when it has to fall back. Without that, CI would install the pinned tree and
then validate with whatever `ajv` happened to be on PATH.
* `schemas.yml`'s two `paths:` filters gain `hack/schemas-validator/**`, and
dependabot gains an npm ecosystem for that directory. A lockfile that never
triggers the job consuming it, and that nothing updates, is a pin that rots
into a stale-CVE pin.
SEC-03 — `persist-credentials: false` on the three checkouts that lacked it
(verify.yaml both jobs, schemas.yml), matching the seven that already had it.
None of those jobs pushes, so the workflow token has no business surviving in
.git/config where any later step — or anything a dependency's build executes —
could use it.
Gate (REQ-AUD-S14-01/02), extending hack/lint/workflow_pins_test.sh: the
checkout check is STEP-scoped, not file-scoped, because a file-wide grep passes
as soon as one checkout in the file sets the flag; an ambiguous parse splits the
step early and reports a violation, i.e. it fails closed. Twelve mutation
controls now run on every invocation — one of them caught a defect in this very
commit, where the `$`-anchored sed used to delete a persist-credentials line
silently matched nothing once the line grew a trailing comment, which would have
"proven" the check fires while mutating nothing.
Verified with the global ajv fully off PATH, so a leaked global install cannot
be mistaken for a working local one: the pinned validator runs the stock gate
green and prints no fallback warning; hiding node_modules flips it to the
warning branch.
Residual, reported not silently accepted: ajv-cli@5.0.0 requires
fast-json-patch ^2.0.0, and 2.2.1 carries GHSA-8gh8-hqwg-xf34 (prototype
pollution). It is not overridable inside the range, it was already being
installed before this commit, and it is reachable only from ajv-cli's
`diff`/`test` subcommands, which this repo never invokes (only `compile` and
`validate`) over repo-committed inputs. Committing the lockfile makes the
exposure visible to Dependabot rather than creating it.
…elease body
Flagged in the session INBOX as a 🔴 READ BEFORE TAGGING item: release.yaml ran
git-cliff with `--strip header`, and cliff.toml's changelog header is exactly
where the long-lived "Compatibility notes" live. The result was that the D-120
warning — `pins.toolDigest` changes value after v0.1.0, so a mismatch across
that boundary means "derived differently", not "different build" — reached
CHANGELOG.md and never reached the GitHub Release body. Consumers reading only
the Release page, which is most of them, got no warning at all.
The header is the ONLY possible home for such notes: CHANGELOG.md is
regenerated in full from commit history, so a hand-edit there is wiped by the
next `task changelog-write`. So the fix is to stop stripping it, per the INBOX
item's own first option, rather than to duplicate the note somewhere.
Regression cover (hack/release/changelog_gate_test.sh §6, already wired into
`task check` via release-changelog-gate-test) is behavioural, not a text
assertion about the workflow: it EXTRACTS the git-cliff step's `args:` from
release.yaml, renders the release body with those exact arguments using the
pinned git-cliff, and fails if the note is absent. Positive controls guard the
render (non-empty, carries a real `## [X.Y.Z]` section), and a polarity control
re-renders with `--strip header` and requires the note to disappear — so the
check cannot pass for an unrelated reason. Verified by mutation: restoring
`--strip header` in release.yaml turns the gate red naming both the finding and
the fix.
Residual, unchanged by this commit and NOT part of the finding: the cliff.toml
footer ("## [0.0.0] — pre-release development history") was already being
appended to every Release body and still is. Worth a separate decision about
whether a per-release body should carry it.
konih
force-pushed
the
lane/aud-s09-s14-workflow-pins
branch
from
August 8, 2026 11:18
24a77f0 to
7cbd124
Compare
…ate (review F1/F3/F4/F5/F6)
F1 (P1, fail-OPEN) — `check_persist_credentials` matched
`persist-credentials:[[:space:]]*false` on ANY line, comments included. So
# persist-credentials: false # TEMPORARILY DISABLED
left the workflow token in .git/config while the gate printed
"OK: all actions/checkout steps scrub the workflow token" and exited 0.
Reproduced here before the fix (exit 0 with the token exposed) and after
(exit 1). What makes this worth more than a one-line patch: `check_task_pinned`
ALREADY skipped comment lines. The failure mode was understood in one function
and not carried to the others, so the fix is structural rather than local —
a shared `code_view` helper, and an audit of every check in the file:
* PRESENCE assertions ("the flag is set", "the gate is invoked") fail OPEN
when comment-blind. Three were: persist-credentials (F1), the npm install
check (F5), the CI-wiring check (F6). All now read through `code_view`.
* ABSENCE assertions ("no floating tag anywhere") fail CLOSED when
comment-blind — a mention in prose merely reds the gate. `check_no_latest`
keeps its blunt total ban deliberately, now with that reasoning written down
instead of implied.
* `check_validator_lockfile` reads strict JSON, which has no comments. Not
affected, and now stated so the next reader does not re-derive it.
F3 (P2) — the "two jobs cannot skew" invariant was not the one being checked.
Only TWO-space `TASK_VERSION:` definitions were counted, so a JOB-level
`env: TASK_VERSION: v3.0.0` at six-space indent silently overrode the
workflow-level pin and the gate still passed. Definitions are now counted at ANY
indent, exactly one is required, and that one must be workflow-scoped.
F5 (P2) — besides the comment blindness, the negative pattern
`npm[[:space:]]+install` never matched `npm --global install` (nor `npm i`, nor
`npm add`). Enumerating bad forms is a losing game, so the polarity is inverted:
every npm invocation must be an `npm ci`, and anything else is a finding.
F4 (P2) — the headline "step-scoped, not file-scoped" claim had ZERO coverage.
All three persist mutants deleted every occurrence in their file, which a
file-scoped implementation would red on identically. Added the control that
discriminates: delete only the SECOND occurrence in release.yaml.
F6 (P3) — the CI-wiring assertion was a straight-line `grep -Fqs` with no
mutation control, the one shape this file's own preamble forbids. It is now a
check function with two controls (deleted, and commented out).
Six new controls, 13 -> 19, and `expect_red` is strengthened for all of them:
each control must now red for its OWN stated reason, pinned by a required
fragment of the finding text. A control that reds because an extraction broke,
or a file went missing, is no longer accepted as proof the mutation was
detected. The review reached this method by patching `expect_red` locally;
making it permanent means it is enforced on every run, not during one review.
Portability, stated precisely because a vague claim here is worthless: the
script sees BSD grep 2.6.0-FreeBSD + awk 20200816 locally, and GNU grep 3.11 +
mawk 1.3.4 under debian:stable-slim. All 19 controls red under both. (The
interactive shell on this machine shims `grep` to ugrep 7.5.0 via a
non-exported function, which a `bash script.sh` child does NOT inherit — the
same trap the review hit, verified rather than assumed.) The two new mutations
that sed cannot express portably — inserting lines, and changing only the Nth
occurrence — go through a new awk-based `mutate_awk` with the same
did-the-mutation-land assertion.
…gh8-hqwg-xf34 (review F2) The previous commit accepted this advisory as unfixable on a false premise: I wrote that `fast-json-patch ^2.0.0` was "not overridable in-range". npm `overrides` exists precisely to bypass a transitive dependency's declared range, so the constraint I treated as binding was never binding. Corrected, and the acceptance rationale withdrawn rather than reworded — no D-row is needed for a CVE that can simply be fixed. "overrides": { "fast-json-patch": "^3.1.1" } Verified end to end on this machine rather than taken from the review: * `npm install --ignore-scripts --package-lock-only` → exit 0; the lockfile SHRINKS 304 → 292 lines (3.x drops the fast-deep-equal dependency). * `npm ci --ignore-scripts` → exit 0, 26 packages added, no EINTEGRITY. * `npm audit` → exit 0, **found 0 vulnerabilities** (was 2 high). * `hack/validate-schemas-stock.sh` with NO ajv anywhere on PATH → exit 0, zero fallback warnings, every schema compiled and every contract fixture validated. ajv-cli works unchanged against fast-json-patch 3.x. * `hack/lint/workflow_pins_test.sh` → PASS, and under GNU grep 3.11 + mawk. Correcting the record on reachability too, since the earlier commit stated it wrongly (review F7): `fast-json-patch` is `require`d at module load by ajv-cli's `dist/commands/validate.js` — the very subcommand this repo runs — not only by `diff`/`test`. The earlier conclusion happened to survive for a different reason (ajv-cli only ever calls `jsonPatch.compare()`, never `applyPatch`/`applyOperation`, which is where the advisory lives). That reasoning is now moot, and it is recorded here only because a wrong rationale that reached the right answer is still a wrong rationale. Guarded against silent reversal, both halves, because either alone is reversible: the gate asserts package.json still declares the override AND that the locked tree actually resolves fast-json-patch 3.x — drop the override and the next regeneration walks straight back to 2.2.1. New mutation control (20th) deletes the override and requires the gate to red naming it. Consequence worth stating: no Dependabot alert will land against the tagged release, which was the reason the previous commit flagged this as a pre-tag residual at all.
…N1/N2/N4)
N1 (P2) — `code_view` did not close the class the previous commit credited it
with closing. It drops a line only when the FIRST non-blank character is `#`,
so the searched-for text hiding in a live line's COMMENT TAIL still satisfied
every presence check. All three reproduced here with the gate at exit 0 and
actionlint clean, before fixing:
fetch-depth: 1 # was: persist-credentials: false
run: npm --global install ajv-cli # was: run: npm ci --ignore-scripts …
run: echo skipped # was: bash hack/lint/workflow_pins_test.sh
The second is the one that matters: a plausible "just unblock it" edit that
leaves the ENTIRE SEC-01 supply-chain pin inert while the gate reports OK.
Extending the class rather than patching the three reported sites turned up a
FOURTH, unreported instance in `check_task_pinned`:
`cmd/task@v3.0.0 # cmd/task@"${TASK_VERSION}"` passed the interpolation check
and carries no floating tag, so it slipped both checks and re-opened exactly
the job-to-job skew SEC-04 exists to prevent. Fixed and controlled alongside.
The fix is structural, not three anchors: presence checks no longer read raw
lines. A new `command_view` yields what actually EXECUTES — inline comment tail
removed (YAML and shell agree: whitespace then `#`), then the list dash and the
`run:` key stripped — and the checks anchor on that, so a comment tail is
structurally unable to satisfy them. `persist-credentials` keeps a line-based
parse but its match is now anchored with only an optional tail permitted.
Confirmed first that no workflow line carries ` #` inside a quoted value, the
one case where tail-stripping could lose real content.
N2 (P2) — present is not enabled. `continue-on-error: true` on the gate step
leaves both this gate and actionlint green while the CI half is neutered, which
is the most realistic "unblock CI" edit there is and the silent one (`if: false`
disarms it too, but actionlint flags that). The step's own block must now carry
neither. Both variants controlled.
N4 (P3) — only the package.json half of the F2 override guard had a control.
Added the other half: the lockfile rewritten to fast-json-patch-2.2.1.tgz with
the override still declared — what a careless regeneration actually produces.
One more anti-vacuity save worth recording, because it is the exact failure this
file exists to prevent and it happened during this commit: the first version of
the npm extraction anchored on `:[0-9]+:`, a prefix the single-file view does
not emit. It matched nothing, so the "every npm invocation must be npm ci" check
swept an empty set and passed — over a tree with a live global install. Only a
sibling assertion failing exposed it. The extraction now carries its own
positive control (an npm command MUST be found in schemas.yml), so that shape
fails loudly instead of silently.
20 -> 27 mutation controls. Green under BSD grep 2.6.0-FreeBSD + awk 20200816
and GNU grep 3.11 + mawk 1.3.4; actionlint clean.
…lar precondition (review N5/N6) N5 (P1) — the N2 guard was not fixed, and its controls proved the wrong thing. The extraction switched `inblock` on at the MARKER line, which is the `run:` line, having already reset it at the preceding `- name:`. So the "step" was one line, and everything between `- name:` and `run:` was invisible — which is the CONVENTIONAL position for `if:` and `continue-on-error:`, the ordering GitHub's own docs use. Reproduced before fixing: `continue-on-error: true` placed before `run:` gave gate exit 0 AND actionlint exit 0. Completely silent. The worse half is the controls. Both inserted the key AFTER `run:`, so the matrix certified the one position that happened to work and never exercised the one that did not. That is this lane's own recurring failure — an assertion that cannot observe what it claims to — moved up a level into the control itself. Given the subject matter, it had to be right. Secondary defect in the same extraction: verify.yaml:15 mentions this script in the file-header comment, and nothing reset `inblock` until the first `^ - ` at line 25, so the region also swallowed the header block plus `env:`, `jobs:`, `verify:`, `runs-on:` and `steps:` — 11 lines, exactly one of them from the step. The `[[ -s ]]` control passed happily on that. Fixed with a two-pass extraction: start from the line the command was actually found on, walk BACK to the nearest preceding step start, then forward to the next one. It now yields exactly the two real lines. Positive controls upgraded accordingly — the block must be 1..6 lines, must START with a step marker, and must contain the invocation, so a slab of file header can no longer pass as a step. Four controls now, both keys in both positions, plus one for the extraction losing its step start. N6 (P2) — `command_view` strips from the leftmost ` #`, which is exactly YAML's rule for a PLAIN scalar (that case is genuinely fine: `run: echo "step # 1" && …` is truncated by YAML too, and actionlint reds it with SC1072). It is NOT the rule inside a BLOCK or QUOTED scalar, where ` #` is ordinary content — there, stripping discards live code, and `echo "stage # 2" && npm --global install ajv-cli` reduces to `echo "stage` with SEC-01 inert. The previous commit documented this as a precondition of the current tree. Documentation is not enforcement: the next workflow edit reopens it silently. It is now a gate — no workflow line may carry ` #` preceded by an unbalanced quote. Both carrier shapes (double- and single-quoted) verified caught. Zero hits across all 8 workflows, so it lands without touching a workflow file. Worth noting what already held: the block-scalar carrier was ALSO caught, before section 6 even ran, by the npm extraction's own non-empty positive control — the one added after hitting that exact bug during the N1 fix. Defence in depth behaving as intended rather than by luck. 27 -> 31 controls. Green under BSD grep 2.6.0-FreeBSD + awk 20200816 and GNU grep 3.11 + mawk 1.3.4; actionlint clean.
This was referenced Aug 8, 2026
konih
added a commit
that referenced
this pull request
Aug 8, 2026
…nership line Two corrections to the row landed one commit ago, both material to how a future reader acts on it. 1. The margin was stated as '0.01pp, about half a statement'. That reads the raw ratio, but the gate compares the ONE-DECIMAL total that `go tool cover -func` prints -- pct is the string '91.0', not '91.010'. Anything rounding to 91.0 passes, so the effective floor is ~90.95% and the true minimum is 4513 of 4961 statements. Actual slack is 2 statements, not half of one. Understating it invites someone to conclude the 91% floor is unsustainable and walk it back. 2. The row assigned the verify.yaml raise to 'Lane B'. PR #25 (AUD-S09/S14), the workflow-owning lane, merged 2026-08-08 -- before this lane landed. No open lane owns .github/workflows/**, so the split-gate residual is UNOWNED, and a reader who trusts the old wording would assume it is covered while CI quietly keeps enforcing 90%. Row text only; no gate behavior change.
konih
added a commit
that referenced
this pull request
Aug 8, 2026
The aggregate ./internal/... total sat at 90.28% against a 90% floor, so the gate had stopped MEASURING and started STEERING: any lane adding a branch without a test reddened task check for a reason unrelated to that lane, and the cheapest green was a filler test. AUD-S13's three behavior families lift it to 91.010% (4479 -> 4515 of 4961 statements), so the floor moves with them. Taskfile.yml coverage: desc, echo, awk threshold and FAIL message all move together -- a half-updated gate prints a message that lies. GUIDELINES.md's '>=90% coverage gate' claim moves with it. Logged as D-128, which also records the two things a future reader needs: the margin is 0.01pp (about half a statement), and .github/workflows/ verify.yaml still enforces 90% because that path belongs to Lane B / PR #25 -- so CI enforces the OLD floor until Lane B raises it. D-128 supersedes the AUD-S13 spec's 'Not in scope: raising the D-010 floor' line; the operator ruled otherwise for this change.
konih
added a commit
that referenced
this pull request
Aug 8, 2026
…nership line Two corrections to the row landed one commit ago, both material to how a future reader acts on it. 1. The margin was stated as '0.01pp, about half a statement'. That reads the raw ratio, but the gate compares the ONE-DECIMAL total that `go tool cover -func` prints -- pct is the string '91.0', not '91.010'. Anything rounding to 91.0 passes, so the effective floor is ~90.95% and the true minimum is 4513 of 4961 statements. Actual slack is 2 statements, not half of one. Understating it invites someone to conclude the 91% floor is unsustainable and walk it back. 2. The row assigned the verify.yaml raise to 'Lane B'. PR #25 (AUD-S09/S14), the workflow-owning lane, merged 2026-08-08 -- before this lane landed. No open lane owns .github/workflows/**, so the split-gate residual is UNOWNED, and a reader who trusts the old wording would assume it is covered while CI quietly keeps enforcing 90%. Row text only; no gate behavior change.
konih
added a commit
that referenced
this pull request
Aug 8, 2026
…t skew (D-128) The fence that kept AUD-S13 out of .github/workflows/** was PR #25, which merged. So the split gate this lane flagged is now closable, and the right fix is not to raise a second literal -- that only resets the drift clock. verify.yaml no longer re-implements the coverage gate. The 'install Task' step moves above it and the step body is just `task coverage`, so CI runs the same gate developers run. The threshold now exists in exactly one place: Taskfile.yml's coverage: task var COVERAGE_MIN, interpolated into the echo and the awk comparison, with desc naming the var instead of the number. verify.yaml carries no coverage threshold at all any more. Proven at both polarities rather than assumed: COVERAGE_MIN=99 reds the gate with the right message. That control matters specifically because an unrendered {{.COVERAGE_MIN}} would yield min="", min+0 == 0, and a gate that passes everything -- a templating typo would have been a silent fail-open, not a visible break. hack/lint/workflow_pins_test.sh stays green (39 controls), including 'both Task installs interpolate a single workflow-level TASK_VERSION' after the step move; verified the YAML still parses and the step order is right. Also logs the two fail-opens AUD-S13 found while writing its tests, as OQ-27 (toCEL lexical compare returns a silently wrong boolean when BOTH operands are over-range -- and evaldecode.go:61 understates the known residual, so it is a docs-truth defect too) and OQ-28 (repo_file enforces path containment but not filesystem containment; os.DirFS is not a security boundary and the escaping symlink can arrive in the MR under evaluation). Neither is fixed here -- both are routed for a severity ruling. No new D-row: they are undecided, so they are questions, not decisions. D-128 updated: its split-gate residual is closed, and the closure mechanism recorded so a future editor does not re-inline the shell.
konih
added a commit
that referenced
this pull request
Aug 8, 2026
The aggregate ./internal/... total sat at 90.28% against a 90% floor, so the gate had stopped MEASURING and started STEERING: any lane adding a branch without a test reddened task check for a reason unrelated to that lane, and the cheapest green was a filler test. AUD-S13's three behavior families lift it to 91.010% (4479 -> 4515 of 4961 statements), so the floor moves with them. Taskfile.yml coverage: desc, echo, awk threshold and FAIL message all move together -- a half-updated gate prints a message that lies. GUIDELINES.md's '>=90% coverage gate' claim moves with it. Logged as D-128, which also records the two things a future reader needs: the margin is 0.01pp (about half a statement), and .github/workflows/ verify.yaml still enforces 90% because that path belongs to Lane B / PR #25 -- so CI enforces the OLD floor until Lane B raises it. D-128 supersedes the AUD-S13 spec's 'Not in scope: raising the D-010 floor' line; the operator ruled otherwise for this change.
konih
added a commit
that referenced
this pull request
Aug 8, 2026
…nership line Two corrections to the row landed one commit ago, both material to how a future reader acts on it. 1. The margin was stated as '0.01pp, about half a statement'. That reads the raw ratio, but the gate compares the ONE-DECIMAL total that `go tool cover -func` prints -- pct is the string '91.0', not '91.010'. Anything rounding to 91.0 passes, so the effective floor is ~90.95% and the true minimum is 4513 of 4961 statements. Actual slack is 2 statements, not half of one. Understating it invites someone to conclude the 91% floor is unsustainable and walk it back. 2. The row assigned the verify.yaml raise to 'Lane B'. PR #25 (AUD-S09/S14), the workflow-owning lane, merged 2026-08-08 -- before this lane landed. No open lane owns .github/workflows/**, so the split-gate residual is UNOWNED, and a reader who trusts the old wording would assume it is covered while CI quietly keeps enforcing 90%. Row text only; no gate behavior change.
konih
added a commit
that referenced
this pull request
Aug 8, 2026
…t skew (D-128) The fence that kept AUD-S13 out of .github/workflows/** was PR #25, which merged. So the split gate this lane flagged is now closable, and the right fix is not to raise a second literal -- that only resets the drift clock. verify.yaml no longer re-implements the coverage gate. The 'install Task' step moves above it and the step body is just `task coverage`, so CI runs the same gate developers run. The threshold now exists in exactly one place: Taskfile.yml's coverage: task var COVERAGE_MIN, interpolated into the echo and the awk comparison, with desc naming the var instead of the number. verify.yaml carries no coverage threshold at all any more. Proven at both polarities rather than assumed: COVERAGE_MIN=99 reds the gate with the right message. That control matters specifically because an unrendered {{.COVERAGE_MIN}} would yield min="", min+0 == 0, and a gate that passes everything -- a templating typo would have been a silent fail-open, not a visible break. hack/lint/workflow_pins_test.sh stays green (39 controls), including 'both Task installs interpolate a single workflow-level TASK_VERSION' after the step move; verified the YAML still parses and the step order is right. Also logs the two fail-opens AUD-S13 found while writing its tests, as OQ-27 (toCEL lexical compare returns a silently wrong boolean when BOTH operands are over-range -- and evaldecode.go:61 understates the known residual, so it is a docs-truth defect too) and OQ-28 (repo_file enforces path containment but not filesystem containment; os.DirFS is not a security boundary and the escaping symlink can arrive in the MR under evaluation). Neither is fixed here -- both are routed for a severity ruling. No new D-row: they are undecided, so they are questions, not decisions. D-128 updated: its split-gate residual is closed, and the closure mechanism recorded so a future editor does not re-inline the shell.
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 the two remaining supply-chain pin findings from the 2026-08-06 audit, plus the release-body defect flagged 🔴 READ BEFORE TAGGING in the session INBOX.
Four commits, three logical changes plus the mandatory changelog regeneration. No Go code is touched — the diff is CI configuration, two shell gates, and a committed npm lockfile.
1.
bfd6606— AUD-S09 / SEC-04: pin the Task version inverify.yamlBoth
verify:andrelease-exitgate:installed Task with a floating upstream tag, so a mutable release could change gate behaviour — or supply-chain posture — under CI with no diff in this repo. The version is now defined once, at workflow scope (env: TASK_VERSION: v3.52.0), and both install sites interpolate it, so the two jobs cannot skew either.Spec conflict, reconciled explicitly rather than silently. AUD-S09's acceptance criterion says "every hit carries the pinned version", while its Goal mandates single-sourcing through a workflow-level
env. These pull in opposite directions: withtask@"${TASK_VERSION}"the install line carries no literal semver. The Goal wins, and the gate asserts the AC's intent instead:.github/workflows/**,cmd/task@site interpolating${TASK_VERSION},vX.Y.Z.v3.52.0was verified as a real pin, not just a plausible string:go install …@v3.52.0succeeds andhack/compare/exitgate_test.shpasses under that binary.2.
b9b5dfe— AUD-S14 / SEC-01 + SEC-03SEC-01 — the schemas job installed its stock Draft 2020-12 validator with
npm install -g --ignore-scripts ajv-cli@5.0.0 ajv-formats@3.0.1. Only the two roots were pinned; every transitive dependency was resolved from a semver range at run time, so what CI validated the frozen schemas with could change without a diff here. Nowhack/schemas-validator/{package.json,package-lock.json}are committed and CI runsnpm ci --ignore-scripts— 27 packages, every one hash-pinned.Two consequences worth spelling out:
hack/validate-schemas-stock.shnow prefers the lockfile-pinned binary over any globally installedajv, warning when it must fall back. Without this, CI would install the pinned tree and then validate with whateverajvhappened to be onPATH.schemas.yml's twopaths:filters gainhack/schemas-validator/**, and Dependabot gains annpmecosystem for that directory. A lockfile that never triggers the job consuming it, and that nothing updates, is a pin that rots into a stale-CVE pin.SEC-03 —
persist-credentials: falseon the three checkouts that lacked it (verify.yamlboth jobs,schemas.yml), matching the seven that already had it. None of those jobs pushes; the exit-gate scripts were grepped forgit push/ghfirst and are clean.3.
df679ee— release-body fix (INBOX 🔴 pre-tag blocker)release.yamlran git-cliff with--strip header, andcliff.toml's changelog header is exactly where the long-lived Compatibility notes live. The D-120 warning —pins.toolDigestchanges value afterv0.1.0, so a mismatch across that boundary means "derived differently", not "different build" — reachedCHANGELOG.mdand never reached the GitHub Release body. Consumers reading only the Release page got no warning at all.The header is the only possible home for such notes:
CHANGELOG.mdis regenerated in full from commit history, so a hand-edit there is wiped by the nexttask changelog-write. The fix is therefore to stop stripping it — the INBOX item's own first option — not to duplicate the note.Verified by rendering the body locally with the pinned git-cliff v2.13.1;
hack/release/README.mdis updated to match.4.
24a77f0—CHANGELOG.mdregenerationGate anti-fragility evidence
This lane's deliverable is largely negative grep assertions in a shell gate — the shape that most often ships green while testing nothing. Every countermeasure below is enforced on every run, not just once at authoring time.
Mutation testing — 31 controls, built into the gate
hack/lint/workflow_pins_test.shis built as check functions over a workflows directory. Each runs twice: against the real tree (must be GREEN) and against a temp copy carrying the very violation it exists to catch (must be RED). Output of a normal run:Every mutation is asserted to have landed before its red is believed — a no-op
sedwould otherwise make the mutant identical to the clean tree and "prove" a check fires when nothing changed. This is not theoretical: it caught a defect in this very PR. The$-anchored sed used to delete apersist-credentialsline silently matched nothing once that line grew a trailing comment:TDD red-first, on the real findings
The gate was written before the fixes and reded naming exactly the sites the audit identified:
It also immediately reded on prose I had just written that contained the banned token — fixed by rewording the comment and by making the Task-site check skip comment lines, never by weakening the ban.
Release-body fix — behavioural check, mutation-proven
hack/release/changelog_gate_test.sh§6 is not a text assertion aboutrelease.yaml. It extracts the git-cliff step'sargs:from the workflow (step-scoped —args:also appears in both goreleaser steps), renders the release body with those exact arguments using the pinned git-cliff, and greps the result. Positive controls guard the render (non-empty, carries a real## [X.Y.Z]section); a polarity control re-renders with--strip headerand requires the note to disappear, so the check cannot pass for an unrelated reason.Mutation — restoring
--strip headerinrelease.yaml:Linux / GNU grep + mawk
macOS BSD grep honours
\tin an ERE; GNU grep does not, sogrep -qE '\t'silently never matches — and as a negative assertion it then passes OPEN. Run under Debian:All 31 mutation controls go red under GNU grep too — that is the part that matters, since it proves the patterns actually match there. The container has no
gitand the repo is a worktree (.gitis a file), which also exercises the${BASH_SOURCE}root resolution.Constructs deliberately avoided, each a real divergence or flake source:
\t,\s,\b, nogrep -P— portable ERE and[[:space:]]only;${…}matched withgrep -F, since braces are interval syntax in an ERE where GNU and BSD disagree;grep -qon the read end of a pipe (SIGPIPE → exit 141 underpipefail, which flaked 10/20 on Linux in a previous lane) — every match goes to a file first;sed -i(BSD requires an argument, GNU must not have one) and nogitinvocation;SEC-01 verified against the local install, not a global leak
The machine has a global
ajv, which would have made "the lockfile works" indistinguishable from "the global one served". WithPATHcarrying noajvat all:Zero fallback warnings ⇒ the lockfile-pinned binary was used. Hiding
node_modulesflips it to the warning branch, confirming the A/B is real and not a constant.The lockfile was also proven to reproduce from scratch, which the generation step alone does not show —
--package-lock-onlyresolves metadata without ever fetching a tarball, whereasnpm civerifies everyintegrityhash against real downloads:Wiring — proven non-self-referentially
A gate that checks whether
task checkinvokes it is unreachable precisely when the answer is "no". Solint-workflow-pins-testwas added tohack/release/changelog_gate_test.sh'sWIRED_TASKS, reached through a differentcheck:entry, which already re-runs each wiring assertion against a Taskfile copy with the line deleted:And the end-to-end proof, with the wiring line actually deleted from
check::Restored, and
task checkis green again. The gate is additionally wired into theverify:job as its first toolchain-free step, so an unpinned workflow reds a PR in seconds; the gate asserts that step's presence itself, andtask checkruns locally either way.Gate matrix
task check(full, final tree)actionlint(all 8 workflows)bash hack/lint/workflow_pins_test.shdebian:stable-slim(GNU grep 3.11 / mawk)bash hack/validate-schemas-stock.sh(globalajvshadowed)bash hack/release/changelog_gate_test.shbash hack/release/verify_tag_gate_test.shgitleaks v8.30.1over HEADintegrityhashes are not false positives)go install …/task@v3.52.0+hack/compare/exitgate_test.shConfirmed on real CI (not just locally)
All checks green:
verify,schema-validation,Lint workflow files(actionlint), CodeQL (go + actions), MkDocs, goreleaser snapshot. The three steps this PR is actually about:verify:stepworkflow supply-chain pins (AUD-S09 — SEC-04)ubuntu-latest/GNU grep beforesetup-go, needing no toolchaininstall Task (exit gate scripts)task@"${TASK_VERSION}"resolves and installsschema-validationjobnpm ci --ignore-scripts --prefix hack/schemas-validatorbehaves as expected under Actions' Node 22Note on one flake, unrelated to this branch: the first full
task checkhitTestExecDigestPin/match_allows_exec— "signal: killed" ininternal/provider. It passes in isolation and on re-run, and this branch touches zero Go files. Pre-existing flake, worth a separate look.Residual risks (reported, not silently accepted)
— withdrawn and fixed infast-json-patch@2.2.1/ GHSA-8gh8-hqwg-xf3499661a2. The original text claimed the advisory was "not overridable in-range"; that premise was wrong (npmoverridesexists to bypass a transitive's declared range).npm auditnow reports 0 vulnerabilities, so no acceptance decision row is needed and no Dependabot alert will land against the tagged release.cliff.toml's footer (## [0.0.0] — pre-release development history) was already being appended to every Release body and still is. Out of scope for the INBOX finding; worth a separate decision about whether a per-release body should carry it.TASK_VERSIONmust now be bumped by hand. Dependabot does not update ago installpin inside arun:step. The gate enforces that it is a literal version, not that it is current.Review round 1 — REQUEST CHANGES addressed (
f2ee0a5,99661a2,8e4baa7)Rebased onto
277b0ceviagh pr update-branch --rebase(server-side, no force-push). All findings fixed; both P1s were real.F1 (P1) — comment-blind
check_persist_credentialsfailed OPENReproduced before fixing, under GNU grep 3.11 + mawk:
After:
EXIT=1,actions/checkout without an ACTIVE persist-credentials: false (a commented-out flag is not a flag).The reviewer's framing is the important part —
check_task_pinnedalready skipped comments, so this was a failure mode understood in one function and not carried to the others. The fix is therefore structural, not local: a sharedcode_viewhelper plus an audit of every check in the file, classified by polarity:check_persist_credentialscheck_npm_ci_in_workflowcheck_ci_wiring(§5)check_task_pinned(sites)code_viewcheck_task_pinned(defs)check_no_latestcheck_validator_lockfileEach of F1/F5/F6 was reproduced as a live fail-open first and re-verified red after.
F2 (P1) — the CVE was fixable; my premise was wrong
I claimed
fast-json-patch ^2.0.0was "not overridable in-range." npmoverridesexists to bypass a transitive's declared range, so the constraint I treated as binding never was. Verified myself rather than trusting the review transcript:The lockfile shrinks because 3.x drops
fast-deep-equal. Guarded against silent reversal in both halves — the gate asserts package.json still declares the override and that the locked tree resolves 3.x — because dropping the override alone sends the next regeneration straight back to 2.2.1. 20th mutation control added for it.The acceptance rationale is withdrawn, not reworded. No decision row is needed for a CVE that can simply be fixed, and no Dependabot alert will land against the tagged release.
F7 (P3) — my reachability claim was also wrong:
fast-json-patchisrequired at module load bydist/commands/validate.js, the exact subcommand this repo runs, not only bydiff/test. The conclusion survived for a different reason (ajv-cli only callsjsonPatch.compare(), neverapplyPatch/applyOperation, where the advisory lives). Moot now, but recorded: a wrong rationale that reaches the right answer is still wrong.F3, F4, F5, F6 (P2/P3)
TASK_VERSION:definitions were counted, so a job-levelenv:at 6-space indent silently overrode the workflow-level pin — precisely the skew the story exists to prevent. Now: definitions counted at any indent, exactly one required, and that one must be workflow-scoped. Reproduced as a fail-open, now red.release.yaml.npm[[:space:]]+installnever matchednpm --global install(nornpm i, nornpm add). Enumerating bad forms is a losing game, so the polarity is inverted: every npm invocation must be annpm ci, anything else is a finding.grep -Fqswith no control, the one shape this file's own preamble forbids. Now a check function with two controls (deleted, commented out).expect_rednow pins the reason, permanentlyThe reviewer verified each control reds for its own stated reason by patching
expect_redlocally. That property is now enforced on every run: each control passes a required fragment of the finding text, and a control that reds because an extraction broke or a file went missing is no longer accepted as proof the mutation was detected.13 → 20 controls, all green locally and under GNU grep.
On grep flavours — the reviewer's ugrep lesson applied to my own machine
Checking rather than assuming turned up that this machine's interactive shell shims
grepto ugrep 7.5.0 via a shell function. It is not exported, so abash script.shchild does not inherit it — verified:So the gate runs were genuinely BSD grep 2.6.0-FreeBSD + awk 20200816 locally, and GNU grep 3.11 + mawk 1.3.4 under
debian:stable-slim. Two real, named targets — ad-hoc greps I typed at the prompt did go through ugrep, which is worth knowing but does not touch the gate.Gates re-run after the fixes
task checkexit 0 (fully green, changelog-verify included) ·actionlintexit 0 ·workflow_pins_test.shPASS with 20 controls, locally and under GNU grep ·validate-schemas-stock.shexit 0 with ajv fully off PATH ·npm audit0 vulnerabilities ·gitleaksno leaks · tree is changelog-clean aftertask changelog-write.TestExecDigestPinflake not observed this round; it is being fixed in its own lane.One item referred back rather than actioned
Reviewer F8 (P3) suggests indexing the AUD-S09 AC-vs-Goal reconciliation as a decision row. Not done here:
docs/decisions/decisions.mdis being edited concurrently by another session with D-126/D-127 in flight, so this lane does not touch it. Referred to the coordinator — it would need D-128 or higher.Review round 2 — N1/N2/N3/N4 addressed (
68ec454,a59539c,e4483fb)N1 (P2) —
code_viewdid not close the class it was credited with closingYou were right, and the previous commit message overclaimed.
code_viewdrops a line only when its first non-blank character is#, so the searched-for text hiding in a live line's comment tail still satisfied every presence check. All three reproduced at exit 0, actionlint clean, before fixing:fetch-depth: 1 # was: persist-credentials: falseOK: all actions/checkout steps scrub the workflow tokencheckout step(s) leaving the workflow token in .git/configrun: npm --global install ajv-cli # was: run: npm ci --ignore-scripts …OK: schemas.yml installs the validator with 'npm ci --ignore-scripts'— SEC-01 fully inertinvokes npm in a form other than 'npm ci'run: echo skipped # was: bash hack/lint/workflow_pins_test.shOK: verify.yaml runs …printeddoes not RUN … as an actual commandExtending the class rather than patching the three named sites found a fourth, unreported instance in
check_task_pinned:cmd/task@v3.0.0 # cmd/task@"${TASK_VERSION}"satisfies the interpolation check and carries no floating tag, so it passed both checks and re-opened precisely the job-to-job skew SEC-04 exists to prevent. Fixed and controlled alongside the others.The fix is structural rather than three anchors. A new
command_viewyields what actually executes — inline comment tail removed (YAML and shell agree: whitespace then#), then the list dash andrun:key stripped — and presence checks anchor on that, so a comment tail is structurally unable to satisfy them.persist-credentialskeeps a line-based parse with the anchored form you suggested. Confirmed beforehand that no workflow line carries#inside a quoted value, the only case where tail-stripping could lose real content.N2 (P2) — present ≠ enabled
continue-on-error: trueon the gate step left this gate and actionlint green while the CI half was neutered. Now the step's own block must carry neithercontinue-on-errornorif:; both variants have controls. (Confirmed your observation:if: falseis caught by actionlint,continue-on-erroris the silent one.)N4 (P3) — lockfile half of the F2 guard now controlled
21st control rewrites the lock to
fast-json-patch-2.2.1.tgzwith the override still declared — what a careless regeneration actually produces — and requiresdoes not resolve fast-json-patch to a 3.x release.N3 (mandatory) — up to date with
a2a2b17gh pr update-branch --rebasefailed with a conflict exactly as you predicted. Mergedorigin/mainlocally instead (no force-push); onlyCHANGELOG.mdconflicted. The merge commit is named:wrench: chore(release): merge …, matching the convention main's own lane merges already use —cliff.toml's firstcommit_parserskips that prefix, so the merge subject does not leak intoCHANGELOG.md(verified: 0 occurrences).task changelog-writeis the last commit, andtask changelog-verifyis green one4483fb, which is 0 commits behindorigin/main.One more anti-vacuity save, recorded because it is this file's whole purpose
While writing the N1 fix, the first version of the npm extraction anchored on
:[0-9]+:— a prefix the single-file view does not emit (only the directory view does). It matched nothing, so "every npm invocation must benpm ci" swept an empty set and passed, over a tree containing a live global install. Only a sibling assertion failing exposed it. The extraction now carries its own positive control (an npm command must be found inschemas.yml), so that shape fails loudly. Same bug class as N1, found in my own fix for N1.20 → 27 mutation controls.
Gates re-run
task checkexit 0 to completion ·actionlintexit 0 ·workflow_pins_test.shPASS with 27 controls under BSD grep 2.6.0-FreeBSD + awk 20200816 and GNU grep 3.11 + mawk 1.3.4 ·validate-schemas-stock.shexit 0 with ajv fully off PATH, 0 warnings ·npm audit0 vulnerabilities ·gitleaksno leaks ·task changelog-verifyexit 0 · tree clean.On your correction about the flake: it fired on the first
task checkof this round, which aborted the run before coverage, build, dogfood, compare-exitgate and changelog-verify. Re-ran the whole matrix to completion — the exit 0 above is from a run where all of those actually executed (27 controls and the coverage table both present in the log). Not fixed here; it is its own lane.Re-based onto
9e50e17and re-verified (53dc69a,2b65620)mainadvanced 15 commits (AUD-S10..S12 forge-hardening, PR #26) while this lane sat.gh pr update-branch --rebasefailed on conflict again, soorigin/mainwas merged in locally — no force-push. OnlyCHANGELOG.mdconflicted; the merge commit uses the:wrench: chore(release): merge …subject thatcliff.toml's first parser skips, so it does not leak into the changelog (verified 0 occurrences).task changelog-writeis the last commit. Branch is 0 commits behindorigin/main.All 14
task checkstages confirmed executed — not inferred from the exit codeThe
TestExecDigestPinflake fired on the first run and aborted at stage 4, so stages 5-14 never ran. Re-ran to completion and verified each stage by its own output banner:N1 / N2 bypasses re-verified against the merged tree
fetch-depth: 1 # was: persist-credentials: falsecheckout step(s) leaving the workflow token in .git/config (SEC-03)run: npm --global install ajv-cli # was: run: npm ci …invokes npm in a form other than 'npm ci'run: echo skipped # was: bash hack/lint/workflow_pins_test.shdoes not RUN … as an actual commandcontinue-on-error: trueon the gate step (N2)present but DISARMED, while actionlint exits 0 on the same treeThat last row is the point of N2: actionlint is silent, so this gate is the only thing standing between "unblock CI" and a permanently inert pin.
Every control still reds for its own pinned reason — and that enforcement is itself proven
expect_redrequires each control to produce a specific fragment of its finding text. To show that requirement is load-bearing rather than decorative, one fragment was replaced with a string that can never appear:27 controls, all green under BSD grep 2.6.0-FreeBSD + awk 20200816 and GNU grep 3.11 + mawk 1.3.4.
Gates on the merged head
task checkexit 0 with all 14 stages executed ·actionlintexit 0 ·workflow_pins_test.shPASS ×27 under both greps ·validate-schemas-stock.shexit 0 with ajv fully off PATH, 0 fallback warnings ·npm audit0 vulnerabilities ·task changelog-verifyexit 0 ·gitleaksno leaks · tree clean.Review round 3 — N5 (P1) and N6 fixed (
630c04d,62ef5a9)N5 — the N2 guard was not fixed, and its controls proved the wrong thing
You were right on both halves, and the second is the worse one.
The extraction.
inblockswitched on at the marker line — therun:line — having already reset at the preceding- name:. The isolated "step" was therefore one line, and everything between- name:andrun:was invisible. That is the conventional position forif:andcontinue-on-error:, the ordering GitHub's own docs use. Reproduced before fixing:The controls. Both inserted the key after
run:, so the matrix certified the one position that happened to work and never exercised the one that didn't. That is this lane's own recurring failure — an assertion that cannot observe what it claims to — moved up one level, into the control itself.Secondary defect, also as described.
verify.yaml:15names this script in the file-header comment and nothing resetinblockuntil the first^ -at line 25, so the region was 11 lines of which exactly one came from the step:The
[[ -s ]]control passed on that happily.Fixed with the two-pass extraction you suggested — from the line the command was actually found on, walk back to the nearest preceding
^ -, then forward to the next. It now yields exactly:Positive controls upgraded to match: the block must be 1..6 lines, must start with a step marker, and must contain the invocation. Four controls now —
continue-on-errorandif:in both positions — plus one for the extraction losing its step start. Both before-run:bypasses verified red:N6 — precondition turned into a gate
You confirmed the plain-scalar case is genuinely fine, so this is only about block and quoted scalars. Both carriers verified caught by the new predicate:
No workflow line may carry
#preceded by an unbalanced quote. Zero hits across all 8 workflows, so it landed without touching a workflow file:Worth recording: the block-scalar carrier was already caught before section 6 even ran, by the npm extraction's own non-empty positive control — the one added after I hit that exact bug during the N1 fix. Defence in depth working by construction rather than luck.
27 → 31 controls, green under BSD grep 2.6.0-FreeBSD + awk 20200816 and GNU grep 3.11 + mawk 1.3.4.
Gates — graded on stage banners, not the exit code
The flake fired on two consecutive runs, and a third aborted at
changelog-verify(my own new commit's drift). Only the fourth completed. Banner grading is what made that visible rather than guessable:I also tightened one banner mid-round:
coverage:could match per-packagego testoutput, so it now keys on(required: 90%), which only the coverage task emits. A stage-banner grader that can false-positive is the same bug as everything else in this lane.task checkexit 0 with all 14 stages ·actionlint0 · 31 controls under both greps ·validate-schemas-stock.sh0 with ajv fully off PATH, 0 warnings ·npm audit0 vulnerabilities ·task changelog-verify0 ·gitleaksno leaks · tree clean · 0 commits behindorigin/main(9e50e17) ·task changelog-writeis the last commit.