fix(build): re-verify the seed across the step boundary; refuse a cross-series seed #31

Merged
zach merged 5 commits from fix/seed-integrity-and-series into main 2026-07-25 21:47:19 +00:00
Owner

Three gaps around the config seed, plus the validator that would have caught the stale cross-series pin fixed in #28.

1. The seed was never re-verified after the step that verified it

build/fetch-seed.sh checks the signed commit and the sha256, then the workflow step ends. build/configure-kernel.sh runs in a later step and did a bare cp "$seed_config" .config. Anything that mutated $WORKDIR in between went undetected — and the fidelity assertion cannot see it, because that only proves configs/intent.config landed. The seed supplies every hardening default we deliberately inherit rather than pin (~31 symbols).

build/apply-patches.sh:29-35 already re-checks input-hashes.txt across the same boundary, with the threat model spelled out. This is the symmetric guard: fetch-seed.sh records seed-hashes.txt from the same $seed_sha256 that feeds seed-info.json, and configure-kernel.sh runs sha256sum -c before the copy. A missing hash file is a hard failure, never a skip — a guard that disables itself when its input is absent is the failure class this repo keeps hitting. sha256sum (coreutils) rather than jq, because repro-check.sh's rebuild container deliberately has no jq.

2. A tag whose series had no seed pin silently built from another series' seed

fetch-seed.sh:47-53 fell through to configs/upstream-seed.toml — a symlink to the current stable line — whenever configs/seeds/$SERIES.toml was absent. Pushing v7.2.1-hardened1 before adding seeds/7.2.toml would build a 7.2 kernel from the 7.1 config, and it turns both safety nets off at once: the fidelity assertion still passes (intent is satisfied by either seed) and the drift guard finds no releases/v7.2.* baseline, so it skips. Green log, wrong config.

build-kernel.yml:120-127 already hard-fails the analogous cross-series hazard for the stale-tag case; this matches that precedent, with an ALLOW_CROSS_SERIES_SEED hatch for a deliberate one-off. repro-check.sh never sets SERIES (it overrides via FETCH_SEED_*), so its path is unchanged.

3. lines.py's seed_path_for() returned the configured path unchecked

fetch-seed.sh resolves the seed by convention (configs/seeds/$SERIES.toml) and never reads lines.toml, so a seed value pointing anywhere else is silently ignored by the build while seed-bump.yml — which does go through seed_path_for() — would faithfully bump the wrong file. Now asserts the convention and existence. Deliberately not in load(): prune.py calls that for retention bucketing and must keep working for a line whose seed file has been removed.

4. New validate.yml job: seed-pins-well-formed

Per configs/seeds/<X.Y>.toml: the pinned tag belongs to that series; all six keys parse; commit is 40 hex; sha256 is 64 hex. Plus the upstream-seed.toml symlink tracks the stable line (a documented manual step when advancing the line, previously unchecked), and every registered line satisfies seed_path_for().

A cross-series seed is sometimes legitimate — a line stood up before anthraxx packages a config for it — so the escape hatch is a # cross-series-seed: <reason> marker in the pin file itself rather than a workflow variable. It belongs with the pin, and it forces the reason to be written down where the next reader will see it. This is the check that would have caught seeds/7.1.toml sitting on a 7.0.12 config for four releases while its header claimed otherwise.

Verification

Seed re-verification (real script block, fixture $WORKDIR):

case result
intact seed + hashes exit 0, upstream-seed.config: OK
bytes tampered after verification exit 1, FAILED, copy never reached
seed-hashes.txt missing exit 1, names the file

Cross-series resolution:

case result
SERIES=7.2, no seeds/7.2.toml exit 1, and aborts before any git clone (confirmed no clone dir created)
same + ALLOW_CROSS_SERIES_SEED=1 ::warning:: fires, resolution proceeds to upstream-seed.toml

seed-pins-well-formed, extracted from the workflow and run against copies of the real tree:

case result
unmodified exit 0, 2 file(s) examined; symlink -> seeds/7.1.toml
7.1.toml pinned to a 7.0 tag exit 1, names the series mismatch
same + # cross-series-seed: marker exit 0
sha256 truncated exit 1
lines.toml seed pointing at the wrong file exit 1, via seed_path_for
symlink pointing at 7.0 exit 1

Confirmed repro-check.sh runs fetch-seed.sh before configure-kernel.sh, so the hash file exists on the rebuild path too, and in repro mode it records the historical sha from the manifest.

Validators: shellcheck clean, yamllint -d relaxed exit 0, py_compile clean, workflow-expressions clean, lines.py CLI and library paths both still resolve for the real registry.

🤖 Generated with Claude Code

