From 5a8e4e04b170e8344fa06652e7985a75f807cbd4 Mon Sep 17 00:00:00 2001 From: Kiran Date: Thu, 13 Aug 2026 18:06:19 +0100 Subject: [PATCH 1/8] Carry a captain-approved supersession into CI on a signature The required check `Base assertions re-verified` re-runs the base's assertions on a GitHub runner. It cannot see data/supersessions/.md, which is captain-private and gitignored by design, so a branch the captain has approved superseding reports the same findings there and stays red forever. Nothing the branch pushes fixes it, because the branch is not what is wrong: it is the approval CI cannot read. The only ways out were a mislabelled CI waiver or a GitHub-UI merge - the exact bypass that check exists to prevent. This follows the CI waiver's own pattern rather than inventing a second one: a master key that never leaves the captain's machine, a key derived per repository for its Actions secrets, and a signed line in the PR body. - bin/fm-supersession-lib.sh stays the one parser and gains the one matcher, so the merge gate, the viewer, and CI answer "is this covered?" identically. Its canonical entry line is the wire between the record and a reader that cannot see it, and it carries no date and no reason. - bin/fm-supersession-attest-lib.sh owns the attestation's payload, entry token and published line. Its HMAC domain is separate from the waiver's, and so is the key it publishes: the waiver key skips a whole suite, this one only excuses findings the captain named, so a theft of one cannot escalate into the other. - bin/fm-supersession-attest.sh signs from the record itself, which is the authority: a worker holds neither the key nor a path anything invites it to write that record on. - bin/fm-supersession-verify.sh reads the PR body LIVE, because an approval always arrives as a body edit and a re-run replays the original payload. - bin/fm-reverify-base.sh excuses covered findings, blocks uncovered ones unchanged, names every override, and renders a fourth outcome, superseded, rather than reporting an authorized override as a pass. The workflow wiring and its tests are the next commits. --- bin/fm-ci-waiver-lib.sh | 35 +++ bin/fm-ci-waiver.sh | 30 +-- bin/fm-reverify-base.sh | 129 ++++++++++- bin/fm-supersession-attest-lib.sh | 192 +++++++++++++++++ bin/fm-supersession-attest.sh | 344 ++++++++++++++++++++++++++++++ bin/fm-supersession-lib.sh | 153 +++++++++++-- bin/fm-supersession-verify.sh | 224 +++++++++++++++++++ 7 files changed, 1057 insertions(+), 50 deletions(-) create mode 100644 bin/fm-supersession-attest-lib.sh create mode 100755 bin/fm-supersession-attest.sh create mode 100755 bin/fm-supersession-verify.sh diff --git a/bin/fm-ci-waiver-lib.sh b/bin/fm-ci-waiver-lib.sh index 5ce7a953fb8..577edcd6557 100755 --- a/bin/fm-ci-waiver-lib.sh +++ b/bin/fm-ci-waiver-lib.sh @@ -258,6 +258,41 @@ fm_ci_waiver_valid_repo() { return 0 } +# fm_ci_waiver_task_repo_slug : the owner/repo the task's own checkout +# pushes to, or nothing (exit 1) when that cannot be determined. Only a GitHub +# remote is resolved, because GitHub Actions is where anything signed here is +# ever verified; anything else is deliberately reported as unknown rather than +# parsed into a guess. +# +# It lives here rather than in either signer because BOTH of them refuse to sign +# for a repository the task does not belong to, for the same reason +# (bin/fm-ci-waiver.sh's sign_waiver states it), and a refusal that reads one way +# in one signer and another way in the other is a refusal an operator cannot +# reason about. +fm_ci_waiver_task_repo_slug() { + local meta=$1 dir url slug + dir=$(grep -m1 '^worktree=' "$meta" | cut -d= -f2- || true) + if [ -z "$dir" ] || [ ! -d "$dir" ]; then + dir=$(grep -m1 '^project=' "$meta" | cut -d= -f2- || true) + fi + [ -n "$dir" ] && [ -d "$dir" ] || return 1 + command -v git >/dev/null 2>&1 || return 1 + url=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1 + case "$url" in + *github.com[:/]*) : ;; + *) return 1 ;; + esac + slug=${url%.git} + slug=${slug##*github.com} + slug=${slug#:} + slug=${slug#/} + case "$slug" in + */*/*|*/) return 1 ;; + */*) printf '%s\n' "$slug" | tr '[:upper:]' '[:lower:]' ;; + *) return 1 ;; + esac +} + # fm_ci_waiver_secret_readable : 0 iff is a usable secret file - a # non-empty regular file that is not a symlink pointing somewhere else. # diff --git a/bin/fm-ci-waiver.sh b/bin/fm-ci-waiver.sh index 256fb8eec57..2518f70852e 100755 --- a/bin/fm-ci-waiver.sh +++ b/bin/fm-ci-waiver.sh @@ -120,34 +120,6 @@ read_secret_or_die() { fi } -# task_repo_slug : the owner/repo the task's own checkout pushes to, or -# nothing (exit 1) when that cannot be determined. Only a GitHub remote is -# resolved, because GitHub Actions is where a waiver is ever verified; anything -# else is deliberately reported as unknown rather than parsed into a guess. -task_repo_slug() { - local meta=$1 dir url slug - dir=$(grep -m1 '^worktree=' "$meta" | cut -d= -f2- || true) - if [ -z "$dir" ] || [ ! -d "$dir" ]; then - dir=$(grep -m1 '^project=' "$meta" | cut -d= -f2- || true) - fi - [ -n "$dir" ] && [ -d "$dir" ] || return 1 - command -v git >/dev/null 2>&1 || return 1 - url=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1 - case "$url" in - *github.com[:/]*) : ;; - *) return 1 ;; - esac - slug=${url%.git} - slug=${slug##*github.com} - slug=${slug#:} - slug=${slug#/} - case "$slug" in - */*/*|*/) return 1 ;; - */*) printf '%s\n' "$slug" | tr '[:upper:]' '[:lower:]' ;; - *) return 1 ;; - esac -} - # sign_waiver : validate, check the dispatch # authorization, and print the one publishable line. THE authority check lives # here, and both `sign` and `waive` go through this single function, so no @@ -179,7 +151,7 @@ sign_waiver() { # cannot be resolved at all is reported and allowed, because that is exactly # what this script did before the check existed: it closes the mismatch # wherever the information exists and breaks no dispatch where it does not. - TASK_REPO=$(task_repo_slug "$META") || TASK_REPO= + TASK_REPO=$(fm_ci_waiver_task_repo_slug "$META") || TASK_REPO= REPO_LOWER=$(printf '%s' "$REPO" | tr '[:upper:]' '[:lower:]') if [ -n "$TASK_REPO" ] && [ "$TASK_REPO" != "$REPO_LOWER" ]; then echo "error: task $ID's own checkout pushes to $TASK_REPO, not $REPO; refusing to sign a waiver for a repository this task does not belong to" >&2 diff --git a/bin/fm-reverify-base.sh b/bin/fm-reverify-base.sh index da35fea2a9c..b54c4e1c696 100755 --- a/bin/fm-reverify-base.sh +++ b/bin/fm-reverify-base.sh @@ -26,7 +26,7 @@ # sets of changes. Merging the moved base forward and re-verifying remains the # complete answer; this is the fast, cheap first read, never its replacement. # -# THREE OUTCOMES, NEVER TWO. A check that renders "nothing was left to run" the +# FOUR OUTCOMES, NEVER TWO. A check that renders "nothing was left to run" the # same way as "everything ran and held" turns a never-evaluated result into a # pass, so the first stdout line is always exactly one of: # reverify: verified the base's assertions were RE-RUN against this @@ -38,6 +38,12 @@ # verified-green suite already ran, so no # assertion was left for this check to re-run # (executed-files = 0, no findings). Exit 0. +# reverify: superseded the base's assertions did NOT all hold, and +# every finding is one the CAPTAIN has approved +# the branch superseding - never a pass over +# those assertions, an authorized override of +# them. Exit 0. See "The captain's approvals" +# below. # reverify: not-verified the base's assertions did NOT hold: an # identifier the base had is missing from the # branch, or one of its assertions failed against @@ -47,6 +53,28 @@ # not run is the exact false green this whole gate # family exists to eliminate. # +# THE CAPTAIN'S APPROVALS. A branch may deliberately supersede behaviour the +# base asserts, and that is a product decision only the captain makes. At merge +# time bin/fm-pr-merge.sh reads the approval from a private record and excuses +# exactly the identifiers it names. This check runs on a GitHub runner that +# cannot see that record, so without a way to carry the approval here it would +# report the same findings and stay red forever, with nothing the branch could +# push to fix it - the approval, not the branch, being what CI cannot read. +# - FM_SUPERSESSION_ENTRIES carries the approvals, as the canonical entry +# lines bin/fm-supersession-lib.sh owns. It is set ONLY from the verified +# output of bin/fm-supersession-verify.sh, which is what checks the +# captain's signature; this script trusts the caller for that and checks +# everything else, so an unsigned approval never reaches it. +# - Each finding is tested through the same matcher the merge gate uses, so an +# identifier is excused here exactly when it is excused there. +# - A finding NO entry covers still blocks, in its own class, exactly as +# today. The excuse is never blanket. +# - Every excused finding is printed as `superseded: ()`, so a +# green check never hides which assertions were overridden. +# - Entries that cannot be read are could-not-verify, never "no approvals": +# the difference between "nothing was approved" and "the approval is +# unreadable" is exactly the difference this file exists to keep. +# # EVERY WAY IT CAN FAIL TO EVALUATE IS could-not-verify, never a pass: # - the owner refused outright (its exit 2: bad usage, missing worktree, # unresolvable ref, an unreadable or wrong-repository PR base); @@ -78,6 +106,8 @@ # - `reverify: ` always, as the FIRST line. # - `reverify-detail: ` always, as the second: the outcome in words, # including the counts it was decided from. +# - `superseded: :: ()` per finding a captain-approved +# entry excused, after the detail and before the owner's own output. # - the owner's own stdout verbatim after that, so every finding stays visible # in the same log. The owner's stderr is not captured and reaches the # caller's stderr untouched. @@ -129,6 +159,8 @@ cleanup() { rm -rf -- "$TMP_DIR"; } trap cleanup EXIT OUT="$TMP_DIR/out" +EXCUSED_OUT="$TMP_DIR/superseded" +: > "$EXCUSED_OUT" RC=0 "$OWNER" "$@" > "$OUT" || RC=$? @@ -150,6 +182,9 @@ verdict() { # local outcome=$1 sentence=$2 printf 'reverify: %s\n' "$outcome" printf 'reverify-detail: %s\n' "$sentence" + # Before the owner's own output, so what was OVERRIDDEN is read next to the + # outcome rather than found among the findings it explains. + cat "$EXCUSED_OUT" cat "$OUT" if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { @@ -158,7 +193,7 @@ verdict() { # } >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true fi case "$outcome" in - verified|nothing-to-verify) exit 0 ;; + verified|nothing-to-verify|superseded) exit 0 ;; *) exit 1 ;; esac } @@ -218,15 +253,101 @@ if [ "$RC" -eq 1 ] && [ "$FINDINGS" -eq 0 ]; then "the base-assertion check exited 1 while its own summary reports no blocking finding; the two disagree, so neither can be trusted" fi +# --- the captain's approvals (header contract) -------------------------------- +# Applied AFTER the two consistency checks above, which are about whether the +# owner's own two statements agree and must be judged on everything it reported, +# and BEFORE any verdict, which is decided on what is left UNEXCUSED. +EXCUSED=0 +if [ -n "${FM_SUPERSESSION_ENTRIES-}" ] && [ "$FINDINGS" -ne 0 ]; then + MATCHER="$SCRIPT_DIR/fm-supersession-lib.sh" + if [ ! -f "$MATCHER" ]; then + verdict could-not-verify \ + "captain-approved supersessions were supplied but the matcher $MATCHER is missing, so which findings they cover cannot be decided; refusing rather than excusing none or all of them" + fi + # shellcheck source=bin/fm-supersession-lib.sh + # shellcheck disable=SC1090,SC1091 + . "$MATCHER" || verdict could-not-verify \ + "captain-approved supersessions were supplied but $MATCHER could not be read, so which findings they cover cannot be decided" + + ENTRIES="$TMP_DIR/entries" + printf '%s\n' "$FM_SUPERSESSION_ENTRIES" | grep -v '^[[:space:]]*$' > "$ENTRIES" || true + while IFS= read -r entry_line || [ -n "$entry_line" ]; do + [ -n "$entry_line" ] || continue + fm_supersession_entry_line_valid "$entry_line" && continue + verdict could-not-verify \ + "a supplied captain-approved supersession entry is not one this fleet's matcher can read, so what was approved cannot be established; refusing rather than acting on a partial reading of it" + done < "$ENTRIES" + if [ ! -s "$ENTRIES" ]; then + verdict could-not-verify \ + "captain-approved supersessions were supplied but hold no entry at all, so what was approved cannot be established" + fi + + # Every finding line is reclassified as excused or still-counted. The counts + # are then REPLACED by the unexcused ones, so every verdict below is decided + # on what the captain has not approved. + u_missing=0 + u_failing=0 + u_unexec=0 + u_unstable=0 + parsed=0 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + 'missing: '*) finding_class=missing ; ident=${line#missing: } ;; + 'failing: '*) finding_class=failing ; ident=${line#failing: } ;; + 'unexecuted: '*) finding_class=unexecuted ; ident=${line#unexecuted: } ;; + 'unstable: '*) finding_class=unstable ; ident=${line#unstable: } ;; + *) continue ;; + esac + parsed=$((parsed + 1)) + if fm_supersession_covered "$ENTRIES" "$ident" "$finding_class"; then + EXCUSED=$((EXCUSED + 1)) + printf 'superseded: %s (%s)\n' "$ident" "$finding_class" >> "$EXCUSED_OUT" + continue + fi + case "$finding_class" in + missing) u_missing=$((u_missing + 1)) ;; + failing) u_failing=$((u_failing + 1)) ;; + unexecuted) u_unexec=$((u_unexec + 1)) ;; + unstable) u_unstable=$((u_unstable + 1)) ;; + esac + done < "$OUT" + # The summary counts and the finding lines are two statements about the same + # run, and the excusal is applied per LINE. If the lines do not account for + # every counted finding, a finding could be excused by a line that is not + # there, so neither reading can be trusted. + if [ "$parsed" -ne "$FINDINGS" ]; then + : > "$EXCUSED_OUT" + verdict could-not-verify \ + "the base-assertion check's summary counts $FINDINGS blocking finding(s) but its output holds $parsed finding line(s), so a captain-approved supersession cannot be applied to them one by one" + fi + MISSING=$u_missing + FAILING=$u_failing + UNEXEC=$u_unexec + UNSTABLE=$u_unstable + FINDINGS=$((MISSING + FAILING + UNEXEC + UNSTABLE)) +fi + # A genuine regression outranks an unverifiable one: it is the actionable # statement, and the sentence still names the unverifiable half. +# Named in every sentence below once anything was excused, so a reader can never +# take a count as "this is all the check found". +EXCUSED_NOTE= +if [ "$EXCUSED" -ne 0 ]; then + EXCUSED_NOTE=" ($EXCUSED further finding(s) are covered by captain-approved supersessions and are not counted here)" +fi + if [ "$((MISSING + FAILING))" -ne 0 ]; then verdict not-verified \ - "the base's assertions do not hold against this branch: $MISSING identifier(s) the base has are missing from it and $FAILING of the base's assertion(s) failed against its code (also unverifiable: unexecuted=$UNEXEC unstable=$UNSTABLE)" + "the base's assertions do not hold against this branch: $MISSING identifier(s) the base has are missing from it and $FAILING of the base's assertion(s) failed against its code (also unverifiable: unexecuted=$UNEXEC unstable=$UNSTABLE)$EXCUSED_NOTE" fi if [ "$((UNEXEC + UNSTABLE))" -ne 0 ]; then verdict could-not-verify \ - "$UNEXEC of the base's assertion(s) could not be executed here at all and $UNSTABLE could not be compared because the base's own test named them differently on two runs; neither is a pass" + "$UNEXEC of the base's assertion(s) could not be executed here at all and $UNSTABLE could not be compared because the base's own test named them differently on two runs; neither is a pass$EXCUSED_NOTE" +fi + +if [ "$EXCUSED" -ne 0 ]; then + verdict superseded \ + "the base's assertions do not all hold against this branch, and every one of the $EXCUSED finding(s) is covered by a captain-approved supersession carried by a signature verified for this exact commit (named above); nothing else was left unexplained, $EXECUTED base test file(s) were re-run, and the approvals' stated reasons stay in the fleet's private record" fi if [ "$EXECUTED" -eq 0 ]; then diff --git a/bin/fm-supersession-attest-lib.sh b/bin/fm-supersession-attest-lib.sh new file mode 100644 index 00000000000..a2dc4c8a641 --- /dev/null +++ b/bin/fm-supersession-attest-lib.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Shared wire format for the captain-signed SUPERSESSION ATTESTATION: the one +# thing that carries a captain's approval of a superseded base assertion into +# CI, which cannot read the approval record itself. +# +# THE PROBLEM IT EXISTS FOR. bin/fm-pr-merge.sh honours an approved entry in +# $FM_HOME/data/supersessions/.md and proceeds. The required check +# `Base assertions re-verified` re-runs the same base assertions on a GitHub +# runner, cannot see that record - it is captain-private and gitignored by +# design - reports the same findings, and goes red. Nothing the branch pushes +# fixes that, because the branch is not what is wrong: it is the APPROVAL that +# CI cannot read. Without this file the only ways out are a mislabelled CI +# waiver or a GitHub-UI merge, which is the exact bypass that check exists to +# prevent. +# +# THIS FILE IS THE SINGLE OWNER of the attestation's cryptographic contract: +# the signed payload's exact bytes, the token that carries the approved entries, +# and the published line's grammar. Both sides source it - the signer +# (bin/fm-supersession-attest.sh, on the captain's machine) and the verifier +# (bin/fm-supersession-verify.sh, inside CI) - because a drift between two +# copies of the payload definition would either invalidate every real +# attestation or, far worse, let one PR's approval verify on another's code. +# +# It owns the WIRE only. What an entry means, and whether one covers a finding, +# stays with bin/fm-supersession-lib.sh, which is the one matcher every reader +# of the record and of this token goes through. The HMAC itself, and the +# per-repository key derivation, stay with bin/fm-ci-waiver-lib.sh, which is +# this fleet's one implementation of both. This file adds a wire format and +# borrows both; it reimplements neither. +# +# WHAT IS PUBLISHED, and what is deliberately not. The token carries the +# MATCHING HALF of each approved entry and nothing else: its identifier or glob, +# and the finding class it excuses. The captain's stated REASON and the DATE of +# the approval never leave the private record - they are the fleet's own +# deliberation, the matcher has never read them, and a PR body is a public +# place. bin/fm-supersession-lib.sh's canonical entry line is that half's exact +# form. +# +# Signed payload, exactly these bytes and no trailing newline: +# +# fm-supersession.v1\n\n. +# +# THE SHA IS THE LOCK, for the same reason it is in a CI waiver +# (bin/fm-ci-waiver-lib.sh owns that reasoning): the verifier accepts a line +# SOLELY on its signature matching the CURRENT head, and never checks that the +# task named in it has anything to do with the pull request carrying it. A +# signature bound to a task id alone would therefore excuse those identifiers in +# any PR of that repository the line was pasted into - and the line is published +# in a PR body. The accepted consequence is the same too: every new head commit +# needs a fresh attestation. +# +# THE ENTRIES ARE IN THE PAYLOAD, not merely beside it, so the set the captain +# approved cannot be widened after signing. Editing one character of the token +# is a different payload and a signature that no longer verifies. +# +# The two are ONE payload field, joined by a dot, because bin/fm-ci-waiver-lib.sh's +# payload has exactly two fields and its shape must not grow a third: every +# signature and dispatch token already in flight is bytes under the current +# shape, and adding a field would change all of them at once. The join is +# unambiguous by construction - the SHA is validated as exactly 40 hex digits +# and the token's alphabet excludes the dot - so no two different (sha, token) +# pairs can produce the same field. +# +# Published line grammar, one line anywhere in the PR body: +# +# fm-supersession: v1 +# +# Publishing it is harmless by design: it is bound to one commit, it carries no +# private text, and it reveals nothing about the secret. +# +# THE LABEL IS NOT THE CI WAIVER'S, deliberately, and neither is the key. The +# two mean opposite things: a waiver says there is NO test evidence, so nothing +# ran; an attestation says there IS evidence and the captain has overridden a +# specific assertion the evidence reports. Rendering them the same way in a +# check's output would make a merge log unreadable exactly where it matters +# most. The separate HMAC domain below enforces that at the wire: neither line +# can ever verify as the other, whichever body it is pasted into. + +FM_SUPERSESSION_ATTEST_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Sourced here rather than left to each caller, so no caller can end up with +# this file's wire format and a missing matcher or a missing HMAC. +# shellcheck source=bin/fm-ci-waiver-lib.sh +# shellcheck disable=SC1091 +. "$FM_SUPERSESSION_ATTEST_LIB_DIR/fm-ci-waiver-lib.sh" +# shellcheck source=bin/fm-supersession-lib.sh +# shellcheck disable=SC1091 +. "$FM_SUPERSESSION_ATTEST_LIB_DIR/fm-supersession-lib.sh" + +# Domain-separation tag that opens every signed attestation payload. Bumping it +# invalidates every previously issued attestation, which is the intended effect +# of a scheme change. +FM_SUPERSESSION_ATTEST_SCHEME='fm-supersession.v1' +# A SEPARATE domain for deriving the per-repository key an attestation is signed +# with, so that key is not the repository's CI-waiver key either. +# +# It is derived from the same master (bin/fm-ci-waiver.sh's header owns why the +# master is never published), so the containment property is unchanged: a theft +# from one repository's Actions secrets reveals nothing about the master and +# therefore nothing about any other repository. +# +# It is a DIFFERENT published key from FM_CI_WAIVER_SECRET for one reason worth +# stating plainly: the waiver key is strictly the more powerful grant, since it +# skips a PR's entire test suite, while this one only excuses findings the +# captain has already approved by name. Two keys mean a theft of this one cannot +# escalate into that one, and each Actions secret has an answerable "what can +# this authorize?". +FM_SUPERSESSION_REPO_SCHEME='fm-supersession-repo.v1' +# Version token in the published line, paired one-to-one with the scheme above. +FM_SUPERSESSION_LINE_VERSION='v1' +FM_SUPERSESSION_LINE_PREFIX='fm-supersession:' +# The Actions secret name a repository holds this attestation's key under. +# Stated here rather than only in the workflow so the publisher and the +# workflow cannot drift into two different names. +FM_SUPERSESSION_SECRET_NAME='FM_SUPERSESSION_SECRET' + +# base64url without padding, so the token is one whitespace-free field that +# survives a PR body, a Markdown renderer and a copy-paste. It is an ENCODING +# and not a secrecy measure: anyone can decode it, and the plain-text entries it +# holds are test identifiers from a public repository. What keeps the private +# half private is that it was never put in here. +# shellcheck disable=SC2016 # deliberately literal: this is a node program, not shell +FM_SUPERSESSION_ENCODE_PROGRAM=' +const fs = require("fs"); +process.stdout.write(fs.readFileSync(0).toString("base64url")); +' +# Decoding REFUSES a token that is not the canonical encoding of what it +# decodes to - standard base64, padding, stray characters - rather than +# accepting a second spelling of the same bytes. The signature covers the token +# as written, so a second spelling could never verify anyway; refusing here as +# well means the failure reads as "this is not a token" rather than as "this +# signature is wrong". +# shellcheck disable=SC2016 # deliberately literal: this is a node program, not shell +FM_SUPERSESSION_DECODE_PROGRAM=' +const token = process.argv[1] || ""; +const bytes = Buffer.from(token, "base64url"); +if (bytes.toString("base64url") !== token) { process.exitCode = 1; } +else { process.stdout.write(bytes); } +' + +# fm_supersession_valid_token : 0 iff is a non-empty unpadded +# base64url string. Length is bounded because the token rides in a PR body and +# an unbounded one would be a way to make that body unusable; a fleet whose +# approval record outgrows it has a record to prune, not a limit to raise. +fm_supersession_valid_token() { + local token=${1-} + local LC_ALL=C + case "$token" in + ''|*[!A-Za-z0-9_-]*) return 1 ;; + esac + [ "${#token}" -le 60000 ] +} + +# fm_supersession_token_encode: canonical entry lines on stdin, token on stdout. +fm_supersession_token_encode() { + node -e "$FM_SUPERSESSION_ENCODE_PROGRAM" +} + +# fm_supersession_token_decode : canonical entry lines on stdout. Exit 1 +# when the argument is not a canonical unpadded base64url token. +fm_supersession_token_decode() { + fm_supersession_valid_token "${1-}" || return 1 + node -e "$FM_SUPERSESSION_DECODE_PROGRAM" "$1" +} + +# fm_supersession_attest_sign ; the REPOSITORY key on +# stdin. Prints the hex digest. +fm_supersession_attest_sign() { + fm_ci_waiver_hmac_hex "$FM_SUPERSESSION_ATTEST_SCHEME" "$1" "$2.$3" +} + +# fm_supersession_attest_check ; the +# repository key on stdin. Exit 0 iff the candidate matches in constant time, 1 +# if it does not, 3 if the secret was empty. A non-zero exit is NEVER an +# authorization. +fm_supersession_attest_check() { + fm_ci_waiver_hmac_check "$FM_SUPERSESSION_ATTEST_SCHEME" "$1" "$2.$3" "$4" +} + +# fm_supersession_attest_repo_key ; the MASTER secret on stdin. +# Prints the hex secret that repository's CI verifies attestations against, and +# the only value the publisher ever sends to GitHub. +fm_supersession_attest_repo_key() { + fm_ci_waiver_hmac_hex "$FM_SUPERSESSION_REPO_SCHEME" "$1" '' +} + +# fm_supersession_attest_line : the publishable +# PR-body line. +fm_supersession_attest_line() { + printf '%s %s %s %s %s %s\n' \ + "$FM_SUPERSESSION_LINE_PREFIX" "$FM_SUPERSESSION_LINE_VERSION" "$1" "$2" "$3" "$4" +} diff --git a/bin/fm-supersession-attest.sh b/bin/fm-supersession-attest.sh new file mode 100755 index 00000000000..89105582b60 --- /dev/null +++ b/bin/fm-supersession-attest.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# Issue and provision captain-signed SUPERSESSION ATTESTATIONS. FIRSTMATE-SIDE +# ONLY: this script reads the master key and the captain's private approval +# record, so it runs on the captain's machine, never in a worker's worktree and +# never in CI. +# +# Usage: +# fm-supersession-attest.sh publish push that repo's derived +# key to its Actions secrets +# fm-supersession-attest.sh sign +# print the publishable line +# fm-supersession-attest.sh attest [--print-only] +# sign for the PR's CURRENT +# head and publish the line +# into its body +# +# WHAT THIS IS FOR. bin/fm-pr-merge.sh honours a captain-approved entry in +# $FM_HOME/data/supersessions/.md and lets the merge proceed. The +# required check `Base assertions re-verified` re-runs the same base assertions +# on a GitHub runner, cannot see that record, reports the same findings and goes +# red - permanently, because the branch is not what is wrong. This script is how +# the approval reaches CI: bin/fm-supersession-attest-lib.sh owns the wire it +# travels on, and bin/fm-supersession-verify.sh is what reads it there. +# +# THE SECRET is the same MASTER key the CI waiver uses, at +# $FM_HOME/config/ci-waiver-secret (local, gitignored, mode 0600, created by +# `fm-ci-waiver.sh init`). The master is never published anywhere. What each +# repository receives is a key DERIVED for that repository AND for this purpose +# alone, published as the Actions secret FM_SUPERSESSION_SECRET; +# bin/fm-supersession-attest-lib.sh owns the derivation and the reasoning, +# including why it is deliberately not the repository's CI-waiver key. +# +# Run `publish` once per project whose CI must honour supersessions. It is +# independent of `fm-ci-waiver.sh publish`: a repository can hold either secret, +# both, or neither, and each grants only its own thing. +# +# THE AUTHORITY CHECK, and why it is the record rather than a dispatch flag. +# What a worker may not be able to do is excuse its OWN findings, so the test to +# apply is whether the entity being checked could have produced the thing being +# checked. Two things are required to mint an attestation and a worker is +# invited to neither: +# - the master key, which lives only in the captain's config/ and is never +# handed to a worker in any form; +# - a fully-formed entry in the captain's private approval record, which no +# brief, scaffold, or status protocol ever points a worker at. That is the +# same standing bin/fm-pr-merge.sh's attestation exemption gives +# $FM_HOME/data/projects.md, and for the same stated reason: it is not a +# file a worker is invited to touch, unlike the state/ directory it appends +# its own status lines into. +# bin/fm-ci-waiver-lib.sh states the residual same-user limit no design in this +# space can remove; it applies here unchanged. +# +# NO DISPATCH FLAG GATES THIS, deliberately, and the difference from +# fm-ci-waiver.sh's `sign` is worth naming: a CI waiver is a decision made at +# DISPATCH about a task that has not run yet, so a token minted at dispatch is +# exactly the right authority for it. A supersession is the opposite: it can +# only be decided AFTER the gate has reported which assertion the branch +# supersedes, so there is nothing to have flagged in advance. The record IS the +# decision, and it is written by the captain's approval and nothing else. +# +# WHAT IS SIGNED, and what is never published: the matching half of every +# well-formed entry in that project's record - the identifier or glob, and the +# finding class it excuses - bound to this task and to ONE commit. The captain's +# stated reason and the approval date stay in the private record, which is where +# the fleet's deliberation belongs; a PR body is public. +# +# WHY IT IS SIGNED FOR THE PR'S CURRENT HEAD, and why `attest` reads that head +# from GitHub rather than from the task's own record: the signature is bound to +# one commit (bin/fm-supersession-attest-lib.sh owns why), and a recorded +# pr_head= is only as fresh as the last time it was written. A signature for a +# commit that is no longer the head verifies nowhere and would look like a +# broken attestation rather than a stale one. +# +# EDITING THE PR BODY DOES NOT RE-TRIGGER THE CHECK, and does not need to: the +# re-verification workflow exists to be re-run in place, and its verifier reads +# the body live at job time for exactly this reason. `attest` prints the one +# command that re-runs it. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +case "${1:-}" in + -h|--help|'') usage; exit 0 ;; +esac + +# shellcheck source=bin/fm-supersession-attest-lib.sh +. "$SCRIPT_DIR/fm-supersession-attest-lib.sh" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" + +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +SECRET_FILE="$CONFIG/ci-waiver-secret" +GH_CMD=${FM_SUPERSESSION_GH:-gh} + +require_node() { + command -v node >/dev/null 2>&1 && return 0 + echo "error: node is required to compute the attestation signature (docs/configuration.md \"Toolchain\")" >&2 + return 1 +} + +# The same shape rule bin/fm-ci-waiver.sh applies to the same file, and for the +# same reason: the secret must live in the home's own config dir rather than +# wherever a symlink points. Each case names its own remedy. +read_secret_or_die() { + if [ ! -e "$SECRET_FILE" ]; then + echo "error: no signing secret at $SECRET_FILE; run 'fm-ci-waiver.sh init' first (both signers share this home's one master key)" >&2 + exit 1 + fi + if [ -L "$SECRET_FILE" ] || [ ! -f "$SECRET_FILE" ]; then + echo "error: $SECRET_FILE must be a regular file, not a symlink or directory" >&2 + exit 1 + fi + if [ ! -s "$SECRET_FILE" ]; then + echo "error: $SECRET_FILE is empty; re-run 'fm-ci-waiver.sh init --rotate'" >&2 + exit 1 + fi +} + +# project_for_task : the project name whose approval record governs this +# task - the basename of the meta's project= path, exactly as bin/fm-pr-merge.sh +# resolves it, so the signer and the merge gate can never read two different +# records for one task. +project_for_task() { + local meta=$1 proj_path + proj_path=$(grep -m1 '^project=' "$meta" | cut -d= -f2- || true) + [ -n "$proj_path" ] || return 1 + basename "$proj_path" +} + +# sign_attestation : validate, read the captain's +# approvals, and print the one publishable line. THE authority check lives here, +# and both `sign` and `attest` go through this single function, so no +# convenience path can accumulate a weaker version of it. +sign_attestation() { + local ID=$1 SHA=$2 REPO=$3 + local META RECORD PROJ TOKEN SIG REPO_KEY TASK_REPO REPO_LOWER ENTRIES COUNT + fm_ci_waiver_valid_task_id "$ID" || { echo "error: invalid task id" >&2; exit 2; } + [ -n "$REPO" ] || { + echo "error: sign requires the the PR is open against" >&2 + echo "error: each repository verifies against its own derived key, so a signature must name the repository it is for" >&2 + exit 2 + } + fm_ci_waiver_valid_repo "$REPO" || { echo "error: '$REPO' is not a valid " >&2; exit 2; } + fm_ci_waiver_valid_sha "$SHA" || { + echo "error: '' must be a full 40-character lowercase commit id; an abbreviation cannot be signed because the verifier compares against GitHub's full head SHA" >&2 + exit 2 + } + META="$STATE/$ID.meta" + if [ ! -f "$META" ] || [ -L "$META" ]; then + echo "error: no durable record for task $ID at $META; refusing to sign" >&2 + exit 1 + fi + # The same refusal bin/fm-ci-waiver.sh's sign_waiver makes, through the same + # shared resolver: the verifier accepts a line on its signature alone and + # never checks that the task named in it has anything to do with the pull + # request carrying it, so a signature issued for a repository this task has + # nothing to do with would excuse findings on someone else's PR. A repository + # that cannot be resolved at all is allowed, because that is the state of a + # project with no GitHub origin, and nothing about it is a mismatch. + TASK_REPO=$(fm_ci_waiver_task_repo_slug "$META") || TASK_REPO= + REPO_LOWER=$(printf '%s' "$REPO" | tr '[:upper:]' '[:lower:]') + if [ -n "$TASK_REPO" ] && [ "$TASK_REPO" != "$REPO_LOWER" ]; then + echo "error: task $ID's own checkout pushes to $TASK_REPO, not $REPO; refusing to sign an attestation for a repository this task does not belong to" >&2 + exit 1 + fi + PROJ=$(project_for_task "$META") || { + echo "error: task $ID's record names no project, so there is no approval record to read" >&2 + exit 1 + } + RECORD="$DATA/supersessions/$PROJ.md" + if [ ! -f "$RECORD" ] || [ -L "$RECORD" ]; then + echo "error: $PROJ has no captain-approved supersession record at $RECORD, so there is nothing to attest" >&2 + echo "error: an attestation carries approvals that already exist; it never creates one. If the branch deliberately supersedes the base's behaviour, that is the captain's decision to record first (entry format in bin/fm-pr-merge.sh's header)" >&2 + exit 1 + fi + # The ONE reader of the record's grammar, shared with the merge gate that + # enforces it, so what CI is told was approved and what the merge gate excuses + # cannot be two different sets. + ENTRIES=$(fm_supersession_entries_extract "$RECORD" "$PROJ") + COUNT=$(printf '%s' "$ENTRIES" | grep -c . || true) + if [ "${COUNT:-0}" -eq 0 ]; then + echo "error: $RECORD holds no fully-formed entry for $PROJ, so there is nothing to attest" >&2 + echo "error: any malformed entry is named above; a refused entry excuses nothing at merge time either" >&2 + exit 1 + fi + require_node || exit 1 + read_secret_or_die + TOKEN=$(printf '%s\n' "$ENTRIES" | fm_supersession_token_encode) || { + echo "error: could not encode the approved entries" >&2 + exit 1 + } + fm_supersession_valid_token "$TOKEN" || { + echo "error: encoding the approved entries produced an unusable token" >&2 + exit 1 + } + # Signed with the key derived for THIS repository and THIS purpose, which is + # the only key its re-verification job holds. + REPO_KEY=$(fm_supersession_attest_repo_key "$REPO" < "$SECRET_FILE") || { + echo "error: could not derive the repository key for $REPO" >&2 + exit 1 + } + SIG=$(printf '%s' "$REPO_KEY" | fm_supersession_attest_sign "$ID" "$SHA" "$TOKEN") || { + echo "error: could not compute the attestation signature" >&2 + exit 1 + } + fm_ci_waiver_valid_sig "$SIG" || { echo "error: signature computation produced an unusable value" >&2; exit 1; } + echo "attesting $COUNT captain-approved entry(ies) from $RECORD for $ID at $SHA on $REPO" >&2 + fm_supersession_attest_line "$ID" "$SHA" "$TOKEN" "$SIG" +} + +cmd=$1 +shift + +case "$cmd" in + publish) + repo=${1:-} + [ -n "$repo" ] || { echo "error: publish requires an explicit " >&2; exit 2; } + fm_ci_waiver_valid_repo "$repo" || { + echo "error: '$repo' is not a valid " >&2; exit 2 + } + require_node || exit 1 + read_secret_or_die + command -v gh-axi >/dev/null 2>&1 || { + echo "error: gh-axi is required to set the repository secret" >&2 + exit 1 + } + repo_key=$(fm_supersession_attest_repo_key "$repo" < "$SECRET_FILE") || { + echo "error: could not derive the repository key for $repo" >&2 + exit 1 + } + fm_ci_waiver_valid_sig "$repo_key" || { + echo "error: key derivation for $repo produced an unusable value" >&2 + exit 1 + } + # printf is a shell builtin, so the value is not exposed in any argv, and + # gh-axi secret set reads it only from stdin and never prints it back. + if ! printf '%s' "$repo_key" | gh-axi secret set "$FM_SUPERSESSION_SECRET_NAME" -R "$repo"; then + echo "error: could not set $FM_SUPERSESSION_SECRET_NAME on $repo" >&2 + exit 1 + fi + echo "published $FM_SUPERSESSION_SECRET_NAME to $repo (derived for that repo and this purpose, value not printed)" + ;; + + sign) + sign_attestation "${1:-}" "${2:-}" "${3:-}" + ;; + + attest) + ID=${1:-} + shift 2>/dev/null || true + PRINT_ONLY=0 + for a in "$@"; do + case "$a" in + --print-only) PRINT_ONLY=1 ;; + *) echo "error: unknown attest argument '$a'" >&2; exit 2 ;; + esac + done + fm_ci_waiver_valid_task_id "$ID" || { echo "error: invalid task id" >&2; exit 2; } + META="$STATE/$ID.meta" + if [ ! -f "$META" ] || [ -L "$META" ]; then + echo "error: no durable record for task $ID at $META" >&2 + exit 1 + fi + PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) + if [ -z "$PR_URL" ]; then + echo "error: task $ID has no recorded PR, so there is no body to publish an attestation into; run bin/fm-pr-check.sh first" >&2 + exit 1 + fi + fm_pr_url_parse "$PR_URL" || { + echo "error: task $ID's recorded pr= value '$PR_URL' is not a GitHub pull request link" >&2 + exit 1 + } + PR_REPO_SLUG="$FM_PR_OWNER/$FM_PR_REPO" + PR_NUMBER=$FM_PR_NUMBER + command -v "$GH_CMD" >/dev/null 2>&1 || { + echo "error: $GH_CMD is required to read the PR's current head commit" >&2 + exit 1 + } + # Read rather than taken from the task's record: an attestation covers ONE + # commit, and a recorded head is only as fresh as the last time it was + # written. gh's raw JSON is used because gh-axi's pr view renders a summary + # rather than returning the head object id, the same reason + # bin/fm-pr-lib.sh reads baseRefName this way. + HEAD_SHA=$("$GH_CMD" pr view "$PR_URL" --json headRefOid -q .headRefOid) || { + echo "error: could not read the current head commit of $PR_URL from GitHub; refusing to sign for a commit that cannot be confirmed" >&2 + exit 1 + } + fm_ci_waiver_valid_sha "$HEAD_SHA" || { + echo "error: GitHub reported '$HEAD_SHA' as the head of $PR_URL, which is not a full 40-character commit id" >&2 + exit 1 + } + LINE=$(sign_attestation "$ID" "$HEAD_SHA" "$PR_REPO_SLUG") || exit 1 + # Printed before any delivery attempt, so a failed edit still leaves the + # valid line in hand rather than losing it with the failure. + printf '%s\n' "$LINE" + if [ "$PRINT_ONLY" -eq 1 ]; then + echo "attestation for $ID covers $HEAD_SHA on $PR_REPO_SLUG (not published)" + exit 0 + fi + BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/fm-supersession-body.XXXXXX") || { + echo "error: could not create a scratch file for the PR body" >&2 + exit 1 + } + trap 'rm -f "$BODY_FILE"' EXIT + "$GH_CMD" pr view "$PR_URL" --json body -q .body > "$BODY_FILE" || { + echo "error: could not read the current body of $PR_URL; the line above is valid, publish it by hand" >&2 + exit 1 + } + if grep -qxF "$LINE" "$BODY_FILE"; then + echo "the PR body already carries this exact attestation; nothing to publish" + else + # APPENDED, never rewritten: an earlier attestation for a superseded head + # simply stops verifying, exactly as a stale CI waiver line does, so there + # is no reason to edit anything a human wrote. + printf '\n%s\n' "$LINE" >> "$BODY_FILE" + if ! gh-axi pr edit "$PR_NUMBER" --repo "$PR_REPO_SLUG" --body-file "$BODY_FILE"; then + echo "error: the attestation line above is valid but could not be published into $PR_URL; add it to the body by hand" >&2 + exit 1 + fi + echo "published the attestation into $PR_URL" + fi + # Editing a body triggers no workflow, and the re-verification is designed + # to be re-run in place; its verifier reads the body live for that reason. + echo "next: re-run the base re-verification so it reads the new body:" + echo " gh run rerun \$(gh run list --repo $PR_REPO_SLUG --workflow 'Base re-verification' --branch --limit 1 --json databaseId -q '.[0].databaseId')" + ;; + + *) + echo "error: unknown subcommand '$cmd'" >&2 + exit 2 + ;; +esac diff --git a/bin/fm-supersession-lib.sh b/bin/fm-supersession-lib.sh index 5a9fbc2ad32..99470a7c867 100644 --- a/bin/fm-supersession-lib.sh +++ b/bin/fm-supersession-lib.sh @@ -1,15 +1,28 @@ #!/usr/bin/env bash # fm-supersession-lib.sh - the ONE parser for the captain-approved supersession -# record's entry grammar. +# record's entry grammar, and the ONE matcher that decides whether an entry +# covers a finding. # # The grammar itself (entry format, required fields, every case that refuses # rather than guesses, and the safety reasoning behind `kind`) is owned and # documented by bin/fm-pr-merge.sh's header; this file is its single # implementation, extracted so a second reader of the record cannot drift from # the gate that enforces it. bin/fm-pr-merge.sh decides whether a merge -# proceeds; bin/fm-nm-flow.sh only classifies findings for display. Both must +# proceeds; bin/fm-nm-flow.sh only classifies findings for display; the CI-side +# attestation (bin/fm-supersession-attest-lib.sh) carries the same approvals to +# a GitHub runner that cannot read the private record at all. All three must # answer "is this identifier covered?" identically, which is why there is one -# matcher and not two. +# matcher and not three. +# +# THREE FUNCTIONS, one parse and one match between them: +# +# fm_supersession_entries_extract +# Print one CANONICAL ENTRY LINE per fully-formed captain-approved entry in +# that record, in the order they appear. This is the file's only reader of the +# entry grammar; everything else here is built on it. +# +# fm_supersession_entry_covers :: +# Returns 0 iff one canonical entry covers that identifier for that class. # # fm_supersession_approved :: # Returns 0 iff the record holds a fully-formed captain-approved entry that @@ -17,12 +30,80 @@ # absent file means no approvals. A malformed entry is warned about on stderr # and never honored: this grammar governs what may BYPASS the merge gate, so # every unclear case fails closed. -fm_supersession_approved() { - local file=$1 proj=$2 ident=$3 finding_class=$4 +# +# fm_supersession_covered :: +# The same question asked of a file of canonical entry lines rather than of +# the record, for a reader that never sees the record - CI, which is handed +# the verified entries and nothing else. +# +# THE CANONICAL ENTRY LINE, this file's own wire format between the record and +# any reader that cannot see it: +# +# +# +# sel `id` for an exact identifier, `ids` for the glob batch form. +# kind the finding class this entry excuses, or `any`. An entry that omits +# kind is canonicalized to `any`, which is what the record's own +# grammar means by an absent kind. +# value the identifier or the glob, LAST because a test identifier contains +# spaces routinely. A value carrying a tab is refused rather than +# canonicalized, because the field it would split into is not what the +# captain approved. +# +# It deliberately carries NO date and NO reason. Those are the captain's private +# record of WHY an assertion was superseded; the matcher has never read them, +# and a reader that only has to answer "is this covered?" must not be handed +# them (bin/fm-supersession-attest.sh's header owns that boundary). + +# The canonical line's field separator, resolved once into a named constant so +# every reader below splits on a tab that is visible in this source rather than +# on an invisible literal one. +FM_SUPERSESSION_FIELD_SEP=$'\t' + +# fm_supersession_entry_line_valid : 0 iff is a well-formed +# canonical entry line. Every reader of a canonical line validates it with this +# before matching on it, including readers whose input was already signed: a +# signature proves who wrote a line, never that it says something this matcher +# can act on, and a line this file cannot read must never widen into one it +# guesses at. +fm_supersession_entry_line_valid() { + local line=${1-} sel kind value rest sep=$FM_SUPERSESSION_FIELD_SEP + case "$line" in + *"$sep"*"$sep"*) ;; + *) return 1 ;; + esac + sel=${line%%"$sep"*} + rest=${line#*"$sep"} + kind=${rest%%"$sep"*} + value=${rest#*"$sep"} + case "$sel" in id|ids) ;; *) return 1 ;; esac + case "$kind" in missing|failing|unexecuted|unstable|any) ;; *) return 1 ;; esac + [ -n "$value" ] || return 1 + case "$value" in *"$sep"*) return 1 ;; esac + return 0 +} + +# fm_supersession_entry_covers +fm_supersession_entry_covers() { + local sel=$1 kind=$2 value=$3 ident=$4 finding_class=$5 + if [ "$kind" != any ] && [ "$kind" != "$finding_class" ]; then + return 1 + fi + case "$sel" in + id) [ "$value" = "$ident" ] ;; + # shellcheck disable=SC2053 # unquoted glob match is the documented `ids:` batch mechanism + ids) [[ "$ident" == $value ]] ;; + *) return 1 ;; + esac +} + +# fm_supersession_entries_extract +fm_supersession_entries_extract() { + local file=$1 proj=$2 local line head field key val bad req kind_seen seen_keys local entry_id entry_ids entry_proj entry_date entry_reason entry_kind - [ -n "$proj" ] || return 1 - [ -f "$file" ] || return 1 + [ -n "$proj" ] || return 0 + [ -f "$file" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- id: "*|"- ids: "*) ;; @@ -126,18 +207,56 @@ fm_supersession_approved() { echo "warning: ignoring supersession entry for project '$entry_proj' found in $proj's record: $line" >&2 continue fi - if [ "$entry_kind" != any ] && [ "$entry_kind" != "$finding_class" ]; then - continue - fi + # A tab inside the identifier would split the canonical line into fields the + # captain never wrote, so it is refused here rather than carried into a + # reader that would then match on a truncated value. + case "$entry_id$entry_ids" in + *"$FM_SUPERSESSION_FIELD_SEP"*) + echo "warning: ignoring supersession entry whose identifier contains a tab: $line" >&2 + continue + ;; + esac if [ -n "$entry_id" ]; then - if [ "$entry_id" = "$ident" ]; then - return 0 - fi + printf 'id%s%s%s%s\n' \ + "$FM_SUPERSESSION_FIELD_SEP" "$entry_kind" "$FM_SUPERSESSION_FIELD_SEP" "$entry_id" else - # shellcheck disable=SC2053 # unquoted glob match is the documented `ids:` batch mechanism - if [[ "$ident" == $entry_ids ]]; then - return 0 - fi + printf 'ids%s%s%s%s\n' \ + "$FM_SUPERSESSION_FIELD_SEP" "$entry_kind" "$FM_SUPERSESSION_FIELD_SEP" "$entry_ids" + fi + done < "$file" +} + +# fm_supersession_approved :: +fm_supersession_approved() { + local file=$1 proj=$2 ident=$3 finding_class=$4 sel kind value + [ -n "$proj" ] || return 1 + [ -f "$file" ] || return 1 + while IFS=$FM_SUPERSESSION_FIELD_SEP read -r sel kind value; do + [ -n "$sel" ] || continue + if fm_supersession_entry_covers "$sel" "$kind" "$value" "$ident" "$finding_class"; then + return 0 + fi + done < <(fm_supersession_entries_extract "$file" "$proj") + return 1 +} + +# fm_supersession_covered :: +# A line the validator above refuses is skipped rather than guessed at, and a +# caller that must not proceed on a partial reading validates the file itself +# first (bin/fm-reverify-base.sh does, and refuses). +fm_supersession_covered() { + local file=$1 ident=$2 finding_class=$3 line sel kind value rest + local sep=$FM_SUPERSESSION_FIELD_SEP + [ -f "$file" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + fm_supersession_entry_line_valid "$line" || continue + sel=${line%%"$sep"*} + rest=${line#*"$sep"} + kind=${rest%%"$sep"*} + value=${rest#*"$sep"} + if fm_supersession_entry_covers "$sel" "$kind" "$value" "$ident" "$finding_class"; then + return 0 fi done < "$file" return 1 diff --git a/bin/fm-supersession-verify.sh b/bin/fm-supersession-verify.sh new file mode 100755 index 00000000000..ed8c3ac6611 --- /dev/null +++ b/bin/fm-supersession-verify.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Verify a published supersession attestation. CI-SIDE ONLY: this is the job +# that decides which base-assertion findings the re-verification check may +# excuse. +# +# Inputs, all from the environment so that no untrusted text is ever +# interpolated into a shell command by the workflow: +# FM_SUPERSESSION_SECRET the Actions repository secret; absent means no verdict +# FM_SUPERSESSION_HEAD_SHA the PR's full head commit SHA +# FM_SUPERSESSION_REPO owner/repo the PR lives in +# FM_SUPERSESSION_PR the PR number whose body carries the attestation +# FM_SUPERSESSION_GH overrides the `gh` command, for tests only +# +# Output: +# stdout "superseded=true" or "superseded=false", then, when true, +# one `entry: ` line per approved entry so +# the log says in plain words what may be excused and by what +# $GITHUB_OUTPUT superseded=, and on true a multi-line `entries` +# holding the canonical entry lines +# (bin/fm-supersession-lib.sh owns that format) +# exit status 0 whenever the check completed, whatever the verdict +# +# THE BODY IS READ LIVE, never from the workflow event's payload, and that is +# load-bearing rather than incidental. A supersession is approved AFTER the +# check has already reported red, so the line always arrives by an edit to an +# existing PR - and editing a body triggers no workflow. The remedy is to re-run +# the re-verification in place, which the workflow is built for, but a re-run +# replays the ORIGINAL event payload, whose body predates the edit. Reading the +# body from the API at job time is what makes the re-run see the attestation at +# all. +# +# The verdict is superseded=true ONLY when a line in the body carries a +# signature that this repository's secret reproduces over the CURRENT head SHA. +# Every other outcome - no line, no secret, no head SHA, an unreadable body, a +# malformed line, a line bound to a different commit, a wrong signature, an +# undecodable token, an entry this fleet's matcher cannot read, no node - is +# superseded=false, which reproduces the behaviour before this check existed +# exactly: every finding blocks. A verification failure is therefore never +# treated as an excuse, and every failure that is not simply "no attestation was +# offered" is annotated loudly rather than passed over in silence. +# +# The job exits 0 even when it refuses an attestation, deliberately. Failing the +# job instead would leave the required check that depends on it with a failed +# dependency, and the re-verification's whole point is to report a verdict on +# every PR; refusing an attestation is a verdict (excuse nothing), not an error. +# +# NOTHING HERE DECIDES WHAT IS EXCUSED. It decides only what the captain +# approved and signed. bin/fm-reverify-base.sh applies those entries to the +# findings, through the same matcher bin/fm-pr-merge.sh uses at merge time, so +# an identifier is excused in CI exactly when it is excused at the merge - and a +# finding no entry covers still blocks, unchanged. +# +# The signed payload, the token and the published line's grammar are owned by +# bin/fm-supersession-attest-lib.sh. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-supersession-attest-lib.sh +. "$SCRIPT_DIR/fm-supersession-attest-lib.sh" + +HEAD_SHA=${FM_SUPERSESSION_HEAD_SHA-} +SECRET=${FM_SUPERSESSION_SECRET-} +REPO=${FM_SUPERSESSION_REPO-} +PR=${FM_SUPERSESSION_PR-} +GH_CMD=${FM_SUPERSESSION_GH:-gh} + +# The heredoc delimiter for the multi-line GITHUB_OUTPUT value. Content that +# contains it is refused rather than written, because a value that closes its +# own heredoc early would hand the next job a truncated approval set. +OUT_DELIM='FM_SUPERSESSION_ENTRIES_EOF' + +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-supersession-verify.XXXXXX") +# shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap below. +cleanup() { rm -rf -- "$TMP_DIR"; } +trap cleanup EXIT + +annotate() { # + printf '::%s::%s\n' "$1" "$2" +} + +# verdict false, or verdict true . The only exit path. +verdict() { # [] + local value=$1 file=${2-} line sel kind value_field rest + printf 'superseded=%s\n' "$value" + if [ -n "${GITHUB_OUTPUT-}" ]; then + printf 'superseded=%s\n' "$value" >> "$GITHUB_OUTPUT" + fi + if [ "$value" = true ] && [ -n "$file" ]; then + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + sel=${line%%"$FM_SUPERSESSION_FIELD_SEP"*} + rest=${line#*"$FM_SUPERSESSION_FIELD_SEP"} + kind=${rest%%"$FM_SUPERSESSION_FIELD_SEP"*} + value_field=${rest#*"$FM_SUPERSESSION_FIELD_SEP"} + printf 'entry: %s %s %s\n' "$sel" "$kind" "$value_field" + done < "$file" + if [ -n "${GITHUB_OUTPUT-}" ]; then + { + printf 'entries<<%s\n' "$OUT_DELIM" + cat "$file" + printf '%s\n' "$OUT_DELIM" + } >> "$GITHUB_OUTPUT" + fi + fi + exit 0 +} + +# Reading the body needs GitHub access; without it there is no claim to check, +# which is the ordinary "no attestation" verdict rather than an error, but it is +# annotated because a PR that DOES carry one would otherwise fail silently. +if [ -z "$REPO" ] || [ -z "$PR" ]; then + annotate warning "No pull request was named, so no supersession attestation could be read. Every base-assertion finding blocks as before." + verdict false +fi +if ! command -v "$GH_CMD" >/dev/null 2>&1; then + annotate warning "The GitHub CLI is unavailable, so this PR's body could not be read for a supersession attestation. Every base-assertion finding blocks as before." + verdict false +fi +CLAIM="$TMP_DIR/claim" +if ! "$GH_CMD" pr view "$PR" --repo "$REPO" --json body -q .body > "$CLAIM" 2>"$TMP_DIR/gh.err"; then + annotate warning "This PR's body could not be read from GitHub, so a supersession attestation could not be looked for. Every base-assertion finding blocks as before." + sed 's/^/gh: /' "$TMP_DIR/gh.err" >&2 || true + verdict false +fi + +# A body with no attestation line at all is the normal case for almost every PR, +# so it must stay completely quiet. Everything past this point IS an attestation +# attempt and is reported. +CANDIDATES=$(tr -d '\r' < "$CLAIM" | grep -E "^[[:space:]]*${FM_SUPERSESSION_LINE_PREFIX}" || true) +if [ -z "$CANDIDATES" ]; then + verdict false +fi + +if [ -z "$HEAD_SHA" ]; then + annotate warning "A supersession attestation is present but this event carries no pull-request head SHA, so it cannot be bound to a commit. Every base-assertion finding blocks." + verdict false +fi +if ! fm_ci_waiver_valid_sha "$HEAD_SHA"; then + annotate warning "A supersession attestation is present but the event's head SHA is not a full 40-character commit id. Every base-assertion finding blocks." + verdict false +fi +if [ -z "$SECRET" ]; then + annotate warning "A supersession attestation is present but this repository has no ${FM_SUPERSESSION_SECRET_NAME}, so no attestation can be verified. Every base-assertion finding blocks. (Set it with bin/fm-supersession-attest.sh publish .)" + verdict false +fi +if ! command -v node >/dev/null 2>&1; then + annotate warning "A supersession attestation is present but node is unavailable, so no attestation can be verified. Every base-assertion finding blocks." + verdict false +fi + +# Every candidate is checked. More than one line is normal after a re-push: the +# captain adds a fresh line for the new head commit, and the stale line for the +# previous commit is simply one that no longer verifies. Any single line that +# verifies against the CURRENT head SHA is a valid attestation, and one that does +# not can never become one, so scanning them all is exactly as strict as scanning +# a single line would be. +LINE_RE="^[[:space:]]*${FM_SUPERSESSION_LINE_PREFIX}[[:space:]]+${FM_SUPERSESSION_LINE_VERSION}[[:space:]]+([A-Za-z0-9._-]+)[[:space:]]+([0-9a-f]{40})[[:space:]]+([A-Za-z0-9_-]+)[[:space:]]+([0-9a-f]{64})[[:space:]]*$" +saw_malformed=0 +saw_other_commit= +saw_bad_signature= +saw_unreadable= +ENTRIES="$TMP_DIR/entries" +while IFS= read -r line; do + [ -n "$line" ] || continue + if [[ ! "$line" =~ $LINE_RE ]]; then + saw_malformed=1 + continue + fi + claim_id=${BASH_REMATCH[1]} + claim_sha=${BASH_REMATCH[2]} + claim_token=${BASH_REMATCH[3]} + claim_sig=${BASH_REMATCH[4]} + if [ "$claim_sha" != "$HEAD_SHA" ]; then + saw_other_commit=$claim_sha + continue + fi + if ! printf '%s' "$SECRET" \ + | fm_supersession_attest_check "$claim_id" "$claim_sha" "$claim_token" "$claim_sig"; then + saw_bad_signature=$claim_id + continue + fi + # Signed by this repository's key, for this commit. What it carries is still + # validated before it is acted on: a signature proves who wrote the token, not + # that every entry in it is one this fleet's matcher can read, and an entry + # that cannot be read must never widen into one that is guessed at. + if ! fm_supersession_token_decode "$claim_token" > "$ENTRIES"; then + saw_unreadable="$claim_id (its entry token is not decodable)" + continue + fi + if ! [ -s "$ENTRIES" ]; then + saw_unreadable="$claim_id (it carries no entries at all)" + continue + fi + bad_entry= + while IFS= read -r entry || [ -n "$entry" ]; do + [ -n "$entry" ] || continue + fm_supersession_entry_line_valid "$entry" && continue + bad_entry=$entry + break + done < "$ENTRIES" + if [ -n "$bad_entry" ]; then + saw_unreadable="$claim_id (it carries an entry this fleet's matcher cannot read)" + continue + fi + if grep -qxF "$OUT_DELIM" "$ENTRIES"; then + saw_unreadable="$claim_id (an entry collides with this job's output delimiter)" + continue + fi + annotate notice "Supersession attestation verified for task $claim_id at commit $claim_sha: the captain has approved the base assertions it names, so the re-verification may excuse THOSE findings and no others. This PR's test evidence is unaffected; the approvals' stated reasons stay in the fleet's private record." + verdict true "$ENTRIES" +done < <40-hex-sha> <64-hex-signature>'. Every base-assertion finding blocks." +fi +verdict false From 146a81f4a0329c617c93a105d4896500a8f129bc Mon Sep 17 00:00:00 2001 From: Kiran Date: Thu, 13 Aug 2026 18:16:41 +0100 Subject: [PATCH 2/8] Wire the attestation into the re-verification workflow, with tests The verifying job checks out the BASE, like ci.yml's waiver job: the re-verification job runs the branch's own scripts by construction, so a secret in its environment would be a secret every PR could read. It hands over only its non-secret verdict. The required job takes a `needs:` on it, so it also takes `if: always()` - a required check that is skipped never reports, and branch protection then waits on it forever. A failed verifying job therefore yields no approvals and every finding blocks, which is the state before this change. tests/fm-supersession-attest.test.sh covers both directions: a covered finding with a valid signature passes, and no signature, a wrong signature, a wrong commit, a wrong task, a widened token, the master key, another repository's key, that repository's CI-waiver key, and an uncovered finding each still fail. tests/fm-reverify-base.test.sh covers the excusal itself, including the class narrowing and the unreadable-approval refusal, and asserts the workflow shape the secret separation depends on. --- .github/workflows/reverify-base.yml | 81 +++- bin/fm-supersession-attest-lib.sh | 1 + bin/fm-supersession-lib.sh | 10 +- tests/fm-reverify-base.test.sh | 297 ++++++++++++- tests/fm-supersession-attest.test.sh | 638 +++++++++++++++++++++++++++ 5 files changed, 1008 insertions(+), 19 deletions(-) create mode 100755 tests/fm-supersession-attest.test.sh diff --git a/.github/workflows/reverify-base.yml b/.github/workflows/reverify-base.yml index 16cb58d50b3..49ab3023845 100644 --- a/.github/workflows/reverify-base.yml +++ b/.github/workflows/reverify-base.yml @@ -27,10 +27,29 @@ # either publishes a verdict or FAILS; none of them can go green having checked # nothing. # +# THE CAPTAIN'S APPROVALS reach this check the only way they can: as a signed +# line in the PR body, verified in a job of its own. A branch may deliberately +# supersede behaviour the base asserts, and bin/fm-pr-merge.sh honours that from +# a private record - which a GitHub runner cannot read, so this check would +# otherwise report the same findings and stay red with nothing the branch could +# push to fix it. bin/fm-supersession-verify.sh decides what the captain signed; +# bin/fm-reverify-base.sh decides what that excuses. A finding no approval +# covers still blocks. +# +# WHY THAT VERIFICATION IS ITS OWN JOB, checked out at the BASE. This job runs +# the branch's own scripts by construction - that is what re-verification means +# - so a secret in ITS environment would be a secret every PR could read. The +# verifying job runs the BASE's copy of the verifier and nothing of the PR's, +# exactly as .github/workflows/ci.yml's waiver job does, and hands this job only +# its non-secret verdict. Neither closes the broader path where a PR edits this +# workflow file; that is inherent to pull_request events and is mitigated by +# branch protection. +# # NEITHER SCRIPT'S WORK IS RE-SPELLED HERE. bin/fm-assert-tests-kept.sh is the # single owner of which of the base's test files still need running, -# bin/fm-reverify-base.sh renders its verdict, and bin/fm-reverify-premise.sh -# establishes the premise the skip rests on. This file wires them together and +# bin/fm-reverify-base.sh renders its verdict, bin/fm-reverify-premise.sh +# establishes the premise the skip rests on, and bin/fm-supersession-verify.sh +# establishes what the captain approved. This file wires them together and # decides nothing. name: Base re-verification @@ -43,11 +62,64 @@ permissions: # The premise is this head's own behaviour-suite result, read from its check # runs. Nothing here writes. checks: read + # The captain's attestation is read from the PR body at job time, never from + # the event payload: an approval always arrives as an edit to an open PR, and + # editing a body triggers no workflow, so the body a re-run replays predates + # it (bin/fm-supersession-verify.sh owns that reasoning). + pull-requests: read jobs: + supersession: + # Cheap (a checkout, one API read and at most one HMAC) and always exits 0, + # whatever the verdict, so the required job below is never skipped by a + # failed dependency. A PR with no attestation - almost every PR - passes + # through it silently. + name: Supersession attestation + runs-on: ubuntu-latest + outputs: + superseded: ${{ steps.verify.outputs.superseded }} + entries: ${{ steps.verify.outputs.entries }} + steps: + - uses: actions/checkout@v6 + with: + # The BASE's verifier and libraries, never the PR's. A pull_request + # build otherwise runs PR-authored code, which would let a PR replace + # the script that decides whether its own findings may be excused - + # and it holds the secret. + ref: ${{ github.event.pull_request.base.sha }} + - name: Verify the captain's supersession attestation + id: verify + env: + FM_SUPERSESSION_SECRET: ${{ secrets.FM_SUPERSESSION_SECRET }} + FM_SUPERSESSION_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + FM_SUPERSESSION_REPO: ${{ github.repository }} + FM_SUPERSESSION_PR: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + run: | + set -eu + # A base that predates this check carries no verifier at all, so this + # reports nothing-approved rather than exiting 127 and failing the + # job. That is the fail-closed verdict, not a workaround: a base with + # no verifier can honour no approval, so "everything blocks" is + # correct. It self-heals, because every base from this change onwards + # has the verifier. + if [ ! -x bin/fm-supersession-verify.sh ]; then + echo "::warning::the base branch carries no supersession verifier, so no captain approval can be honoured here; every base-assertion finding blocks" + echo "superseded=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + bin/fm-supersession-verify.sh + reverify-base: name: Base assertions re-verified runs-on: ubuntu-latest + needs: supersession + # A required check that is skipped never reports, and branch protection then + # waits on it forever. `always()` is what keeps the dependency above from + # being able to cause that: this job runs and reports even if that job + # failed outright, in which case it simply receives no approvals and every + # finding blocks, exactly as before this check existed. + if: always() # Two waits in series: up to ~25 minutes for this head's behaviour suite to # conclude (the premise), then the re-verification itself. Its worst case is # a PR that rewrites many of the base's test files, since each is run twice - @@ -127,6 +199,11 @@ jobs: env: BASE_REF: ${{ github.event.pull_request.base.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + # Empty unless the job above verified the captain's signature over + # this exact head. Through env rather than interpolated into the + # command, matching ci.yml's rule for every value that comes off the + # event payload. + FM_SUPERSESSION_ENTRIES: ${{ needs.supersession.outputs.entries }} run: | set -eu bin/fm-reverify-base.sh \ diff --git a/bin/fm-supersession-attest-lib.sh b/bin/fm-supersession-attest-lib.sh index a2dc4c8a641..84b044b4922 100644 --- a/bin/fm-supersession-attest-lib.sh +++ b/bin/fm-supersession-attest-lib.sh @@ -112,6 +112,7 @@ FM_SUPERSESSION_LINE_PREFIX='fm-supersession:' # The Actions secret name a repository holds this attestation's key under. # Stated here rather than only in the workflow so the publisher and the # workflow cannot drift into two different names. +# shellcheck disable=SC2034 # read by the publisher and the verifier that source this file FM_SUPERSESSION_SECRET_NAME='FM_SUPERSESSION_SECRET' # base64url without padding, so the token is one whitespace-free field that diff --git a/bin/fm-supersession-lib.sh b/bin/fm-supersession-lib.sh index 99470a7c867..435b50d134e 100644 --- a/bin/fm-supersession-lib.sh +++ b/bin/fm-supersession-lib.sh @@ -90,11 +90,15 @@ fm_supersession_entry_covers() { return 1 fi case "$sel" in - id) [ "$value" = "$ident" ] ;; - # shellcheck disable=SC2053 # unquoted glob match is the documented `ids:` batch mechanism - ids) [[ "$ident" == $value ]] ;; + id) [ "$value" = "$ident" ]; return $? ;; + ids) ;; *) return 1 ;; esac + # The batch form's value is a GLOB matched against the identifier, so the + # pattern side is deliberately unquoted; that is the documented `ids:` + # mechanism rather than an oversight. + # shellcheck disable=SC2053 + [[ "$ident" == $value ]] } # fm_supersession_entries_extract diff --git a/tests/fm-reverify-base.test.sh b/tests/fm-reverify-base.test.sh index 26a6efcf077..b6bf90d4d91 100755 --- a/tests/fm-reverify-base.test.sh +++ b/tests/fm-reverify-base.test.sh @@ -11,11 +11,17 @@ # "everything ran and held" are both exit 0 with no findings, and a check that # renders them the same way reports a never-evaluated result as verified. # -# So the assertions here are mostly about the THREE outcomes staying three: -# every way the check can fail to establish a verdict - an owner that refused, -# an owner whose findings are `unexecuted:`/`unstable:`, an owner whose exit code -# and summary disagree, an owner too old to publish the count, no summary at all -# - must render as could-not-verify and BLOCK, never as a pass. +# So the assertions here are mostly about the outcomes staying DISTINCT: every +# way the check can fail to establish a verdict - an owner that refused, an owner +# whose findings are `unexecuted:`/`unstable:`, an owner whose exit code and +# summary disagree, an owner too old to publish the count, no summary at all - +# must render as could-not-verify and BLOCK, never as a pass. +# +# The fourth outcome, `superseded`, is the same discipline applied to the +# captain's approvals: a branch that deliberately supersedes an assertion the +# base makes is not a branch whose assertions held, and rendering the two the +# same way would hide an authorized override inside a pass. Its own cases below +# pair every excusal with the finding beside it that must still block. # # The selection itself is not re-tested here: bin/fm-assert-tests-kept.sh owns # it and tests/fm-assert-tests-kept.test.sh tests it. What is tested here is @@ -88,7 +94,27 @@ run_shim() { # [...] local d=$1 shift RC=0 - OUT=$(env -u GITHUB_STEP_SUMMARY "$d/fm-reverify-base.sh" "$@" 2>/dev/null) || RC=$? + OUT=$(env -u GITHUB_STEP_SUMMARY -u FM_SUPERSESSION_ENTRIES \ + "$d/fm-reverify-base.sh" "$@" 2>/dev/null) || RC=$? +} + +# run_shim_with_entries [...]: the same, with a +# verified captain-approved entry set supplied the way the workflow supplies it. +run_shim_with_entries() { # [...] + local d=$1 entries=$2 + shift 2 + RC=0 + OUT=$(env -u GITHUB_STEP_SUMMARY FM_SUPERSESSION_ENTRIES="$entries" \ + "$d/fm-reverify-base.sh" "$@" 2>/dev/null) || RC=$? +} + +# entry : one canonical entry line, built here with a real +# tab so a case states the exact bytes bin/fm-supersession-verify.sh hands over. +# A case wanting several calls them in ONE substitution - `$(entry a; entry b)` +# - because a substitution per line would strip each line's terminator and run +# the entries together into one unreadable line. +entry() { # + printf '%s\t%s\t%s\n' "$1" "$2" "$3" } # A whole summary line, so each case states the full contract it is driving and @@ -144,7 +170,7 @@ EOF } test_the_three_outcomes_render_as_three_distinct_first_lines() { - local d out verified nothing could + local d out verified nothing could superseded # Stated as one assertion in its own right, because the contract is not # "each outcome has a sentence" but "no two of them are the same string". d=$(shim_dir distinct-verified) @@ -172,10 +198,179 @@ EOF out=$OUT could=$(printf '%s\n' "$out" | head -1) + d=$(shim_dir distinct-superseded) + make_owner "$d" 1 <: a home with config/, state/, data/ and a project checkout +# whose origin is $REPO. Echoes the home dir. +make_home() { # + local home="$TMP_ROOT/$1" + mkdir -p "$home/config" "$home/state" "$home/data/supersessions" "$home/project" + git -C "$home/project" init --quiet + git -C "$home/project" remote add origin "https://github.com/$REPO.git" + node -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))' \ + > "$home/config/ci-waiver-secret" + chmod 600 "$home/config/ci-waiver-secret" + printf '%s\n' "$home" +} + +write_task() { # + fm_write_meta "$1/state/$2.meta" \ + "window=fm-$2" \ + "worktree=$1/project" \ + "project=$1/project" \ + "kind=ship" \ + "mode=direct-PR" \ + "yolo=off" +} + +# The record the captain approves. Its reason field is the private half. +write_record() { # + cat > "$1/data/supersessions/$2.md" < ... + local home=$1 + shift + FM_ROOT_OVERRIDE='' \ + FM_HOME="$home" \ + FM_STATE_OVERRIDE='' \ + FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE='' \ + FM_CONFIG_OVERRIDE='' \ + "$ATTEST" "$@" 2>&1 +} + +# The key a repository's re-verification job actually holds: derived from the +# home's master for that repo and this purpose alone, never the master itself. +repo_key() { # [] + bash -c '. "$0/bin/fm-supersession-attest-lib.sh"; fm_supersession_attest_repo_key "$1"' \ + "$ROOT" "${2:-$REPO}" < "$1/config/ci-waiver-secret" +} + +# The CI-WAIVER key for the same repository, for the cases that prove the two +# published keys are different keys with different powers. +waiver_repo_key() { # [] + bash -c '. "$0/bin/fm-ci-waiver-lib.sh"; fm_ci_waiver_repo_key "$1"' \ + "$ROOT" "${2:-$REPO}" < "$1/config/ci-waiver-secret" +} + +# fake_gh : a `gh` whose `pr view --json body` prints that +# file. The verifier reads the PR body through gh at job time; the cases drive +# that read rather than an env-var seam production never uses. +fake_gh() { # + local dir=$1 body=$2 bin + bin="$1/fakebin" + mkdir -p "$bin" + { + printf '#!/usr/bin/env bash\n' + printf 'printf "%%s\\n" "$*" >> "%s/gh-argv"\n' "$dir" + printf 'cat "%s"\n' "$body" + } > "$bin/gh" + chmod +x "$bin/gh" + printf '%s\n' "$bin/gh" +} + +RC=0 +OUT= +run_verify() { # [] + RC=0 + OUT=$(FM_SUPERSESSION_GH="$1" \ + FM_SUPERSESSION_SECRET="$2" \ + FM_SUPERSESSION_HEAD_SHA="$3" \ + FM_SUPERSESSION_REPO="$REPO" \ + FM_SUPERSESSION_PR="$PR_NUMBER" \ + GITHUB_OUTPUT="${4-}" \ + "$VERIFY" 2>&1) || RC=$? +} + +# body_with ...: a PR body carrying prose and those lines. +body_with() { # ... + local dir=$1 slug=$2 file line + shift 2 + file="$dir/body-$slug" + printf 'This PR does a thing.\n\nSome prose.\n' > "$file" + for line in "$@"; do + printf '%s\n' "$line" >> "$file" + done + printf '%s\n' "$file" +} + +# sign_for []: the published line alone, with the +# signer's own stderr note dropped. +sign_for() { # [] + FM_ROOT_OVERRIDE='' \ + FM_HOME="$1" \ + FM_STATE_OVERRIDE='' \ + FM_DATA_OVERRIDE='' \ + FM_CONFIG_OVERRIDE='' \ + "$ATTEST" sign "$2" "$3" "${4:-$REPO}" 2>/dev/null +} + +# --- the signer refuses ----------------------------------------------------- + +test_sign_refuses_a_task_with_no_durable_record() { + local home out + home=$(make_home no-task) + out=$(run_attest "$home" sign ghost "$SHA_A" "$REPO") && fail "sign accepted a task with no record" + assert_contains "$out" 'no durable record' \ + "sign must name the missing record rather than inventing an approval for it" + pass "sign refuses a task with no durable record" +} + +test_sign_refuses_when_the_project_has_no_approval_record() { + local home out + # The case that must never soften: an attestation carries an approval that + # already exists. If it could stand in for one, the signature would be the + # approval, and the captain's decision would have been skipped entirely. + home=$(make_home no-record) + write_task "$home" demo + out=$(run_attest "$home" sign demo "$SHA_A" "$REPO") && fail "sign attested a project with no approvals" + assert_contains "$out" 'nothing to attest' \ + "sign must refuse rather than attest an approval that was never granted" + pass "sign refuses a project whose captain has approved nothing" +} + +test_sign_refuses_a_record_with_no_fully_formed_entry() { + local home out + home=$(make_home empty-record) + write_task "$home" demo + cat > "$home/data/supersessions/project.md" < "$out_file" + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" "$out_file" + + expect_code 0 "$RC" "the verifier must complete whatever its verdict" + assert_contains "$OUT" 'superseded=true' \ + "a line signed by this repository's key for this commit must verify" + assert_contains "$OUT" 'entry: id any tests/a.test.sh::alpha holds' \ + "the verifier must say in plain words which approvals it accepted" + assert_contains "$(cat "$out_file")" 'ids failing tests/b.test.sh::*' \ + "the verified entries must reach the next job in the canonical form the matcher reads" + assert_not_contains "$(cat "$out_file")" "$PRIVATE_REASON" \ + "nothing the verifier hands on may carry the captain's private reason" + pass "a signed attestation verifies and hands on exactly the approved entries" +} + +test_a_forged_signature_does_not_verify() { + local home line forged body gh + home=$(make_home forged) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + # Every field kept, the signature replaced: what an agent that can read the + # grammar but not the secret would be able to produce. + forged=$(printf '%s\n' "$line" | awk '{ $NF = "0000000000000000000000000000000000000000000000000000000000000000"; print }') + body=$(body_with "$home" forged "$forged") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + + expect_code 0 "$RC" "a refused attestation is a verdict, not an error" + assert_contains "$OUT" 'superseded=false' \ + "a signature this repository's key does not reproduce must excuse nothing" + assert_contains "$OUT" '::warning::' \ + "a refused attestation must be loud, not silently ignored" + pass "a forged signature excuses nothing" +} + +test_a_line_bound_to_another_commit_does_not_verify() { + local home line body gh + home=$(make_home other-commit) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + body=$(body_with "$home" stale "$line") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_B" + + assert_contains "$OUT" 'superseded=false' \ + "an approval for one commit must not carry to another" + assert_contains "$OUT" "$SHA_A" \ + "the warning must name the commit the line was actually issued for" + pass "an attestation issued for one commit does not verify for another" +} + +test_a_line_relabelled_with_another_task_does_not_verify() { + local home line relabelled body gh + home=$(make_home relabel) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + relabelled=${line/ demo / other } + body=$(body_with "$home" relabelled "$relabelled") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + + assert_contains "$OUT" 'superseded=false' \ + "the task id is signed too, so relabelling a line must break it" + pass "an attestation relabelled with another task id does not verify" +} + +test_a_widened_entry_token_does_not_verify() { + local home line widened wide_token body gh + # The attack this closes: take a real signature and swap in an approval set the + # captain never granted - the batch entry that excuses everything. + home=$(make_home widened) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + wide_token=$(printf 'ids\tany\t*\n' \ + | bash -c '. "$0/bin/fm-supersession-attest-lib.sh"; fm_supersession_token_encode' "$ROOT") + widened=$(printf '%s\n' "$line" | awk -v t="$wide_token" '{ $(NF - 1) = t; print }') + body=$(body_with "$home" widened "$widened") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + + assert_contains "$OUT" 'superseded=false' \ + "the approved entries are inside the signature, so widening them must break it" + pass "an entry set edited after signing does not verify" +} + +test_a_body_with_no_attestation_is_quiet_and_unverified() { + local home body gh + home=$(make_home quiet) + body=$(body_with "$home" plain) + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + + assert_contains "$OUT" 'superseded=false' \ + "the ordinary PR must excuse nothing" + assert_not_contains "$OUT" '::warning::' \ + "the ordinary PR must produce no noise at all" + pass "a PR body with no attestation is quiet and excuses nothing" +} + +test_an_attestation_with_no_repository_secret_is_loud() { + local home line body gh + home=$(make_home no-repo-secret) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + body=$(body_with "$home" nosecret "$line") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" '' "$SHA_A" + + assert_contains "$OUT" 'superseded=false' \ + "no secret means no verdict, which means no excuse" + assert_contains "$OUT" 'FM_SUPERSESSION_SECRET' \ + "the warning must name the secret that is missing and how it is set" + pass "an attestation offered to a repository holding no key is refused and loud" +} + +test_a_malformed_attestation_line_is_refused_and_loud() { + local home body gh + home=$(make_home malformed) + body=$(body_with "$home" malformed 'fm-supersession: v1 demo not-a-sha token sig') + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + + assert_contains "$OUT" 'superseded=false' \ + "a line that does not match the grammar must never be guessed at" + assert_contains "$OUT" 'does not match the attestation grammar' \ + "the warning must name the grammar the line failed" + pass "a malformed attestation line is refused and reported" +} + +test_a_crlf_body_verifies() { + local home line body gh + # GitHub stores PR bodies with CRLF line endings, so a verifier that did not + # strip them would refuse every real attestation while passing every test. + home=$(make_home crlf) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + body="$home/body-crlf" + printf 'Prose.\r\n\r\n%s\r\n' "$line" > "$body" + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + + assert_contains "$OUT" 'superseded=true' \ + "a body with GitHub's own line endings must verify" + pass "an attestation in a CRLF PR body verifies" +} + +test_the_verifier_exits_zero_for_every_verdict() { + local home line body gh case_body + # The required check `Base assertions re-verified` depends on this job. A job + # that failed would leave that check never reporting at all, and branch + # protection waiting on it forever, so refusing an attestation must be a + # verdict rather than an error. + home=$(make_home exit-zero) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + for case_body in "$line" 'fm-supersession: nonsense' ''; do + body=$(body_with "$home" "exit-$RANDOM" "$case_body") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + expect_code 0 "$RC" "the verifier must exit 0 for the verdict on: ${case_body:-an empty body}" + done + run_verify "$gh" '' '' + expect_code 0 "$RC" "the verifier must exit 0 even with no secret and no head SHA" + pass "the verifier exits 0 for every verdict, so the required check always reports" +} + +test_the_body_is_read_live_from_github() { + local home line body gh argv + # Load-bearing rather than incidental: an approval always arrives as an edit to + # an open PR, editing a body triggers no workflow, and re-running the check + # replays the payload the PR had BEFORE the edit. Only a live read sees it. + home=$(make_home live-read) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + body=$(body_with "$home" live "$line") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + argv=$(cat "$home/gh-argv") + + assert_contains "$OUT" 'superseded=true' \ + "the verifier must find an attestation that is in the body now" + assert_contains "$argv" "pr view $PR_NUMBER" \ + "the verifier must read the PR itself rather than a payload captured earlier" + assert_contains "$argv" '--json body' \ + "the verifier must ask GitHub for the body it is scanning" + pass "the attestation is read from the PR's current body, not from the event payload" +} + +# --- key separation --------------------------------------------------------- + +test_the_published_key_is_derived_for_that_repository_and_this_purpose() { + local home master key other waiver + home=$(make_home keys) + master=$(cat "$home/config/ci-waiver-secret") + key=$(repo_key "$home") + other=$(repo_key "$home" "$OTHER_REPO") + waiver=$(waiver_repo_key "$home") + + [ "$key" != "$master" ] || fail "the published key is the master itself, so a stolen Actions secret would be the fleet's key" + [ "$key" != "$other" ] || fail "two repositories are published the same key, so a theft from one is a theft from both" + [ "$key" != "$waiver" ] || fail "the attestation key is the repository's CI-waiver key, so stealing one grants the other" + pass "each repository's attestation key is derived for that repository and for this purpose alone" +} + +test_neither_the_master_nor_another_repositorys_key_verifies() { + local home line body gh + home=$(make_home foreign-keys) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + body=$(body_with "$home" foreign "$line") + gh=$(fake_gh "$home" "$body") + + run_verify "$gh" "$(cat "$home/config/ci-waiver-secret")" "$SHA_A" + assert_contains "$OUT" 'superseded=false' \ + "the master must not verify what only a derived key may" + run_verify "$gh" "$(repo_key "$home" "$OTHER_REPO")" "$SHA_A" + assert_contains "$OUT" 'superseded=false' \ + "another repository's key must not verify this repository's attestation" + run_verify "$gh" "$(waiver_repo_key "$home")" "$SHA_A" + assert_contains "$OUT" 'superseded=false' \ + "the repository's CI-waiver key must not verify an attestation" + pass "neither the master, another repository's key, nor the CI-waiver key verifies an attestation" +} + +test_a_ci_waiver_line_is_not_an_attestation() { + local home body gh waiver_line + # The two mean opposite things - a waiver says there is no test evidence, an + # attestation says there is and one assertion was overridden - so neither may + # ever be read as the other, whichever body it is pasted into. + home=$(make_home labels) + waiver_line=$(bash -c '. "$0/bin/fm-ci-waiver-lib.sh"; fm_ci_waiver_line "$1" "$2" "$3"' \ + "$ROOT" demo "$SHA_A" \ + "$(printf '%s' "$(waiver_repo_key "$home")" \ + | bash -c '. "$0/bin/fm-ci-waiver-lib.sh"; fm_ci_waiver_sign "$1" "$2"' "$ROOT" demo "$SHA_A")") + body=$(body_with "$home" waiver "$waiver_line") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" + + assert_contains "$OUT" 'superseded=false' \ + "a verified CI waiver must excuse no base assertion at all" + assert_not_contains "$OUT" '::warning::' \ + "a waiver line is not a malformed attestation; it is simply not one" + pass "a CI waiver line excuses nothing here, whatever it authorizes elsewhere" +} + +# --- end to end ------------------------------------------------------------- + +test_end_to_end_the_signed_approvals_excuse_only_what_they_cover() { + local home line body gh out_file entries d rc out + # The whole chain, in both directions: sign from the captain's record, verify + # in CI, apply to real findings. What is approved is excused; what is not still + # blocks; and the same approvals with no signature excuse nothing. + home=$(make_home end-to-end) + write_task "$home" demo + write_record "$home" project + line=$(sign_for "$home" demo "$SHA_A") + body=$(body_with "$home" e2e "$line") + gh=$(fake_gh "$home" "$body") + out_file="$home/gh-output" + : > "$out_file" + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" "$out_file" + assert_contains "$OUT" 'superseded=true' "the signed line must verify before the rest of the case means anything" + entries=$(sed -n '/^entries< "$d/fm-assert-tests-kept.sh" <<'OWNER' +#!/usr/bin/env bash +cat <<'BODY' +base: origin/main (explicit --base) +missing: tests/a.test.sh::alpha holds +failing: tests/b.test.sh::beta holds +failing: tests/c.test.sh::gamma holds +summary: missing=1 failing=2 unexecuted=0 skipped=0 unaccounted=0 assumed-covered=40 unstable=0 executed-files=3 +BODY +exit 1 +OWNER + chmod +x "$d/fm-assert-tests-kept.sh" + + rc=0 + out=$(env -u GITHUB_STEP_SUMMARY FM_SUPERSESSION_ENTRIES="$entries" \ + "$d/fm-reverify-base.sh" --worktree . 2>/dev/null) || rc=$? + expect_code 1 "$rc" "a finding no approval covers must still block the check" + assert_contains "$out" 'reverify: not-verified' \ + "the uncovered finding must still render as the actionable outcome" + assert_contains "$out" 'superseded: tests/a.test.sh::alpha holds (missing)' \ + "the exactly-approved finding must be excused" + assert_contains "$out" 'superseded: tests/b.test.sh::beta holds (failing)' \ + "the glob-approved finding must be excused" + assert_not_contains "$out" 'superseded: tests/c.test.sh::gamma holds' \ + "a finding outside every approval must not be excused" + + # The same approvals, unsigned: the file is not the authority, the signature + # is, so CI is handed nothing and every finding blocks. + : > "$out_file" + body=$(body_with "$home" unsigned "fm-supersession: v1 demo $SHA_A $(printf 'ids\tany\t*\n' | bash -c '. "$0/bin/fm-supersession-attest-lib.sh"; fm_supersession_token_encode' "$ROOT") 0000000000000000000000000000000000000000000000000000000000000000") + gh=$(fake_gh "$home" "$body") + run_verify "$gh" "$(repo_key "$home")" "$SHA_A" "$out_file" + assert_contains "$OUT" 'superseded=false' \ + "an unsigned approval must reach the verdict step as no approval at all" + assert_not_contains "$(cat "$out_file")" 'entries<<' \ + "a refused attestation must publish no entries for the next job to apply" + pass "end to end, signed approvals excuse exactly what they cover and unsigned ones excuse nothing" +} + +test_sign_refuses_a_task_with_no_durable_record +test_sign_refuses_when_the_project_has_no_approval_record +test_sign_refuses_a_record_with_no_fully_formed_entry +test_sign_refuses_an_abbreviated_sha +test_sign_refuses_a_repository_the_task_does_not_belong_to +test_sign_refuses_with_no_secret_at_all +test_the_line_carries_the_approvals_matching_half_and_never_the_reason +test_a_signed_line_verifies_and_hands_on_the_approved_entries +test_a_forged_signature_does_not_verify +test_a_line_bound_to_another_commit_does_not_verify +test_a_line_relabelled_with_another_task_does_not_verify +test_a_widened_entry_token_does_not_verify +test_a_body_with_no_attestation_is_quiet_and_unverified +test_an_attestation_with_no_repository_secret_is_loud +test_a_malformed_attestation_line_is_refused_and_loud +test_a_crlf_body_verifies +test_the_verifier_exits_zero_for_every_verdict +test_the_body_is_read_live_from_github +test_the_published_key_is_derived_for_that_repository_and_this_purpose +test_neither_the_master_nor_another_repositorys_key_verifies +test_a_ci_waiver_line_is_not_an_attestation +test_end_to_end_the_signed_approvals_excuse_only_what_they_cover From 45b6232967c865c256b293c8417c7fdc3e2ddaf8 Mon Sep 17 00:00:00 2001 From: Kiran Date: Thu, 13 Aug 2026 18:18:11 +0100 Subject: [PATCH 3/8] Document the attestation where each owner already documents its half docs/configuration.md owns the secret's provisioning, as it does for the CI waiver; the script headers keep the payload, verdict and signing mechanics. Every other mention is a cross-reference: AGENTS.md gains one line telling firstmate what to run when an approved entry clears the merge gate and leaves the PR's own check red, and docs/architecture.md and bin/fm-pr-merge.sh's header each gain a pointer rather than a second copy of the contract. --- AGENTS.md | 5 +++-- bin/fm-pr-merge.sh | 7 +++++++ docs/architecture.md | 1 + docs/configuration.md | 23 ++++++++++++++++++++++- docs/scripts.md | 7 +++++-- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d66c2b233e0..c6667b36036 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ config/cmux-socket-password optional cmux control-socket password; LOCAL, gitig config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/statusline-base optional override of the operator's own status-line command, composed above bin/fm-statusline.sh's fleet-control line; LOCAL, gitignored; absent means the harness's own user-level status line is composed instead, so no home needs this file; inherited by secondmate homes and forwarded to task worktrees as FM_STATUSLINE_BASE (docs/configuration.md "Status-line composition") config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present -config/ci-waiver-secret optional captain-held MASTER signing key, used by the CI testing waiver and by the per-task monitoring exemption; LOCAL, gitignored, mode 600; created by bin/fm-ci-waiver.sh init, never printed, published, given to a worker, or inherited by secondmate homes (docs/configuration.md "The CI testing waiver secret") +config/ci-waiver-secret optional captain-held MASTER signing key, used by the CI testing waiver, by the per-task monitoring exemption, and by the supersession attestation; LOCAL, gitignored, mode 600; created by bin/fm-ci-waiver.sh init, never printed, published, given to a worker, or inherited by secondmate homes (docs/configuration.md "The CI testing waiver secret") data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update @@ -80,7 +80,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store projects.md thin fleet navigation registry; firstmate-private, parsed by fm-project-mode.sh (section 6) secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) - supersessions/.md captain-approved test-assertion supersessions consumed by bin/fm-pr-merge.sh's test-keep gate (entry format in its header); LOCAL, gitignored; created lazily by captain approval only, never auto-generated, absent means no approvals + supersessions/.md captain-approved test-assertion supersessions consumed by bin/fm-pr-merge.sh's test-keep gate (entry format in its header) and carried to CI, which cannot read this record, by bin/fm-supersession-attest.sh's signed attestation; LOCAL, gitignored; created lazily by captain approval only, never auto-generated, absent means no approvals exec-gate/ per-project marker making the test-keep gate's unexecuted findings block instead of warn (contract in bin/fm-pr-merge.sh's header); LOCAL, gitignored; created lazily by the captain only, never auto-generated, absent means warn-only no-pr-ci/ per-project marker confirming the project intentionally runs no PR CI, letting a zero-check PR pass the checks-green merge gate (contract in bin/fm-pr-merge.sh's header, which also owns the per-task signed-ci_skip route to the same gate); LOCAL, gitignored; created lazily by the captain only, never auto-generated, absent means a zero-check PR is refused unless that task's own CI skip is signed /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate @@ -296,6 +296,7 @@ Both refuse a landing whose commit messages or PR description carry AI attributi A base assertion the gate could not execute at all against the branch is reported as unexecuted, and it refuses only for a project the captain has enabled with a `data/exec-gate/` marker; otherwise it is informational. A base assertion the gate could not compare because the base's own test named it differently across two runs is reported as unstable and always refuses; that is a defect in the base's test, so the fix is an ordinary test-fix task naming the assertion with a constant string, never a captain decision. On that refusal, rebase damage is the worker's to fix, while a deliberate supersession of the base's asserted behavior is a product decision that is never the worker's or firstmate's to make - relay it to the captain, and only a captain-approved entry in `data/supersessions/.md` (entry format in `bin/fm-pr-merge.sh`'s header) lets that merge proceed. +An approved entry clears the merge gate but not the PR's own re-verification check, which cannot read that private record, so run `bin/fm-supersession-attest.sh attest ` to publish the captain-signed attestation that carries the same approvals into CI and re-run that check. `bin/fm-pr-merge.sh` also enforces "never merge a red PR" in code: it refuses when any PR check is failing, still pending, or unreadable, and treats a PR reporting no checks at all as unverified rather than green unless a captain's decision says that absence was deliberate - either a captain-created `data/no-pr-ci/` marker or a signed `ci_skip` on that task, never a bare flag line and never a local skip - with that script's header owning the contract. The single exception is the no-mistakes attestation check, which a PR that legitimately never used the pipeline can never pass: its failure alone is excused when the task carries a signed testing skip or the project ships `direct-PR`, every other check still has to be green, and that script's header owns the contract. After an autonomous merge, give the captain a one-line full-URL or local-main outcome. diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 46c94329350..93f37f622ea 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -151,6 +151,13 @@ # - Only the captain approves an entry. There is no mechanical guarantee of # this: nothing physically prevents another writer, and the required fields # exist so a fabricated entry is visible rather than silent. +# - CI cannot read this record, and must not: it is private by design. An +# approval reaches the required `Base assertions re-verified` check as a +# captain-signed attestation carrying each entry's matching half and nothing +# else, so a finding is excused there exactly when it is excused here. +# bin/fm-supersession-attest.sh is how one is issued and +# bin/fm-supersession-attest-lib.sh owns its wire; neither changes anything +# in this grammar or in this gate. # # Unexecuted findings and per-project enablement: check 2 reports # `unexecuted: ::` for a base assertion it could not execute at all diff --git a/docs/architecture.md b/docs/architecture.md index 95059b38356..0157a9e3ff0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -206,6 +206,7 @@ PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and an The helper requires a full `https://github.com///pull/` URL, invokes `gh-axi pr merge --repo /`, defaults to `--squash`, preserves explicit merge-method flags, and rejects malformed URLs or repo override flags before recording merge state. Between recording and merging it also runs `bin/fm-assert-tests-kept.sh`, refusing the merge on that check's findings about the authoritative base's test assertions, and refusing unverified when the check cannot run at all; the check's own header owns the identifier contract, its output classes, and its exit codes. `bin/fm-pr-merge.sh`'s header owns everything on the consuming side: the four blocking finding classes, the per-project `data/exec-gate/` marker that decides whether an unexecuted finding blocks or only warns, the refuse/proceed decision rule across all four classes, and the supersession entry grammar. +The same base assertions are re-run in CI by the required `Base assertions re-verified` check, which cannot read that private approval record, so a captain-signed attestation carries the approvals there and `bin/fm-supersession-attest-lib.sh` owns its wire; docs/configuration.md "The supersession attestation secret" owns the provisioning. One of those four, `unstable:`, is the gate refusing to compare an assertion whose own name changed between check 2's two runs of the identical base file, which is a defect in that test rather than in the branch under review. After the test-keep gate it also runs a checks-green gate against the PR's current check rollup, refusing a failing check (red), a pending check (distinctly - the remedy is waiting, not fixing), an unclassifiable or unreadable check state (unverified), and a PR reporting zero checks unless a captain's decision says that absence was deliberate - either the per-project `data/no-pr-ci/` marker or a signed `ci_skip` on that task; the same header owns the classification table and the zero-checks contract. Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned. diff --git a/docs/configuration.md b/docs/configuration.md index caca6a45dac..3a0d406b1c7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -160,7 +160,7 @@ A single shared secret would instead have handed over every repository at once a The dispatch token keeps using the master, so a stolen repository key cannot mint one either. The master is this home's captain-held signing key, and CI waivers were its first use rather than its only one. -The per-task monitoring exemption (`bin/fm-monitor.sh --exempt`) signs under its own payload domain with the same master, for the same reason: without a key there is a string to type or a line to append that grants the authorization, and with one there is not. +The per-task monitoring exemption (`bin/fm-monitor.sh --exempt`) and the supersession attestation ("The supersession attestation secret" below) both sign under their own payload domains with the same master, for the same reason: without a key there is a string to type or a line to append that grants the authorization, and with one there is not. `bin/fm-ci-waiver-lib.sh` owns every domain, and each is separated so a token minted for one can never be replayed as the other. A home with no master can therefore neither waive CI nor exempt a task from monitoring; both refuse rather than falling back to an unsigned marker. @@ -196,6 +196,27 @@ When the checked-out base predates the waiver and carries no verifier at all, bo A repository that never publishes the secret leaves that `check` job permanently red for every PR the pipeline did not raise, which firstmate itself authorizes in two ordinary cases, so the remedy for the merge is firstmate-side rather than CI-side. `bin/fm-pr-merge.sh` excuses that one check by exact name when the task carries a signed testing skip or the project is registered as `direct-PR`, refuses every other failing or unfinished check unchanged, and discloses each such merge loudly; that script's header owns the full contract, including why a rename of the job fails towards refusing. +## The supersession attestation secret (FM_SUPERSESSION_SECRET) + +A captain-approved test-assertion supersession lives in `data/supersessions/.md`, which is captain-private and gitignored, so `bin/fm-pr-merge.sh` can honour it and CI cannot see it at all. +That asymmetry made the required `Base assertions re-verified` check unpassable for an approved override: it re-runs the same base assertions on a runner, reports the same findings, and stays red with nothing the branch can push to fix it, because the branch is not what is wrong. +This section owns the secret's configuration and provisioning; `bin/fm-supersession-attest-lib.sh` owns the signed payload, the entry token, and the published line's grammar, `bin/fm-supersession-verify.sh` owns the verdict rules, and `bin/fm-supersession-attest.sh` owns the signing and publishing commands. + +The signing key is the same master at `config/ci-waiver-secret`, so a home that has run `bin/fm-ci-waiver.sh init` needs no second key. +What each repository receives is a key derived for that repository AND for this purpose alone, `HMAC(master, "fm-supersession-repo.v1", )`, published as the Actions repository secret `FM_SUPERSESSION_SECRET` by `bin/fm-supersession-attest.sh publish `. +It is deliberately not that repository's `FM_CI_WAIVER_SECRET`: the waiver key skips a PR's entire test suite while this one only excuses findings the captain has already approved by name, so two keys mean a theft of this one cannot escalate into that one. + +Until a repository holds the secret the feature is inert there, in the safe direction: an attestation cannot be verified, so the verifier refuses it loudly and every base-assertion finding blocks, which is exactly the behaviour without this feature at all. +Enrol a repository once with `publish`; there is nothing else to configure, and a repository may hold either secret, both, or neither. + +`bin/fm-supersession-attest.sh attest ` is the routine form: it reads the PR's current head commit from GitHub, signs that project's approvals for it, appends the one line to the PR body, and prints the command that re-runs the check. +The authority is the approval record plus the master key, and no dispatch flag gates it, because a supersession can only be decided after the gate has reported which assertion the branch supersedes; that script's header owns the full reasoning, including why a worker can produce neither half. +What the line publishes is each approved entry's matching half - its identifier or glob and the finding class it excuses - and never the captain's stated reason or the approval date, which stay in the private record. + +In [`.github/workflows/reverify-base.yml`](../.github/workflows/reverify-base.yml) a cheap `supersession` job holds the secret and checks out the base ref, exactly as `ci.yml`'s `ci-waiver` job does and for the same reason; the re-verification job runs the branch's own scripts by construction, so it holds no secret and receives only the verified entries. +That job reads the PR body live from the API rather than from the event payload, because an approval always arrives as an edit to an open PR and a re-run replays the payload the PR had before it. +The required job takes `if: always()` alongside its `needs:`, so a failed verifying job leaves it reporting with no approvals rather than skipped and pending forever. + ## Testing skips (bin/fm-spawn.sh --skip-testing / --local-skip / --ci-skip / --all-testing-skip) A ship task can be dispatched with testing switched off, but only by the captain and only mechanically: the skip is enforced by code and by a keyed signature, never by an instruction a worker can decline and never by a string a worker can type. diff --git a/docs/scripts.md b/docs/scripts.md index 2a368f70713..e786bb9878f 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -87,10 +87,13 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-merge-additive-lib.sh` | Single owner of the additive-merge-resolution verdict every enforcer reads (docs/merge-resolution-gate.md) | | `fm-merge-resolution-check.sh` | Refuse a merge commit whose resolution deletes content one side introduced; the second `commit-msg` hook check | | `fm-assert-tests-kept.sh` | Report the base's test assertions the branch under review lost, broke, could not execute at all, or named unstably across two runs (its header owns the finding classes and exit codes) | -| `fm-reverify-base.sh` | Re-verify a branch against a base that moved by running only the base test files that differ, rendering verified, nothing-to-verify and could-not-verify as three distinct outcomes; a thin caller of `fm-assert-tests-kept.sh`, run as CI's required `Base assertions re-verified` check | +| `fm-reverify-base.sh` | Re-verify a branch against a base that moved by running only the base test files that differ, rendering verified, nothing-to-verify, superseded and could-not-verify as four distinct outcomes; a thin caller of `fm-assert-tests-kept.sh`, run as CI's required `Base assertions re-verified` check | | `fm-reverify-premise.sh` | Establish, from this head's own check runs, whether its behaviour suite is verified green, waived, or unreadable - the premise `fm-reverify-base.sh`'s skip rests on | | `fm-test-exec-lib.sh` | Shared per-language test runners (shell, pytest, vitest, jest) and scratch-tree environment lifecycle that `fm-assert-tests-kept.sh` check 2 drives | -| `fm-supersession-lib.sh` | Single matcher for the captain-approved supersession record, shared by the merge gate that enforces it and the viewer that displays it (`fm-pr-merge.sh`'s header owns the entry grammar) | +| `fm-supersession-lib.sh` | Single parser and matcher for the captain-approved supersession record, shared by the merge gate that enforces it, the viewer that displays it, and CI, which is handed its canonical entries and never the record (`fm-pr-merge.sh`'s header owns the entry grammar) | +| `fm-supersession-attest.sh` | Publish a repository's attestation key, and sign the captain's approvals for one commit so CI can honour them; refuses without an approval record, without this home's key, or for a repository the task does not push to | +| `fm-supersession-verify.sh` | Decide in CI which approvals a PR's body carries a valid signature for, reading that body live because an approval always arrives after the check first ran | +| `fm-supersession-attest-lib.sh` | Own the attestation's signed payload, entry token, and published line grammar, shared by signer and verifier; its HMAC domain and published key are separate from the CI waiver's | | `fm-ci-waiver.sh` | Generate, publish, sign, and (as `waive`) issue from the worker's own request the CI testing waiver, refusing to sign for a task the captain did not dispatch with a CI skip or for a repository that task does not push to | | `fm-ci-waiver-verify.sh` | Decide in CI whether a published waiver line covers the pull request's current head commit | | `fm-ci-waiver-lib.sh` | Own the waiver's signed payload, per-repository key derivation, published line grammar, and constant-time comparison, shared by signer and verifier | From deb78a811a5d022378d6d27461c0e516cfae959a Mon Sep 17 00:00:00 2001 From: Kiran Date: Thu, 13 Aug 2026 18:29:57 +0100 Subject: [PATCH 4/8] Name what an attestation cannot answer: a revoked approval An attestation is a snapshot of the approvals as they stood when it was signed, so a revocation does not invalidate a line already published for that same commit. Nothing merges on that - the merge gate reads the live record and refuses there - which is what makes the snapshot acceptable, and saying so is what stops a later reader mistaking the check's green for the record's word. --- bin/fm-supersession-attest-lib.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bin/fm-supersession-attest-lib.sh b/bin/fm-supersession-attest-lib.sh index 84b044b4922..1ac300b25dc 100644 --- a/bin/fm-supersession-attest-lib.sh +++ b/bin/fm-supersession-attest-lib.sh @@ -61,6 +61,15 @@ # and the token's alphabet excludes the dot - so no two different (sha, token) # pairs can produce the same field. # +# WHAT IT DOES NOT ANSWER, stated because a narrower guarantee that reads as a +# broader one is the failure this gate family exists to prevent: an attestation +# is a snapshot of the approvals as they stood when it was signed. A captain who +# REVOKES an entry afterwards does not invalidate a line already published for +# that same commit, and the check can go on excusing what the record no longer +# approves until the head moves. Nothing merges on that, which is why the +# snapshot is acceptable: bin/fm-pr-merge.sh reads the LIVE record at merge time +# and refuses the revoked finding there. The check reports; the record decides. +# # Published line grammar, one line anywhere in the PR body: # # fm-supersession: v1 From a72e8457888b7b1c9a4c92ffe09205ba8bb89f56 Mon Sep 17 00:00:00 2001 From: Kiran Date: Thu, 13 Aug 2026 18:31:07 +0100 Subject: [PATCH 5/8] Add the fourth outcome's distinctness as its own assertion Renaming the three-outcome assertion would delete an assertion the base has, which is precisely the finding this whole change exists to let a captain approve - and approving it would have been the wrong answer here, since the older assertion is still true. It stays exactly as the base wrote it, and the superseded outcome gets an assertion of its own. --- tests/fm-reverify-base.test.sh | 46 +++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/tests/fm-reverify-base.test.sh b/tests/fm-reverify-base.test.sh index b6bf90d4d91..0cdd04c255c 100755 --- a/tests/fm-reverify-base.test.sh +++ b/tests/fm-reverify-base.test.sh @@ -170,7 +170,7 @@ EOF } test_the_three_outcomes_render_as_three_distinct_first_lines() { - local d out verified nothing could superseded + local d out verified nothing could # Stated as one assertion in its own right, because the contract is not # "each outcome has a sentence" but "no two of them are the same string". d=$(shim_dir distinct-verified) @@ -198,7 +198,41 @@ EOF out=$OUT could=$(printf '%s\n' "$out" | head -1) - d=$(shim_dir distinct-superseded) + [ "$verified" != "$nothing" ] || fail "verified and nothing-to-verify rendered the same first line: $verified" + [ "$verified" != "$could" ] || fail "verified and could-not-verify rendered the same first line: $verified" + [ "$nothing" != "$could" ] || fail "nothing-to-verify and could-not-verify rendered the same first line: $nothing" + pass "verified, nothing-to-verify and could-not-verify are three distinct rendered outcomes" +} + +test_the_superseded_outcome_renders_as_its_own_first_line() { + local d out verified nothing could superseded + # The fourth outcome joins the same contract as its own assertion rather than + # by rewriting the one above: an authorized override must not be readable as + # a run that held, as a run with nothing to do, or as one that could not + # decide. + d=$(shim_dir fourth-verified) + make_owner "$d" 0 < Date: Thu, 13 Aug 2026 18:39:03 +0100 Subject: [PATCH 6/8] Use !cancelled() rather than always() on the required job always() also runs a job when the whole run was CANCELLED, so a superseded run would execute the re-verification, see its dependency as cancelled, and leave a spurious red required check that only clears on the next push. .github/workflows/no-mistakes-required.yml already carries that reasoning for the same shape; this follows it rather than restating it. --- .github/workflows/reverify-base.yml | 13 +++++++++---- docs/configuration.md | 2 +- tests/fm-reverify-base.test.sh | 15 +++++++++------ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/reverify-base.yml b/.github/workflows/reverify-base.yml index 49ab3023845..4a02529c1e8 100644 --- a/.github/workflows/reverify-base.yml +++ b/.github/workflows/reverify-base.yml @@ -115,11 +115,16 @@ jobs: runs-on: ubuntu-latest needs: supersession # A required check that is skipped never reports, and branch protection then - # waits on it forever. `always()` is what keeps the dependency above from - # being able to cause that: this job runs and reports even if that job - # failed outright, in which case it simply receives no approvals and every + # waits on it forever. `!cancelled()` is what keeps the dependency above + # from being able to cause that: this job runs and reports even if that job + # FAILED outright, in which case it simply receives no approvals and every # finding blocks, exactly as before this check existed. - if: always() + # + # NOT `always()`, deliberately, for the reason + # .github/workflows/no-mistakes-required.yml's `check` job states in full: + # `always()` also runs a job when the whole run was CANCELLED, which would + # leave a spurious red required check behind a superseded run. + if: ${{ !cancelled() }} # Two waits in series: up to ~25 minutes for this head's behaviour suite to # conclude (the premise), then the re-verification itself. Its worst case is # a PR that rewrites many of the base's test files, since each is run twice - diff --git a/docs/configuration.md b/docs/configuration.md index 3a0d406b1c7..2c183a56c09 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -215,7 +215,7 @@ What the line publishes is each approved entry's matching half - its identifier In [`.github/workflows/reverify-base.yml`](../.github/workflows/reverify-base.yml) a cheap `supersession` job holds the secret and checks out the base ref, exactly as `ci.yml`'s `ci-waiver` job does and for the same reason; the re-verification job runs the branch's own scripts by construction, so it holds no secret and receives only the verified entries. That job reads the PR body live from the API rather than from the event payload, because an approval always arrives as an edit to an open PR and a re-run replays the payload the PR had before it. -The required job takes `if: always()` alongside its `needs:`, so a failed verifying job leaves it reporting with no approvals rather than skipped and pending forever. +The required job takes `if: ${{ !cancelled() }}` alongside its `needs:`, so a failed verifying job leaves it reporting with no approvals rather than skipped and pending forever, and a cancelled run leaves no spurious red check behind. ## Testing skips (bin/fm-spawn.sh --skip-testing / --local-skip / --ci-skip / --all-testing-skip) diff --git a/tests/fm-reverify-base.test.sh b/tests/fm-reverify-base.test.sh index 0cdd04c255c..01eabf4890f 100755 --- a/tests/fm-reverify-base.test.sh +++ b/tests/fm-reverify-base.test.sh @@ -846,16 +846,19 @@ test_the_workflow_runs_on_every_pull_request_and_is_never_skipped() { local code cond # A required check that is skipped never reports, and branch protection then # waits on it forever. Every ordinary condition here is therefore on a STEP. - # The ONE job-level condition allowed is `always()`, which is the opposite of - # a skip: it is what makes a job with a `needs:` still run and still report - # when that dependency failed. Anything else can skip a job. + # The ONE job-level condition allowed is `!cancelled()`, which is the opposite + # of a skip: it is what makes a job with a `needs:` still run and still report + # when that dependency FAILED. Anything else - including a bare `always()`, + # which also runs a job when the whole run was cancelled and leaves a spurious + # red required check behind a superseded run - can only cost a verdict. code=$(workflow_code) assert_contains "$code" 'pull_request:' \ "the workflow must run on pull requests, which is where the check is required" while IFS= read -r cond; do [ -n "$cond" ] || continue - [ "$cond" = "if: always()" ] && continue - fail "a job carries the job-level condition '$cond', which can skip it and leave a required check pending forever" + # shellcheck disable=SC2016 # a GitHub expression, quoted literally on purpose + [ "$cond" = 'if: ${{ !cancelled() }}' ] && continue + fail "a job carries the job-level condition '$cond', which can skip it or run it on a cancelled run; only 'if: \${{ !cancelled() }}' is allowed here" done < <(printf '%s\n' "$code" | grep -E '^ if:' | sed 's/^ *//') pass "the job runs on every pull request and carries no condition that could skip it" } @@ -874,7 +877,7 @@ test_the_required_job_still_reports_when_its_dependency_fails() { ') [ -n "$job_block" ] || fail "the workflow carries no reverify-base job to check" if printf '%s\n' "$job_block" | grep -qE '^ needs:'; then - assert_contains "$job_block" 'if: always()' \ + assert_contains "$job_block" '!cancelled()' \ "a required job that depends on another must run even when that one fails, or a failed dependency leaves the check pending forever" fi pass "the required job reports its verdict even when the job it depends on fails" From 38a42a5714f91f93d82ef76a6e74c135c97f7e98 Mon Sep 17 00:00:00 2001 From: Kiran Date: Thu, 13 Aug 2026 18:42:16 +0100 Subject: [PATCH 7/8] Say which re-run reads the attestation The job that reads the PR body is the one that already succeeded, so `gh run rerun --failed` would re-run only the verdict job and reuse an attestation verdict taken before the line existed. The printed next step now says all jobs, and why. --- bin/fm-supersession-attest.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/fm-supersession-attest.sh b/bin/fm-supersession-attest.sh index 89105582b60..b9500021bd4 100755 --- a/bin/fm-supersession-attest.sh +++ b/bin/fm-supersession-attest.sh @@ -333,7 +333,11 @@ case "$cmd" in fi # Editing a body triggers no workflow, and the re-verification is designed # to be re-run in place; its verifier reads the body live for that reason. - echo "next: re-run the base re-verification so it reads the new body:" + # ALL jobs, never --failed: the job that reads the body is the one that + # already succeeded, so re-running only the failed one would re-read a + # verdict taken before this line existed. + echo "next: re-run ALL jobs of the base re-verification (not --failed, which would" + echo " reuse the attestation verdict taken before this line existed):" echo " gh run rerun \$(gh run list --repo $PR_REPO_SLUG --workflow 'Base re-verification' --branch --limit 1 --json databaseId -q '.[0].databaseId')" ;; From a34a105c38c6c04c92f5f9cc46fc9d49609b397a Mon Sep 17 00:00:00 2001 From: Kiran Date: Thu, 13 Aug 2026 18:52:09 +0100 Subject: [PATCH 8/8] Verify the attestation in a step of the required job, not a second job CI caught this on this PR itself: a second job has to hand its verdict over through `needs:`, a required job that `needs:` another is skipped when that one fails, and the repair for that is a job-level condition - which the base's own test forbids, because a job-level condition is also how a required check stops reporting. Changing that assertion would have needed a captain supersession, and a supersession cannot be honoured until this change is on main, so the PR adding the escape hatch would have needed the escape hatch. Keeping the verification in the required job removes the dependency rather than patching it. The secret stays out of the branch's reach on two properties the tests now assert together: the verifier is the BASE's copy, checked out separately, and it runs before any step that executes the branch's own scripts, while a step's env reaches only that step. --- .github/workflows/reverify-base.yml | 117 ++++++++++++++-------------- docs/configuration.md | 7 +- tests/fm-reverify-base.test.sh | 104 +++++++++++-------------- 3 files changed, 108 insertions(+), 120 deletions(-) diff --git a/.github/workflows/reverify-base.yml b/.github/workflows/reverify-base.yml index 4a02529c1e8..f7b814b52f8 100644 --- a/.github/workflows/reverify-base.yml +++ b/.github/workflows/reverify-base.yml @@ -36,14 +36,29 @@ # bin/fm-reverify-base.sh decides what that excuses. A finding no approval # covers still blocks. # -# WHY THAT VERIFICATION IS ITS OWN JOB, checked out at the BASE. This job runs -# the branch's own scripts by construction - that is what re-verification means -# - so a secret in ITS environment would be a secret every PR could read. The -# verifying job runs the BASE's copy of the verifier and nothing of the PR's, -# exactly as .github/workflows/ci.yml's waiver job does, and hands this job only -# its non-secret verdict. Neither closes the broader path where a PR edits this -# workflow file; that is inherent to pull_request events and is mitigated by -# branch protection. +# WHY THE VERIFICATION IS THE FIRST STEP, RUN FROM THE BASE'S OWN CHECKOUT. This +# job runs the branch's own scripts by construction - that is what +# re-verification means - so the secret must never be readable by them. Two +# properties keep it out of their reach, and both are asserted by +# tests/fm-reverify-base.test.sh: +# - the verifier is the BASE's copy, checked out separately, never the PR's, +# exactly as .github/workflows/ci.yml's waiver job does. A PR cannot replace +# the script that decides whether its own findings may be excused. +# - it runs BEFORE any step that executes the branch's own scripts, and a +# step's `env:` reaches only that step. Nothing PR-authored has run when the +# secret is in the environment, so nothing has been able to put itself on +# PATH or in the way of it. +# Neither closes the broader path where a PR edits this workflow file; that is +# inherent to pull_request events and is mitigated by branch protection. +# +# WHY IT IS A STEP AND NOT A SECOND JOB. A second job would have to hand its +# verdict over through `needs:`, and a required job that `needs:` another is +# SKIPPED when that one fails - a required check that never reports leaves +# branch protection waiting on it forever. The condition that repairs that +# (`if: !cancelled()`) is a job-level condition, and this job deliberately has +# none: every condition here is on a step, so the job always runs and always +# reports. Keeping the verification in this job removes the hazard rather than +# patching it. # # NEITHER SCRIPT'S WORK IS RE-SPELLED HERE. bin/fm-assert-tests-kept.sh is the # single owner of which of the base's test files still need running, @@ -69,26 +84,41 @@ permissions: pull-requests: read jobs: - supersession: - # Cheap (a checkout, one API read and at most one HMAC) and always exits 0, - # whatever the verdict, so the required job below is never skipped by a - # failed dependency. A PR with no attestation - almost every PR - passes - # through it silently. - name: Supersession attestation + reverify-base: + name: Base assertions re-verified runs-on: ubuntu-latest - outputs: - superseded: ${{ steps.verify.outputs.superseded }} - entries: ${{ steps.verify.outputs.entries }} + # Two waits in series: up to ~25 minutes for this head's behaviour suite to + # conclude (the premise), then the re-verification itself. Its worst case is + # a PR that rewrites many of the base's test files, since each is run twice - + # the same worst case bin/fm-pr-merge.sh already pays at merge time, moved + # earlier rather than added. A timeout here fails the job, which is the + # correct reading: no verdict was established. + timeout-minutes: 55 steps: - uses: actions/checkout@v6 with: - # The BASE's verifier and libraries, never the PR's. A pull_request - # build otherwise runs PR-authored code, which would let a PR replace - # the script that decides whether its own findings may be excused - - # and it holds the secret. + # The PR's own head, never refs/pull/N/merge. The question is whether + # the branch AS IT IS still satisfies the base's current assertions, + # and a merge ref has already merged the base in, which would answer a + # different question and answer it green every time. + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # The BASE's copy of the attestation verifier and its libraries, in a + # directory of its own. A pull_request build otherwise runs PR-authored + # code, which would let a PR replace the script that decides whether its + # own findings may be excused - while it holds the secret. + - name: Check out the base's own copy of the attestation verifier + uses: actions/checkout@v6 + with: ref: ${{ github.event.pull_request.base.sha }} + path: .fm-base + + # FIRST, before any step that runs the branch's own scripts: see the + # header. A PR with no attestation - almost every PR - passes through this + # silently. - name: Verify the captain's supersession attestation - id: verify + id: supersession env: FM_SUPERSESSION_SECRET: ${{ secrets.FM_SUPERSESSION_SECRET }} FM_SUPERSESSION_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -103,44 +133,15 @@ jobs: # no verifier can honour no approval, so "everything blocks" is # correct. It self-heals, because every base from this change onwards # has the verifier. - if [ ! -x bin/fm-supersession-verify.sh ]; then + if [ ! -x .fm-base/bin/fm-supersession-verify.sh ]; then echo "::warning::the base branch carries no supersession verifier, so no captain approval can be honoured here; every base-assertion finding blocks" echo "superseded=false" >> "$GITHUB_OUTPUT" - exit 0 + else + .fm-base/bin/fm-supersession-verify.sh fi - bin/fm-supersession-verify.sh - - reverify-base: - name: Base assertions re-verified - runs-on: ubuntu-latest - needs: supersession - # A required check that is skipped never reports, and branch protection then - # waits on it forever. `!cancelled()` is what keeps the dependency above - # from being able to cause that: this job runs and reports even if that job - # FAILED outright, in which case it simply receives no approvals and every - # finding blocks, exactly as before this check existed. - # - # NOT `always()`, deliberately, for the reason - # .github/workflows/no-mistakes-required.yml's `check` job states in full: - # `always()` also runs a job when the whole run was CANCELLED, which would - # leave a spurious red required check behind a superseded run. - if: ${{ !cancelled() }} - # Two waits in series: up to ~25 minutes for this head's behaviour suite to - # conclude (the premise), then the re-verification itself. Its worst case is - # a PR that rewrites many of the base's test files, since each is run twice - - # the same worst case bin/fm-pr-merge.sh already pays at merge time, moved - # earlier rather than added. A timeout here fails the job, which is the - # correct reading: no verdict was established. - timeout-minutes: 55 - steps: - - uses: actions/checkout@v6 - with: - # The PR's own head, never refs/pull/N/merge. The question is whether - # the branch AS IT IS still satisfies the base's current assertions, - # and a merge ref has already merged the base in, which would answer a - # different question and answer it green every time. - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 + # Removed once read, so the base's tree is never in the worktree the + # re-verification below measures. + rm -rf .fm-base - name: Establish the premise the cheap path rests on id: premise @@ -208,7 +209,7 @@ jobs: # this exact head. Through env rather than interpolated into the # command, matching ci.yml's rule for every value that comes off the # event payload. - FM_SUPERSESSION_ENTRIES: ${{ needs.supersession.outputs.entries }} + FM_SUPERSESSION_ENTRIES: ${{ steps.supersession.outputs.entries }} run: | set -eu bin/fm-reverify-base.sh \ diff --git a/docs/configuration.md b/docs/configuration.md index 2c183a56c09..55a4c565373 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -213,9 +213,10 @@ Enrol a repository once with `publish`; there is nothing else to configure, and The authority is the approval record plus the master key, and no dispatch flag gates it, because a supersession can only be decided after the gate has reported which assertion the branch supersedes; that script's header owns the full reasoning, including why a worker can produce neither half. What the line publishes is each approved entry's matching half - its identifier or glob and the finding class it excuses - and never the captain's stated reason or the approval date, which stay in the private record. -In [`.github/workflows/reverify-base.yml`](../.github/workflows/reverify-base.yml) a cheap `supersession` job holds the secret and checks out the base ref, exactly as `ci.yml`'s `ci-waiver` job does and for the same reason; the re-verification job runs the branch's own scripts by construction, so it holds no secret and receives only the verified entries. -That job reads the PR body live from the API rather than from the event payload, because an approval always arrives as an edit to an open PR and a re-run replays the payload the PR had before it. -The required job takes `if: ${{ !cancelled() }}` alongside its `needs:`, so a failed verifying job leaves it reporting with no approvals rather than skipped and pending forever, and a cancelled run leaves no spurious red check behind. +In [`.github/workflows/reverify-base.yml`](../.github/workflows/reverify-base.yml) the verification is the required job's FIRST step, run from a separate checkout of the base ref, exactly as `ci.yml`'s `ci-waiver` job runs the base's verifier and for the same reason. +That job runs the branch's own scripts by construction, so two properties keep the secret out of their reach: the verifier is the base's copy, and it runs before any step that executes the branch's scripts, while a step's `env:` reaches only that step. +It is a step rather than a second job because a required job that `needs:` another is skipped when that one fails, and a skipped required check never reports at all; keeping it in one job removes that hazard instead of repairing it with a job-level condition. +The step reads the PR body live from the API rather than from the event payload, because an approval always arrives as an edit to an open PR and a re-run replays the payload the PR had before it. ## Testing skips (bin/fm-spawn.sh --skip-testing / --local-skip / --ci-skip / --all-testing-skip) diff --git a/tests/fm-reverify-base.test.sh b/tests/fm-reverify-base.test.sh index 01eabf4890f..67b40a0ece9 100755 --- a/tests/fm-reverify-base.test.sh +++ b/tests/fm-reverify-base.test.sh @@ -843,71 +843,57 @@ test_the_workflow_refetches_the_base_at_job_time() { } test_the_workflow_runs_on_every_pull_request_and_is_never_skipped() { - local code cond + local code # A required check that is skipped never reports, and branch protection then - # waits on it forever. Every ordinary condition here is therefore on a STEP. - # The ONE job-level condition allowed is `!cancelled()`, which is the opposite - # of a skip: it is what makes a job with a `needs:` still run and still report - # when that dependency FAILED. Anything else - including a bare `always()`, - # which also runs a job when the whole run was cancelled and leaves a spurious - # red required check behind a superseded run - can only cost a verdict. + # waits on it forever. The job therefore carries no job-level condition at all: + # every condition here is on a STEP, so the job always runs and always reports. code=$(workflow_code) assert_contains "$code" 'pull_request:' \ "the workflow must run on pull requests, which is where the check is required" - while IFS= read -r cond; do - [ -n "$cond" ] || continue - # shellcheck disable=SC2016 # a GitHub expression, quoted literally on purpose - [ "$cond" = 'if: ${{ !cancelled() }}' ] && continue - fail "a job carries the job-level condition '$cond', which can skip it or run it on a cancelled run; only 'if: \${{ !cancelled() }}' is allowed here" - done < <(printf '%s\n' "$code" | grep -E '^ if:' | sed 's/^ *//') + if printf '%s\n' "$code" | grep -qE '^ if:'; then + fail "the job carries a job-level 'if:', which can skip it and leave a required check pending forever" + fi pass "the job runs on every pull request and carries no condition that could skip it" } -test_the_required_job_still_reports_when_its_dependency_fails() { - local code job_block - # The dependency added for the captain's approvals is the hazard this asserts - # away: a job that `needs:` another is SKIPPED when that one fails, and a - # skipped required check never reports at all, so branch protection waits on - # it forever. `always()` is what makes the dependency safe. +test_the_required_check_depends_on_no_other_job() { + local code + # The hazard this asserts away, rather than repairs: a job that `needs:` + # another is SKIPPED when that one fails, and a skipped required check never + # reports at all. The attestation is verified in a step of this job instead, + # so there is no dependency that could ever skip it. code=$(workflow_code) - job_block=$(printf '%s\n' "$code" | awk ' - /^ reverify-base:/ { inblock = 1; next } - inblock && /^ [a-z0-9_-]+:/ { inblock = 0 } - inblock { print } - ') - [ -n "$job_block" ] || fail "the workflow carries no reverify-base job to check" - if printf '%s\n' "$job_block" | grep -qE '^ needs:'; then - assert_contains "$job_block" '!cancelled()' \ - "a required job that depends on another must run even when that one fails, or a failed dependency leaves the check pending forever" + if printf '%s\n' "$code" | grep -qE '^ needs:'; then + fail "the required job depends on another job, which is skipped - and stops reporting - whenever that one fails" fi - pass "the required job reports its verdict even when the job it depends on fails" -} - -test_the_signing_secret_never_reaches_the_job_that_runs_the_branchs_code() { - local code verify_job reverify_job - # The re-verification job runs the BRANCH's own scripts - that is what - # re-verification means - so a secret in its environment would be a secret - # every PR could read and exfiltrate. The verifying job runs the BASE's copy - # instead, and hands over only its non-secret verdict. + pass "the required check depends on no other job, so nothing can skip it" +} + +test_the_signing_secret_is_never_readable_by_the_branchs_own_scripts() { + local code secret_line branch_line base_checkout + # This job runs the branch's own scripts by construction - that is what + # re-verification means - so the secret is kept out of their reach two ways, + # and BOTH are asserted here because either alone is not enough. It runs the + # BASE's copy of the verifier, so a PR cannot replace the script that holds + # the secret; and it runs BEFORE any step that executes the branch's scripts, + # since a step's env reaches only that step but PATH and the filesystem are + # shared, so a PR-authored step running first could lie in wait for it. code=$(workflow_code) - verify_job=$(printf '%s\n' "$code" | awk ' - /^ supersession:/ { inblock = 1; next } - inblock && /^ [a-z0-9_-]+:/ { inblock = 0 } - inblock { print } - ') - reverify_job=$(printf '%s\n' "$code" | awk ' - /^ reverify-base:/ { inblock = 1; next } - inblock && /^ [a-z0-9_-]+:/ { inblock = 0 } - inblock { print } - ') - [ -n "$verify_job" ] || fail "the workflow carries no attestation-verifying job" - assert_contains "$verify_job" 'secrets.FM_SUPERSESSION_SECRET' \ - "the verifying job must hold the secret its verification needs" - assert_contains "$verify_job" 'pull_request.base.sha' \ - "the verifying job must check out the BASE, so a PR cannot replace the verifier that holds the secret" - assert_not_contains "$reverify_job" 'secrets.' \ - "the job that runs the branch's own scripts must hold no secret at all" - pass "the attestation's secret is held only by a job checked out at the base, never beside the branch's code" + assert_contains "$code" '.fm-base/bin/fm-supersession-verify.sh' \ + "the secret-holding step must run the BASE's copy of the verifier" + base_checkout=$(printf '%s\n' "$code" | grep -A3 'path: .fm-base' || true) + printf '%s\n' "$code" | grep -B3 'path: .fm-base' | grep -q 'pull_request.base.sha' \ + || fail "the verifier's own checkout must name the base commit, not the PR's: ${base_checkout:-no checkout found}" + + secret_line=$(printf '%s\n' "$code" | grep -n 'secrets.FM_SUPERSESSION_SECRET' | head -1 | cut -d: -f1) + [ -n "$secret_line" ] || fail "no step holds the attestation secret at all" + # The first step that runs a script out of the BRANCH's own tree. `.fm-base/` + # paths are the base's copy and deliberately do not match. + branch_line=$(printf '%s\n' "$code" | grep -nE '^[[:space:]]+bin/fm-[a-z-]+\.sh' | head -1 | cut -d: -f1) + [ -n "$branch_line" ] || fail "the workflow runs none of the branch's own scripts, so this case is measuring nothing" + [ "$secret_line" -lt "$branch_line" ] \ + || fail "the secret (line $secret_line) is held after the branch's own scripts have already run (line $branch_line)" + pass "the attestation's secret is read only by the base's own verifier, before any of the branch's scripts run" } test_the_workflow_passes_only_the_verified_entries_to_the_verdict() { @@ -916,8 +902,8 @@ test_the_workflow_passes_only_the_verified_entries_to_the_verdict() { # body or any other value the PR itself controls. code=$(workflow_code) # shellcheck disable=SC2016 # a GitHub expression, quoted literally on purpose - assert_contains "$code" 'FM_SUPERSESSION_ENTRIES: ${{ needs.supersession.outputs.entries }}' \ - "the verdict step must take its approvals from the verifying job's output alone" + assert_contains "$code" 'FM_SUPERSESSION_ENTRIES: ${{ steps.supersession.outputs.entries }}' \ + "the verdict step must take its approvals from the signature check's own output alone" assert_contains "$code" 'pull-requests: read' \ "the workflow must be able to read the PR body, where an attestation added after the fact lives" pass "the verdict step is handed only the entries the signature check verified" @@ -1018,8 +1004,8 @@ test_the_workflow_does_not_reimplement_the_test_file_selection test_the_workflow_measures_the_pr_head_not_the_merge_ref test_the_workflow_refetches_the_base_at_job_time test_the_workflow_runs_on_every_pull_request_and_is_never_skipped -test_the_required_job_still_reports_when_its_dependency_fails -test_the_signing_secret_never_reaches_the_job_that_runs_the_branchs_code +test_the_required_check_depends_on_no_other_job +test_the_signing_secret_is_never_readable_by_the_branchs_own_scripts test_the_workflow_passes_only_the_verified_entries_to_the_verdict test_the_workflow_blocks_rather_than_passing_when_its_premise_fails test_the_workflow_reads_the_check_names_ci_actually_publishes