Three gaps around the config seed, plus the validator that would have caught the stale cross-series pin fixed in #28. ## 1. The seed was never re-verified after the step that verified it `build/fetch-seed.sh` checks the signed commit and the sha256, then the workflow step ends. `build/configure-kernel.sh` runs in a **later** step and did a bare `cp "$seed_config" .config`. Anything that mutated `$WORKDIR` in between went undetected — and the fidelity assertion cannot see it, because that only proves `configs/intent.config` landed. The seed supplies every hardening default we deliberately *inherit* rather than pin (~31 symbols). `build/apply-patches.sh:29-35` already re-checks `input-hashes.txt` across the same boundary, with the threat model spelled out. This is the symmetric guard: `fetch-seed.sh` records `seed-hashes.txt` from the same `$seed_sha256` that feeds `seed-info.json`, and `configure-kernel.sh` runs `sha256sum -c` before the copy. A missing hash file is a hard failure, never a skip — a guard that disables itself when its input is absent is the failure class this repo keeps hitting. `sha256sum` (coreutils) rather than `jq`, because `repro-check.sh`'s rebuild container deliberately has no `jq`. ## 2. A tag whose series had no seed pin silently built from another series' seed `fetch-seed.sh:47-53` fell through to `configs/upstream-seed.toml` — a symlink to the *current* stable line — whenever `configs/seeds/$SERIES.toml` was absent. Pushing `v7.2.1-hardened1` before adding `seeds/7.2.toml` would build a 7.2 kernel from the 7.1 config, and it turns **both** safety nets off at once: the fidelity assertion still passes (intent is satisfied by either seed) and the drift guard finds no `releases/v7.2.*` baseline, so it skips. Green log, wrong config. `build-kernel.yml:120-127` already hard-fails the analogous cross-series hazard for the stale-tag case; this matches that precedent, with an `ALLOW_CROSS_SERIES_SEED` hatch for a deliberate one-off. `repro-check.sh` never sets `SERIES` (it overrides via `FETCH_SEED_*`), so its path is unchanged. ## 3. `lines.py`'s `seed_path_for()` returned the configured path unchecked `fetch-seed.sh` resolves the seed **by convention** (`configs/seeds/$SERIES.toml`) and never reads `lines.toml`, so a `seed` value pointing anywhere else is silently ignored by the build while `seed-bump.yml` — which *does* go through `seed_path_for()` — would faithfully bump the wrong file. Now asserts the convention and existence. Deliberately not in `load()`: `prune.py` calls that for retention bucketing and must keep working for a line whose seed file has been removed. ## 4. New `validate.yml` job: `seed-pins-well-formed` Per `configs/seeds/<X.Y>.toml`: the pinned `tag` belongs to that series; all six keys parse; `commit` is 40 hex; `sha256` is 64 hex. Plus the `upstream-seed.toml` symlink tracks the stable line (a documented manual step when advancing the line, previously unchecked), and every registered line satisfies `seed_path_for()`. A cross-series seed *is* sometimes legitimate — a line stood up before anthraxx packages a config for it — so the escape hatch is a `# cross-series-seed: <reason>` marker in the pin file itself rather than a workflow variable. It belongs with the pin, and it forces the reason to be written down where the next reader will see it. This is the check that would have caught `seeds/7.1.toml` sitting on a 7.0.12 config for four releases while its header claimed otherwise. ## Verification **Seed re-verification** (real script block, fixture `$WORKDIR`): | case | result | |---|---| | intact seed + hashes | exit 0, `upstream-seed.config: OK` | | bytes tampered after verification | exit 1, `FAILED`, copy never reached | | `seed-hashes.txt` missing | exit 1, names the file | **Cross-series resolution:** | case | result | |---|---| | `SERIES=7.2`, no `seeds/7.2.toml` | exit 1, and **aborts before any `git clone`** (confirmed no clone dir created) | | same + `ALLOW_CROSS_SERIES_SEED=1` | `::warning::` fires, resolution proceeds to `upstream-seed.toml` | **`seed-pins-well-formed`**, extracted from the workflow and run against copies of the real tree: | case | result | |---|---| | unmodified | exit 0, `2 file(s) examined; symlink -> seeds/7.1.toml` | | 7.1.toml pinned to a 7.0 tag | exit 1, names the series mismatch | | same + `# cross-series-seed:` marker | exit 0 | | sha256 truncated | exit 1 | | `lines.toml` seed pointing at the wrong file | exit 1, via `seed_path_for` | | symlink pointing at 7.0 | exit 1 | Confirmed `repro-check.sh` runs `fetch-seed.sh` before `configure-kernel.sh`, so the hash file exists on the rebuild path too, and in repro mode it records the *historical* sha from the manifest. Validators: `shellcheck` clean, `yamllint -d relaxed` exit 0, `py_compile` clean, `workflow-expressions` clean, `lines.py` CLI and library paths both still resolve for the real registry. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(build): re-verify the seed across the step boundary; refuse a cross-series seed
All checks were successful
validate / shellcheck (pull_request) Successful in 11s
validate / yamllint (pull_request) Successful in 11s
validate / workflow-expressions (pull_request) Successful in 4s
validate / pycompile (pull_request) Successful in 4s
validate / intent-matches-policy (pull_request) Successful in 5s
validate / dep-list-parity (pull_request) Successful in 4s
validate / seed-pins-well-formed (pull_request) Successful in 9s
validate / no-placeholder-digests (pull_request) Has been skipped
168a49512d
Two gaps around the config seed, plus a validator that would have caught the
stale cross-series pin fixed in #28.

1. The seed was never re-verified after the step that verified it.

build/fetch-seed.sh checks the signed commit and the sha256, then the workflow
step ends. build/configure-kernel.sh runs in a LATER step and did a bare
`cp "$seed_config" .config`. Anything that mutated $WORKDIR in between -- a
compromised intermediate step, a poisoned runner cache -- went undetected, and
the fidelity assertion cannot see it: that only proves configs/intent.config
landed, while the seed supplies every hardening default we deliberately
INHERIT rather than pin (~31 symbols).

build/apply-patches.sh already re-checks input-hashes.txt across the same
boundary, with the threat model spelled out in a comment. This is the
symmetric guard: fetch-seed.sh records seed-hashes.txt from the same
$seed_sha256 that feeds seed-info.json, and configure-kernel.sh runs
`sha256sum -c` before the copy. A missing hash file is a hard failure, never
a skip. sha256sum, not jq, because repro-check.sh's rebuild container has no
jq.

2. A tag whose series had no seed pin silently built from another series' seed.

fetch-seed.sh's resolution fell through to configs/upstream-seed.toml -- a
symlink to the CURRENT stable line -- whenever configs/seeds/$SERIES.toml was
absent. Pushing v7.2.1-hardened1 before adding seeds/7.2.toml would build a
7.2 kernel from the 7.1 config, and it turns BOTH safety nets off at once: the
fidelity assertion still passes (intent is satisfied by either seed) and the
drift guard finds no releases/v7.2.* baseline, so it skips. Green log, wrong
config. build-kernel.yml's stale-tag guard hard-fails the analogous
cross-series hazard already; match it, with an ALLOW_CROSS_SERIES_SEED hatch
for a deliberate one-off. Verified it aborts before any git clone.
repro-check.sh never sets SERIES (it overrides via FETCH_SEED_*), so its path
is unchanged.

3. tools/lines.py's seed_path_for() returned the configured path unchecked.

fetch-seed.sh resolves the seed BY CONVENTION and never reads lines.toml, so a
`seed` value pointing elsewhere is ignored by the build while seed-bump.yml
would faithfully bump the wrong file. Assert the convention and existence --
not in load(), which prune.py needs to keep working for a line whose seed file
is gone.

4. New validate.yml job: seed-pins-well-formed.

Per configs/seeds/<X.Y>.toml: the pinned `tag` belongs to that series, all six
keys parse, commit is 40 hex, sha256 is 64 hex. Plus the upstream-seed.toml
symlink tracks the stable line (a documented manual step when advancing the
line, previously unchecked) and every registered line satisfies
seed_path_for(). A cross-series seed is sometimes legitimate, so the escape
hatch is a `# cross-series-seed: <reason>` marker in the pin file itself
rather than a workflow variable -- it belongs with the pin. This is the check
that would have caught seeds/7.1.toml sitting on a 7.0.12 config for four
releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: document the ALLOW_CROSS_SERIES_SEED hatch
All checks were successful
validate / shellcheck (pull_request) Successful in 16s
validate / yamllint (pull_request) Successful in 12s
validate / workflow-expressions (pull_request) Successful in 4s
validate / pycompile (pull_request) Successful in 4s
validate / intent-matches-policy (pull_request) Successful in 5s
validate / dep-list-parity (pull_request) Successful in 4s
validate / seed-pins-well-formed (pull_request) Successful in 9s
validate / no-placeholder-digests (pull_request) Has been skipped
9fbb78b239
Plumbing a repo variable without documenting it leaves an operator with no way
to discover the escape hatch when the build hard-fails at 2am. Caught by the
escape-hatches-are-plumbed validator being added in a follow-up, which asserts
every ALLOW_*/REQUIRE_* name a script reads is both passed via env: vars.<NAME>
by some workflow AND documented — the same class of gap that left
ALLOW_HARDENING_REGRESSION inert.

Documented in config-architecture.md rather than runner-setup.md's variables
table: this is a seed-architecture concern, and it avoids overlapping the table
another branch adds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chore(ci): close three validate.yml coverage gaps
All checks were successful
validate / shellcheck (pull_request) Successful in 11s
validate / yamllint (pull_request) Successful in 11s
validate / workflow-expressions (pull_request) Successful in 5s
validate / pycompile (pull_request) Successful in 5s
validate / intent-matches-policy (pull_request) Successful in 5s
validate / dep-list-parity (pull_request) Successful in 4s
validate / seed-pins-well-formed (pull_request) Successful in 9s
validate / workflow-shell (pull_request) Successful in 12s
validate / escape-hatches-are-plumbed (pull_request) Successful in 4s
validate / container-digests-pinned (pull_request) Successful in 5s
4d8b8180ff
Each of these would have caught a defect already found in this review round.

1. escape-hatches-are-plumbed.

A repo variable only reaches a script if the step running it lists the name in
env:. Nothing enforced that, and the result was a documented override that did
nothing: check-hardening-drift.sh honoured ALLOW_HARDENING_REGRESSION, both
POLICY.md and config-architecture.md told the operator to set it, and the drift
step's env: block listed only WORKDIR and SERIES. The operator's only route past
a drift failure was a commit to main -- which the stale-tag guard then turns into
"delete and re-cut the tag", mid-CVE-SLA.

This job asserts every ALLOW_*/REQUIRE_* name a script reads is BOTH passed via
env: vars.<NAME> by some workflow AND documented under docs/operators/ or in
POLICY.md. Worth noting a workflow-shell linter would NOT have caught the
original: a missing YAML env: key involves no shell at all.

It earned its place immediately -- run against the tree it flagged
ALLOW_CROSS_SERIES_SEED as plumbed but undocumented, a gap left in the seed PR.
Fixed there, in the commit that introduced the hatch.

2. container-digests-pinned, replacing no-placeholder-digests.

The old job grepped containers/ for the placeholder token. That token now appears
ONLY in shim/Dockerfile, which the job deliberately excludes -- so it could not
fail. A check that cannot fail is worse than no check, because it reads as
coverage.

Inverted to a positive assertion that cannot go dormant: every FROM under
containers/ must carry a concrete @sha256 digest (verified that a placeholder
there now fails, i.e. the new form covers the old form's stated purpose). Folded
in the adjacent free win: build-kernel.yml's container.image and the
CONTAINER_IMAGE that goes into every manifest were coupled only by a "keep this
digest in sync" comment. If they diverge, every manifest records a build
environment the build did not use, and repro-check faithfully rebuilds in an
image that never produced the release -- mismatches that look like tampering.

3. workflow-shell + tools/lint-workflow-shell.py.

The shellcheck job covers build/ and tools/ only. The largest and least-tested
shell in this repo is in workflow run: blocks -- cve-watch.yml alone is ~150
lines with jq, comm, an api_call helper and pipefail interactions -- and a parse
error there surfaces only when that job runs, which for cve-watch or prune could
be weeks later.

Gated at severity=error: 43 inline scripts, 0 findings, so it lands green and
stays signal rather than noise. The backlog above that is small and stated in the
job comment rather than hidden: 1 finding at warning, 3 at info.

The linter lives in tools/ rather than as an inline heredoc, precisely because the
premise is that inline workflow shell goes unlinted -- so the linter itself
belongs where shellcheck and py_compile already cover it. It substitutes a
placeholder for each expression template (a raw template is not valid shell),
which produces one deliberate interaction: comparing a template in a run: body
shows up as SC2050 "expression is constant". That is not noise -- it means a
template is being used where workflow-expressions now requires an env: variable,
so the two checks reinforce each other.

Verified in both directions throughout, checking exit codes without pipeline
masking: real tree passes all three; an unplumbed hatch, a renamed hatch, a
de-pinned FROM, a divergent CONTAINER_IMAGE, a containers/ dir with no FROM lines
(vacuity guard), and genuinely broken workflow shell each fail. The linter's own
positive control needed a real parse error -- my first attempt used
warning-level issues against an error-level gate and passed, which is worth
recording as the reason the gate level is stated explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge pull request 'chore(ci): close three validate.yml coverage gaps' (#38) from chore/validate-coverage into fix/seed-integrity-and-series
All checks were successful
validate / shellcheck (pull_request) Successful in 13s
validate / yamllint (pull_request) Successful in 13s
validate / escape-hatches-are-plumbed (pull_request) Successful in 5s
validate / container-digests-pinned (pull_request) Successful in 4s
validate / workflow-expressions (pull_request) Successful in 4s
validate / pycompile (pull_request) Successful in 4s
validate / intent-matches-policy (pull_request) Successful in 5s
validate / dep-list-parity (pull_request) Successful in 4s
validate / seed-pins-well-formed (pull_request) Successful in 8s
validate / workflow-shell (pull_request) Successful in 13s
417dfbc08e
zach merged commit d40ba0694e into main 2026-07-25 21:47:19 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
unredacted/linux-hardened-unredacted!31
No description provided.